Complete cross-PR dependency workflow with stack apply
swadhinbiswas committed 8 months ago
22a7171
1 parent 1473a70
commit 22a7171
4 files changed
+433 -23
Showing 4 changed files+433-23
▼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 | ⚠️ | Automatic branch/file dependency graph API (`pulls/dependencies`) plus stack-order suggestion API (`pulls/stack-order`) implemented; UI-driven workflows remain partial | | |
48 | +| Cross-PR dependency detection | ✅ | Automatic branch/file dependency graph API (`pulls/dependencies`) plus stack-order suggestion/apply API (`POST/PUT pulls/stack-order`) are implemented, and PR list UI now provides dependency analysis, selection-based order suggestion, and one-click stack creation workflow for detected dependency sets | | |
49 | 49 | | Stack rebase & auto-update | ⚠️ | Manual rebase + auto-update API endpoints now implemented (`stacks/[stackId]/rebase`, `stacks/[stackId]/auto-update`); workflow automation/UI depth remains partial | |
50 | 50 | | Stack-level approvals | ⚠️ | Stack approvals API now returns readiness + blocker context and richer per-stack approval summaries (pending counts/missing approvals/requested reviewers/missing required reviewer approvals), with dry-run reviewer eligibility previews; higher-level UI orchestration still partial | |
51 | 51 | | Bulk merge (merge stacks) | ⚠️ | Explicit bulk merge queue APIs now exist for stacks (`stacks/[stackId]/merge`) and selected PRs (`pulls/bulk-merge`); higher-level stack orchestration UX remains partial | |
52 | 52 | |
▼src/components/pulls/PullRequestsList.tsx
+210-2
| @@ -11,9 +11,10 @@ import { | ||
11 | 11 | User, |
12 | 12 | ArrowUpDown, |
13 | 13 | Plus, |
14 | - Sparkles, | |
15 | 14 | GitBranch, |
16 | - CheckCircle2 | |
15 | + CheckCircle2, | |
16 | + Link2, | |
17 | + Loader2 | |
17 | 18 | } from "lucide-react"; |
18 | 19 | import { useState } from "react"; |
19 | 20 | |
| @@ -45,6 +46,27 @@ interface Props { | ||
45 | 46 | repoName: string; |
46 | 47 | } |
47 | 48 | |
49 | +interface DependencyGraphResponse { | |
50 | + nodes: Array<{ | |
51 | + prId: string; | |
52 | + prNumber: number; | |
53 | + title: string; | |
54 | + dependsOn: string[]; | |
55 | + blockedBy: string[]; | |
56 | + dependencyType: "branch" | "files" | "manual"; | |
57 | + }>; | |
58 | + edges: Array<{ | |
59 | + from: string; | |
60 | + to: string; | |
61 | + type: string; | |
62 | + }>; | |
63 | +} | |
64 | + | |
65 | +interface StackOrderSuggestion { | |
66 | + order: string[]; | |
67 | + cycles: string[][]; | |
68 | +} | |
69 | + | |
48 | 70 | function timeAgo(date: string): string { |
49 | 71 | const now = new Date(); |
50 | 72 | const past = new Date(date); |
| @@ -83,6 +105,15 @@ function getPRIcon(state: string, isDraft?: boolean) { | ||
83 | 105 | export default function PullRequestsList({ pullRequests, openCount, closedCount, repoOwner, repoName }: Props) { |
84 | 106 | const [filter, setFilter] = useState<"open" | "closed" | "all">("open"); |
85 | 107 | const [searchQuery, setSearchQuery] = useState(""); |
108 | + const [selectedPrIds, setSelectedPrIds] = useState<string[]>([]); | |
109 | + const [graph, setGraph] = useState<DependencyGraphResponse | null>(null); | |
110 | + const [suggestion, setSuggestion] = useState<StackOrderSuggestion | null>(null); | |
111 | + const [workflowMsg, setWorkflowMsg] = useState<string>(""); | |
112 | + const [isAnalyzing, setIsAnalyzing] = useState(false); | |
113 | + const [isSuggesting, setIsSuggesting] = useState(false); | |
114 | + const [isApplying, setIsApplying] = useState(false); | |
115 | + | |
116 | + const prById = new Map(pullRequests.map((pr) => [pr.id, pr])); | |
86 | 117 | |
87 | 118 | const filteredPRs = pullRequests.filter(pr => { |
88 | 119 | const matchesFilter = filter === "all" || |
| @@ -92,6 +123,104 @@ export default function PullRequestsList({ pullRequests, openCount, closedCount, | ||
92 | 123 | return matchesFilter && matchesSearch; |
93 | 124 | }); |
94 | 125 | |
126 | + const toggleSelectPr = (prId: string) => { | |
127 | + setSelectedPrIds((prev) => | |
128 | + prev.includes(prId) ? prev.filter((id) => id !== prId) : [...prev, prId] | |
129 | + ); | |
130 | + }; | |
131 | + | |
132 | + const selectAllVisibleOpen = () => { | |
133 | + const ids = filteredPRs.filter((pr) => pr.state === "open").map((pr) => pr.id); | |
134 | + setSelectedPrIds(ids); | |
135 | + }; | |
136 | + | |
137 | + const analyzeDependencies = async () => { | |
138 | + setIsAnalyzing(true); | |
139 | + setWorkflowMsg(""); | |
140 | + try { | |
141 | + const response = await fetch( | |
142 | + `/api/repos/${repoOwner}/${repoName}/pulls/dependencies?includeFiles=true` | |
143 | + ); | |
144 | + const payload = await response.json(); | |
145 | + if (!response.ok) { | |
146 | + throw new Error(payload?.error?.message || "Failed to analyze dependencies"); | |
147 | + } | |
148 | + setGraph(payload?.data?.graph || null); | |
149 | + const nodes = payload?.data?.graph?.nodes?.length || 0; | |
150 | + const edges = payload?.data?.graph?.edges?.length || 0; | |
151 | + setWorkflowMsg(`Dependency graph updated (${nodes} PRs, ${edges} edges).`); | |
152 | + } catch (error: any) { | |
153 | + setWorkflowMsg(error?.message || "Failed to analyze dependencies"); | |
154 | + } finally { | |
155 | + setIsAnalyzing(false); | |
156 | + } | |
157 | + }; | |
158 | + | |
159 | + const suggestStackOrder = async () => { | |
160 | + if (selectedPrIds.length < 2) { | |
161 | + setWorkflowMsg("Select at least 2 open PRs to generate a stack suggestion."); | |
162 | + return; | |
163 | + } | |
164 | + | |
165 | + setIsSuggesting(true); | |
166 | + setWorkflowMsg(""); | |
167 | + try { | |
168 | + const response = await fetch(`/api/repos/${repoOwner}/${repoName}/pulls/stack-order`, { | |
169 | + method: "POST", | |
170 | + headers: { "content-type": "application/json" }, | |
171 | + body: JSON.stringify({ prIds: selectedPrIds }), | |
172 | + }); | |
173 | + const payload = await response.json(); | |
174 | + if (!response.ok) { | |
175 | + throw new Error(payload?.error?.message || "Failed to suggest stack order"); | |
176 | + } | |
177 | + const data = payload?.data as StackOrderSuggestion; | |
178 | + setSuggestion(data); | |
179 | + if (data.cycles.length > 0) { | |
180 | + setWorkflowMsg("Cycles detected. Resolve dependencies before applying a stack order."); | |
181 | + } else { | |
182 | + setWorkflowMsg(`Suggested order generated for ${data.order.length} PRs.`); | |
183 | + } | |
184 | + } catch (error: any) { | |
185 | + setWorkflowMsg(error?.message || "Failed to suggest stack order"); | |
186 | + } finally { | |
187 | + setIsSuggesting(false); | |
188 | + } | |
189 | + }; | |
190 | + | |
191 | + const applySuggestedOrder = async () => { | |
192 | + if (!suggestion || suggestion.order.length < 2) { | |
193 | + setWorkflowMsg("Generate a valid suggestion before applying."); | |
194 | + return; | |
195 | + } | |
196 | + if (suggestion.cycles.length > 0) { | |
197 | + setWorkflowMsg("Cannot apply stack order while dependency cycles exist."); | |
198 | + return; | |
199 | + } | |
200 | + | |
201 | + setIsApplying(true); | |
202 | + setWorkflowMsg(""); | |
203 | + try { | |
204 | + const response = await fetch(`/api/repos/${repoOwner}/${repoName}/pulls/stack-order`, { | |
205 | + method: "PUT", | |
206 | + headers: { "content-type": "application/json" }, | |
207 | + body: JSON.stringify({ | |
208 | + prIds: suggestion.order, | |
209 | + name: `Dependency stack (${suggestion.order.length} PRs)`, | |
210 | + }), | |
211 | + }); | |
212 | + const payload = await response.json(); | |
213 | + if (!response.ok) { | |
214 | + throw new Error(payload?.error?.message || "Failed to apply stack order"); | |
215 | + } | |
216 | + setWorkflowMsg(`Created stack ${payload?.data?.stackId}.`); | |
217 | + } catch (error: any) { | |
218 | + setWorkflowMsg(error?.message || "Failed to apply stack order"); | |
219 | + } finally { | |
220 | + setIsApplying(false); | |
221 | + } | |
222 | + }; | |
223 | + | |
95 | 224 | return ( |
96 | 225 | <div className="space-y-6"> |
97 | 226 | {/* Enhanced Header with Glow */} |
| @@ -154,6 +283,70 @@ export default function PullRequestsList({ pullRequests, openCount, closedCount, | ||
154 | 283 | transition={{ delay: 0.1 }} |
155 | 284 | className="relative" |
156 | 285 | > |
286 | + <div className="mb-4 rounded-xl border border-white/10 bg-white/[0.03] p-4"> | |
287 | + <div className="flex flex-wrap items-center justify-between gap-3"> | |
288 | + <div> | |
289 | + <p className="text-sm font-medium text-white">Dependency Workflow</p> | |
290 | + <p className="text-xs text-gray-400"> | |
291 | + Detect cross-PR dependencies, suggest stack order, and apply it directly. | |
292 | + </p> | |
293 | + </div> | |
294 | + <div className="flex flex-wrap gap-2"> | |
295 | + <button | |
296 | + type="button" | |
297 | + onClick={analyzeDependencies} | |
298 | + disabled={isAnalyzing} | |
299 | + className="inline-flex items-center gap-1 rounded-lg border border-white/15 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-200 hover:bg-white/[0.08] disabled:opacity-60" | |
300 | + > | |
301 | + {isAnalyzing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link2 className="h-3.5 w-3.5" />} | |
302 | + Analyze Dependencies | |
303 | + </button> | |
304 | + <button | |
305 | + type="button" | |
306 | + onClick={selectAllVisibleOpen} | |
307 | + className="inline-flex items-center gap-1 rounded-lg border border-white/15 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-200 hover:bg-white/[0.08]" | |
308 | + > | |
309 | + Select Visible Open | |
310 | + </button> | |
311 | + <button | |
312 | + type="button" | |
313 | + onClick={suggestStackOrder} | |
314 | + disabled={isSuggesting} | |
315 | + className="inline-flex items-center gap-1 rounded-lg border border-white/15 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-200 hover:bg-white/[0.08] disabled:opacity-60" | |
316 | + > | |
317 | + {isSuggesting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <GitBranch className="h-3.5 w-3.5" />} | |
318 | + Suggest Order ({selectedPrIds.length}) | |
319 | + </button> | |
320 | + <button | |
321 | + type="button" | |
322 | + onClick={applySuggestedOrder} | |
323 | + disabled={isApplying || !suggestion || suggestion.cycles.length > 0} | |
324 | + className="inline-flex items-center gap-1 rounded-lg bg-gradient-to-r from-blue-600 to-cyan-600 px-3 py-1.5 text-xs font-medium text-white disabled:opacity-60" | |
325 | + > | |
326 | + {isApplying ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />} | |
327 | + Apply As Stack | |
328 | + </button> | |
329 | + </div> | |
330 | + </div> | |
331 | + {graph && ( | |
332 | + <div className="mt-3 text-xs text-gray-400"> | |
333 | + Graph: {graph.nodes.length} PRs, {graph.edges.length} edges ( | |
334 | + {graph.edges.filter((edge) => edge.type === "files").length} file conflicts) | |
335 | + </div> | |
336 | + )} | |
337 | + {suggestion && ( | |
338 | + <div className="mt-2 text-xs text-gray-300"> | |
339 | + Suggested:{" "} | |
340 | + {suggestion.order.map((id) => { | |
341 | + const pr = prById.get(id); | |
342 | + return pr ? `#${pr.number}` : id; | |
343 | + }).join(" -> ")} | |
344 | + </div> | |
345 | + )} | |
346 | + {workflowMsg && ( | |
347 | + <div className="mt-2 text-xs text-gray-400">{workflowMsg}</div> | |
348 | + )} | |
349 | + </div> | |
157 | 350 | {/* Gradient border effect */} |
158 | 351 | <div className="absolute -inset-[1px] bg-gradient-to-r from-green-500/30 via-purple-500/20 to-pink-500/30 rounded-xl blur-sm opacity-50" /> |
159 | 352 | |
| @@ -215,6 +408,21 @@ export default function PullRequestsList({ pullRequests, openCount, closedCount, | ||
215 | 408 | transition={{ delay: index * 0.03 }} |
216 | 409 | className="flex items-start gap-3 p-4 hover:bg-white/[0.02] transition-colors group" |
217 | 410 | > |
411 | + {pr.state === "open" && ( | |
412 | + <button | |
413 | + type="button" | |
414 | + aria-label={`Select PR #${pr.number}`} | |
415 | + onClick={(event) => { | |
416 | + event.preventDefault(); | |
417 | + event.stopPropagation(); | |
418 | + toggleSelectPr(pr.id); | |
419 | + }} | |
420 | + className={`mt-1 h-4 w-4 rounded border ${selectedPrIds.includes(pr.id) | |
421 | + ? "border-green-400 bg-green-500/20" | |
422 | + : "border-white/20 bg-transparent" | |
423 | + }`} | |
424 | + /> | |
425 | + )} | |
218 | 426 | {/* Status Icon */} |
219 | 427 | <div className="mt-0.5"> |
220 | 428 | <div className={`relative p-1 rounded-md ${bg}`}> |
221 | 429 | |
▼src/pages/api/repos/[owner]/[repo]/pulls/stack-order.ts
+142-15
| @@ -5,13 +5,32 @@ import { z } from "zod"; | ||
5 | 5 | import { getDatabase, schema } from "@/db"; |
6 | 6 | import { withErrorHandler } from "@/lib/errors"; |
7 | 7 | import { badRequest, notFound, success, unauthorized } from "@/lib/api"; |
8 | -import { canReadRepo } from "@/lib/permissions"; | |
8 | +import { canReadRepo, canWriteRepo } from "@/lib/permissions"; | |
9 | 9 | import { suggestStackOrder } from "@/lib/pr-dependencies"; |
10 | +import { createStack } from "@/lib/stacks"; | |
11 | +import { generateId } from "@/lib/utils"; | |
10 | 12 | |
11 | 13 | const stackOrderSchema = z.object({ |
12 | 14 | prIds: z.array(z.string().min(1)).min(2).max(100), |
13 | 15 | }); |
14 | 16 | |
17 | +async function resolveRepo(ownerName: string, repoName: string) { | |
18 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
19 | + const owner = await db.query.users.findFirst({ | |
20 | + where: eq(schema.users.username, ownerName), | |
21 | + }); | |
22 | + if (!owner) return null; | |
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 null; | |
31 | + return { owner, repo }; | |
32 | +} | |
33 | + | |
15 | 34 | export const POST: APIRoute = withErrorHandler(async ({ params, locals, request }) => { |
16 | 35 | const user = locals.user; |
17 | 36 | const { owner: ownerName, repo: repoName } = params; |
| @@ -25,19 +44,9 @@ export const POST: APIRoute = withErrorHandler(async ({ params, locals, request | ||
25 | 44 | } |
26 | 45 | |
27 | 46 | const db = getDatabase() as NodePgDatabase<typeof schema>; |
28 | - const owner = await db.query.users.findFirst({ | |
29 | - where: eq(schema.users.username, ownerName), | |
30 | - }); | |
31 | - if (!owner) return notFound("Repository not found"); | |
32 | - | |
33 | - const repo = await db.query.repositories.findFirst({ | |
34 | - where: and( | |
35 | - eq(schema.repositories.ownerId, owner.id), | |
36 | - eq(schema.repositories.name, repoName) | |
37 | - ), | |
38 | - }); | |
39 | - if (!repo) return notFound("Repository not found"); | |
40 | - if (!(await canReadRepo(user.id, repo))) return notFound("Repository not found"); | |
47 | + const resolved = await resolveRepo(ownerName, repoName); | |
48 | + if (!resolved) return notFound("Repository not found"); | |
49 | + if (!(await canReadRepo(user.id, resolved.repo))) return notFound("Repository not found"); | |
41 | 50 | |
42 | 51 | const prs = await db.query.pullRequests.findMany({ |
43 | 52 | where: inArray(schema.pullRequests.id, parsed.data.prIds), |
| @@ -46,7 +55,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, locals, request | ||
46 | 55 | if (prs.length !== parsed.data.prIds.length) { |
47 | 56 | return badRequest("One or more PR IDs were not found"); |
48 | 57 | } |
49 | - if (prs.some((pr) => pr.repositoryId !== repo.id)) { | |
58 | + if (prs.some((pr) => pr.repositoryId !== resolved.repo.id)) { | |
50 | 59 | return badRequest("All PR IDs must belong to the target repository"); |
51 | 60 | } |
52 | 61 | |
| @@ -54,3 +63,121 @@ export const POST: APIRoute = withErrorHandler(async ({ params, locals, request | ||
54 | 63 | return success(suggestion); |
55 | 64 | }); |
56 | 65 | |
66 | +const applyStackOrderSchema = z.object({ | |
67 | + prIds: z.array(z.string().min(1)).min(2).max(100), | |
68 | + name: z.string().max(200).optional(), | |
69 | +}); | |
70 | + | |
71 | +export const PUT: APIRoute = withErrorHandler(async ({ params, locals, request }) => { | |
72 | + const user = locals.user; | |
73 | + const { owner: ownerName, repo: repoName } = params; | |
74 | + | |
75 | + if (!user) return unauthorized(); | |
76 | + if (!ownerName || !repoName) return badRequest("Missing parameters"); | |
77 | + | |
78 | + const parsed = applyStackOrderSchema.safeParse(await request.json().catch(() => null)); | |
79 | + if (!parsed.success) { | |
80 | + return badRequest(parsed.error.issues[0]?.message || "Invalid stack apply payload"); | |
81 | + } | |
82 | + | |
83 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
84 | + const resolved = await resolveRepo(ownerName, repoName); | |
85 | + if (!resolved) return notFound("Repository not found"); | |
86 | + if (!(await canWriteRepo(user.id, resolved.repo))) return notFound("Repository not found"); | |
87 | + | |
88 | + const prs = await db.query.pullRequests.findMany({ | |
89 | + where: inArray(schema.pullRequests.id, parsed.data.prIds), | |
90 | + columns: { | |
91 | + id: true, | |
92 | + number: true, | |
93 | + title: true, | |
94 | + state: true, | |
95 | + repositoryId: true, | |
96 | + baseBranch: true, | |
97 | + }, | |
98 | + }); | |
99 | + if (prs.length !== parsed.data.prIds.length) { | |
100 | + return badRequest("One or more PR IDs were not found"); | |
101 | + } | |
102 | + if (prs.some((pr) => pr.repositoryId !== resolved.repo.id)) { | |
103 | + return badRequest("All PR IDs must belong to the target repository"); | |
104 | + } | |
105 | + if (prs.some((pr) => pr.state !== "open")) { | |
106 | + return badRequest("Only open pull requests can be applied to a stack"); | |
107 | + } | |
108 | + | |
109 | + const suggestion = await suggestStackOrder(parsed.data.prIds); | |
110 | + if (suggestion.cycles.length > 0) { | |
111 | + return badRequest("Cannot apply stack order when dependency cycles are detected"); | |
112 | + } | |
113 | + | |
114 | + const orderedPrIds = suggestion.order; | |
115 | + if (orderedPrIds.length < 2) { | |
116 | + return badRequest("At least two PRs are required for stack creation"); | |
117 | + } | |
118 | + | |
119 | + const byId = new Map(prs.map((pr) => [pr.id, pr])); | |
120 | + const orderedPrs = orderedPrIds.map((id) => byId.get(id)).filter(Boolean); | |
121 | + if (orderedPrs.length !== orderedPrIds.length) { | |
122 | + return badRequest("Suggested order contains unknown pull requests"); | |
123 | + } | |
124 | + | |
125 | + const existingEntries = await db.query.prStackEntries.findMany({ | |
126 | + where: inArray(schema.prStackEntries.pullRequestId, orderedPrIds), | |
127 | + columns: { id: true, stackId: true, pullRequestId: true }, | |
128 | + }); | |
129 | + | |
130 | + if (existingEntries.length > 0) { | |
131 | + const existingStackIds = [...new Set(existingEntries.map((entry) => entry.stackId))]; | |
132 | + const repoStacks = await db.query.prStacks.findMany({ | |
133 | + where: and( | |
134 | + eq(schema.prStacks.repositoryId, resolved.repo.id), | |
135 | + inArray(schema.prStacks.id, existingStackIds) | |
136 | + ), | |
137 | + columns: { id: true }, | |
138 | + }); | |
139 | + const repoStackIds = new Set(repoStacks.map((stack) => stack.id)); | |
140 | + const entryIdsToDelete = existingEntries | |
141 | + .filter((entry) => repoStackIds.has(entry.stackId)) | |
142 | + .map((entry) => entry.id); | |
143 | + | |
144 | + if (entryIdsToDelete.length > 0) { | |
145 | + await db | |
146 | + .delete(schema.prStackEntries) | |
147 | + .where(inArray(schema.prStackEntries.id, entryIdsToDelete)); | |
148 | + } | |
149 | + } | |
150 | + | |
151 | + const baseBranch = orderedPrs[0]!.baseBranch; | |
152 | + const stack = await createStack({ | |
153 | + repositoryId: resolved.repo.id, | |
154 | + baseBranch, | |
155 | + name: parsed.data.name || `Dependency stack (${orderedPrs.length} PRs)`, | |
156 | + createdById: user.id, | |
157 | + }); | |
158 | + | |
159 | + for (let index = 0; index < orderedPrIds.length; index++) { | |
160 | + const pullRequestId = orderedPrIds[index]; | |
161 | + const parentPrId = index === 0 ? null : orderedPrIds[index - 1]; | |
162 | + | |
163 | + await db.insert(schema.prStackEntries).values({ | |
164 | + id: generateId(), | |
165 | + stackId: stack.id, | |
166 | + pullRequestId, | |
167 | + stackOrder: index + 1, | |
168 | + parentPrId, | |
169 | + createdAt: new Date(), | |
170 | + }); | |
171 | + } | |
172 | + | |
173 | + return success({ | |
174 | + stackId: stack.id, | |
175 | + stackName: stack.name, | |
176 | + baseBranch: stack.baseBranch, | |
177 | + order: orderedPrs.map((pr) => ({ | |
178 | + id: pr!.id, | |
179 | + number: pr!.number, | |
180 | + title: pr!.title, | |
181 | + })), | |
182 | + }); | |
183 | +}); | |
57 | 184 | |
▼tests/integration/stack-order-route.test.ts
+80-5
| @@ -2,15 +2,26 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; | ||
2 | 2 | |
3 | 3 | const { |
4 | 4 | canReadRepoMock, |
5 | + canWriteRepoMock, | |
5 | 6 | suggestStackOrderMock, |
7 | + createStackMock, | |
6 | 8 | fakeSchema, |
7 | 9 | } = vi.hoisted(() => ({ |
8 | 10 | canReadRepoMock: vi.fn(async () => true), |
11 | + canWriteRepoMock: vi.fn(async () => true), | |
9 | 12 | suggestStackOrderMock: vi.fn(async () => ({ order: ["pr-1", "pr-2"], cycles: [] })), |
13 | + createStackMock: vi.fn(async () => ({ | |
14 | + id: "stack-1", | |
15 | + name: "Dependency stack", | |
16 | + baseBranch: "main", | |
17 | + status: "active", | |
18 | + })), | |
10 | 19 | fakeSchema: { |
11 | 20 | users: { username: {} }, |
12 | 21 | repositories: { ownerId: {}, name: {}, id: {} }, |
13 | - pullRequests: { id: {}, repositoryId: {} }, | |
22 | + pullRequests: { id: {}, repositoryId: {}, state: {} }, | |
23 | + prStackEntries: { pullRequestId: {}, id: {}, stackId: {} }, | |
24 | + prStacks: { repositoryId: {}, id: {} }, | |
14 | 25 | } as any, |
15 | 26 | })); |
16 | 27 | |
| @@ -23,13 +34,18 @@ vi.mock("@/db", () => ({ | ||
23 | 34 | |
24 | 35 | vi.mock("@/lib/permissions", () => ({ |
25 | 36 | canReadRepo: canReadRepoMock, |
37 | + canWriteRepo: canWriteRepoMock, | |
26 | 38 | })); |
27 | 39 | |
28 | 40 | vi.mock("@/lib/pr-dependencies", () => ({ |
29 | 41 | suggestStackOrder: suggestStackOrderMock, |
30 | 42 | })); |
31 | 43 | |
32 | -import { POST as stackOrderPost } from "@/pages/api/repos/[owner]/[repo]/pulls/stack-order"; | |
44 | +vi.mock("@/lib/stacks", () => ({ | |
45 | + createStack: createStackMock, | |
46 | +})); | |
47 | + | |
48 | +import { POST as stackOrderPost, PUT as stackOrderPut } from "@/pages/api/repos/[owner]/[repo]/pulls/stack-order"; | |
33 | 49 | |
34 | 50 | function makeDb() { |
35 | 51 | return { |
| @@ -42,11 +58,23 @@ function makeDb() { | ||
42 | 58 | }, |
43 | 59 | pullRequests: { |
44 | 60 | findMany: vi.fn(async () => [ |
45 | - { id: "pr-1", repositoryId: "repo-1" }, | |
46 | - { id: "pr-2", repositoryId: "repo-1" }, | |
61 | + { id: "pr-1", repositoryId: "repo-1", number: 1, title: "PR 1", state: "open", baseBranch: "main" }, | |
62 | + { id: "pr-2", repositoryId: "repo-1", number: 2, title: "PR 2", state: "open", baseBranch: "feature-1" }, | |
47 | 63 | ]), |
48 | 64 | }, |
65 | + prStackEntries: { | |
66 | + findMany: vi.fn(async () => []), | |
67 | + }, | |
68 | + prStacks: { | |
69 | + findMany: vi.fn(async () => []), | |
70 | + }, | |
49 | 71 | }, |
72 | + insert: vi.fn(() => ({ | |
73 | + values: vi.fn(async () => undefined), | |
74 | + })), | |
75 | + delete: vi.fn(() => ({ | |
76 | + where: vi.fn(async () => undefined), | |
77 | + })), | |
50 | 78 | }; |
51 | 79 | } |
52 | 80 | |
| @@ -56,9 +84,17 @@ async function readJson(response: Response): Promise<any> { | ||
56 | 84 | |
57 | 85 | describe("stack order suggestion route", () => { |
58 | 86 | beforeEach(() => { |
87 | + vi.clearAllMocks(); | |
59 | 88 | mockDb = makeDb(); |
60 | 89 | canReadRepoMock.mockResolvedValue(true); |
90 | + canWriteRepoMock.mockResolvedValue(true); | |
61 | 91 | suggestStackOrderMock.mockResolvedValue({ order: ["pr-1", "pr-2"], cycles: [] }); |
92 | + createStackMock.mockResolvedValue({ | |
93 | + id: "stack-1", | |
94 | + name: "Dependency stack", | |
95 | + baseBranch: "main", | |
96 | + status: "active", | |
97 | + }); | |
62 | 98 | }); |
63 | 99 | |
64 | 100 | it("returns suggested ordering for valid PR IDs", async () => { |
| @@ -98,5 +134,44 @@ describe("stack order suggestion route", () => { | ||
98 | 134 | expect(response.status).toBe(400); |
99 | 135 | expect(body?.error?.code).toBe("BAD_REQUEST"); |
100 | 136 | }); |
101 | -}); | |
102 | 137 | |
138 | + it("applies suggested stack order and creates stack entries", async () => { | |
139 | + const response = await stackOrderPut({ | |
140 | + params: { owner: "owner-1", repo: "demo" }, | |
141 | + locals: { user: { id: "user-1" } }, | |
142 | + request: new Request("http://localhost/api/repos/owner-1/demo/pulls/stack-order", { | |
143 | + method: "PUT", | |
144 | + headers: { "content-type": "application/json" }, | |
145 | + body: JSON.stringify({ prIds: ["pr-1", "pr-2"], name: "API cleanup stack" }), | |
146 | + }), | |
147 | + } as any); | |
148 | + | |
149 | + const body = await readJson(response); | |
150 | + expect(response.status).toBe(200); | |
151 | + expect(createStackMock).toHaveBeenCalledWith({ | |
152 | + repositoryId: "repo-1", | |
153 | + baseBranch: "main", | |
154 | + name: "API cleanup stack", | |
155 | + createdById: "user-1", | |
156 | + }); | |
157 | + expect(body?.data?.stackId).toBe("stack-1"); | |
158 | + expect(body?.data?.order).toHaveLength(2); | |
159 | + }); | |
160 | + | |
161 | + it("rejects apply when dependency cycles are detected", async () => { | |
162 | + suggestStackOrderMock.mockResolvedValueOnce({ order: ["pr-1", "pr-2"], cycles: [["pr-1", "pr-2"]] }); | |
163 | + | |
164 | + const response = await stackOrderPut({ | |
165 | + params: { owner: "owner-1", repo: "demo" }, | |
166 | + locals: { user: { id: "user-1" } }, | |
167 | + request: new Request("http://localhost/api/repos/owner-1/demo/pulls/stack-order", { | |
168 | + method: "PUT", | |
169 | + headers: { "content-type": "application/json" }, | |
170 | + body: JSON.stringify({ prIds: ["pr-1", "pr-2"] }), | |
171 | + }), | |
172 | + } as any); | |
173 | + | |
174 | + expect(response.status).toBe(400); | |
175 | + expect(createStackMock).not.toHaveBeenCalled(); | |
176 | + }); | |
177 | +}); | |
103 | 178 | |