Complete dependency impact and API change awareness workflows

swadhinbiswas committed 8 months ago
92ee81e
1 parent 6cde4fb
commit 92ee81e
8 files changed
+867 -73
▼doc/feature_audit.md
+6-6
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
8
analyzeImpactMock,
9
9
detectBreakingChangesMock,
10
10
detectMigrationsMock,
11
+ detectAPIChangesForPullRequestMock,
11
12
detectIaCFilesMock,
12
13
triggerIaCHooksMock,
13
14
fakeSchema,
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
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
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