Complete cross-PR dependency workflow with stack apply

swadhinbiswas committed 8 months ago
22a7171
1 parent 1473a70
commit 22a7171
4 files changed
+433 -23
▼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 | ⚠️ | 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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