Add automatic PR dependency graph API and docs
swadhinbiswas committed 8 months ago
f003069
1 parent f2f9667
commit f003069
4 files changed
+214 -1
Showing 4 changed files+214-1
▼doc/feature_audit.md
+1-1
| @@ -45,7 +45,7 @@ | ||
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,6 +208,43 @@ export const openApiSpec = { | ||
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,6 +732,43 @@ export const openApiSpec = { | ||
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
| @@ -0,0 +1,45 @@ | ||
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
| @@ -0,0 +1,94 @@ | ||
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 | |