Complete dependency impact and API change awareness workflows
swadhinbiswas committed 8 months ago
92ee81e
1 parent 6cde4fb
commit 92ee81e
8 files changed
+867 -73
Showing 8 changed files+867-73
doc/feature_audit.mdsrc/db/schema/missing-schemas.tssrc/lib/dependency-awareness.tssrc/pages/api/repos/[owner]/[repo]/pulls/[number]/api-changes.tssrc/pages/api/repos/[owner]/[repo]/pulls/[number]/impact.tssrc/pages/change-sets.astrotests/integration/pr-api-changes-route.test.tstests/integration/pr-impact-path-permissions.test.ts
▼doc/feature_audit.md
+6-6
| @@ -13,13 +13,13 @@ | ||
13 | 13 | | **Issues & Planning** | 10 | 0 | 0 | 10 | |
14 | 14 | | **CI/CD & Automation** | 7 | 1 | 0 | 8 | |
15 | 15 | | **Third-Party Integrations** | 22 | 0 | 0 | 22 | |
16 | -| **Dependency & Impact Awareness** | 1 | 4 | 0 | 5 | | |
16 | +| **Dependency & Impact Awareness** | 5 | 0 | 0 | 5 | | |
17 | 17 | | **Security** | 9 | 3 | 0 | 12 | |
18 | 18 | | **Analytics & Insights** | 6 | 2 | 0 | 8 | |
19 | 19 | | **Notifications & Collaboration** | 5 | 3 | 0 | 8 | |
20 | 20 | | **Interfaces & Extensibility** | 5 | 2 | 0 | 7 | |
21 | 21 | | **Self-Hosted & Deployment** | 4 | 3 | 0 | 7 | |
22 | -| **Total** | **96** | **29** | **0** | **125** | | |
22 | +| **Total** | **100** | **25** | **0** | **125** | | |
23 | 23 | |
24 | 24 | --- |
25 | 25 | |
| @@ -146,10 +146,10 @@ | ||
146 | 146 | | Feature | Status | Implementation Notes | |
147 | 147 | | :--- | :---: | :--- | |
148 | 148 | | PR dependency visualization | ✅ | Stack visualization in `stacks.ts` | |
149 | -| Cross-repo change sets | ⚠️ | Implemented via change-set schema/library and API endpoints; UI workflow still limited | | |
150 | -| Breaking-change detection | ⚠️ | Implemented with PR diff/file heuristics + persistence; semantic precision can be improved | | |
151 | -| Database migration detection | ⚠️ | Implemented from changed-file pattern detection + persistence; deeper SQL semantic analysis pending | | |
152 | -| API change awareness | ⚠️ | AI review can detect, no dedicated system | | |
149 | +| Cross-repo change sets | ✅ | Change-set schema/library and APIs are implemented, and dedicated UI workflow is now available via `src/pages/change-sets.astro` (create/list/detail cross-repo sets with linked PR ordering context) | | |
150 | +| Breaking-change detection | ✅ | PR diff/file heuristics + persistence are implemented with improved semantic precision (deduplicated detections, source-file aware export/public API change signals, destructive schema/dependency removal scoring) | | |
151 | +| Database migration detection | ✅ | Migration detection now combines changed-file pattern matching with SQL/content semantics (destructive operation classification, reversibility signal extraction, downtime inference, and persisted explanatory notes) | | |
152 | +| API change awareness | ✅ | Dedicated API-awareness system is implemented with persisted `api_change_detections`, PR scan/read endpoints (`pulls/{number}/api-changes`), impact scan integration, and breaking API change propagation into merge-impact risk scoring | | |
153 | 153 | |
154 | 154 | ## 8. Security |
155 | 155 | | Feature | Status | Implementation Notes | |
156 | 156 | |
▼src/db/schema/missing-schemas.ts
+15-0
| @@ -514,6 +514,20 @@ export const migrationDetections = pgTable("migration_detections", { | ||
514 | 514 | createdAt: timestamp("created_at").notNull().defaultNow(), |
515 | 515 | }); |
516 | 516 | |
517 | +export const apiChangeDetections = pgTable("api_change_detections", { | |
518 | + id: text("id").primaryKey(), | |
519 | + pullRequestId: text("pull_request_id") | |
520 | + .notNull() | |
521 | + .references(() => pullRequests.id, { onDelete: "cascade" }), | |
522 | + changeType: text("change_type").notNull(), // added, removed, modified | |
523 | + path: text("path").notNull(), | |
524 | + method: text("method"), | |
525 | + breaking: boolean("breaking").default(false), | |
526 | + details: text("details").notNull(), | |
527 | + affectedFiles: jsonb("affected_files").$type<string[]>(), | |
528 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
529 | +}); | |
530 | + | |
517 | 531 | // ============== Deployments ============== |
518 | 532 | |
519 | 533 | export const deployments = pgTable("cloud_deployments", { |
| @@ -556,3 +570,4 @@ export type EmailSetting = typeof emailSettings.$inferSelect; | ||
556 | 570 | export type CoverageReport = typeof coverageReports.$inferSelect; |
557 | 571 | export type CustomDashboard = typeof customDashboards.$inferSelect; |
558 | 572 | export type Deployment = typeof deployments.$inferSelect; |
573 | +export type APIChangeDetection = typeof apiChangeDetections.$inferSelect; | |
559 | 574 | |
▼src/lib/dependency-awareness.ts
+412-65
| @@ -9,6 +9,9 @@ import { eq, and, inArray } from "drizzle-orm"; | ||
9 | 9 | import { logger } from "./logger"; |
10 | 10 | import { repositories } from "@/db/schema/repositories"; |
11 | 11 | import { pullRequests } from "@/db/schema/pull-requests"; |
12 | +import crypto from "node:crypto"; | |
13 | +import { getChangedFiles, getGit } from "./git"; | |
14 | +import { resolveRepoPath } from "./git-storage"; | |
12 | 15 | |
13 | 16 | // ============================================================================ |
14 | 17 | // SCHEMA |
| @@ -65,9 +68,24 @@ export const migrationDetections = pgTable("migration_detections", { | ||
65 | 68 | createdAt: timestamp("created_at").notNull().defaultNow(), |
66 | 69 | }); |
67 | 70 | |
71 | +export const apiChangeDetections = pgTable("api_change_detections", { | |
72 | + id: text("id").primaryKey(), | |
73 | + pullRequestId: text("pull_request_id") | |
74 | + .notNull() | |
75 | + .references(() => pullRequests.id, { onDelete: "cascade" }), | |
76 | + changeType: text("change_type").notNull(), // added, removed, modified | |
77 | + path: text("path").notNull(), | |
78 | + method: text("method"), | |
79 | + breaking: boolean("breaking").default(false), | |
80 | + details: text("details").notNull(), | |
81 | + affectedFiles: jsonb("affected_files").$type<string[]>(), | |
82 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
83 | +}); | |
84 | + | |
68 | 85 | export type ChangeSet = typeof changeSets.$inferSelect; |
69 | 86 | export type BreakingChange = typeof breakingChanges.$inferSelect; |
70 | 87 | export type MigrationDetection = typeof migrationDetections.$inferSelect; |
88 | +export type APIChangeDetection = typeof apiChangeDetections.$inferSelect; | |
71 | 89 | |
72 | 90 | // ============================================================================ |
73 | 91 | // CROSS-REPO CHANGE SETS |
| @@ -160,67 +178,129 @@ export async function detectBreakingChanges(pullRequestId: string): Promise<Brea | ||
160 | 178 | |
161 | 179 | if (!pr) return detectedChanges; |
162 | 180 | |
163 | - // Analyze diff for breaking changes | |
164 | - const patterns = [ | |
181 | + const repository = await db.query.repositories.findFirst({ | |
182 | + where: eq(schema.repositories.id, pr.repositoryId), | |
183 | + }); | |
184 | + if (!repository) return detectedChanges; | |
185 | + | |
186 | + const repoPath = await resolveRepoPath(repository.diskPath); | |
187 | + const changedFiles = await getChangedFiles(repoPath, pr.baseBranch, pr.headBranch); | |
188 | + const git = getGit(repoPath); | |
189 | + const diff = await git.raw(["diff", `${pr.baseBranch}...${pr.headBranch}`]); | |
190 | + | |
191 | + // Clear previous detections for idempotent re-runs. | |
192 | + // @ts-expect-error - Drizzle multi-db union type issue | |
193 | + await db.delete(schema.breakingChanges).where(eq(schema.breakingChanges.pullRequestId, pullRequestId)); | |
194 | + | |
195 | + const fileRules = [ | |
165 | 196 | { |
166 | - pattern: /^-\s*export\s+(function|const|class|interface|type)\s+(\w+)/gm, | |
197 | + match: (f: string) => /openapi\.(ya?ml|json)$/i.test(f) || /schema\.(graphql|gql)$/i.test(f), | |
167 | 198 | type: "api", |
168 | 199 | severity: "high", |
169 | - template: "Removed export: $2", | |
200 | + description: "API contract file changed", | |
170 | 201 | }, |
171 | 202 | { |
172 | - pattern: /^-\s*(public|protected)\s+\w+\s*\(/gm, | |
173 | - type: "api", | |
174 | - severity: "medium", | |
175 | - template: "Removed public method", | |
176 | - }, | |
177 | - { | |
178 | - pattern: /\.drop(Table|Column|Index)\s*\(/gi, | |
203 | + match: (f: string) => /migrations?\/.*\.(sql|ts|js)$/i.test(f), | |
179 | 204 | type: "schema", |
180 | - severity: "critical", | |
181 | - template: "Database schema drop detected", | |
182 | - }, | |
183 | - { | |
184 | - pattern: /ALTER\s+TABLE.*DROP\s+COLUMN/gi, | |
185 | - type: "schema", | |
186 | - severity: "critical", | |
187 | - template: "Column drop detected", | |
188 | - }, | |
189 | - { | |
190 | - pattern: /"version"\s*:\s*"(\d+)\.(\d+)\.(\d+)"/g, | |
191 | - type: "dependency", | |
192 | 205 | severity: "medium", |
193 | - template: "Major version change in dependencies", | |
206 | + description: "Migration file changed", | |
194 | 207 | }, |
195 | 208 | { |
196 | - pattern: /^-\s*"[^"]+"\s*:\s*"[\^~]?\d+/gm, | |
209 | + match: (f: string) => /package\.json$|pnpm-lock\.yaml$|yarn\.lock$|package-lock\.json$/i.test(f), | |
197 | 210 | type: "dependency", |
198 | - severity: "low", | |
199 | - template: "Dependency removed", | |
211 | + severity: "medium", | |
212 | + description: "Dependency manifest changed", | |
200 | 213 | }, |
201 | 214 | ]; |
202 | 215 | |
203 | - // Simulated diff analysis | |
204 | - const diff = ""; // Would be fetched from Git | |
216 | + const sourceFiles = changedFiles.filter((file) => | |
217 | + /\.(ts|tsx|js|jsx|mjs|cjs|go|java|kt|py|rb|rs|cs|php)$/i.test(file) | |
218 | + ); | |
219 | + const removedExportPattern = /^-\s*export\s+(?:async\s+)?(?:function|const|class|interface|type)\s+([A-Za-z0-9_]+)/gm; | |
220 | + const removedPublicMethodPattern = /^-\s*(?:public|protected)\s+[A-Za-z0-9_]+\s*\(/gm; | |
221 | + const schemaDropPattern = /\.drop(Table|Column|Index)\s*\(/gi; | |
222 | + const sqlDestructivePattern = /\b(?:DROP\s+(?:TABLE|COLUMN|INDEX)|ALTER\s+TABLE.+DROP\s+COLUMN)\b/gi; | |
223 | + const dependencyRemovalPattern = /^-\s*"([^"]+)"\s*:\s*"([^"]+)"/gm; | |
224 | + | |
225 | + const seen = new Set<string>(); | |
226 | + | |
227 | + const insertBreakingChange = async (change: { | |
228 | + changeType: string; | |
229 | + severity: string; | |
230 | + description: string; | |
231 | + affectedFiles: string[]; | |
232 | + }) => { | |
233 | + const key = `${change.changeType}:${change.severity}:${change.description}:${change.affectedFiles.sort().join(",")}`; | |
234 | + if (seen.has(key)) return; | |
235 | + seen.add(key); | |
236 | + | |
237 | + const row = { | |
238 | + id: crypto.randomUUID(), | |
239 | + pullRequestId, | |
240 | + changeType: change.changeType, | |
241 | + severity: change.severity, | |
242 | + description: change.description, | |
243 | + affectedFiles: change.affectedFiles, | |
244 | + suggestedAction: getSuggestedAction(change.changeType, change.severity), | |
245 | + acknowledged: false, | |
246 | + createdAt: new Date(), | |
247 | + }; | |
205 | 248 | |
206 | - for (const { pattern, type, severity, template } of patterns) { | |
207 | - const matches = diff.matchAll(pattern); | |
208 | - for (const match of matches) { | |
209 | - const change = { | |
210 | - id: crypto.randomUUID(), | |
211 | - pullRequestId, | |
212 | - changeType: type, | |
213 | - severity, | |
214 | - description: template.replace("$2", match[2] || ""), | |
215 | - affectedFiles: [], | |
216 | - suggestedAction: getSuggestedAction(type, severity), | |
217 | - acknowledged: false, | |
218 | - createdAt: new Date(), | |
219 | - }; | |
249 | + // @ts-expect-error - Drizzle multi-db union type issue | |
250 | + await db.insert(schema.breakingChanges).values(row); | |
251 | + detectedChanges.push(row as BreakingChange); | |
252 | + }; | |
220 | 253 | |
221 | - // @ts-expect-error - Drizzle multi-db union type issue | |
222 | - await db.insert(schema.breakingChanges).values(change); | |
223 | - detectedChanges.push(change as BreakingChange); | |
254 | + if (sourceFiles.length > 0) { | |
255 | + for (const match of diff.matchAll(removedExportPattern)) { | |
256 | + await insertBreakingChange({ | |
257 | + changeType: "api", | |
258 | + severity: "high", | |
259 | + description: `Removed exported symbol: ${match[1]}`, | |
260 | + affectedFiles: sourceFiles.slice(0, 10), | |
261 | + }); | |
262 | + } | |
263 | + | |
264 | + if (removedPublicMethodPattern.test(diff)) { | |
265 | + await insertBreakingChange({ | |
266 | + changeType: "api", | |
267 | + severity: "medium", | |
268 | + description: "Removed public/protected method signature", | |
269 | + affectedFiles: sourceFiles.slice(0, 10), | |
270 | + }); | |
271 | + } | |
272 | + } | |
273 | + | |
274 | + if (schemaDropPattern.test(diff) || sqlDestructivePattern.test(diff)) { | |
275 | + await insertBreakingChange({ | |
276 | + changeType: "schema", | |
277 | + severity: "critical", | |
278 | + description: "Destructive schema operation detected in diff", | |
279 | + affectedFiles: changedFiles.filter((f) => /migrations?|schema|sql|drizzle|prisma/i.test(f)).slice(0, 10), | |
280 | + }); | |
281 | + } | |
282 | + | |
283 | + const removedDependencies = Array.from(diff.matchAll(dependencyRemovalPattern)) | |
284 | + .map((match) => ({ name: match[1], version: match[2] })) | |
285 | + .filter((dep) => !dep.name.startsWith("@types/")); | |
286 | + if (removedDependencies.length > 0) { | |
287 | + await insertBreakingChange({ | |
288 | + changeType: "dependency", | |
289 | + severity: "medium", | |
290 | + description: `Dependency removals detected (${removedDependencies.length})`, | |
291 | + affectedFiles: changedFiles.filter((f) => /package\.json$|pnpm-lock\.yaml$|yarn\.lock$|package-lock\.json$/i.test(f)), | |
292 | + }); | |
293 | + } | |
294 | + | |
295 | + for (const file of changedFiles) { | |
296 | + for (const rule of fileRules) { | |
297 | + if (!rule.match(file)) continue; | |
298 | + await insertBreakingChange({ | |
299 | + changeType: rule.type, | |
300 | + severity: rule.severity, | |
301 | + description: `${rule.description}: ${file}`, | |
302 | + affectedFiles: [file], | |
303 | + }); | |
224 | 304 | } |
225 | 305 | } |
226 | 306 | |
| @@ -259,6 +339,8 @@ function getSuggestedAction(type: string, severity: string): string { | ||
259 | 339 | export async function detectMigrations(pullRequestId: string, changedFiles: string[]): Promise<MigrationDetection[]> { |
260 | 340 | const db = getDatabase(); |
261 | 341 | const detections: MigrationDetection[] = []; |
342 | + // @ts-expect-error - Drizzle multi-db union type issue | |
343 | + await db.delete(schema.migrationDetections).where(eq(schema.migrationDetections.pullRequestId, pullRequestId)); | |
262 | 344 | |
263 | 345 | const migrationPatterns = [ |
264 | 346 | { pattern: /migrations?\/.*\.(sql|ts|js)$/i, tool: "generic", type: "database" }, |
| @@ -286,16 +368,40 @@ export async function detectMigrations(pullRequestId: string, changedFiles: stri | ||
286 | 368 | } |
287 | 369 | } |
288 | 370 | |
371 | + let pr: typeof schema.pullRequests.$inferSelect | undefined; | |
372 | + let repository: typeof schema.repositories.$inferSelect | undefined; | |
373 | + let git: ReturnType<typeof getGit> | null = null; | |
374 | + if (Object.keys(matchedFiles).length > 0) { | |
375 | + pr = await db.query.pullRequests.findFirst({ | |
376 | + where: eq(schema.pullRequests.id, pullRequestId), | |
377 | + }); | |
378 | + if (pr) { | |
379 | + repository = await db.query.repositories.findFirst({ | |
380 | + where: eq(schema.repositories.id, pr.repositoryId), | |
381 | + }) || undefined; | |
382 | + } | |
383 | + if (repository) { | |
384 | + const repoPath = await resolveRepoPath(repository.diskPath); | |
385 | + git = getGit(repoPath); | |
386 | + } | |
387 | + } | |
388 | + | |
289 | 389 | for (const [, { tool, type, files }] of Object.entries(matchedFiles)) { |
390 | + const analyzed = await analyzeMigrationFiles({ | |
391 | + git, | |
392 | + baseRef: pr?.baseBranch, | |
393 | + headRef: pr?.headBranch, | |
394 | + files, | |
395 | + }); | |
290 | 396 | const detection = { |
291 | 397 | id: crypto.randomUUID(), |
292 | 398 | pullRequestId, |
293 | 399 | migrationType: type, |
294 | 400 | tool, |
295 | 401 | files, |
296 | - isReversible: await checkMigrationReversibility(files), | |
297 | - requiresDowntime: type === "database" && files.some(f => /drop|alter/i.test(f)), | |
298 | - notes: null, | |
402 | + isReversible: analyzed.isReversible, | |
403 | + requiresDowntime: analyzed.requiresDowntime, | |
404 | + notes: analyzed.notes, | |
299 | 405 | createdAt: new Date(), |
300 | 406 | }; |
301 | 407 | |
| @@ -307,9 +413,43 @@ export async function detectMigrations(pullRequestId: string, changedFiles: stri | ||
307 | 413 | return detections; |
308 | 414 | } |
309 | 415 | |
310 | -async function checkMigrationReversibility(files: string[]): Promise<boolean> { | |
311 | - // Check for down migrations | |
312 | - return files.some(f => /down|rollback|revert/i.test(f)); | |
416 | +async function analyzeMigrationFiles(options: { | |
417 | + git: ReturnType<typeof getGit> | null; | |
418 | + baseRef?: string; | |
419 | + headRef?: string; | |
420 | + files: string[]; | |
421 | +}): Promise<{ isReversible: boolean; requiresDowntime: boolean; notes: string | null }> { | |
422 | + const downFileHint = options.files.some((f) => /down|rollback|revert/i.test(f)); | |
423 | + let reversibleSignals = downFileHint ? 1 : 0; | |
424 | + let downtimeSignals = 0; | |
425 | + const noteParts: string[] = []; | |
426 | + | |
427 | + for (const file of options.files) { | |
428 | + const headContent = await getFileContentAtRef(options.git, options.headRef, file); | |
429 | + const baseContent = await getFileContentAtRef(options.git, options.baseRef, file); | |
430 | + const combined = `${baseContent || ""}\n${headContent || ""}`.toLowerCase(); | |
431 | + | |
432 | + if (/\b(drop\s+table|drop\s+column|alter\s+table.+drop\s+column)\b/.test(combined)) { | |
433 | + downtimeSignals += 2; | |
434 | + noteParts.push(`destructive sql in ${file}`); | |
435 | + } | |
436 | + if (/\b(rename\s+column|alter\s+type|drop\s+constraint)\b/.test(combined)) { | |
437 | + downtimeSignals += 1; | |
438 | + noteParts.push(`schema transition in ${file}`); | |
439 | + } | |
440 | + if (/\b(down|rollback|revert|undo)\b/.test(combined)) { | |
441 | + reversibleSignals += 1; | |
442 | + } | |
443 | + if (/\b(create\s+table|add\s+column)\b/.test(combined)) { | |
444 | + noteParts.push(`additive migration in ${file}`); | |
445 | + } | |
446 | + } | |
447 | + | |
448 | + return { | |
449 | + isReversible: reversibleSignals > 0, | |
450 | + requiresDowntime: downtimeSignals >= 2, | |
451 | + notes: noteParts.length > 0 ? Array.from(new Set(noteParts)).join("; ") : null, | |
452 | + }; | |
313 | 453 | } |
314 | 454 | |
315 | 455 | // ============================================================================ |
| @@ -322,6 +462,7 @@ export interface APIChange { | ||
322 | 462 | method?: string; |
323 | 463 | breaking: boolean; |
324 | 464 | details: string; |
465 | + sourceFile?: string; | |
325 | 466 | } |
326 | 467 | |
327 | 468 | export async function detectAPIChanges( |
| @@ -392,26 +533,187 @@ export async function detectAPIChanges( | ||
392 | 533 | } |
393 | 534 | } |
394 | 535 | |
395 | - // Store breaking changes | |
536 | + await persistAPIChanges(pullRequestId, changes, []); | |
537 | + | |
538 | + return changes; | |
539 | +} | |
540 | + | |
541 | +export async function detectAPIChangesForPullRequest( | |
542 | + pullRequestId: string, | |
543 | + changedFiles: string[] | |
544 | +): Promise<APIChange[]> { | |
545 | + const db = getDatabase(); | |
546 | + const pr = await db.query.pullRequests.findFirst({ | |
547 | + where: eq(schema.pullRequests.id, pullRequestId), | |
548 | + }); | |
549 | + if (!pr) return []; | |
550 | + | |
551 | + const repository = await db.query.repositories.findFirst({ | |
552 | + where: eq(schema.repositories.id, pr.repositoryId), | |
553 | + }); | |
554 | + if (!repository) return []; | |
555 | + | |
556 | + const apiSpecFiles = changedFiles.filter((file) => | |
557 | + /openapi\.(ya?ml|json)$|swagger\.(ya?ml|json)$|schema\.(graphql|gql)$|\.proto$/i.test(file) | |
558 | + ); | |
559 | + if (apiSpecFiles.length === 0) { | |
560 | + await persistAPIChanges(pullRequestId, [], []); | |
561 | + return []; | |
562 | + } | |
563 | + | |
564 | + const repoPath = await resolveRepoPath(repository.diskPath); | |
565 | + const git = getGit(repoPath); | |
566 | + const changes: APIChange[] = []; | |
567 | + | |
568 | + for (const file of apiSpecFiles) { | |
569 | + const oldContent = await getFileContentAtRef(git, pr.baseBranch, file); | |
570 | + const newContent = await getFileContentAtRef(git, pr.headBranch, file); | |
571 | + const fileChanges = detectAPIChangesFromText(oldContent || "", newContent || "", file); | |
572 | + changes.push(...fileChanges); | |
573 | + } | |
574 | + | |
575 | + await persistAPIChanges(pullRequestId, changes, apiSpecFiles); | |
576 | + return changes; | |
577 | +} | |
578 | + | |
579 | +async function persistAPIChanges( | |
580 | + pullRequestId: string, | |
581 | + changes: APIChange[], | |
582 | + sourceFiles: string[] | |
583 | +) { | |
396 | 584 | const db = getDatabase(); |
397 | - for (const change of changes.filter(c => c.breaking)) { | |
585 | + | |
586 | + // @ts-expect-error - Drizzle multi-db union type issue | |
587 | + await db.delete(schema.apiChangeDetections).where(eq(schema.apiChangeDetections.pullRequestId, pullRequestId)); | |
588 | + | |
589 | + for (const change of changes) { | |
398 | 590 | // @ts-expect-error - Drizzle multi-db union type issue |
399 | - await db.insert(schema.breakingChanges).values({ | |
591 | + await db.insert(schema.apiChangeDetections).values({ | |
400 | 592 | id: crypto.randomUUID(), |
401 | 593 | pullRequestId, |
402 | - changeType: "api", | |
403 | - severity: change.type === "removed" ? "high" : "medium", | |
404 | - description: change.details, | |
405 | - affectedFiles: [], | |
406 | - suggestedAction: "Update API consumers before merging", | |
407 | - acknowledged: false, | |
594 | + changeType: change.type, | |
595 | + path: change.path, | |
596 | + method: change.method || null, | |
597 | + breaking: change.breaking, | |
598 | + details: change.details, | |
599 | + affectedFiles: change.sourceFile ? [change.sourceFile] : sourceFiles, | |
408 | 600 | createdAt: new Date(), |
409 | 601 | }); |
602 | + | |
603 | + if (change.breaking) { | |
604 | + // @ts-expect-error - Drizzle multi-db union type issue | |
605 | + await db.insert(schema.breakingChanges).values({ | |
606 | + id: crypto.randomUUID(), | |
607 | + pullRequestId, | |
608 | + changeType: "api", | |
609 | + severity: change.type === "removed" ? "high" : "medium", | |
610 | + description: change.details, | |
611 | + affectedFiles: change.sourceFile ? [change.sourceFile] : sourceFiles, | |
612 | + suggestedAction: "Update API consumers before merging", | |
613 | + acknowledged: false, | |
614 | + createdAt: new Date(), | |
615 | + }); | |
616 | + } | |
617 | + } | |
618 | +} | |
619 | + | |
620 | +function detectAPIChangesFromText(oldContent: string, newContent: string, sourceFile: string): APIChange[] { | |
621 | + const changes: APIChange[] = []; | |
622 | + const oldLines = oldContent.split("\n"); | |
623 | + const newLines = newContent.split("\n"); | |
624 | + | |
625 | + const parseOpenApiSignals = (lines: string[]) => { | |
626 | + const endpoints = new Set<string>(); | |
627 | + for (const raw of lines) { | |
628 | + const line = raw.trim(); | |
629 | + const pathMatch = /^\/[A-Za-z0-9_{}\-./]+:\s*$/.exec(line); | |
630 | + if (pathMatch) endpoints.add(pathMatch[0].replace(/:\s*$/, "")); | |
631 | + } | |
632 | + return { endpoints }; | |
633 | + }; | |
634 | + | |
635 | + const parseGraphqlSignals = (lines: string[]) => { | |
636 | + const defs = new Set<string>(); | |
637 | + for (const raw of lines) { | |
638 | + const line = raw.trim(); | |
639 | + const typeMatch = /^(type|interface|enum|input)\s+([A-Za-z0-9_]+)/.exec(line); | |
640 | + if (typeMatch) defs.add(`${typeMatch[1]}:${typeMatch[2]}`); | |
641 | + const fieldMatch = /^([A-Za-z0-9_]+)\s*\([^)]*\)?\s*:\s*([A-Za-z0-9_[\]!]+)/.exec(line); | |
642 | + if (fieldMatch) defs.add(`field:${fieldMatch[1]}:${fieldMatch[2]}`); | |
643 | + } | |
644 | + return defs; | |
645 | + }; | |
646 | + | |
647 | + if (/openapi|swagger/i.test(sourceFile)) { | |
648 | + const oldSignals = parseOpenApiSignals(oldLines); | |
649 | + const newSignals = parseOpenApiSignals(newLines); | |
650 | + | |
651 | + for (const endpoint of oldSignals.endpoints) { | |
652 | + if (!newSignals.endpoints.has(endpoint)) { | |
653 | + changes.push({ | |
654 | + type: "removed", | |
655 | + path: endpoint, | |
656 | + breaking: true, | |
657 | + details: `Endpoint ${endpoint} removed from spec`, | |
658 | + sourceFile, | |
659 | + }); | |
660 | + } | |
661 | + } | |
662 | + for (const endpoint of newSignals.endpoints) { | |
663 | + if (!oldSignals.endpoints.has(endpoint)) { | |
664 | + changes.push({ | |
665 | + type: "added", | |
666 | + path: endpoint, | |
667 | + breaking: false, | |
668 | + details: `Endpoint ${endpoint} added to spec`, | |
669 | + sourceFile, | |
670 | + }); | |
671 | + } | |
672 | + } | |
673 | + } else if (/schema\.(graphql|gql)$|\.proto$/i.test(sourceFile)) { | |
674 | + const oldDefs = parseGraphqlSignals(oldLines); | |
675 | + const newDefs = parseGraphqlSignals(newLines); | |
676 | + | |
677 | + for (const def of oldDefs) { | |
678 | + if (!newDefs.has(def)) { | |
679 | + changes.push({ | |
680 | + type: "removed", | |
681 | + path: def, | |
682 | + breaking: true, | |
683 | + details: `Schema symbol removed: ${def}`, | |
684 | + sourceFile, | |
685 | + }); | |
686 | + } | |
687 | + } | |
688 | + for (const def of newDefs) { | |
689 | + if (!oldDefs.has(def)) { | |
690 | + changes.push({ | |
691 | + type: "added", | |
692 | + path: def, | |
693 | + breaking: false, | |
694 | + details: `Schema symbol added: ${def}`, | |
695 | + sourceFile, | |
696 | + }); | |
697 | + } | |
698 | + } | |
410 | 699 | } |
411 | 700 | |
412 | 701 | return changes; |
413 | 702 | } |
414 | 703 | |
704 | +async function getFileContentAtRef( | |
705 | + git: ReturnType<typeof getGit> | null, | |
706 | + ref: string | undefined, | |
707 | + filePath: string | |
708 | +): Promise<string | null> { | |
709 | + if (!git || !ref || !filePath) return null; | |
710 | + try { | |
711 | + return await git.show([`${ref}:${filePath}`]); | |
712 | + } catch { | |
713 | + return null; | |
714 | + } | |
715 | +} | |
716 | + | |
415 | 717 | // ============================================================================ |
416 | 718 | // IMPACT ANALYSIS |
417 | 719 | // ============================================================================ |
| @@ -419,7 +721,13 @@ export async function detectAPIChanges( | ||
419 | 721 | export async function analyzeImpact(pullRequestId: string): Promise<{ |
420 | 722 | breakingChanges: BreakingChange[]; |
421 | 723 | migrations: MigrationDetection[]; |
724 | + apiChanges: APIChangeDetection[]; | |
422 | 725 | affectedRepos: string[]; |
726 | + affectedChangeSets: { | |
727 | + changeSetId: string; | |
728 | + repositoryIds: string[]; | |
729 | + pullRequestIds: string[]; | |
730 | + }[]; | |
423 | 731 | riskScore: number; |
424 | 732 | }> { |
425 | 733 | const db = getDatabase(); |
| @@ -431,10 +739,46 @@ export async function analyzeImpact(pullRequestId: string): Promise<{ | ||
431 | 739 | const migrations = await db.query.migrationDetections?.findMany({ |
432 | 740 | where: eq(schema.migrationDetections.pullRequestId, pullRequestId), |
433 | 741 | }) || []; |
742 | + const apiChanges = await db.query.apiChangeDetections?.findMany({ | |
743 | + where: eq(schema.apiChangeDetections.pullRequestId, pullRequestId), | |
744 | + }) || []; | |
434 | 745 | |
435 | - // Find affected repositories through cross-repo links | |
436 | - const crossLinks = await db.query.crossRepoIssueLinks?.findMany({}) || []; | |
437 | - const affectedRepos: string[] = []; | |
746 | + const pr = await db.query.pullRequests.findFirst({ | |
747 | + where: eq(schema.pullRequests.id, pullRequestId), | |
748 | + }); | |
749 | + if (!pr) { | |
750 | + return { breakingChanges, migrations, apiChanges, affectedRepos: [], affectedChangeSets: [], riskScore: 0 }; | |
751 | + } | |
752 | + | |
753 | + const sets = await db.query.changeSetItems?.findMany({ | |
754 | + where: eq(schema.changeSetItems.pullRequestId, pullRequestId), | |
755 | + }) || []; | |
756 | + const affectedRepoSet = new Set<string>(); | |
757 | + const affectedChangeSets: { | |
758 | + changeSetId: string; | |
759 | + repositoryIds: string[]; | |
760 | + pullRequestIds: string[]; | |
761 | + }[] = []; | |
762 | + for (const item of sets) { | |
763 | + const siblings = await db.query.changeSetItems?.findMany({ | |
764 | + where: eq(schema.changeSetItems.changeSetId, item.changeSetId), | |
765 | + }) || []; | |
766 | + const repoIds = new Set<string>(); | |
767 | + const prIds = new Set<string>(); | |
768 | + for (const sibling of siblings) { | |
769 | + repoIds.add(sibling.repositoryId); | |
770 | + if (sibling.pullRequestId) prIds.add(sibling.pullRequestId); | |
771 | + if (sibling.repositoryId !== pr.repositoryId) { | |
772 | + affectedRepoSet.add(sibling.repositoryId); | |
773 | + } | |
774 | + } | |
775 | + affectedChangeSets.push({ | |
776 | + changeSetId: item.changeSetId, | |
777 | + repositoryIds: Array.from(repoIds), | |
778 | + pullRequestIds: Array.from(prIds), | |
779 | + }); | |
780 | + } | |
781 | + const affectedRepos = Array.from(affectedRepoSet); | |
438 | 782 | |
439 | 783 | // Calculate risk score (0-100) |
440 | 784 | let riskScore = 0; |
| @@ -454,10 +798,13 @@ export async function analyzeImpact(pullRequestId: string): Promise<{ | ||
454 | 798 | if (!migration.isReversible) riskScore += 15; |
455 | 799 | else riskScore += 5; |
456 | 800 | } |
801 | + for (const apiChange of apiChanges) { | |
802 | + riskScore += apiChange.breaking ? 12 : 3; | |
803 | + } | |
457 | 804 | |
458 | 805 | riskScore = Math.min(100, riskScore); |
459 | 806 | |
460 | - return { breakingChanges, migrations, affectedRepos, riskScore }; | |
807 | + return { breakingChanges, migrations, apiChanges, affectedRepos, affectedChangeSets, riskScore }; | |
461 | 808 | } |
462 | 809 | |
463 | 810 | // ============================================================================ |
464 | 811 | |
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/api-changes.ts
+90-0
| @@ -0,0 +1,90 @@ | ||
1 | +import type { APIRoute } from "astro"; | |
2 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
3 | +import { and, eq } from "drizzle-orm"; | |
4 | +import { getDatabase, schema } from "@/db"; | |
5 | +import { badRequest, forbidden, notFound, success, unauthorized } from "@/lib/api"; | |
6 | +import { withErrorHandler } from "@/lib/errors"; | |
7 | +import { canReadRepo, canWriteRepo } from "@/lib/permissions"; | |
8 | +import { compareBranches } from "@/lib/git"; | |
9 | +import { resolveRepoPath } from "@/lib/git-storage"; | |
10 | +import { detectAPIChangesForPullRequest } from "@/lib/dependency-awareness"; | |
11 | + | |
12 | +async function resolveRepoAndPr(owner: string, repoName: string, number: number) { | |
13 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
14 | + const repoOwner = await db.query.users.findFirst({ | |
15 | + where: eq(schema.users.username, owner), | |
16 | + }); | |
17 | + if (!repoOwner) return null; | |
18 | + | |
19 | + const repository = await db.query.repositories.findFirst({ | |
20 | + where: and( | |
21 | + eq(schema.repositories.ownerId, repoOwner.id), | |
22 | + eq(schema.repositories.name, repoName) | |
23 | + ), | |
24 | + }); | |
25 | + if (!repository) return null; | |
26 | + | |
27 | + const pr = await db.query.pullRequests.findFirst({ | |
28 | + where: and( | |
29 | + eq(schema.pullRequests.repositoryId, repository.id), | |
30 | + eq(schema.pullRequests.number, number) | |
31 | + ), | |
32 | + }); | |
33 | + if (!pr) return null; | |
34 | + | |
35 | + return { repository, pr }; | |
36 | +} | |
37 | + | |
38 | +export const GET: APIRoute = withErrorHandler(async ({ params, locals }) => { | |
39 | + const owner = params.owner; | |
40 | + const repoName = params.repo; | |
41 | + const number = Number(params.number); | |
42 | + if (!owner || !repoName || Number.isNaN(number)) return badRequest("Invalid route parameters"); | |
43 | + | |
44 | + const resolved = await resolveRepoAndPr(owner, repoName, number); | |
45 | + if (!resolved) return notFound("Pull request not found"); | |
46 | + | |
47 | + if (!(await canReadRepo(locals.user?.id, resolved.repository, { isAdmin: locals.user?.isAdmin }))) { | |
48 | + return notFound("Pull request not found"); | |
49 | + } | |
50 | + | |
51 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
52 | + const apiChanges = await db.query.apiChangeDetections?.findMany({ | |
53 | + where: eq(schema.apiChangeDetections.pullRequestId, resolved.pr.id), | |
54 | + }) || []; | |
55 | + | |
56 | + return success({ | |
57 | + total: apiChanges.length, | |
58 | + breaking: apiChanges.filter((item) => item.breaking).length, | |
59 | + changes: apiChanges, | |
60 | + }); | |
61 | +}); | |
62 | + | |
63 | +export const POST: APIRoute = withErrorHandler(async ({ params, locals }) => { | |
64 | + const owner = params.owner; | |
65 | + const repoName = params.repo; | |
66 | + const number = Number(params.number); | |
67 | + if (!owner || !repoName || Number.isNaN(number)) return badRequest("Invalid route parameters"); | |
68 | + if (!locals.user) return unauthorized(); | |
69 | + | |
70 | + const resolved = await resolveRepoAndPr(owner, repoName, number); | |
71 | + if (!resolved) return notFound("Pull request not found"); | |
72 | + | |
73 | + if (!(await canWriteRepo(locals.user.id, resolved.repository, { isAdmin: locals.user.isAdmin }))) { | |
74 | + return forbidden(); | |
75 | + } | |
76 | + | |
77 | + const repoPath = await resolveRepoPath(resolved.repository.diskPath); | |
78 | + const { diffs } = await compareBranches(repoPath, resolved.pr.baseBranch, resolved.pr.headBranch); | |
79 | + const changedFiles = diffs.map((d) => d.file).filter(Boolean); | |
80 | + | |
81 | + const changes = await detectAPIChangesForPullRequest(resolved.pr.id, changedFiles); | |
82 | + | |
83 | + return success({ | |
84 | + scannedFiles: changedFiles.length, | |
85 | + apiSpecFiles: changedFiles.filter((file) => /openapi\.(ya?ml|json)$|swagger\.(ya?ml|json)$|schema\.(graphql|gql)$|\.proto$/i.test(file)), | |
86 | + total: changes.length, | |
87 | + breaking: changes.filter((item) => item.breaking).length, | |
88 | + changes, | |
89 | + }); | |
90 | +}); | |
0 | 91 | |
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/impact.ts
+18-1
| @@ -11,11 +11,12 @@ import { resolveRepoPath } from "@/lib/git-storage"; | ||
11 | 11 | import { checkPathPermissions } from "@/lib/path-scoping"; |
12 | 12 | import { |
13 | 13 | analyzeImpact, |
14 | + detectAPIChangesForPullRequest, | |
14 | 15 | detectBreakingChanges, |
15 | 16 | detectMigrations, |
16 | 17 | } from "@/lib/dependency-awareness"; |
17 | 18 | import { detectIaCFiles, triggerIaCHooks } from "@/lib/iac-hooks"; |
18 | -import type { BreakingChange, MigrationDetection } from "@/lib/dependency-awareness"; | |
19 | +import type { BreakingChange, MigrationDetection, APIChangeDetection } from "@/lib/dependency-awareness"; | |
19 | 20 | |
20 | 21 | const scanSchema = z.object({ |
21 | 22 | persist: z.boolean().optional().default(true), |
| @@ -52,18 +53,21 @@ function filterImpactByReadablePaths(options: { | ||
52 | 53 | repositoryId: string; |
53 | 54 | breakingChanges: BreakingChange[]; |
54 | 55 | migrations: MigrationDetection[]; |
56 | + apiChanges: APIChangeDetection[]; | |
55 | 57 | }) { |
56 | 58 | return (async () => { |
57 | 59 | const scopedPaths = Array.from( |
58 | 60 | new Set([ |
59 | 61 | ...options.breakingChanges.flatMap((change) => change.affectedFiles || []), |
60 | 62 | ...options.migrations.flatMap((migration) => migration.files || []), |
63 | + ...options.apiChanges.flatMap((change) => change.affectedFiles || []), | |
61 | 64 | ]) |
62 | 65 | ); |
63 | 66 | if (scopedPaths.length === 0) { |
64 | 67 | return { |
65 | 68 | breakingChanges: options.breakingChanges, |
66 | 69 | migrations: options.migrations, |
70 | + apiChanges: options.apiChanges, | |
67 | 71 | hiddenPathArtifacts: 0, |
68 | 72 | }; |
69 | 73 | } |
| @@ -94,10 +98,19 @@ function filterImpactByReadablePaths(options: { | ||
94 | 98 | files: visible, |
95 | 99 | }; |
96 | 100 | }); |
101 | + const apiChanges = options.apiChanges.map((change) => { | |
102 | + const visible = (change.affectedFiles || []).filter((file) => !denied.has(file)); | |
103 | + hiddenPathArtifacts += (change.affectedFiles || []).length - visible.length; | |
104 | + return { | |
105 | + ...change, | |
106 | + affectedFiles: visible, | |
107 | + }; | |
108 | + }); | |
97 | 109 | |
98 | 110 | return { |
99 | 111 | breakingChanges, |
100 | 112 | migrations, |
113 | + apiChanges, | |
101 | 114 | hiddenPathArtifacts, |
102 | 115 | }; |
103 | 116 | })(); |
| @@ -122,12 +135,14 @@ export const GET: APIRoute = withErrorHandler(async ({ params, locals }) => { | ||
122 | 135 | repositoryId: resolved.repository.id, |
123 | 136 | breakingChanges: impact.breakingChanges, |
124 | 137 | migrations: impact.migrations, |
138 | + apiChanges: impact.apiChanges, | |
125 | 139 | }); |
126 | 140 | |
127 | 141 | return success({ |
128 | 142 | ...impact, |
129 | 143 | breakingChanges: filtered.breakingChanges, |
130 | 144 | migrations: filtered.migrations, |
145 | + apiChanges: filtered.apiChanges, | |
131 | 146 | hiddenPathArtifacts: filtered.hiddenPathArtifacts, |
132 | 147 | }); |
133 | 148 | }); |
| @@ -166,6 +181,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, locals, request | ||
166 | 181 | |
167 | 182 | const breaking = await detectBreakingChanges(resolved.pr.id); |
168 | 183 | const migrations = await detectMigrations(resolved.pr.id, changedFiles); |
184 | + const apiChanges = await detectAPIChangesForPullRequest(resolved.pr.id, changedFiles); | |
169 | 185 | const impact = await analyzeImpact(resolved.pr.id); |
170 | 186 | const iacFiles = detectIaCFiles(changedFiles); |
171 | 187 | const iacHookResults = |
| @@ -181,6 +197,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, locals, request | ||
181 | 197 | return success({ |
182 | 198 | breakingDetected: breaking.length, |
183 | 199 | migrationsDetected: migrations.length, |
200 | + apiChangesDetected: apiChanges.length, | |
184 | 201 | iacFilesDetected: iacFiles.length, |
185 | 202 | iacHookRuns: iacHookResults, |
186 | 203 | impact, |
187 | 204 | |
▼src/pages/change-sets.astro
+192-0
| @@ -0,0 +1,192 @@ | ||
1 | +--- | |
2 | +import BaseLayout from "@/layouts/BaseLayout.astro"; | |
3 | +import { Button } from "@/components/ui/button"; | |
4 | +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; | |
5 | +import { getDatabase, schema } from "@/db"; | |
6 | +import { eq, desc } from "drizzle-orm"; | |
7 | + | |
8 | +const user = Astro.locals.user; | |
9 | +if (!user) return Astro.redirect("/login"); | |
10 | + | |
11 | +const db = getDatabase(); | |
12 | +const initialSets = (await db.query.changeSets?.findMany({ | |
13 | + where: eq(schema.changeSets.createdById, user.id), | |
14 | + orderBy: [desc(schema.changeSets.createdAt)], | |
15 | + limit: 25, | |
16 | +})) || []; | |
17 | +--- | |
18 | + | |
19 | +<BaseLayout title="Change Sets"> | |
20 | + <div class="container py-6 space-y-6"> | |
21 | + <Card> | |
22 | + <CardHeader> | |
23 | + <CardTitle>Cross-Repo Change Sets</CardTitle> | |
24 | + <CardDescription> | |
25 | + Group pull requests across repositories into one coordinated change workflow. | |
26 | + </CardDescription> | |
27 | + </CardHeader> | |
28 | + <CardContent className="space-y-3"> | |
29 | + <div class="grid gap-3 md:grid-cols-2"> | |
30 | + <input id="change-set-name" class="h-9 rounded-md border bg-background px-3 text-sm" placeholder="Release train: March platform rollout" /> | |
31 | + <input id="change-set-description" class="h-9 rounded-md border bg-background px-3 text-sm" placeholder="Optional description" /> | |
32 | + </div> | |
33 | + <textarea id="change-set-items" class="min-h-[110px] w-full rounded-md border bg-background px-3 py-2 text-sm" placeholder="One per line: owner/repo#123 or owner/repo"></textarea> | |
34 | + <div class="flex items-center gap-2"> | |
35 | + <Button id="create-change-set" variant="outline">Create Change Set</Button> | |
36 | + <span id="create-change-set-msg" class="text-xs text-muted-foreground"></span> | |
37 | + </div> | |
38 | + </CardContent> | |
39 | + </Card> | |
40 | + | |
41 | + <Card> | |
42 | + <CardHeader> | |
43 | + <CardTitle>Your Change Sets</CardTitle> | |
44 | + <CardDescription>Open an item to inspect cross-repository linked pull requests.</CardDescription> | |
45 | + </CardHeader> | |
46 | + <CardContent> | |
47 | + <div id="change-set-list" class="space-y-2 text-sm"> | |
48 | + {initialSets.length === 0 ? ( | |
49 | + <div class="text-muted-foreground">No change sets yet.</div> | |
50 | + ) : ( | |
51 | + initialSets.map((item) => ( | |
52 | + <button data-id={item.id} class="change-set-entry w-full text-left rounded-md border border-white/10 bg-white/5 px-3 py-2 hover:bg-white/10"> | |
53 | + <div class="font-medium">{item.name}</div> | |
54 | + <div class="text-xs text-muted-foreground">{item.status} · {new Date(item.updatedAt).toLocaleString()}</div> | |
55 | + </button> | |
56 | + )) | |
57 | + )} | |
58 | + </div> | |
59 | + </CardContent> | |
60 | + </Card> | |
61 | + | |
62 | + <Card> | |
63 | + <CardHeader> | |
64 | + <CardTitle>Selected Change Set</CardTitle> | |
65 | + <CardDescription>Cross-repo linked items and execution order.</CardDescription> | |
66 | + </CardHeader> | |
67 | + <CardContent> | |
68 | + <div id="change-set-detail" class="text-sm text-muted-foreground">Select a change set to inspect details.</div> | |
69 | + </CardContent> | |
70 | + </Card> | |
71 | + </div> | |
72 | +</BaseLayout> | |
73 | + | |
74 | +<script define:vars={{ initialSets }}> | |
75 | + const nameInput = document.getElementById("change-set-name"); | |
76 | + const descInput = document.getElementById("change-set-description"); | |
77 | + const itemsInput = document.getElementById("change-set-items"); | |
78 | + const createBtn = document.getElementById("create-change-set"); | |
79 | + const createMsg = document.getElementById("create-change-set-msg"); | |
80 | + const listContainer = document.getElementById("change-set-list"); | |
81 | + const detailContainer = document.getElementById("change-set-detail"); | |
82 | + | |
83 | + function parseItems(raw) { | |
84 | + return raw | |
85 | + .split("\n") | |
86 | + .map((line) => line.trim()) | |
87 | + .filter(Boolean) | |
88 | + .map((line) => { | |
89 | + const match = /^([^\/\s]+)\/([^#\s]+)(?:#(\d+))?$/.exec(line); | |
90 | + if (!match) return null; | |
91 | + return { | |
92 | + owner: match[1], | |
93 | + repo: match[2], | |
94 | + prNumber: match[3] ? Number.parseInt(match[3], 10) : undefined, | |
95 | + }; | |
96 | + }) | |
97 | + .filter(Boolean); | |
98 | + } | |
99 | + | |
100 | + function renderList(sets) { | |
101 | + if (!listContainer) return; | |
102 | + if (!sets.length) { | |
103 | + listContainer.innerHTML = '<div class="text-muted-foreground">No change sets yet.</div>'; | |
104 | + return; | |
105 | + } | |
106 | + | |
107 | + listContainer.innerHTML = sets.map((item) => ` | |
108 | + <button data-id="${item.id}" class="change-set-entry w-full text-left rounded-md border border-white/10 bg-white/5 px-3 py-2 hover:bg-white/10"> | |
109 | + <div class="font-medium">${item.name}</div> | |
110 | + <div class="text-xs text-muted-foreground">${item.status} · ${new Date(item.updatedAt).toLocaleString()}</div> | |
111 | + </button> | |
112 | + `).join(""); | |
113 | + } | |
114 | + | |
115 | + async function fetchSets() { | |
116 | + const res = await fetch("/api/change-sets"); | |
117 | + const body = await res.json(); | |
118 | + if (!res.ok) throw new Error(body?.error?.message || body?.message || "Failed to load change sets"); | |
119 | + const data = body?.data || body; | |
120 | + renderList(data); | |
121 | + return data; | |
122 | + } | |
123 | + | |
124 | + async function fetchSetDetail(id) { | |
125 | + const res = await fetch(`/api/change-sets/${id}`); | |
126 | + const body = await res.json(); | |
127 | + if (!res.ok) throw new Error(body?.error?.message || body?.message || "Failed to load change set detail"); | |
128 | + const data = body?.data || body; | |
129 | + if (!detailContainer) return; | |
130 | + | |
131 | + const items = data?.items || []; | |
132 | + detailContainer.innerHTML = ` | |
133 | + <div class="space-y-2"> | |
134 | + <div class="font-medium">${data.changeSet?.name || "Change set"}</div> | |
135 | + <div class="text-xs text-muted-foreground">Status: ${data.changeSet?.status || "draft"}</div> | |
136 | + <div class="space-y-1"> | |
137 | + ${items.length === 0 | |
138 | + ? '<div class="text-muted-foreground">No linked repositories.</div>' | |
139 | + : items.map((item, idx) => ` | |
140 | + <div class="rounded border border-white/10 bg-white/5 px-2 py-1.5"> | |
141 | + <div>#${idx + 1} ${item.repository?.name || "repo"}</div> | |
142 | + <div class="text-xs text-muted-foreground">PR ${item.pullRequest?.number ? `#${item.pullRequest.number}` : "(none)"}</div> | |
143 | + </div> | |
144 | + `).join("")} | |
145 | + </div> | |
146 | + </div> | |
147 | + `; | |
148 | + } | |
149 | + | |
150 | + createBtn?.addEventListener("click", async () => { | |
151 | + if (!createMsg) return; | |
152 | + createMsg.textContent = "Creating change set..."; | |
153 | + | |
154 | + const items = parseItems(itemsInput?.value || ""); | |
155 | + if (!nameInput?.value?.trim() || items.length === 0) { | |
156 | + createMsg.textContent = "Name and at least one valid item are required."; | |
157 | + return; | |
158 | + } | |
159 | + | |
160 | + try { | |
161 | + const res = await fetch("/api/change-sets", { | |
162 | + method: "POST", | |
163 | + headers: { "Content-Type": "application/json" }, | |
164 | + body: JSON.stringify({ | |
165 | + name: nameInput.value.trim(), | |
166 | + description: descInput?.value?.trim() || undefined, | |
167 | + items, | |
168 | + }), | |
169 | + }); | |
170 | + const body = await res.json(); | |
171 | + if (!res.ok) throw new Error(body?.error?.message || body?.message || "Failed to create change set"); | |
172 | + | |
173 | + createMsg.textContent = "Change set created."; | |
174 | + if (itemsInput) itemsInput.value = ""; | |
175 | + await fetchSets(); | |
176 | + } catch (error) { | |
177 | + createMsg.textContent = error.message || "Failed to create change set"; | |
178 | + } | |
179 | + }); | |
180 | + | |
181 | + listContainer?.addEventListener("click", (event) => { | |
182 | + const target = event.target.closest(".change-set-entry"); | |
183 | + if (!target) return; | |
184 | + const id = target.getAttribute("data-id"); | |
185 | + if (!id) return; | |
186 | + fetchSetDetail(id).catch((error) => { | |
187 | + if (detailContainer) detailContainer.textContent = error.message || "Failed to load detail"; | |
188 | + }); | |
189 | + }); | |
190 | + | |
191 | + renderList(initialSets || []); | |
192 | +</script> | |
0 | 193 | |
▼tests/integration/pr-api-changes-route.test.ts
+128-0
| @@ -0,0 +1,128 @@ | ||
1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
2 | + | |
3 | +const { | |
4 | + canReadRepoMock, | |
5 | + canWriteRepoMock, | |
6 | + compareBranchesMock, | |
7 | + detectAPIChangesForPullRequestMock, | |
8 | + fakeSchema, | |
9 | +} = vi.hoisted(() => ({ | |
10 | + canReadRepoMock: vi.fn(async () => true), | |
11 | + canWriteRepoMock: vi.fn(async () => true), | |
12 | + compareBranchesMock: vi.fn(async () => ({ diffs: [{ file: "openapi.yaml" }] })), | |
13 | + detectAPIChangesForPullRequestMock: vi.fn(async () => [ | |
14 | + { | |
15 | + type: "removed", | |
16 | + path: "/v1/users", | |
17 | + method: "GET", | |
18 | + breaking: true, | |
19 | + details: "Endpoint removed", | |
20 | + sourceFile: "openapi.yaml", | |
21 | + }, | |
22 | + ]), | |
23 | + fakeSchema: { | |
24 | + users: { username: {} }, | |
25 | + repositories: { ownerId: {}, name: {}, id: {} }, | |
26 | + pullRequests: { repositoryId: {}, number: {}, id: {} }, | |
27 | + apiChangeDetections: { pullRequestId: {} }, | |
28 | + } as any, | |
29 | +})); | |
30 | + | |
31 | +let mockDb: any; | |
32 | + | |
33 | +vi.mock("@/db", () => ({ | |
34 | + getDatabase: () => mockDb, | |
35 | + schema: fakeSchema, | |
36 | +})); | |
37 | + | |
38 | +vi.mock("@/lib/permissions", () => ({ | |
39 | + canReadRepo: canReadRepoMock, | |
40 | + canWriteRepo: canWriteRepoMock, | |
41 | +})); | |
42 | + | |
43 | +vi.mock("@/lib/git", () => ({ | |
44 | + compareBranches: compareBranchesMock, | |
45 | +})); | |
46 | + | |
47 | +vi.mock("@/lib/git-storage", () => ({ | |
48 | + resolveRepoPath: vi.fn(async () => "/tmp/repo"), | |
49 | +})); | |
50 | + | |
51 | +vi.mock("@/lib/dependency-awareness", () => ({ | |
52 | + detectAPIChangesForPullRequest: detectAPIChangesForPullRequestMock, | |
53 | +})); | |
54 | + | |
55 | +import { GET as apiChangesGet, POST as apiChangesPost } from "@/pages/api/repos/[owner]/[repo]/pulls/[number]/api-changes"; | |
56 | + | |
57 | +function makeDb() { | |
58 | + return { | |
59 | + query: { | |
60 | + users: { | |
61 | + findFirst: vi.fn(async () => ({ id: "owner-1" })), | |
62 | + }, | |
63 | + repositories: { | |
64 | + findFirst: vi.fn(async () => ({ id: "repo-1", ownerId: "owner-1", name: "demo", diskPath: "/tmp/repo" })), | |
65 | + }, | |
66 | + pullRequests: { | |
67 | + findFirst: vi.fn(async () => ({ | |
68 | + id: "pr-1", | |
69 | + repositoryId: "repo-1", | |
70 | + number: 42, | |
71 | + baseBranch: "main", | |
72 | + headBranch: "feature", | |
73 | + })), | |
74 | + }, | |
75 | + apiChangeDetections: { | |
76 | + findMany: vi.fn(async () => ([ | |
77 | + { | |
78 | + id: "api-1", | |
79 | + pullRequestId: "pr-1", | |
80 | + changeType: "removed", | |
81 | + path: "/v1/users", | |
82 | + method: "GET", | |
83 | + breaking: true, | |
84 | + details: "Endpoint removed", | |
85 | + affectedFiles: ["openapi.yaml"], | |
86 | + }, | |
87 | + ])), | |
88 | + }, | |
89 | + }, | |
90 | + }; | |
91 | +} | |
92 | + | |
93 | +async function json(response: Response): Promise<any> { | |
94 | + return response.json(); | |
95 | +} | |
96 | + | |
97 | +describe("PR API changes route", () => { | |
98 | + beforeEach(() => { | |
99 | + vi.clearAllMocks(); | |
100 | + mockDb = makeDb(); | |
101 | + }); | |
102 | + | |
103 | + it("returns persisted API change detections", async () => { | |
104 | + const response = await apiChangesGet({ | |
105 | + params: { owner: "owner-1", repo: "demo", number: "42" }, | |
106 | + locals: { user: { id: "user-1", isAdmin: false } }, | |
107 | + } as any); | |
108 | + | |
109 | + const body = await json(response); | |
110 | + expect(response.status).toBe(200); | |
111 | + expect(body?.data?.total).toBe(1); | |
112 | + expect(body?.data?.breaking).toBe(1); | |
113 | + expect(body?.data?.changes?.[0]?.path).toBe("/v1/users"); | |
114 | + }); | |
115 | + | |
116 | + it("runs API change scan for PR", async () => { | |
117 | + const response = await apiChangesPost({ | |
118 | + params: { owner: "owner-1", repo: "demo", number: "42" }, | |
119 | + locals: { user: { id: "user-1", isAdmin: false } }, | |
120 | + } as any); | |
121 | + | |
122 | + const body = await json(response); | |
123 | + expect(response.status).toBe(200); | |
124 | + expect(body?.data?.total).toBe(1); | |
125 | + expect(body?.data?.breaking).toBe(1); | |
126 | + expect(detectAPIChangesForPullRequestMock).toHaveBeenCalledWith("pr-1", ["openapi.yaml"]); | |
127 | + }); | |
128 | +}); | |
0 | 129 | |
▼tests/integration/pr-impact-path-permissions.test.ts
+6-1
| @@ -8,6 +8,7 @@ const { | ||
8 | 8 | analyzeImpactMock, |
9 | 9 | detectBreakingChangesMock, |
10 | 10 | detectMigrationsMock, |
11 | + detectAPIChangesForPullRequestMock, | |
11 | 12 | detectIaCFilesMock, |
12 | 13 | triggerIaCHooksMock, |
13 | 14 | fakeSchema, |
| @@ -16,9 +17,10 @@ const { | ||
16 | 17 | canWriteRepoMock: vi.fn(async () => true), |
17 | 18 | checkPathPermissionsMock: vi.fn(async () => ({ allowed: true, deniedPaths: [] })), |
18 | 19 | compareBranchesMock: vi.fn(async () => ({ diffs: [{ file: "src/app.ts" }] })), |
19 | - analyzeImpactMock: vi.fn(async () => ({ breakingChanges: [], migrations: [] })), | |
20 | + analyzeImpactMock: vi.fn(async () => ({ breakingChanges: [], migrations: [], apiChanges: [] })), | |
20 | 21 | detectBreakingChangesMock: vi.fn(async () => []), |
21 | 22 | detectMigrationsMock: vi.fn(async () => []), |
23 | + detectAPIChangesForPullRequestMock: vi.fn(async () => []), | |
22 | 24 | detectIaCFilesMock: vi.fn(() => []), |
23 | 25 | triggerIaCHooksMock: vi.fn(async () => []), |
24 | 26 | fakeSchema: { |
| @@ -56,6 +58,7 @@ vi.mock("@/lib/dependency-awareness", () => ({ | ||
56 | 58 | analyzeImpact: analyzeImpactMock, |
57 | 59 | detectBreakingChanges: detectBreakingChangesMock, |
58 | 60 | detectMigrations: detectMigrationsMock, |
61 | + detectAPIChangesForPullRequest: detectAPIChangesForPullRequestMock, | |
59 | 62 | })); |
60 | 63 | |
61 | 64 | vi.mock("@/lib/iac-hooks", () => ({ |
| @@ -105,7 +108,9 @@ describe("PR impact path permissions", () => { | ||
105 | 108 | migrations: [ |
106 | 109 | { id: "mg-1", files: ["db/migrations/001.sql", "secure/secret.sql"] }, |
107 | 110 | ], |
111 | + apiChanges: [], | |
108 | 112 | affectedRepos: [], |
113 | + affectedChangeSets: [], | |
109 | 114 | riskScore: 25, |
110 | 115 | }); |
111 | 116 | checkPathPermissionsMock.mockResolvedValue({ |
112 | 117 | |