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.
| bed6b57 | 1 | /** |
| 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 | ||
| 15 | import { and, eq, inArray } from "drizzle-orm"; | |
| 16 | import { db } from "../db"; | |
| 17 | import { issueDependencies, issues, users } from "../db/schema"; | |
| 18 | ||
| 19 | export interface DepEdge { | |
| 20 | blockerIssueId: string; | |
| 21 | blockedIssueId: string; | |
| 22 | } | |
| 23 | ||
| 24 | /** Pure BFS: would adding (blocker → blocked) introduce a cycle? */ | |
| 25 | export 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. */ | |
| 59 | export 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 | */ | |
| 78 | export 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. */ | |
| 148 | export 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). */ | |
| 170 | export 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). */ | |
| 202 | export 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 | ||
| 233 | export const __internal = { wouldCreateCycle, summariseBlockers }; |