Commitbed6b57unknown_key
feat(BLOCK-J): J14 issue dependencies (blocked-by relationships)
feat(BLOCK-J): J14 issue dependencies (blocked-by relationships)
- drizzle/0036_issue_dependencies.sql: new issue_dependencies table
with CHECK no-self, unique on (blocker, blocked), indexes both sides
- src/lib/issue-dependencies.ts: pure wouldCreateCycle BFS (forward
blocks-direction adjacency) + summariseBlockers + {ok,reason} DB helpers
(addDependency / removeDependency / listBlockersOf / listBlockedBy)
- src/routes/issues.tsx: "Dependencies" panel on issue detail page
(blocked-by + blocks lists, state pills, add #N form, dismiss per row)
plus POST routes for add/remove; permissions gated to owner + author
- src/db/schema.ts: issueDependencies pgTable + type
- 14 new tests; total suite 867/8676 files changed+716−1bed6b5795b3841887a775cd6c93b67a2557d2384
6 changed files+716−1
ModifiedBUILD_BIBLE.md+5−1View fileUnifiedSplit
@@ -136,6 +136,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
136136| CODEOWNERS auto-assign reviewers | ✅ | J11 — on PR open, `git diff --numstat base...head` → CODEOWNERS rule match → user IDs → `pr_review_requests` rows + `review_requested` notifications. PR detail page renders a Reviewers panel with state pills (pending/approved/changes_requested/dismissed), manual `@username` add, and dismiss. `src/lib/review-requests.ts` + `drizzle/0034_pr_review_requests.sql`. |
137137| Community profile (health standards) | ✅ | J12 — `GET /:owner/:repo/community` scores the repo on 8 items (description, README, LICENSE — required; CODE_OF_CONDUCT, CONTRIBUTING, issue templates, PR template, topics — recommended). Pure `checklistFromInputs` + `buildReport`, git-layer `computeHealth`. One-click "Add <path>" links route to the web editor. `src/lib/community.ts` + `src/routes/community.tsx`. |
138138| Pinned repositories on profile | ✅ | J13 — users pin up to 6 repos ordered explicitly; `drizzle/0035_pinned_repos.sql` adds `pinned_repositories`. Manage at `/settings/pins`. Profile page renders "Pinned" grid above the repo list with viewer-aware private filtering. `src/lib/pinned-repos.ts` + `src/routes/pinned-repos.tsx`. |
139| Issue dependencies (blocked-by / blocks) | ✅ | J14 — `drizzle/0036_issue_dependencies.sql` adds `issue_dependencies` (CHECK no-self, unique on pair, both-side indexes). Pure `wouldCreateCycle` BFS + `summariseBlockers`. `addDependency` enforces same-repo, no-self, no-dup, no-cycle with `{ok, reason}` error taxonomy. Issue detail page gets a "Dependencies" panel with "Blocked by" / "Blocks" lists, state pills, `#number` add form + per-row dismiss. `src/lib/issue-dependencies.ts` + routes in `src/routes/issues.tsx`. |
139140| GitHub Actions equivalent (workflow runner) | ✅ | `src/lib/workflow-parser.ts`, `src/lib/workflow-runner.ts`, `src/routes/workflows.tsx`; `.gluecron/workflows/*.yml` auto-discovered on push; Bun subprocess executor, per-step timeouts, size-capped logs |
140141| Dependabot equivalent (AI dep bumper) | ✅ | D2 — `dep_update_runs` table, npm registry fetch, plan + apply bumps, creates `gluecron/dep-update-*` branch + PR row via git plumbing. `src/lib/dep-updater.ts`, `src/routes/dep-updater.tsx`, settings UI at `/:owner/:repo/settings/dep-updater`. |
141142| Code scanning UI | ✅ | I5 — `src/routes/code-scanning.tsx`, `GET /:owner/:repo/security`. Aggregates last-100 `gate_runs` matching `%scan%`/`%security%`, rolls up latest status per gate, shows failed/repaired/total cards + scanner status list + recent runs. |
@@ -297,6 +298,7 @@ This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
297298- **J7** — Closing keywords auto-close issues on PR merge → ✅ shipped. `src/lib/close-keywords.ts` exports pure `extractClosingRefs(text)` and `extractClosingRefsMulti(sources[])` — scans for `(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[:|-]? #N` with case-insensitive, punctuation-tolerant, word-boundary-respecting matching. Rejects cross-repo refs (`owner/repo#N`), embedded-in-word verbs (`disclose`, `unresolved`), and non-positive numbers. Wired into the PR merge handler in `src/routes/pulls.tsx` — after a successful merge, scans `pr.title + pr.body`, looks up each referenced open issue in the same repo, closes it, and posts a "Closed by pull request #N" comment. Wrapped in try/catch so close-keyword failures never block the merge redirect. 14 new tests covering verb forms, punctuation variants, de-dup+sort, cross-repo rejection, embedded-word rejection, case-insensitivity, and multi-source merging. Total suite 749/749.
298299- **J8** — Commit status API (external CI signals) → ✅ shipped. `drizzle/0033_commit_statuses.sql` adds `commit_statuses` (unique on `(repository_id, commit_sha, context)`, state vocabulary pending/success/failure/error). `src/lib/commit-statuses.ts` exposes pure helpers (`isValidSha`, `isValidState`, `sanitiseContext`, `reduceCombined`) and DB helpers (`setStatus` with delete-then-insert upsert, `listStatuses`, `combinedStatus`). `src/routes/commit-statuses.ts` serves `POST /api/v1/repos/:owner/:repo/statuses/:sha` (requireAuth + owner check), `GET /api/v1/repos/:owner/:repo/commits/:sha/statuses` (list, private-repo visibility), `GET /api/v1/repos/:owner/:repo/commits/:sha/status` (combined rollup). Commit detail view now renders a "Checks" pill row when statuses exist, colour-coded per state with clickable target URLs. 18 new tests covering the pure helpers + route auth + invalid-sha rejection. Total suite 767/767.
299300- **J13** — Pinned repositories on user profile → ✅ shipped. `drizzle/0035_pinned_repos.sql` adds `pinned_repositories` (unique on `(user_id, repository_id)`, `position` int for explicit ordering). `src/lib/pinned-repos.ts` exposes `MAX_PINS=6`, pure `sanitisePinIds` (de-dup + clamp + trim), `listPinnedForUser` (ordered with owner username joined), `setPinsForUser` (delete-then-insert, filters out private repos the viewer doesn't own), `listPinCandidates`. `src/routes/pinned-repos.tsx` serves `GET/POST /settings/pins` (requireAuth) with a checkbox grid preview. The profile page in `src/routes/web.tsx` now renders a "Pinned" section above the repo grid when the user has any pinned repos; private pins hidden from other viewers. 9 new tests. Total suite 853/853.
301- **J14** — Issue dependencies / blocked-by relationships → ✅ shipped. `drizzle/0036_issue_dependencies.sql` adds `issue_dependencies` (`blocker_issue_id`, `blocked_issue_id`, CHECK `issue_dep_no_self`, unique on the pair, indexes on both sides). `src/lib/issue-dependencies.ts` exposes pure `wouldCreateCycle(edges, blocker, blocked)` (BFS following forward blocks edges) + `summariseBlockers` plus DB helpers: `addDependency` (`{ok, reason}` taxonomy rejecting self / cross_repo / exists / cycle / not_found / error), `removeDependency`, `listBlockersOf`, `listBlockedBy` (join issues + users for number/title/state/author). Issue detail page in `src/routes/issues.tsx` now renders a "Dependencies" panel above the body showing Blocked-by + Blocks lists, per-row dismiss buttons (owner/author only), and a `#number`-form to add a new blocker. Two POST routes mirror the J11 pattern: `/:o/:r/issues/:n/dependencies` (add) and `/:o/:r/issues/:n/dependencies/:which/:otherId/remove`. Permission gated by `user.id === owner.id || user.id === issue.authorId`. 14 new tests covering cycle detection (direct + transitive + diamond + deep chains), summariseBlockers counts, and route-auth smokes. Total suite 867/867.
300302- **J12** — Community profile / health scorecard → ✅ shipped. `GET /:owner/:repo/community` renders a GitHub-parity "Community standards" page scoring the repo on 8 checklist items: description, README, LICENSE (all required), CODE_OF_CONDUCT, CONTRIBUTING, issue templates, PR template, topics (recommended). `src/lib/community.ts` exposes pure matchers (`isReadme`, `isLicense`, `isCodeOfConduct`, `isContributing`, `isPrTemplate`), pure `checklistFromInputs` (drives all the I/O-free unit tests), `buildReport` (→ percent + required breakdown), and `computeHealth` (reads default-branch root tree + `.github/` subtree + repo metadata + topics). Each missing item offers a one-click "Add <path>" or "Edit settings" link. `src/routes/community.tsx` degrades to a zero-score report on git/DB failure — never 500s. 21 new tests. Total suite 844/844.
301303- **J11** — PR auto-assign reviewers from CODEOWNERS + requested-reviewers tracking → ✅ shipped. `drizzle/0034_pr_review_requests.sql` adds `pr_review_requests` (unique on `(pull_request_id, reviewer_id)`, source enum `codeowners|manual|ai`, state enum `pending|approved|changes_requested|dismissed`). `src/lib/review-requests.ts` exposes pure helpers (`isValidSource`, `isValidState`, `nextState`, `sanitiseCandidates`) and DB helpers (`requestReviewers` idempotent, `listForPr` with username join, `dismissRequest`, `recordReviewOutcome`, `autoAssignFromCodeowners`, `countPendingForUser`). On PR creation, `src/routes/pulls.tsx` runs `git diff --numstat base...head` to extract changed paths, calls `reviewersForChangedFiles` (Block B3 CODEOWNERS parser), resolves usernames → user IDs, excludes the PR author, and fires `review_requested` notifications. PR detail page renders a `ReviewersPanel` with per-reviewer state pills, source labels, and dismiss + manual-add forms for owner/author. Auto-assign runs fire-and-forget — CODEOWNERS failures never block PR creation. 17 new tests. Total suite 823/823.
302304- **J10** — Repository status badges (shields.io-style SVG) → ✅ shipped. `src/lib/badge.ts` renders shields.io-style flat two-segment badges with zero IO — exports `renderBadge`, `escapeXml`, `estimateTextWidth` (Verdana-11 heuristic), `colorForState`. Named colour table (green/red/yellow/blue/grey/orange) + hex-literal passthrough. Label + value clamped to 64 chars. `src/routes/badges.ts` serves `/:o/:r/badge/gates.svg` (latest 20 gate_runs rollup → passing/running/failing), `/issues.svg` + `/prs.svg` (open counts), `/status.svg` (combined commit status on default-branch HEAD), `/status/:context.svg` (single named context). Every handler wrapped in try/catch and returns a grey "unknown" badge on DB or git failure — never 500. `image/svg+xml; charset=utf-8`, `Cache-Control: public, max-age=60, stale-while-revalidate=300`. `softAuth` so public-repo badges don't require cookies. 21 new tests. Total suite 806/806.
@@ -316,7 +318,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
316318- `src/app.tsx` — route composition, middleware order, error handlers
317319- `src/index.ts` — Bun server entry
318320- `src/lib/config.ts` — env getters (late-binding)
319- `src/db/schema.ts` — 97 tables. New tables only via new migration.
321- `src/db/schema.ts` — 98 tables. New tables only via new migration.
320322- `src/db/index.ts` — lazy proxy DB connection
321323- `src/db/migrate.ts` — migration runner
322324- `drizzle/0000_initial.sql`, `drizzle/0001_green_ecosystem.sql` — migrations
@@ -352,6 +354,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
352354- `drizzle/0033_commit_statuses.sql` (Block J8) — migration, never edited in place. Adds `commit_statuses` (unique on `(repository_id, commit_sha, context)`, state vocabulary pending/success/failure/error).
353355- `drizzle/0034_pr_review_requests.sql` (Block J11) — migration, never edited in place. Adds `pr_review_requests` (unique on `(pull_request_id, reviewer_id)`, source enum codeowners/manual/ai, state enum pending/approved/changes_requested/dismissed, reviewer+state index for inbox queries).
354356- `drizzle/0035_pinned_repos.sql` (Block J13) — migration, never edited in place. Adds `pinned_repositories` (unique on `(user_id, repository_id)`, `(user_id, position)` index for ordered listing).
357- `drizzle/0036_issue_dependencies.sql` (Block J14) — migration, never edited in place. Adds `issue_dependencies` (CHECK `issue_dep_no_self`, unique on `(blocker_issue_id, blocked_issue_id)`, indexes on both sides). Same-repo constraint enforced at application layer.
355358
356359### 4.2 Git layer (locked)
357360- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
@@ -492,6 +495,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
492495- `src/routes/pinned-repos.tsx` (Block J13) — serves `GET/POST /settings/pins` (requireAuth). Checkbox grid over own repos; the first MAX_PINS ticked are stored in position order. `?saved=1` flash on success.
493496- `src/routes/community.tsx` (Block J12) — serves `/:owner/:repo/community`. softAuth. Renders progress bar (green ≥80%, yellow ≥50%, else red) + per-item row with required badge + "Add <path>" or "Edit settings" CTA.
494497- `src/lib/review-requests.ts` (Block J11) — PR review-request lifecycle helpers. Pure: `isValidSource`, `isValidState`, `nextState` (state machine; `dismissed` terminal, `commented` is no-op), `sanitiseCandidates` (de-dup + drop author). DB: `requestReviewers` (idempotent, skips existing (pr, reviewer) rows), `listForPr` (joins `users` for username), `dismissRequest`, `recordReviewOutcome`, `autoAssignFromCodeowners` (diff paths → CODEOWNERS → user IDs → review requests + `review_requested` notifications), `countPendingForUser` (for inbox badges). Every DB helper swallows errors and returns safe defaults — never throws.
498- `src/lib/issue-dependencies.ts` (Block J14) — issue "blocker blocks blocked" dependency helpers. Pure: `wouldCreateCycle` (BFS following forward blocks edges; self-refs return true), `summariseBlockers` (counts {open, closed, total}). DB: `addDependency` (rejects with `{ok:false, reason: 'self'|'cross_repo'|'exists'|'cycle'|'not_found'|'error'}`), `removeDependency`, `listBlockersOf`, `listBlockedBy` (join issues + users for number/title/state/author). Same-repo enforcement at app layer. `__internal` re-exports for tests.
495499
496500### 4.7 Views (locked contracts)
497501- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
Addeddrizzle/0036_issue_dependencies.sql+23−0View fileUnifiedSplit
@@ -0,0 +1,23 @@
1-- Block J14 — Issue dependencies / blocked-by relationships.
2--
3-- One row = "blocker blocks blocked". The dependency is considered resolved
4-- when the blocker issue is closed. Both issues must belong to the same repo
5-- (enforced at the application layer).
6
7CREATE TABLE IF NOT EXISTS "issue_dependencies" (
8 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
9 "blocker_issue_id" uuid NOT NULL REFERENCES "issues"("id") ON DELETE CASCADE,
10 "blocked_issue_id" uuid NOT NULL REFERENCES "issues"("id") ON DELETE CASCADE,
11 "created_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
12 "created_at" timestamp NOT NULL DEFAULT now(),
13 CONSTRAINT "issue_dep_no_self" CHECK ("blocker_issue_id" <> "blocked_issue_id")
14);
15
16CREATE UNIQUE INDEX IF NOT EXISTS "issue_deps_blocker_blocked_unique"
17 ON "issue_dependencies" ("blocker_issue_id", "blocked_issue_id");
18
19CREATE INDEX IF NOT EXISTS "issue_deps_blocked_idx"
20 ON "issue_dependencies" ("blocked_issue_id");
21
22CREATE INDEX IF NOT EXISTS "issue_deps_blocker_idx"
23 ON "issue_dependencies" ("blocker_issue_id");
Addedsrc/__tests__/issue-dependencies.test.ts+125−0View fileUnifiedSplit
@@ -0,0 +1,125 @@
1/**
2 * Block J14 — Issue dependencies. Pure helpers + route-auth smokes.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7import { __internal } from "../lib/issue-dependencies";
8
9const { wouldCreateCycle, summariseBlockers } = __internal;
10
11describe("issue-dependencies — wouldCreateCycle", () => {
12 it("rejects self-references", () => {
13 expect(wouldCreateCycle([], "a", "a")).toBe(true);
14 });
15
16 it("empty graph — no cycle", () => {
17 expect(wouldCreateCycle([], "a", "b")).toBe(false);
18 });
19
20 it("detects a direct back-edge", () => {
21 // b already blocks a → adding (a blocks b) would close the loop.
22 const edges = [{ blockerIssueId: "b", blockedIssueId: "a" }];
23 expect(wouldCreateCycle(edges, "a", "b")).toBe(true);
24 });
25
26 it("detects a transitive cycle (a → b → c, then c blocks a?)", () => {
27 // Existing: a blocks b, b blocks c. Proposed: c blocks a → cycle.
28 const edges = [
29 { blockerIssueId: "a", blockedIssueId: "b" },
30 { blockerIssueId: "b", blockedIssueId: "c" },
31 ];
32 expect(wouldCreateCycle(edges, "c", "a")).toBe(true);
33 });
34
35 it("allows unrelated edges", () => {
36 const edges = [
37 { blockerIssueId: "a", blockedIssueId: "b" },
38 { blockerIssueId: "c", blockedIssueId: "d" },
39 ];
40 expect(wouldCreateCycle(edges, "e", "f")).toBe(false);
41 expect(wouldCreateCycle(edges, "a", "c")).toBe(false);
42 });
43
44 it("allows adding an edge that does not close any path", () => {
45 // a blocks b. Adding b blocks c is fine — no cycle.
46 const edges = [{ blockerIssueId: "a", blockedIssueId: "b" }];
47 expect(wouldCreateCycle(edges, "b", "c")).toBe(false);
48 });
49
50 it("detects deeply transitive cycle (length 4)", () => {
51 // a → b → c → d, then proposing d blocks a → cycle.
52 const edges = [
53 { blockerIssueId: "a", blockedIssueId: "b" },
54 { blockerIssueId: "b", blockedIssueId: "c" },
55 { blockerIssueId: "c", blockedIssueId: "d" },
56 ];
57 expect(wouldCreateCycle(edges, "d", "a")).toBe(true);
58 });
59
60 it("diamond shapes do not count as cycles", () => {
61 // a blocks b, a blocks c, b blocks d, c blocks d. Adding e blocks a is fine.
62 const edges = [
63 { blockerIssueId: "a", blockedIssueId: "b" },
64 { blockerIssueId: "a", blockedIssueId: "c" },
65 { blockerIssueId: "b", blockedIssueId: "d" },
66 { blockerIssueId: "c", blockedIssueId: "d" },
67 ];
68 expect(wouldCreateCycle(edges, "e", "a")).toBe(false);
69 });
70});
71
72describe("issue-dependencies — summariseBlockers", () => {
73 it("returns zeros for empty input", () => {
74 expect(summariseBlockers([])).toEqual({ open: 0, closed: 0, total: 0 });
75 });
76
77 it("counts open and closed blockers", () => {
78 expect(
79 summariseBlockers([
80 { blockerIssueId: "a", blockerState: "open" },
81 { blockerIssueId: "b", blockerState: "closed" },
82 { blockerIssueId: "c", blockerState: "open" },
83 ])
84 ).toEqual({ open: 2, closed: 1, total: 3 });
85 });
86
87 it("treats any non-open state as closed (defensive)", () => {
88 expect(
89 summariseBlockers([
90 { blockerIssueId: "a", blockerState: "merged" },
91 { blockerIssueId: "b", blockerState: "open" },
92 ])
93 ).toEqual({ open: 1, closed: 1, total: 2 });
94 });
95});
96
97describe("issue-dependencies — routes", () => {
98 it("POST /:o/:r/issues/:n/dependencies requires auth", async () => {
99 const res = await app.request(
100 "/alice/nope/issues/1/dependencies",
101 { method: "POST", body: "blockerNumber=2" }
102 );
103 expect([302, 401].includes(res.status)).toBe(true);
104 });
105
106 it("POST remove route requires auth", async () => {
107 const res = await app.request(
108 "/alice/nope/issues/1/dependencies/blockers/xyz/remove",
109 { method: "POST" }
110 );
111 expect([302, 401].includes(res.status)).toBe(true);
112 });
113
114 it("POST with invalid bearer → 401 JSON", async () => {
115 const res = await app.request(
116 "/alice/nope/issues/1/dependencies",
117 {
118 method: "POST",
119 headers: { authorization: "Bearer glc_garbage" },
120 body: "blockerNumber=2",
121 }
122 );
123 expect(res.status).toBe(401);
124 });
125});
Modifiedsrc/db/schema.ts+36−0View fileUnifiedSplit
@@ -2434,3 +2434,39 @@ export const pinnedRepositories = pgTable(
24342434);
24352435
24362436export type PinnedRepository = typeof pinnedRepositories.$inferSelect;
2437
2438// ============================================================================
2439// ISSUE DEPENDENCIES (Block J14)
2440// ============================================================================
2441/**
2442 * Blocker/blocked directed edges between issues in the same repo.
2443 * blocker_issue_id → blocked_issue_id
2444 * Resolved when the blocker closes. Application layer enforces same-repo
2445 * pairing + cycle prevention.
2446 */
2447export const issueDependencies = pgTable(
2448 "issue_dependencies",
2449 {
2450 id: uuid("id").primaryKey().defaultRandom(),
2451 blockerIssueId: uuid("blocker_issue_id")
2452 .notNull()
2453 .references(() => issues.id, { onDelete: "cascade" }),
2454 blockedIssueId: uuid("blocked_issue_id")
2455 .notNull()
2456 .references(() => issues.id, { onDelete: "cascade" }),
2457 createdBy: uuid("created_by").references(() => users.id, {
2458 onDelete: "set null",
2459 }),
2460 createdAt: timestamp("created_at").defaultNow().notNull(),
2461 },
2462 (table) => [
2463 uniqueIndex("issue_deps_blocker_blocked_unique").on(
2464 table.blockerIssueId,
2465 table.blockedIssueId
2466 ),
2467 index("issue_deps_blocked_idx").on(table.blockedIssueId),
2468 index("issue_deps_blocker_idx").on(table.blockerIssueId),
2469 ]
2470);
2471
2472export type IssueDependency = typeof issueDependencies.$inferSelect;
Addedsrc/lib/issue-dependencies.ts+233−0View fileUnifiedSplit
@@ -0,0 +1,233 @@
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
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 };
Modifiedsrc/routes/issues.tsx+294−0View fileUnifiedSplit
@@ -324,6 +324,27 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, async (c) => {
324324 user &&
325325 (user.id === resolved.owner.id || user.id === issue.authorId);
326326
327 // J14 — issue dependencies: blockers + blocked lists.
328 const [blockers, blocked] = await Promise.all([
329 (async () => {
330 try {
331 const { listBlockersOf } = await import("../lib/issue-dependencies");
332 return await listBlockersOf(issue.id);
333 } catch {
334 return [];
335 }
336 })(),
337 (async () => {
338 try {
339 const { listBlockedBy } = await import("../lib/issue-dependencies");
340 return await listBlockedBy(issue.id);
341 } catch {
342 return [];
343 }
344 })(),
345 ]);
346 const depError = c.req.query("depError");
347
327348 return c.html(
328349 <Layout
329350 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
@@ -352,6 +373,16 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, async (c) => {
352373 </span>
353374 </div>
354375
376 <DependenciesPanel
377 owner={ownerName}
378 repo={repoName}
379 issueNumber={issue.number}
380 blockers={blockers}
381 blocked={blocked}
382 canManage={!!canManage}
383 depError={depError}
384 />
385
355386 {issue.body && (
356387 <div class="issue-comment-box">
357388 <div class="comment-header">
@@ -533,6 +564,269 @@ issueRoutes.post(
533564 }
534565);
535566
567// J14 — Add blocker dependency.
568issueRoutes.post(
569 "/:owner/:repo/issues/:number/dependencies",
570 softAuth,
571 requireAuth,
572 async (c) => {
573 const { owner: ownerName, repo: repoName } = c.req.param();
574 const issueNum = parseInt(c.req.param("number"), 10);
575 const user = c.get("user")!;
576 const body = await c.req.parseBody();
577 const blockerRaw = String(body.blockerNumber || "").trim().replace(/^#/, "");
578 const blockerNum = parseInt(blockerRaw, 10);
579
580 const resolved = await resolveRepo(ownerName, repoName);
581 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
582
583 if (!Number.isFinite(blockerNum) || blockerNum <= 0) {
584 return c.redirect(
585 `/${ownerName}/${repoName}/issues/${issueNum}?depError=invalid`
586 );
587 }
588
589 const [blocked] = await db
590 .select()
591 .from(issues)
592 .where(
593 and(
594 eq(issues.repositoryId, resolved.repo.id),
595 eq(issues.number, issueNum)
596 )
597 )
598 .limit(1);
599 if (!blocked) return c.redirect(`/${ownerName}/${repoName}/issues`);
600
601 const [blocker] = await db
602 .select()
603 .from(issues)
604 .where(
605 and(
606 eq(issues.repositoryId, resolved.repo.id),
607 eq(issues.number, blockerNum)
608 )
609 )
610 .limit(1);
611 if (!blocker) {
612 return c.redirect(
613 `/${ownerName}/${repoName}/issues/${issueNum}?depError=not_found`
614 );
615 }
616
617 const canManage =
618 user.id === resolved.owner.id || user.id === blocked.authorId;
619 if (!canManage) {
620 return c.redirect(
621 `/${ownerName}/${repoName}/issues/${issueNum}?depError=forbidden`
622 );
623 }
624
625 const { addDependency } = await import("../lib/issue-dependencies");
626 const result = await addDependency({
627 blockerIssueId: blocker.id,
628 blockedIssueId: blocked.id,
629 createdBy: user.id,
630 });
631 if (!result.ok) {
632 return c.redirect(
633 `/${ownerName}/${repoName}/issues/${issueNum}?depError=${result.reason}`
634 );
635 }
636 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}`);
637 }
638);
639
640// J14 — Remove blocker dependency. :which is either "blockers" or "blocks".
641issueRoutes.post(
642 "/:owner/:repo/issues/:number/dependencies/:which/:otherId/remove",
643 softAuth,
644 requireAuth,
645 async (c) => {
646 const { owner: ownerName, repo: repoName } = c.req.param();
647 const issueNum = parseInt(c.req.param("number"), 10);
648 const which = c.req.param("which");
649 const otherId = c.req.param("otherId");
650 const user = c.get("user")!;
651
652 const resolved = await resolveRepo(ownerName, repoName);
653 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
654
655 const [thisIssue] = await db
656 .select()
657 .from(issues)
658 .where(
659 and(
660 eq(issues.repositoryId, resolved.repo.id),
661 eq(issues.number, issueNum)
662 )
663 )
664 .limit(1);
665 if (!thisIssue) return c.redirect(`/${ownerName}/${repoName}/issues`);
666
667 const canManage =
668 user.id === resolved.owner.id || user.id === thisIssue.authorId;
669 if (!canManage) {
670 return c.redirect(
671 `/${ownerName}/${repoName}/issues/${issueNum}?depError=forbidden`
672 );
673 }
674
675 const { removeDependency } = await import("../lib/issue-dependencies");
676 // which === "blockers" → otherId is the blocker; this issue is blocked.
677 // which === "blocks" → otherId is the blocked; this issue is blocker.
678 if (which === "blockers") {
679 await removeDependency(otherId, thisIssue.id);
680 } else if (which === "blocks") {
681 await removeDependency(thisIssue.id, otherId);
682 }
683 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}`);
684 }
685);
686
687// J14 — Dependencies UI panel.
688type DepRow = {
689 id: string;
690 issueId: string;
691 number: number;
692 title: string;
693 state: string;
694 authorUsername: string;
695};
696
697function depErrorMessage(reason: string | undefined): string | null {
698 if (!reason) return null;
699 switch (reason) {
700 case "self":
701 return "An issue cannot block itself.";
702 case "cross_repo":
703 return "Both issues must belong to the same repository.";
704 case "exists":
705 return "That dependency already exists.";
706 case "cycle":
707 return "That dependency would create a cycle.";
708 case "not_found":
709 return "Issue not found.";
710 case "invalid":
711 return "Invalid issue number.";
712 case "forbidden":
713 return "You don't have permission to change dependencies on this issue.";
714 default:
715 return "Could not update dependencies.";
716 }
717}
718
719const DependenciesPanel = ({
720 owner,
721 repo,
722 issueNumber,
723 blockers,
724 blocked,
725 canManage,
726 depError,
727}: {
728 owner: string;
729 repo: string;
730 issueNumber: number;
731 blockers: DepRow[];
732 blocked: DepRow[];
733 canManage: boolean;
734 depError: string | undefined;
735}) => {
736 if (blockers.length === 0 && blocked.length === 0 && !canManage) return null;
737 const errMsg = depErrorMessage(depError);
738 const renderRow = (row: DepRow, which: "blockers" | "blocks") => (
739 <div
740 style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-top: 1px solid var(--border)"
741 >
742 <span
743 class={`issue-badge ${row.state === "open" ? "badge-open" : "badge-closed"}`}
744 style="font-size: 11px; padding: 1px 6px"
745 >
746 {row.state === "open" ? "\u25CB" : "\u2713"}
747 </span>
748 <a
749 href={`/${owner}/${repo}/issues/${row.number}`}
750 style="flex: 1; text-decoration: none"
751 >
752 <span style="color: var(--text-muted)">#{row.number}</span>{" "}
753 <span>{row.title}</span>
754 </a>
755 <span style="color: var(--text-muted); font-size: 12px">
756 by {row.authorUsername}
757 </span>
758 {canManage && (
759 <form
760 method="POST"
761 action={`/${owner}/${repo}/issues/${issueNumber}/dependencies/${which}/${row.issueId}/remove`}
762 style="margin: 0"
763 >
764 <button
765 type="submit"
766 class="btn"
767 style="padding: 2px 8px; font-size: 11px"
768 title="Remove dependency"
769 >
770 {"\u2715"}
771 </button>
772 </form>
773 )}
774 </div>
775 );
776 return (
777 <div
778 style="margin: 16px 0; padding: 12px 16px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-secondary)"
779 >
780 <div style="font-weight: 600; margin-bottom: 8px">Dependencies</div>
781 {errMsg && <div class="auth-error" style="margin-bottom: 8px">{errMsg}</div>}
782
783 <div style="margin-bottom: 12px">
784 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px">
785 Blocked by ({blockers.length})
786 </div>
787 {blockers.length === 0 ? (
788 <div style="font-size: 12px; color: var(--text-muted); padding: 4px 0">
789 No blockers.
790 </div>
791 ) : (
792 blockers.map((r) => renderRow(r, "blockers"))
793 )}
794 {canManage && (
795 <form
796 method="POST"
797 action={`/${owner}/${repo}/issues/${issueNumber}/dependencies`}
798 style="display: flex; gap: 6px; margin-top: 8px"
799 >
800 <input
801 type="text"
802 name="blockerNumber"
803 required
804 placeholder="#123"
805 style="flex: 1; padding: 4px 8px; font-size: 12px"
806 />
807 <button type="submit" class="btn" style="padding: 4px 10px; font-size: 12px">
808 Add blocker
809 </button>
810 </form>
811 )}
812 </div>
813
814 <div>
815 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px">
816 Blocks ({blocked.length})
817 </div>
818 {blocked.length === 0 ? (
819 <div style="font-size: 12px; color: var(--text-muted); padding: 4px 0">
820 This issue does not block any others.
821 </div>
822 ) : (
823 blocked.map((r) => renderRow(r, "blocks"))
824 )}
825 </div>
826 </div>
827 );
828};
829
536830// Shared nav component with issues tab
537831const IssueNav = ({
538832 owner,
539833