Add automatic PR dependency graph API and docs

swadhinbiswas committed 8 months ago
f003069
1 parent f2f9667
commit f003069
4 files changed
+214 -1
▼doc/feature_audit.md
+1-1
45
45
| :--- | :---: | :--- |
46
46
| Native stacked PRs | ✅ | `stacks.ts` - create, add, reorder, visualize |
47
47
| PR dependency graphs (DAG) | ✅ | `getStackVisualization()` implemented |
48
-| Cross-PR dependency detection | ⚠️ | Basic via stacks, no automatic detection |
48
+| Cross-PR dependency detection | ⚠️ | Automatic branch/file dependency graph API now implemented (`pulls/dependencies` via `pr-dependencies.ts`); UI-driven workflows remain partial |
49
49
| Stack rebase & auto-update | ⚠️ | Partial - manual rebase exists |
50
50
| Stack-level approvals | ⚠️ | Basic - individual PR approvals only |
51
51
| Bulk merge (merge stacks) | ⚠️ | Via merge queue, not explicit bulk action |
52
52
▼src/lib/openapi.ts
+74-0
208
208
},
209
209
required: ["name"],
210
210
},
211
+ PRDependencyGraphResponse: {
212
+ type: "object",
213
+ properties: {
214
+ repositoryId: { type: "string" },
215
+ includeFiles: { type: "boolean" },
216
+ graph: {
217
+ type: "object",
218
+ properties: {
219
+ nodes: {
220
+ type: "array",
221
+ items: {
222
+ type: "object",
223
+ properties: {
224
+ prId: { type: "string" },
225
+ prNumber: { type: "integer" },
226
+ title: { type: "string" },
227
+ dependsOn: { type: "array", items: { type: "string" } },
228
+ blockedBy: { type: "array", items: { type: "string" } },
229
+ dependencyType: { type: "string", enum: ["branch", "files", "manual"] },
230
+ },
231
+ },
232
+ },
233
+ edges: {
234
+ type: "array",
235
+ items: {
236
+ type: "object",
237
+ properties: {
238
+ from: { type: "string" },
239
+ to: { type: "string" },
240
+ type: { type: "string" },
241
+ },
242
+ },
243
+ },
244
+ },
245
+ },
246
+ },
247
+ },
211
248
},
212
249
},
213
250
paths: {
695
732
},
696
733
},
697
734
},
735
+ "/repos/{owner}/{repo}/pulls/dependencies": {
736
+ get: {
737
+ tags: ["Pull Requests"],
738
+ summary: "Get automatic cross-PR dependency graph",
739
+ description: "Returns branch and optional file-overlap dependency graph for open pull requests in a repository.",
740
+ security: [{ bearerAuth: [] }],
741
+ parameters: [
742
+ { name: "owner", in: "path", required: true, schema: { type: "string" } },
743
+ { name: "repo", in: "path", required: true, schema: { type: "string" } },
744
+ {
745
+ name: "includeFiles",
746
+ in: "query",
747
+ required: false,
748
+ schema: { type: "boolean", default: true },
749
+ description: "Set false to return branch-based dependencies only.",
750
+ },
751
+ ],
752
+ responses: {
753
+ 200: {
754
+ description: "Dependency graph returned",
755
+ content: {
756
+ "application/json": {
757
+ schema: {
758
+ type: "object",
759
+ properties: {
760
+ success: { type: "boolean", example: true },
761
+ data: { $ref: "#/components/schemas/PRDependencyGraphResponse" },
762
+ },
763
+ },
764
+ },
765
+ },
766
+ },
767
+ 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
768
+ 404: { description: "Repository not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
769
+ },
770
+ },
771
+ },
698
772
"/repos/{owner}/{repo}/workflow/states": {
699
773
get: {
700
774
tags: ["Workflow"],
701
775
▼src/pages/api/repos/[owner]/[repo]/pulls/dependencies.ts
+45-0
1
+import type { APIRoute } from "astro";
2
+import { and, eq } from "drizzle-orm";
3
+import type { NodePgDatabase } from "drizzle-orm/node-postgres";
4
+import { getDatabase, schema } from "@/db";
5
+import { withErrorHandler } from "@/lib/errors";
6
+import { badRequest, notFound, success, unauthorized } from "@/lib/api";
7
+import { canReadRepo } from "@/lib/permissions";
8
+import { detectBranchDependencies, getDependencyGraph } from "@/lib/pr-dependencies";
9
+
10
+export const GET: APIRoute = withErrorHandler(async ({ params, locals, request }) => {
11
+ const user = locals.user;
12
+ const { owner: ownerName, repo: repoName } = params;
13
+
14
+ if (!user) return unauthorized();
15
+ if (!ownerName || !repoName) return badRequest("Missing parameters");
16
+
17
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
18
+
19
+ const owner = await db.query.users.findFirst({
20
+ where: eq(schema.users.username, ownerName),
21
+ });
22
+ if (!owner) return notFound("Repository not found");
23
+
24
+ const repo = await db.query.repositories.findFirst({
25
+ where: and(
26
+ eq(schema.repositories.ownerId, owner.id),
27
+ eq(schema.repositories.name, repoName)
28
+ ),
29
+ });
30
+ if (!repo) return notFound("Repository not found");
31
+ if (!(await canReadRepo(user.id, repo))) return notFound("Repository not found");
32
+
33
+ const url = new URL(request.url);
34
+ const includeFiles = url.searchParams.get("includeFiles") !== "false";
35
+ const graph = includeFiles
36
+ ? await getDependencyGraph(repo.id)
37
+ : await detectBranchDependencies(repo.id);
38
+
39
+ return success({
40
+ repositoryId: repo.id,
41
+ includeFiles,
42
+ graph,
43
+ });
44
+});
45
+
0
46
▼tests/integration/pr-dependencies-route.test.ts
+94-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+const {
4
+ canReadRepoMock,
5
+ getDependencyGraphMock,
6
+ detectBranchDependenciesMock,
7
+ fakeSchema,
8
+} = vi.hoisted(() => ({
9
+ canReadRepoMock: vi.fn(async () => true),
10
+ getDependencyGraphMock: vi.fn(async () => ({
11
+ nodes: [{ prId: "pr-2", prNumber: 2, title: "Feature", dependsOn: ["pr-1"], blockedBy: [], dependencyType: "branch" }],
12
+ edges: [{ from: "pr-2", to: "pr-1", type: "branch" }],
13
+ })),
14
+ detectBranchDependenciesMock: vi.fn(async () => ({
15
+ nodes: [{ prId: "pr-2", prNumber: 2, title: "Feature", dependsOn: ["pr-1"], blockedBy: [], dependencyType: "branch" }],
16
+ edges: [{ from: "pr-2", to: "pr-1", type: "branch" }],
17
+ })),
18
+ fakeSchema: {
19
+ users: { username: {} },
20
+ repositories: { ownerId: {}, name: {}, id: {} },
21
+ } as any,
22
+}));
23
+
24
+let mockDb: any;
25
+
26
+vi.mock("@/db", () => ({
27
+ getDatabase: () => mockDb,
28
+ schema: fakeSchema,
29
+}));
30
+
31
+vi.mock("@/lib/permissions", () => ({
32
+ canReadRepo: canReadRepoMock,
33
+}));
34
+
35
+vi.mock("@/lib/pr-dependencies", () => ({
36
+ getDependencyGraph: getDependencyGraphMock,
37
+ detectBranchDependencies: detectBranchDependenciesMock,
38
+}));
39
+
40
+import { GET as dependencyGraphGet } from "@/pages/api/repos/[owner]/[repo]/pulls/dependencies";
41
+
42
+function makeDb() {
43
+ return {
44
+ query: {
45
+ users: {
46
+ findFirst: vi.fn(async () => ({ id: "owner-1" })),
47
+ },
48
+ repositories: {
49
+ findFirst: vi.fn(async () => ({ id: "repo-1", ownerId: "owner-1", name: "demo" })),
50
+ },
51
+ },
52
+ };
53
+}
54
+
55
+async function readJson(response: Response): Promise<any> {
56
+ return response.json();
57
+}
58
+
59
+describe("PR dependency graph route", () => {
60
+ beforeEach(() => {
61
+ mockDb = makeDb();
62
+ canReadRepoMock.mockResolvedValue(true);
63
+ getDependencyGraphMock.mockClear();
64
+ detectBranchDependenciesMock.mockClear();
65
+ });
66
+
67
+ it("returns full dependency graph by default", async () => {
68
+ const response = await dependencyGraphGet({
69
+ params: { owner: "owner-1", repo: "demo" },
70
+ locals: { user: { id: "user-1" } },
71
+ request: new Request("http://localhost/api/repos/owner-1/demo/pulls/dependencies"),
72
+ } as any);
73
+
74
+ const body = await readJson(response);
75
+ expect(response.status).toBe(200);
76
+ expect(body?.data?.includeFiles).toBe(true);
77
+ expect(getDependencyGraphMock).toHaveBeenCalledWith("repo-1");
78
+ expect(detectBranchDependenciesMock).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it("supports branch-only dependency mode", async () => {
82
+ const response = await dependencyGraphGet({
83
+ params: { owner: "owner-1", repo: "demo" },
84
+ locals: { user: { id: "user-1" } },
85
+ request: new Request("http://localhost/api/repos/owner-1/demo/pulls/dependencies?includeFiles=false"),
86
+ } as any);
87
+
88
+ const body = await readJson(response);
89
+ expect(response.status).toBe(200);
90
+ expect(body?.data?.includeFiles).toBe(false);
91
+ expect(detectBranchDependenciesMock).toHaveBeenCalledWith("repo-1");
92
+ });
93
+});
94
+
0
95