Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

issue-dependencies.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

issue-dependencies.tsBlame233 lines · 1 contributor
bed6b57Claude1/**
2 * Block J14 — Issue dependencies (blocked-by / blocks relationships).
3 *
4 * A dependency is "blocker blocks blocked" — the blocked issue cannot
5 * reasonably be worked on until the blocker closes. We enforce:
6 *
7 * - same-repo pairing (application level; DB only knows issues)
8 * - no self-dependencies (DB CHECK constraint)
9 * - no direct back-and-forth cycles (we reject if the reverse edge exists)
10 *
11 * Cycle detection is kept pure + breadth-first over a dependency graph so
12 * unit tests can drive it without touching the DB.
13 */
14
15import { and, eq, inArray } from "drizzle-orm";
16import { db } from "../db";
17import { issueDependencies, issues, users } from "../db/schema";
18
19export interface DepEdge {
20 blockerIssueId: string;
21 blockedIssueId: string;
22}
23
24/** Pure BFS: would adding (blocker → blocked) introduce a cycle? */
25export function wouldCreateCycle(
26 edges: DepEdge[],
27 blocker: string,
28 blocked: string
29): boolean {
30 if (blocker === blocked) return true;
31 // Each edge is a directed "blocker → blocked" relation. Adding
32 // (blocker → blocked) creates a cycle iff there is already a path from
33 // `blocked` to `blocker` that follows existing blocks edges forward.
34 // So we build "blockerIssueId → [blockedIssueId]" adjacency and BFS
35 // from `blocked` looking for `blocker`.
36 const adj = new Map<string, string[]>();
37 for (const e of edges) {
38 const list = adj.get(e.blockerIssueId) || [];
39 list.push(e.blockedIssueId);
40 adj.set(e.blockerIssueId, list);
41 }
42 const seen = new Set<string>([blocked]);
43 const queue: string[] = [blocked];
44 while (queue.length) {
45 const cur = queue.shift()!;
46 if (cur === blocker) return true;
47 const nexts = adj.get(cur) || [];
48 for (const n of nexts) {
49 if (!seen.has(n)) {
50 seen.add(n);
51 queue.push(n);
52 }
53 }
54 }
55 return false;
56}
57
58/** Pure: compute counts {open, closed} of blockers for each blocked issue. */
59export function summariseBlockers(
60 blockers: Array<{ blockerIssueId: string; blockerState: string }>
61): { open: number; closed: number; total: number } {
62 let open = 0;
63 let closed = 0;
64 for (const b of blockers) {
65 if (b.blockerState === "open") open++;
66 else closed++;
67 }
68 return { open, closed, total: open + closed };
69}
70
71/**
72 * Add a blocker→blocked dependency. Returns `{ ok, reason? }`. Rejects:
73 * - self-reference
74 * - cross-repo pairs
75 * - duplicate (already exists)
76 * - would introduce a cycle
77 */
78export async function addDependency(opts: {
79 blockerIssueId: string;
80 blockedIssueId: string;
81 createdBy: string | null;
82}): Promise<
83 | { ok: true; id: string }
84 | { ok: false; reason: "self" | "cross_repo" | "exists" | "cycle" | "error" | "not_found" }
85> {
86 if (opts.blockerIssueId === opts.blockedIssueId) {
87 return { ok: false, reason: "self" };
88 }
89 try {
90 const rows = await db
91 .select({ id: issues.id, repositoryId: issues.repositoryId })
92 .from(issues)
93 .where(
94 inArray(issues.id, [opts.blockerIssueId, opts.blockedIssueId])
95 );
96 if (rows.length !== 2) return { ok: false, reason: "not_found" };
97 if (rows[0].repositoryId !== rows[1].repositoryId) {
98 return { ok: false, reason: "cross_repo" };
99 }
100 const repoId = rows[0].repositoryId;
101 const existing = await db
102 .select({ id: issueDependencies.id })
103 .from(issueDependencies)
104 .innerJoin(issues, eq(issues.id, issueDependencies.blockerIssueId))
105 .where(
106 and(
107 eq(issues.repositoryId, repoId),
108 eq(issueDependencies.blockerIssueId, opts.blockerIssueId),
109 eq(issueDependencies.blockedIssueId, opts.blockedIssueId)
110 )
111 )
112 .limit(1);
113 if (existing.length > 0) return { ok: false, reason: "exists" };
114 // Cycle check: pull all existing edges within this repo.
115 const repoEdges = await db
116 .select({
117 blockerIssueId: issueDependencies.blockerIssueId,
118 blockedIssueId: issueDependencies.blockedIssueId,
119 })
120 .from(issueDependencies)
121 .innerJoin(issues, eq(issues.id, issueDependencies.blockerIssueId))
122 .where(eq(issues.repositoryId, repoId));
123 if (
124 wouldCreateCycle(
125 repoEdges,
126 opts.blockerIssueId,
127 opts.blockedIssueId
128 )
129 ) {
130 return { ok: false, reason: "cycle" };
131 }
132 const [row] = await db
133 .insert(issueDependencies)
134 .values({
135 blockerIssueId: opts.blockerIssueId,
136 blockedIssueId: opts.blockedIssueId,
137 createdBy: opts.createdBy,
138 })
139 .returning({ id: issueDependencies.id });
140 return { ok: true, id: row.id };
141 } catch (err) {
142 console.error("[issue-deps] addDependency failed:", err);
143 return { ok: false, reason: "error" };
144 }
145}
146
147/** Remove a dependency by its composite key. */
148export async function removeDependency(
149 blockerIssueId: string,
150 blockedIssueId: string
151): Promise<boolean> {
152 try {
153 const res = await db
154 .delete(issueDependencies)
155 .where(
156 and(
157 eq(issueDependencies.blockerIssueId, blockerIssueId),
158 eq(issueDependencies.blockedIssueId, blockedIssueId)
159 )
160 )
161 .returning({ id: issueDependencies.id });
162 return res.length > 0;
163 } catch (err) {
164 console.error("[issue-deps] removeDependency failed:", err);
165 return false;
166 }
167}
168
169/** List what blocks a given issue (i.e. the issue's "Blocked by" section). */
170export async function listBlockersOf(issueId: string): Promise<
171 Array<{
172 id: string;
173 issueId: string;
174 number: number;
175 title: string;
176 state: string;
177 authorUsername: string;
178 }>
179> {
180 try {
181 const rows = await db
182 .select({
183 id: issueDependencies.id,
184 issueId: issues.id,
185 number: issues.number,
186 title: issues.title,
187 state: issues.state,
188 authorUsername: users.username,
189 })
190 .from(issueDependencies)
191 .innerJoin(issues, eq(issues.id, issueDependencies.blockerIssueId))
192 .innerJoin(users, eq(users.id, issues.authorId))
193 .where(eq(issueDependencies.blockedIssueId, issueId));
194 return rows;
195 } catch (err) {
196 console.error("[issue-deps] listBlockersOf failed:", err);
197 return [];
198 }
199}
200
201/** List what a given issue blocks (i.e. its "Blocks" section). */
202export async function listBlockedBy(issueId: string): Promise<
203 Array<{
204 id: string;
205 issueId: string;
206 number: number;
207 title: string;
208 state: string;
209 authorUsername: string;
210 }>
211> {
212 try {
213 const rows = await db
214 .select({
215 id: issueDependencies.id,
216 issueId: issues.id,
217 number: issues.number,
218 title: issues.title,
219 state: issues.state,
220 authorUsername: users.username,
221 })
222 .from(issueDependencies)
223 .innerJoin(issues, eq(issues.id, issueDependencies.blockedIssueId))
224 .innerJoin(users, eq(users.id, issues.authorId))
225 .where(eq(issueDependencies.blockerIssueId, issueId));
226 return rows;
227 } catch (err) {
228 console.error("[issue-deps] listBlockedBy failed:", err);
229 return [];
230 }
231}
232
233export const __internal = { wouldCreateCycle, summariseBlockers };