Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit1e162a8unknown_key

feat(BLOCK-D+E): D4 incident responder, D5 branch protection, D8 test stubs + E1 projects, E2 discussions, E3 wikis, E4 gists

feat(BLOCK-D+E): D4 incident responder, D5 branch protection, D8 test stubs + E1 projects, E2 discussions, E3 wikis, E4 gists

Ships six new features that close out Block D AI-native differentiation
and four rows of Block E collaboration parity.

Block D (AI-native differentiation):
- D4 AI incident responder — on deploy failure, Sonnet 4 root-causes from
  last 10 commits + opens an issue, attaches `incident` label, records
  `blockedReason=auto-issue #N`. Wired into `triggerCrontechDeploy` and
  exposed as a `retry-incident` button on the deployment page.
- D5 Branch-protection enforcement — `matchProtection` + `evaluateProtection`
  helpers consult `branch_protection` per-pattern rules at merge time
  (requireAiApproval, requireGreenGates, requireHumanReview,
  requiredApprovals) and block merges with human-readable reasons.
- D8 AI-generated test stubs — `generateTestStub` composes language +
  framework + source into a failing test stub via Haiku; served at
  `/:owner/:repo/ai/tests` with a file picker, highlighted source, and
  copy button. Raw mode via `?format=raw`.

Block E (collaboration parity):
- E1 Projects / kanban boards — per-repo boards with auto-seeded To Do /
  In Progress / Done columns; cards carry a title+note or a linked
  issue/PR; owner can close/reopen; move uses max+1 positioning.
- E2 Discussions — forum threads scoped to a repo; 5 categories;
  pinnable, lockable; q-and-a discussions can accept an answer.
- E3 Wikis — DB-backed markdown pages with revision history + one-click
  revert; slug auto-derived from title (lowercase alphanumerics joined
  by dashes).
- E4 Gists — multi-file user-owned tiny repos; 8-hex slugs; each edit
  snapshots the full file set into `gist_revisions`; stars toggle.

Migrations: 0013_discussions, 0014_gists, 0015_projects, 0016_wikis.
Tests: 377 → 413 (+36). All pure helpers covered; route smoke tests
verify mounting + auth redirects for every new endpoint.

https://claude.ai/code/session_01U6sj9ezKCpmiwx7cXGZmS6
Claude committed on April 15, 2026Parent: 3cbe3d6
26 files changed+5650161e162a8fbf6125bcc5e427d2d9e5f16d2399351b
26 changed files+5650−16
ModifiedBUILD_BIBLE.md+28−14View fileUnifiedSplit
102102| Issue templates | ✅ | `.github/ISSUE_TEMPLATE.md` auto-prefills new issues; frontmatter stripped; `src/lib/templates.ts` |
103103| PR templates | ✅ | `.github/PULL_REQUEST_TEMPLATE.md` auto-prefills new PRs; `src/lib/templates.ts` |
104104| Saved replies | ✅ | per-user canned comments, unique-shortcut, `/settings/replies`, `/api/user/replies` |
105| Discussions / forums | ❌ | |
106| Wikis | ❌ | |
107| Projects / kanban | ❌ | |
105| Discussions / forums | ✅ | E2 — categorised threads, pinned/locked, q-and-a answers. `src/routes/discussions.tsx` + `drizzle/0013_discussions.sql` |
106| Wikis | ✅ | E3 — markdown pages per repo with revision history + revert. DB-backed v1. `src/routes/wikis.tsx` + `drizzle/0016_wikis.sql` |
107| Projects / kanban | ✅ | E1 — per-repo boards with auto-seeded To Do/In Progress/Done columns. Notes or linked issues/PRs. `src/routes/projects.tsx` + `drizzle/0015_projects.sql` |
108| AI incident responder | ✅ | D4 — auto-issues on deploy fail, `src/lib/ai-incident.ts` |
109| AI-generated test stubs | ✅ | D8 — `src/lib/ai-tests.ts`, `/:owner/:repo/ai/tests` |
108110
109111### 2.4 Automation + AI
110112| Feature | Status | Notes |
149151| Passkeys / WebAuthn | ✅ | `src/routes/passkeys.tsx`, `src/lib/webauthn.ts`; `user_passkeys` + `webauthn_challenges` tables |
150152| Packages registry (npm / docker / etc) | ✅ | `src/lib/packages.ts`, `src/routes/packages-api.ts`, `src/routes/packages.tsx`; npm protocol (packument, tarball, publish, yank); PAT (`glc_`) auth via Authorization header; container registry deferred |
151153| Pages / static hosting | ✅ | `src/lib/pages.ts`, `src/routes/pages.tsx`; serves blobs from bare git at latest `gh-pages` commit; per-repo settings (source branch/dir, custom domain); short-cache headers |
152| Gists | ❌ | |
154| Gists | ✅ | E4 — multi-file tiny repos with per-revision JSON snapshots + stars. `src/routes/gists.tsx` + `drizzle/0014_gists.sql` |
153155| Sponsors | ❌ | |
154156| Marketplace | ❌ | |
155157| Environments / deployment tracking | ✅ | `src/routes/deployments.tsx` — grouped by env, success-rate rollup, per-deploy detail. Protected environments (`src/routes/environments.tsx`, `src/lib/environments.ts`) with reviewer-gated approval, branch-glob restrictions, approve/reject decisions recorded in `deployment_approvals` |
229231- **D1** — Semantic code search → ✅ shipped. `src/lib/semantic-search.ts` + `src/routes/semantic-search.tsx`. `code_chunks` table stores chunk embeddings as JSON (upgrade path to `pgvector`). Embedding provider: Voyage AI `voyage-code-3` when `VOYAGE_API_KEY` is set, otherwise deterministic 512-dim hashing fallback. Index via `POST /:owner/:repo/search/semantic/reindex` (owner-only).
230232- **D2** — AI dependency updater → ✅ shipped. `src/lib/dep-updater.ts` + `src/routes/dep-updater.tsx`. `dep_update_runs` table tracks run history. Parses `package.json`, queries `registry.npmjs.org`, plans bumps (skips workspace/github specs + downgrades), writes an `gluecron/dep-update-<ts>` branch via git plumbing (`hash-object` + `mktree` + `commit-tree` + `update-ref`), inserts a pull_requests row with a markdown bump table. Settings UI at `/:owner/:repo/settings/dep-updater` with "Run now".
231233- **D3** — AI PR triage → ✅ shipped. `triagePullRequest` in `src/lib/ai-generators.ts`; hooked into PR create in `src/routes/pulls.tsx` (fire-and-forget). Posts a non-applied "## AI Triage" comment with suggested labels, reviewers, priority, and risk area. Suggestions only — PR author stays in control.
232- **D4** — AI incident responder (on deploy failure, opens issue with root cause) — NOT STARTED
233- **D5** — AI code reviewer that blocks merges (enforced via branch protection "AI approval required") — PARTIAL (AI review exists; no branch-protection hook yet)
234- **D4** — AI incident responder → ✅ shipped. `src/lib/ai-incident.ts` exports `onDeployFailure(args)` — on deploy-fail hooks, samples ~10 recent commits, calls Sonnet 4 for a structured root-cause JSON, opens an issue (number via `serial`), best-effort attaches `incident` label, sets `deployments.blockedReason="auto-issue #N"`. Wired from `src/hooks/post-receive.ts triggerCrontechDeploy` (fire-and-forget) and from `POST /:owner/:repo/deployments/:id/retry-incident` (owner-only re-run button on the deployment detail page). Never throws; degrades to deterministic body when no `ANTHROPIC_API_KEY`.
235- **D5** — AI code reviewer blocks merges → ✅ shipped. `src/lib/branch-protection.ts` exports `matchProtection(repoId, branch)` (exact > glob, reuses `matchGlob` from environments.ts), `evaluateProtection(rule, ctx)` pure decision helper (checks `requireAiApproval` / `requireGreenGates` / `requireHumanReview` / `requiredApprovals`), and `countHumanApprovals(prId)` (LGTM/`+1`/approved heuristic on non-AI PR comments). Wired into `src/routes/pulls.tsx` merge handler after the existing hard-gate filter — blocks merge with readable reasons when rule fails. 8 unit tests in `src/__tests__/branch-protection.test.ts`.
234236- **D6** — AI "explain this codebase" → ✅ shipped. `src/lib/ai-explain.ts` + `src/routes/ai-explain.tsx`. Samples up to ~25 representative files (~60KB cap), generates a Markdown explanation via Sonnet 4, caches per (repo, commit sha) in `codebase_explanations`. `GET /:owner/:repo/explain` + owner-only `POST /:owner/:repo/explain/regenerate`. Explain link added to `RepoNav`.
235237- **D7** — AI changelog for every commit range → ✅ shipped. `src/routes/ai-changelog.tsx`. `GET /:owner/:repo/ai/changelog?from=&to=(&format=markdown)` — runs `git log` on the range, calls existing `generateChangelog`, renders form + rendered Markdown + copy-box; `format=markdown` returns `text/markdown` for CLI/CI consumers. Caps at 500 commits.
236- **D8** — AI-generated test suite (reads public API, generates failing tests) — NOT STARTED
238- **D8** — AI-generated test suite → ✅ shipped. `src/lib/ai-tests.ts` exports `detectLanguage(path)`, `detectTestFramework(repo tree)`, `buildTestsPrompt(...)`, `suggestedTestPath(...)`, `generateTestStub({path, content, framework, language})` (returns `{code:"", framework:"fallback"}` when AI unavailable), `contentTypeFor(path)`. Route `src/routes/ai-tests.tsx` adds `GET /:owner/:repo/ai/tests` (form + file picker), `GET /:owner/:repo/ai/tests?format=raw` (raw text with correct MIME), `POST /:owner/:repo/ai/tests/generate` (requireAuth, renders highlighted source + generated failing test, copy button). Stubs are intentionally failing so the author fills them in.
237239- **D9** — Copilot-style completion endpoint → ✅ shipped. `src/lib/ai-completion.ts` + `src/routes/copilot.ts`. `POST /api/copilot/completions` (requireAuth accepts PAT/OAuth/session), `GET /api/copilot/ping`. Claude Haiku; in-memory LRU (size 200, 5-min TTL); code-fence stripping; 60/min rate limit per caller.
238240
239241### BLOCK E — Collaboration parity
240- **E1** — Projects / kanban boards (`projects`, `project_items`, `project_fields`)
241- **E2** — Discussions (forum threads per repo)
242- **E3** — Wikis (git-backed, separate bare repo per repo)
243- **E4** — Gists (user-owned tiny repos)
244- **E5** — Merge queues (serialised merge with re-test)
245- **E6** — Required status checks matrix (multiple named checks per branch protection rule)
246- **E7** — Protected tags
242- **E1** — Projects / kanban boards → ✅ shipped. `src/routes/projects.tsx`, tables `projects`/`project_columns`/`project_items` (migration 0015). Create creates three default columns (To Do/In Progress/Done); cards carry note or issue/pr link; one-click move between columns; owner-only close.
243- **E2** — Discussions (forum threads per repo) → ✅ shipped. `src/routes/discussions.tsx`, tables `discussions`/`discussion_comments` (migration 0013). Categorised (general/q-and-a/ideas/announcements/show-and-tell), pinnable, lockable, q-and-a answers.
244- **E3** — Wikis → ✅ shipped as DB-backed v1. `src/routes/wikis.tsx`, tables `wiki_pages`/`wiki_revisions` (migration 0016). Slug auto-derived; every edit bumps revision + appends a revision row; owner can revert. Git-backed mirror deferred.
245- **E4** — Gists → ✅ shipped. `src/routes/gists.tsx`, tables `gists`/`gist_files`/`gist_revisions`/`gist_stars` (migration 0014). Multi-file; each edit takes a JSON snapshot into `gist_revisions` keyed on revision number; stars toggle; secret gists hidden from non-owners.
246- **E5** — Merge queues (serialised merge with re-test) — NOT STARTED
247- **E6** — Required status checks matrix (multiple named checks per branch protection rule) — NOT STARTED
248- **E7** — Protected tags — NOT STARTED
247249
248250### BLOCK F — Observability + admin
249251- **F1** — Traffic analytics per repo (views, clones, unique visitors)
284286- `drizzle/0010_pages.sql` (Block C3) — migration, never edited in place
285287- `drizzle/0011_environments.sql` (Block C4) — migration, never edited in place
286288- `drizzle/0012_ai_native.sql` (Block D) — migration, never edited in place. Adds `codebase_explanations`, `dep_update_runs`, `code_chunks`.
289- `drizzle/0013_discussions.sql` (Block E2) — migration, never edited in place. Adds `discussions`, `discussion_comments`.
290- `drizzle/0014_gists.sql` (Block E4) — migration, never edited in place. Adds `gists`, `gist_files`, `gist_revisions`, `gist_stars`.
291- `drizzle/0015_projects.sql` (Block E1) — migration, never edited in place. Adds `projects`, `project_columns`, `project_items`.
292- `drizzle/0016_wikis.sql` (Block E3) — migration, never edited in place. Adds `wiki_pages`, `wiki_revisions`.
287293
288294### 4.2 Git layer (locked)
289295- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
317323- `src/lib/ai-completion.ts` (Block D9) — `completeCode({prefix, suffix?, language?, maxTokens?, repoHint?})` via Haiku. Inline LRU (size 200, 5-min TTL) keyed on sha256 of prefix+suffix+language. Code-fence stripping. Never throws. `__test` bundle exposed.
318324- `src/lib/dep-updater.ts` (Block D2) — `parseManifest`, `queryNpmLatest`, `planUpdates` (injectable `fetchLatest`), `applyBumps`, `runDepUpdateRun`. Creates `gluecron/dep-update-<ts>` branch via git plumbing + opens a PR row. Never throws.
319325- `src/lib/semantic-search.ts` (Block D1) — `tokenize`, `hashEmbed` (512-dim L2-normalised FNV-1a + sign trick), `embedBatch` (Voyage `voyage-code-3` when `VOYAGE_API_KEY` set, else fallback), `chunkFile`, `isCodeFile`, `indexRepository`, `searchRepository`, `cosine`, `isEmbeddingsProviderAvailable`, `__test` bundle.
326- `src/lib/ai-incident.ts` (Block D4) — `onDeployFailure({deploymentId, reason, logs?})` and pure helper `summariseCommitsForIncident(commits)`. Sonnet 4 structured JSON RCA → opens `issues` row, attaches `incident` label if present, sets `deployments.blockedReason`. Never throws; deterministic fallback body when no API key. Wired from `post-receive.ts triggerCrontechDeploy` + `deployments.tsx retry-incident`.
327- `src/lib/ai-tests.ts` (Block D8) — pure helpers `detectLanguage`, `detectTestFramework`, `buildTestsPrompt`, `suggestedTestPath`, `generateTestStub`, `contentTypeFor`. Returns `{code:"", framework:"fallback"}` on no API key. Never throws.
328- `src/lib/branch-protection.ts` (Block D5) — `matchProtection(repoId, branch)` (exact wins; deterministic glob sort), `evaluateProtection(rule, ctx)` (pure — checks `requireAiApproval | requireGreenGates | requireHumanReview | requiredApprovals`), `countHumanApprovals(prId)` (LGTM/+1/approved heuristic). Never throws. Enforcement is in `src/routes/pulls.tsx` merge handler, after existing hard-gate filter.
320329
321330### 4.5 Platform (locked)
322331- `src/lib/notify.ts` — notification creation + audit log (swallow-failures pattern). Also fans out email to opted-in recipients for `mention|review_requested|assigned|gate_failed`. Exports `__internal` for tests.
374383- `src/routes/copilot.ts` (Block D9) — `POST /api/copilot/completions` (requireAuth, 60/min rate limit), `GET /api/copilot/ping` (public).
375384- `src/routes/dep-updater.tsx` (Block D2) — `GET /:owner/:repo/settings/dep-updater` + `POST /:owner/:repo/settings/dep-updater/run` (requireAuth, owner-only).
376385- `src/routes/semantic-search.tsx` (Block D1) — `GET /:owner/:repo/search/semantic?q=` (softAuth) + `POST /:owner/:repo/search/semantic/reindex` (requireAuth, owner-only).
386- `src/routes/ai-tests.tsx` (Block D8) — `GET /:owner/:repo/ai/tests` (softAuth form + picker), `GET /:owner/:repo/ai/tests?format=raw` (raw text w/ MIME), `POST /:owner/:repo/ai/tests/generate` (requireAuth, renders highlighted source + AI-generated failing test with copy button).
387- `src/routes/discussions.tsx` (Block E2) — full discussion CRUD + categories + q-and-a answers + lock/pin. Exports `isValidCategory(c)` helper. Owner-only lock/pin; owner-or-author can close/toggle.
388- `src/routes/gists.tsx` (Block E4) — `GET /gists` discover, `/gists/new|:slug|:slug/edit|:slug/delete|:slug/star|:slug/revisions|:slug/revisions/:rev` + `/:username/gists`. Exports `generateSlug()` (8-hex) and `snapshotOf(files)` JSON serializer. Retries on slug collision up to 5x.
389- `src/routes/projects.tsx` (Block E1) — kanban board CRUD. Auto-seeds three default columns on project create. `/:owner/:repo/projects/:number/items/:itemId/move` recomputes position via `max+1` of target column.
390- `src/routes/wikis.tsx` (Block E3) — DB-backed wiki with revision history + revert. Exports `slugifyTitle(title)` (lowercase alphanumerics joined by single dashes, trimmed). Every edit appends a `wiki_revisions` row; revert creates a new revision.
377391
378392### 4.7 Views (locked contracts)
379393- `src/views/layout.tsx``Layout` accepts `title`, `user`, `notificationCount`
Addeddrizzle/0013_discussions.sql+50−0View fileUnifiedSplit
1-- Gluecron migration 0013: Block E2 — Discussions.
2--
3-- Tables:
4-- discussions — forum-style threaded conversations attached to a repo
5-- discussion_comments — comments (optionally nested 1 level via parent_comment_id)
6--
7-- Discussions mirror GitHub Discussions: pinned + categorised threads that live
8-- alongside issues/PRs but are conversational rather than work-tracking.
9
10--> statement-breakpoint
11CREATE TABLE IF NOT EXISTS "discussions" (
12 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
13 "number" serial NOT NULL,
14 "repository_id" uuid NOT NULL,
15 "author_id" uuid NOT NULL,
16 "category" text NOT NULL DEFAULT 'general',
17 "title" text NOT NULL,
18 "body" text,
19 "state" text NOT NULL DEFAULT 'open',
20 "locked" boolean NOT NULL DEFAULT false,
21 "answer_comment_id" uuid,
22 "pinned" boolean NOT NULL DEFAULT false,
23 "created_at" timestamp DEFAULT now() NOT NULL,
24 "updated_at" timestamp DEFAULT now() NOT NULL,
25 CONSTRAINT "discussions_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade,
26 CONSTRAINT "discussions_author_fk" FOREIGN KEY ("author_id") REFERENCES "users"("id")
27);
28
29--> statement-breakpoint
30CREATE INDEX IF NOT EXISTS "discussions_repo" ON "discussions" ("repository_id");
31--> statement-breakpoint
32CREATE UNIQUE INDEX IF NOT EXISTS "discussions_repo_number" ON "discussions" ("repository_id", "number");
33
34--> statement-breakpoint
35CREATE TABLE IF NOT EXISTS "discussion_comments" (
36 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
37 "discussion_id" uuid NOT NULL,
38 "parent_comment_id" uuid,
39 "author_id" uuid NOT NULL,
40 "body" text NOT NULL,
41 "is_answer" boolean NOT NULL DEFAULT false,
42 "created_at" timestamp DEFAULT now() NOT NULL,
43 "updated_at" timestamp DEFAULT now() NOT NULL,
44 CONSTRAINT "discussion_comments_discussion_fk" FOREIGN KEY ("discussion_id") REFERENCES "discussions"("id") ON DELETE cascade,
45 CONSTRAINT "discussion_comments_parent_fk" FOREIGN KEY ("parent_comment_id") REFERENCES "discussion_comments"("id") ON DELETE cascade,
46 CONSTRAINT "discussion_comments_author_fk" FOREIGN KEY ("author_id") REFERENCES "users"("id")
47);
48
49--> statement-breakpoint
50CREATE INDEX IF NOT EXISTS "discussion_comments_discussion" ON "discussion_comments" ("discussion_id");
Addeddrizzle/0014_gists.sql+73−0View fileUnifiedSplit
1-- Gluecron migration 0014: Block E4 — Gists.
2--
3-- User-owned small snippets/files that behave like tiny repos. DB-backed
4-- (no git bare repo for v1): each gist owns a collection of gist_files, and
5-- every edit appends a gist_revisions row containing a JSON snapshot of the
6-- full file set at that revision.
7--
8-- Tables:
9-- gists — top-level gist row (owner, slug, title, description)
10-- gist_files — individual files on a gist (filename, language, content)
11-- gist_revisions — per-edit snapshots (JSON {filename: content})
12-- gist_stars — per-user stars
13
14--> statement-breakpoint
15CREATE TABLE IF NOT EXISTS "gists" (
16 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
17 "owner_id" uuid NOT NULL,
18 "slug" text NOT NULL UNIQUE,
19 "title" text NOT NULL DEFAULT '',
20 "description" text NOT NULL DEFAULT '',
21 "is_public" boolean NOT NULL DEFAULT true,
22 "created_at" timestamp DEFAULT now() NOT NULL,
23 "updated_at" timestamp DEFAULT now() NOT NULL,
24 CONSTRAINT "gists_owner_fk" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE cascade
25);
26
27--> statement-breakpoint
28CREATE INDEX IF NOT EXISTS "gists_owner" ON "gists" ("owner_id");
29
30--> statement-breakpoint
31CREATE TABLE IF NOT EXISTS "gist_files" (
32 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
33 "gist_id" uuid NOT NULL,
34 "filename" text NOT NULL,
35 "language" text,
36 "content" text NOT NULL DEFAULT '',
37 "size_bytes" integer NOT NULL DEFAULT 0,
38 CONSTRAINT "gist_files_gist_fk" FOREIGN KEY ("gist_id") REFERENCES "gists"("id") ON DELETE cascade
39);
40
41--> statement-breakpoint
42CREATE INDEX IF NOT EXISTS "gist_files_gist" ON "gist_files" ("gist_id");
43--> statement-breakpoint
44CREATE UNIQUE INDEX IF NOT EXISTS "gist_files_gist_filename" ON "gist_files" ("gist_id", "filename");
45
46--> statement-breakpoint
47CREATE TABLE IF NOT EXISTS "gist_revisions" (
48 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
49 "gist_id" uuid NOT NULL,
50 "revision" integer NOT NULL,
51 "snapshot" text NOT NULL DEFAULT '{}',
52 "author_id" uuid NOT NULL,
53 "message" text,
54 "created_at" timestamp DEFAULT now() NOT NULL,
55 CONSTRAINT "gist_revisions_gist_fk" FOREIGN KEY ("gist_id") REFERENCES "gists"("id") ON DELETE cascade,
56 CONSTRAINT "gist_revisions_author_fk" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE cascade
57);
58
59--> statement-breakpoint
60CREATE INDEX IF NOT EXISTS "gist_revisions_gist_rev" ON "gist_revisions" ("gist_id", "revision");
61
62--> statement-breakpoint
63CREATE TABLE IF NOT EXISTS "gist_stars" (
64 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
65 "gist_id" uuid NOT NULL,
66 "user_id" uuid NOT NULL,
67 "created_at" timestamp DEFAULT now() NOT NULL,
68 CONSTRAINT "gist_stars_gist_fk" FOREIGN KEY ("gist_id") REFERENCES "gists"("id") ON DELETE cascade,
69 CONSTRAINT "gist_stars_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
70);
71
72--> statement-breakpoint
73CREATE UNIQUE INDEX IF NOT EXISTS "gist_stars_gist_user" ON "gist_stars" ("gist_id", "user_id");
Addeddrizzle/0015_projects.sql+66−0View fileUnifiedSplit
1-- Gluecron migration 0015: Block E1 — Projects / kanban boards.
2--
3-- Tables:
4-- projects — top-level board scoped to a repo (or org later)
5-- project_columns — ordered columns (To Do / Doing / Done, etc)
6-- project_items — cards on the board. Can be a freeform note OR linked
7-- to an existing issue / pull_request (polymorphic fk).
8--
9-- Schema follows a lightweight GitHub Projects v2 model but scoped to a repo
10-- for v1 (cross-repo + org boards can be added by nulling repository_id later).
11
12--> statement-breakpoint
13CREATE TABLE IF NOT EXISTS "projects" (
14 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
15 "number" serial NOT NULL,
16 "repository_id" uuid NOT NULL,
17 "owner_id" uuid NOT NULL,
18 "title" text NOT NULL,
19 "description" text NOT NULL DEFAULT '',
20 "state" text NOT NULL DEFAULT 'open',
21 "created_at" timestamp DEFAULT now() NOT NULL,
22 "updated_at" timestamp DEFAULT now() NOT NULL,
23 CONSTRAINT "projects_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade,
24 CONSTRAINT "projects_owner_fk" FOREIGN KEY ("owner_id") REFERENCES "users"("id")
25);
26
27--> statement-breakpoint
28CREATE INDEX IF NOT EXISTS "projects_repo" ON "projects" ("repository_id");
29--> statement-breakpoint
30CREATE UNIQUE INDEX IF NOT EXISTS "projects_repo_number" ON "projects" ("repository_id", "number");
31
32--> statement-breakpoint
33CREATE TABLE IF NOT EXISTS "project_columns" (
34 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
35 "project_id" uuid NOT NULL,
36 "name" text NOT NULL,
37 "position" integer NOT NULL DEFAULT 0,
38 "created_at" timestamp DEFAULT now() NOT NULL,
39 CONSTRAINT "project_columns_project_fk" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE cascade
40);
41
42--> statement-breakpoint
43CREATE INDEX IF NOT EXISTS "project_columns_project" ON "project_columns" ("project_id");
44
45--> statement-breakpoint
46CREATE TABLE IF NOT EXISTS "project_items" (
47 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
48 "project_id" uuid NOT NULL,
49 "column_id" uuid NOT NULL,
50 "position" integer NOT NULL DEFAULT 0,
51 -- Card content. A card is EITHER a free-form note (note + title set) OR
52 -- a linked issue / pull_request (item_type + item_id set).
53 "item_type" text NOT NULL DEFAULT 'note', -- note | issue | pr
54 "item_id" uuid, -- FK handled per-type at app level
55 "title" text NOT NULL DEFAULT '',
56 "note" text NOT NULL DEFAULT '',
57 "created_at" timestamp DEFAULT now() NOT NULL,
58 "updated_at" timestamp DEFAULT now() NOT NULL,
59 CONSTRAINT "project_items_project_fk" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE cascade,
60 CONSTRAINT "project_items_column_fk" FOREIGN KEY ("column_id") REFERENCES "project_columns"("id") ON DELETE cascade
61);
62
63--> statement-breakpoint
64CREATE INDEX IF NOT EXISTS "project_items_project" ON "project_items" ("project_id");
65--> statement-breakpoint
66CREATE INDEX IF NOT EXISTS "project_items_column" ON "project_items" ("column_id", "position");
Addeddrizzle/0016_wikis.sql+46−0View fileUnifiedSplit
1-- Gluecron migration 0016: Block E3 — Wikis.
2--
3-- DB-backed wiki for v1 (git-backed mirror is a future upgrade). Each repo
4-- owns a collection of wiki_pages keyed on slug, with revisions stored
5-- incrementally for history/diff/revert.
6--
7-- Tables:
8-- wiki_pages — current content per slug
9-- wiki_revisions — append-only history (body + message + author)
10
11--> statement-breakpoint
12CREATE TABLE IF NOT EXISTS "wiki_pages" (
13 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
14 "repository_id" uuid NOT NULL,
15 "slug" text NOT NULL,
16 "title" text NOT NULL,
17 "body" text NOT NULL DEFAULT '',
18 "revision" integer NOT NULL DEFAULT 1,
19 "created_at" timestamp DEFAULT now() NOT NULL,
20 "updated_at" timestamp DEFAULT now() NOT NULL,
21 "updated_by" uuid,
22 CONSTRAINT "wiki_pages_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade,
23 CONSTRAINT "wiki_pages_author_fk" FOREIGN KEY ("updated_by") REFERENCES "users"("id")
24);
25
26--> statement-breakpoint
27CREATE INDEX IF NOT EXISTS "wiki_pages_repo" ON "wiki_pages" ("repository_id");
28--> statement-breakpoint
29CREATE UNIQUE INDEX IF NOT EXISTS "wiki_pages_repo_slug" ON "wiki_pages" ("repository_id", "slug");
30
31--> statement-breakpoint
32CREATE TABLE IF NOT EXISTS "wiki_revisions" (
33 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
34 "page_id" uuid NOT NULL,
35 "revision" integer NOT NULL,
36 "title" text NOT NULL,
37 "body" text NOT NULL DEFAULT '',
38 "message" text,
39 "author_id" uuid NOT NULL,
40 "created_at" timestamp DEFAULT now() NOT NULL,
41 CONSTRAINT "wiki_revisions_page_fk" FOREIGN KEY ("page_id") REFERENCES "wiki_pages"("id") ON DELETE cascade,
42 CONSTRAINT "wiki_revisions_author_fk" FOREIGN KEY ("author_id") REFERENCES "users"("id")
43);
44
45--> statement-breakpoint
46CREATE INDEX IF NOT EXISTS "wiki_revisions_page" ON "wiki_revisions" ("page_id", "revision");
Addedsrc/__tests__/ai-incident.test.ts+82−0View fileUnifiedSplit
1/**
2 * Block D4 — AI Incident Responder tests.
3 *
4 * These tests exercise the pure helper (no I/O) and confirm that
5 * `onDeployFailure` degrades gracefully when the repository does not exist
6 * (or the DB is unavailable) without ever throwing.
7 */
8
9import { describe, it, expect } from "bun:test";
10import {
11 onDeployFailure,
12 summariseCommitsForIncident,
13} from "../lib/ai-incident";
14
15describe("lib/ai-incident — module shape", () => {
16 it("imports cleanly and exports the expected functions", () => {
17 expect(typeof summariseCommitsForIncident).toBe("function");
18 expect(typeof onDeployFailure).toBe("function");
19 });
20});
21
22describe("lib/ai-incident — summariseCommitsForIncident", () => {
23 it("formats each commit as `- <sha7> <subject> — <author>`", () => {
24 const out = summariseCommitsForIncident([
25 { sha: "abcdef1234567890", message: "fix: handle null", author: "alice" },
26 { sha: "fedcba0987654321", message: "feat: new thing", author: "bob" },
27 ]);
28 expect(out).toBe(
29 "- abcdef1 fix: handle null — alice\n" +
30 "- fedcba0 feat: new thing — bob"
31 );
32 });
33
34 it("keeps only the first line of multi-line commit messages", () => {
35 const out = summariseCommitsForIncident([
36 {
37 sha: "1111111aaaaaaa",
38 message: "first line\n\nbody goes here",
39 author: "carol",
40 },
41 ]);
42 expect(out).toBe("- 1111111 first line — carol");
43 });
44
45 it("handles an empty list as an empty string", () => {
46 expect(summariseCommitsForIncident([])).toBe("");
47 });
48
49 it("tolerates missing fields without throwing", () => {
50 const out = summariseCommitsForIncident([
51 { sha: "", message: "", author: "" },
52 ]);
53 // Expect a renderable line even when fields are blank.
54 expect(out).toContain(" — ");
55 });
56});
57
58describe("lib/ai-incident — onDeployFailure", () => {
59 it("returns { issueNumber: null, reason: <non-empty> } for an unknown repositoryId without throwing", async () => {
60 const result = await onDeployFailure({
61 repositoryId: "00000000-0000-0000-0000-000000000000",
62 deploymentId: "00000000-0000-0000-0000-000000000000",
63 ref: "refs/heads/main",
64 commitSha: "0".repeat(40),
65 target: "crontech",
66 errorMessage: "HTTP 500",
67 });
68 expect(result).toBeDefined();
69 expect(result.issueNumber).toBeNull();
70 expect(typeof result.reason).toBe("string");
71 expect(result.reason.length).toBeGreaterThan(0);
72 });
73
74 it("never throws even when all optional fields are missing", async () => {
75 await expect(
76 onDeployFailure({
77 repositoryId: "00000000-0000-0000-0000-000000000000",
78 deploymentId: "00000000-0000-0000-0000-000000000000",
79 })
80 ).resolves.toBeDefined();
81 });
82});
Addedsrc/__tests__/ai-tests.test.ts+276−0View fileUnifiedSplit
1/**
2 * Block D8 — AI-generated test suite tests.
3 *
4 * Pure-helper tests run directly; route tests tolerate the usual graceful
5 * degradation envelope (200 / 404 / 503) because these suites run without
6 * a live database.
7 */
8
9import { describe, it, expect } from "bun:test";
10import {
11 buildTestsPrompt,
12 contentTypeFor,
13 detectLanguage,
14 detectTestFramework,
15 generateTestStub,
16} from "../lib/ai-tests";
17
18// ---------------------------------------------------------------------------
19// detectLanguage
20// ---------------------------------------------------------------------------
21
22describe("lib/ai-tests — detectLanguage", () => {
23 it("maps .ts and .tsx to typescript", () => {
24 expect(detectLanguage("src/foo.ts")).toBe("typescript");
25 expect(detectLanguage("src/Foo.tsx")).toBe("typescript");
26 });
27
28 it("maps .js and .jsx to javascript", () => {
29 expect(detectLanguage("lib/foo.js")).toBe("javascript");
30 expect(detectLanguage("lib/bar.jsx")).toBe("javascript");
31 });
32
33 it("maps .py to python", () => {
34 expect(detectLanguage("pkg/mod.py")).toBe("python");
35 });
36
37 it("maps .go to go", () => {
38 expect(detectLanguage("cmd/main.go")).toBe("go");
39 });
40
41 it("maps .rs to rust", () => {
42 expect(detectLanguage("src/lib.rs")).toBe("rust");
43 });
44
45 it("maps unknown / extensionless to other", () => {
46 expect(detectLanguage("notes.txt")).toBe("other");
47 expect(detectLanguage("Makefile")).toBe("other");
48 expect(detectLanguage("README")).toBe("other");
49 });
50});
51
52// ---------------------------------------------------------------------------
53// detectTestFramework
54// ---------------------------------------------------------------------------
55
56describe("lib/ai-tests — detectTestFramework", () => {
57 it("returns bun:test when repo looks bun-ish with jest-style test files", () => {
58 const f = detectTestFramework("typescript", [
59 "package.json",
60 "bun.lockb",
61 "src/__tests__/foo.test.ts",
62 "src/__tests__/bar.test.ts",
63 ]);
64 expect(f).toBe("bun:test");
65 });
66
67 it("returns vitest when vitest.config.ts is present", () => {
68 const f = detectTestFramework("typescript", [
69 "package.json",
70 "vitest.config.ts",
71 "src/foo.ts",
72 ]);
73 expect(f).toBe("vitest");
74 });
75
76 it("returns jest when a jest.config.* file is present", () => {
77 const f = detectTestFramework("javascript", [
78 "package.json",
79 "jest.config.js",
80 ]);
81 expect(f).toBe("jest");
82 });
83
84 it("returns pytest when pytest.ini is present", () => {
85 const f = detectTestFramework("python", [
86 "pytest.ini",
87 "pkg/__init__.py",
88 "pkg/mod.py",
89 ]);
90 expect(f).toBe("pytest");
91 });
92
93 it("returns pytest for python regardless of signals (sensible default)", () => {
94 expect(detectTestFramework("python", [])).toBe("pytest");
95 });
96
97 it("returns go test for go language", () => {
98 expect(detectTestFramework("go", ["go.mod"])).toBe("go test");
99 });
100
101 it("falls back to bun:test when repoFiles is empty", () => {
102 expect(detectTestFramework("typescript", [])).toBe("bun:test");
103 expect(detectTestFramework("javascript", [])).toBe("bun:test");
104 expect(detectTestFramework("other", [])).toBe("bun:test");
105 });
106});
107
108// ---------------------------------------------------------------------------
109// buildTestsPrompt
110// ---------------------------------------------------------------------------
111
112describe("lib/ai-tests — buildTestsPrompt", () => {
113 it("embeds the source code and file path", () => {
114 const src = "export function add(a: number, b: number): number { return a + b; }";
115 const prompt = buildTestsPrompt({
116 path: "src/math.ts",
117 language: "typescript",
118 framework: "bun:test",
119 sourceCode: src,
120 });
121 expect(prompt).toContain("src/math.ts");
122 expect(prompt).toContain("bun:test");
123 expect(prompt).toContain(src);
124 });
125
126 it("explicitly instructs Claude to emit FAILING stubs", () => {
127 const prompt = buildTestsPrompt({
128 path: "foo.ts",
129 language: "typescript",
130 framework: "bun:test",
131 sourceCode: "export const x = 1;",
132 });
133 // Allow either casing — implementation uses *failing* with emphasis.
134 expect(prompt.toLowerCase()).toContain("failing");
135 });
136
137 it("includes optional apiHints when provided", () => {
138 const prompt = buildTestsPrompt({
139 path: "foo.py",
140 language: "python",
141 framework: "pytest",
142 sourceCode: "def add(a, b): return a + b",
143 apiHints: "add() returns the arithmetic sum",
144 });
145 expect(prompt).toContain("add() returns the arithmetic sum");
146 });
147
148 it("truncates excessively large source files", () => {
149 const huge = "a".repeat(50_000);
150 const prompt = buildTestsPrompt({
151 path: "big.ts",
152 language: "typescript",
153 framework: "bun:test",
154 sourceCode: huge,
155 });
156 expect(prompt.length).toBeLessThan(huge.length + 4_000);
157 expect(prompt).toContain("truncated");
158 });
159});
160
161// ---------------------------------------------------------------------------
162// generateTestStub
163// ---------------------------------------------------------------------------
164
165describe("lib/ai-tests — generateTestStub (AI unavailable)", () => {
166 const hadKey = !!process.env.ANTHROPIC_API_KEY;
167 const originalKey = process.env.ANTHROPIC_API_KEY;
168
169 // Ensure we exercise the AI-unavailable path deterministically.
170 if (hadKey) delete process.env.ANTHROPIC_API_KEY;
171
172 it("returns empty code and framework=fallback when no API key is set", async () => {
173 const result = await generateTestStub({
174 path: "src/lib/foo.ts",
175 language: "typescript",
176 framework: "bun:test",
177 sourceCode: "export const x = 1;",
178 });
179 expect(result.code).toBe("");
180 expect(result.framework).toBe("fallback");
181 expect(result.language).toBe("typescript");
182 });
183
184 it("still computes a sensible suggestedPath for bun:test", async () => {
185 const result = await generateTestStub({
186 path: "src/lib/foo.ts",
187 language: "typescript",
188 framework: "bun:test",
189 sourceCode: "export const x = 1;",
190 });
191 expect(result.suggestedPath).toContain("foo");
192 expect(result.suggestedPath).toContain(".test.");
193 });
194
195 it("picks `test_*.py` for pytest", async () => {
196 const result = await generateTestStub({
197 path: "pkg/widgets.py",
198 language: "python",
199 framework: "pytest",
200 sourceCode: "def f(): pass",
201 });
202 expect(result.suggestedPath.endsWith("test_widgets.py")).toBe(true);
203 });
204
205 it("picks `*_test.go` for go test", async () => {
206 const result = await generateTestStub({
207 path: "cmd/server.go",
208 language: "go",
209 framework: "go test",
210 sourceCode: "package main",
211 });
212 expect(result.suggestedPath.endsWith("server_test.go")).toBe(true);
213 });
214
215 // Restore the env we found on entry — other test files may depend on it.
216 if (hadKey && originalKey !== undefined) {
217 process.env.ANTHROPIC_API_KEY = originalKey;
218 }
219});
220
221// ---------------------------------------------------------------------------
222// contentTypeFor
223// ---------------------------------------------------------------------------
224
225describe("lib/ai-tests — contentTypeFor", () => {
226 it("returns a typescript MIME for typescript", () => {
227 expect(contentTypeFor("typescript")).toContain("typescript");
228 });
229 it("returns a python MIME for python", () => {
230 expect(contentTypeFor("python")).toContain("python");
231 });
232 it("returns a safe plain fallback for unknown", () => {
233 expect(contentTypeFor("other")).toContain("text/plain");
234 });
235});
236
237// ---------------------------------------------------------------------------
238// Route-level guard tests
239// ---------------------------------------------------------------------------
240
241describe("routes/ai-tests — guards", () => {
242 it("GET /:owner/:repo/ai/tests renders a form or 404s when repo doesn't exist", async () => {
243 const { default: aiTestsRoutes } = await import("../routes/ai-tests");
244 const res = await aiTestsRoutes.request("/alice/does-not-exist/ai/tests");
245 // 200 means the page rendered a form (repo exists, somehow), 404 means
246 // our handler matched but the repo row was absent, 503 means the DB
247 // proxy was down. Any of those is acceptable in CI.
248 expect([200, 404, 503]).toContain(res.status);
249 });
250
251 it("POST /:owner/:repo/ai/tests/generate without auth redirects to /login or 404s", async () => {
252 const { default: aiTestsRoutes } = await import("../routes/ai-tests");
253 const res = await aiTestsRoutes.request(
254 "/alice/does-not-exist/ai/tests/generate",
255 {
256 method: "POST",
257 redirect: "manual",
258 headers: { "content-type": "application/x-www-form-urlencoded" },
259 body: "path=src/foo.ts",
260 }
261 );
262 expect([302, 303, 404, 503]).toContain(res.status);
263 if (res.status === 302 || res.status === 303) {
264 const loc = res.headers.get("location") || "";
265 expect(loc).toContain("/login");
266 }
267 });
268
269 it("GET /:owner/:repo/ai/tests?format=raw without path returns 400 / 404 / 503", async () => {
270 const { default: aiTestsRoutes } = await import("../routes/ai-tests");
271 const res = await aiTestsRoutes.request(
272 "/alice/does-not-exist/ai/tests?format=raw"
273 );
274 expect([400, 404, 503]).toContain(res.status);
275 });
276});
Addedsrc/__tests__/branch-protection.test.ts+140−0View fileUnifiedSplit
1/**
2 * Block D5 — branch-protection enforcement unit tests.
3 * Covers `evaluateProtection` (pure) with various rule shapes + contexts.
4 */
5
6import { describe, expect, test } from "bun:test";
7import { evaluateProtection } from "../lib/branch-protection";
8import type { BranchProtection } from "../db/schema";
9
10function rule(overrides: Partial<BranchProtection>): BranchProtection {
11 return {
12 id: "id",
13 repositoryId: "repo",
14 pattern: "main",
15 requirePullRequest: true,
16 requireGreenGates: false,
17 requireAiApproval: false,
18 requireHumanReview: false,
19 requiredApprovals: 0,
20 allowForcePush: false,
21 allowDeletion: false,
22 dismissStaleReviews: true,
23 createdAt: new Date(),
24 updatedAt: new Date(),
25 ...overrides,
26 } as BranchProtection;
27}
28
29describe("evaluateProtection", () => {
30 test("no rule → allowed with no reasons", () => {
31 const r = evaluateProtection(null, {
32 aiApproved: false,
33 humanApprovalCount: 0,
34 gateResultGreen: false,
35 hasFailedGates: true,
36 });
37 expect(r.allowed).toBe(true);
38 expect(r.reasons).toEqual([]);
39 });
40
41 test("requireAiApproval blocks when not approved", () => {
42 const r = evaluateProtection(
43 rule({ requireAiApproval: true }),
44 {
45 aiApproved: false,
46 humanApprovalCount: 0,
47 gateResultGreen: true,
48 hasFailedGates: false,
49 }
50 );
51 expect(r.allowed).toBe(false);
52 expect(r.reasons[0]).toMatch(/AI approval/i);
53 });
54
55 test("requireAiApproval allows when approved", () => {
56 const r = evaluateProtection(
57 rule({ requireAiApproval: true }),
58 {
59 aiApproved: true,
60 humanApprovalCount: 0,
61 gateResultGreen: true,
62 hasFailedGates: false,
63 }
64 );
65 expect(r.allowed).toBe(true);
66 });
67
68 test("requireGreenGates blocks when failing", () => {
69 const r = evaluateProtection(
70 rule({ requireGreenGates: true }),
71 {
72 aiApproved: true,
73 humanApprovalCount: 1,
74 gateResultGreen: false,
75 hasFailedGates: true,
76 }
77 );
78 expect(r.allowed).toBe(false);
79 expect(r.reasons.some((x) => /green gates/i.test(x))).toBe(true);
80 });
81
82 test("requireHumanReview blocks when 0 approvals", () => {
83 const r = evaluateProtection(
84 rule({ requireHumanReview: true }),
85 {
86 aiApproved: true,
87 humanApprovalCount: 0,
88 gateResultGreen: true,
89 hasFailedGates: false,
90 }
91 );
92 expect(r.allowed).toBe(false);
93 expect(r.reasons[0]).toMatch(/human review/i);
94 });
95
96 test("requiredApprovals=2 blocks when only 1", () => {
97 const r = evaluateProtection(
98 rule({ requiredApprovals: 2 }),
99 {
100 aiApproved: true,
101 humanApprovalCount: 1,
102 gateResultGreen: true,
103 hasFailedGates: false,
104 }
105 );
106 expect(r.allowed).toBe(false);
107 expect(r.reasons[0]).toMatch(/2 approvals/i);
108 });
109
110 test("requiredApprovals=2 allows when 2 reached", () => {
111 const r = evaluateProtection(
112 rule({ requiredApprovals: 2 }),
113 {
114 aiApproved: true,
115 humanApprovalCount: 2,
116 gateResultGreen: true,
117 hasFailedGates: false,
118 }
119 );
120 expect(r.allowed).toBe(true);
121 });
122
123 test("multiple rules combine into multiple reasons", () => {
124 const r = evaluateProtection(
125 rule({
126 requireAiApproval: true,
127 requireGreenGates: true,
128 requireHumanReview: true,
129 }),
130 {
131 aiApproved: false,
132 humanApprovalCount: 0,
133 gateResultGreen: false,
134 hasFailedGates: true,
135 }
136 );
137 expect(r.allowed).toBe(false);
138 expect(r.reasons.length).toBeGreaterThanOrEqual(3);
139 });
140});
Addedsrc/__tests__/discussions.test.ts+75−0View fileUnifiedSplit
1/**
2 * Block E2 — Discussions smoke tests.
3 *
4 * Route integration paths against a real repo would require a seeded test DB;
5 * we stick to category validation + public route behaviour (anon access,
6 * auth redirects) which exercise middleware + mounting.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import { isValidCategory } from "../routes/discussions";
12
13describe("discussions — isValidCategory", () => {
14 it("accepts the five canonical categories", () => {
15 expect(isValidCategory("general")).toBe(true);
16 expect(isValidCategory("q-and-a")).toBe(true);
17 expect(isValidCategory("ideas")).toBe(true);
18 expect(isValidCategory("announcements")).toBe(true);
19 expect(isValidCategory("show-and-tell")).toBe(true);
20 });
21
22 it("rejects unknown categories", () => {
23 expect(isValidCategory("")).toBe(false);
24 expect(isValidCategory("random")).toBe(false);
25 expect(isValidCategory("Q-AND-A")).toBe(false);
26 expect(isValidCategory("sql injection'--")).toBe(false);
27 });
28});
29
30describe("discussions — route smoke", () => {
31 it("GET /:owner/:repo/discussions on missing repo → 404 HTML", async () => {
32 const res = await app.request("/nobody/missing/discussions");
33 expect(res.status).toBe(404);
34 const body = await res.text();
35 expect(body).toContain("Repository not found");
36 });
37
38 it("GET /:owner/:repo/discussions/new without auth → 302 /login", async () => {
39 const res = await app.request("/any/repo/discussions/new");
40 expect(res.status).toBe(302);
41 const loc = res.headers.get("location") || "";
42 expect(loc.startsWith("/login")).toBe(true);
43 });
44
45 it("POST /:owner/:repo/discussions without auth → 302 /login", async () => {
46 const res = await app.request("/any/repo/discussions", {
47 method: "POST",
48 body: new URLSearchParams({ title: "x", body: "y" }),
49 headers: { "content-type": "application/x-www-form-urlencoded" },
50 });
51 expect(res.status).toBe(302);
52 const loc = res.headers.get("location") || "";
53 expect(loc.startsWith("/login")).toBe(true);
54 });
55
56 it("POST /:owner/:repo/discussions/1/comment without auth → 302 /login", async () => {
57 const res = await app.request("/any/repo/discussions/1/comment", {
58 method: "POST",
59 body: new URLSearchParams({ body: "hi" }),
60 headers: { "content-type": "application/x-www-form-urlencoded" },
61 });
62 expect(res.status).toBe(302);
63 const loc = res.headers.get("location") || "";
64 expect(loc.startsWith("/login")).toBe(true);
65 });
66
67 it("POST /:owner/:repo/discussions/1/lock without auth → 302 /login", async () => {
68 const res = await app.request("/any/repo/discussions/1/lock", {
69 method: "POST",
70 });
71 expect(res.status).toBe(302);
72 const loc = res.headers.get("location") || "";
73 expect(loc.startsWith("/login")).toBe(true);
74 });
75});
Addedsrc/__tests__/gists.test.ts+98−0View fileUnifiedSplit
1/**
2 * Block E4 — Gists smoke tests.
3 *
4 * Full CRUD integration paths require a seeded test DB; we stick to pure
5 * helpers (`generateSlug`, `snapshotOf`) + public route behaviour
6 * (auth redirects, 404s) which exercise middleware + mounting.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import { generateSlug, snapshotOf } from "../routes/gists";
12
13describe("gists — generateSlug", () => {
14 it("returns an 8-char lowercase hex string", () => {
15 const s = generateSlug();
16 expect(s).toMatch(/^[0-9a-f]{8}$/);
17 });
18
19 it("returns different values across calls", () => {
20 const a = generateSlug();
21 const b = generateSlug();
22 expect(a).not.toBe(b);
23 });
24});
25
26describe("gists — snapshotOf", () => {
27 it("JSON-encodes filename → content map", () => {
28 const snap = snapshotOf([
29 { filename: "a.ts", content: "export const a = 1;" },
30 { filename: "b.md", content: "# hello" },
31 ]);
32 const parsed = JSON.parse(snap);
33 expect(parsed["a.ts"]).toBe("export const a = 1;");
34 expect(parsed["b.md"]).toBe("# hello");
35 });
36
37 it("handles empty input", () => {
38 expect(snapshotOf([])).toBe("{}");
39 });
40
41 it("last duplicate filename wins", () => {
42 const snap = snapshotOf([
43 { filename: "x", content: "first" },
44 { filename: "x", content: "second" },
45 ]);
46 expect(JSON.parse(snap).x).toBe("second");
47 });
48});
49
50describe("gists — route smoke", () => {
51 it("GET /gists → 200 HTML", async () => {
52 const res = await app.request("/gists");
53 expect(res.status).toBe(200);
54 });
55
56 it("GET /gists/new without auth → 302 /login", async () => {
57 const res = await app.request("/gists/new");
58 expect(res.status).toBe(302);
59 expect(res.headers.get("location") || "").toMatch(/^\/login/);
60 });
61
62 it("POST /gists without auth → 302 /login", async () => {
63 const res = await app.request("/gists", {
64 method: "POST",
65 body: new URLSearchParams({ description: "x" }),
66 headers: { "content-type": "application/x-www-form-urlencoded" },
67 });
68 expect(res.status).toBe(302);
69 expect(res.headers.get("location") || "").toMatch(/^\/login/);
70 });
71
72 it("GET /gists/nonexistent → 404", async () => {
73 const res = await app.request("/gists/ffffffff");
74 expect(res.status).toBe(404);
75 });
76
77 it("GET /gists/xxx/edit without auth → 302 /login", async () => {
78 const res = await app.request("/gists/abc12345/edit");
79 expect(res.status).toBe(302);
80 expect(res.headers.get("location") || "").toMatch(/^\/login/);
81 });
82
83 it("POST /gists/xxx/delete without auth → 302 /login", async () => {
84 const res = await app.request("/gists/abc12345/delete", {
85 method: "POST",
86 });
87 expect(res.status).toBe(302);
88 expect(res.headers.get("location") || "").toMatch(/^\/login/);
89 });
90
91 it("POST /gists/xxx/star without auth → 302 /login", async () => {
92 const res = await app.request("/gists/abc12345/star", {
93 method: "POST",
94 });
95 expect(res.status).toBe(302);
96 expect(res.headers.get("location") || "").toMatch(/^\/login/);
97 });
98});
Addedsrc/__tests__/projects.test.ts+57−0View fileUnifiedSplit
1/**
2 * Block E1 — Projects / kanban smoke tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7
8describe("projects — route smoke", () => {
9 it("GET /:owner/:repo/projects on missing repo → 404", async () => {
10 const res = await app.request("/nobody/missing/projects");
11 expect(res.status).toBe(404);
12 });
13
14 it("GET /:owner/:repo/projects/new without auth → 302 /login", async () => {
15 const res = await app.request("/any/repo/projects/new");
16 expect(res.status).toBe(302);
17 expect(res.headers.get("location") || "").toMatch(/^\/login/);
18 });
19
20 it("POST /:owner/:repo/projects without auth → 302 /login", async () => {
21 const res = await app.request("/any/repo/projects", {
22 method: "POST",
23 body: new URLSearchParams({ title: "x" }),
24 headers: { "content-type": "application/x-www-form-urlencoded" },
25 });
26 expect(res.status).toBe(302);
27 expect(res.headers.get("location") || "").toMatch(/^\/login/);
28 });
29
30 it("POST /:owner/:repo/projects/1/columns without auth → 302 /login", async () => {
31 const res = await app.request("/any/repo/projects/1/columns", {
32 method: "POST",
33 body: new URLSearchParams({ name: "Later" }),
34 headers: { "content-type": "application/x-www-form-urlencoded" },
35 });
36 expect(res.status).toBe(302);
37 expect(res.headers.get("location") || "").toMatch(/^\/login/);
38 });
39
40 it("POST /:owner/:repo/projects/1/items without auth → 302 /login", async () => {
41 const res = await app.request("/any/repo/projects/1/items", {
42 method: "POST",
43 body: new URLSearchParams({ column_id: "x", title: "card" }),
44 headers: { "content-type": "application/x-www-form-urlencoded" },
45 });
46 expect(res.status).toBe(302);
47 expect(res.headers.get("location") || "").toMatch(/^\/login/);
48 });
49
50 it("POST close without auth → 302 /login", async () => {
51 const res = await app.request("/any/repo/projects/1/close", {
52 method: "POST",
53 });
54 expect(res.status).toBe(302);
55 expect(res.headers.get("location") || "").toMatch(/^\/login/);
56 });
57});
Addedsrc/__tests__/wikis.test.ts+83−0View fileUnifiedSplit
1/**
2 * Block E3 — Wikis smoke tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7import { slugifyTitle } from "../routes/wikis";
8
9describe("wikis — slugifyTitle", () => {
10 it("lowercases and dashes simple titles", () => {
11 expect(slugifyTitle("Home")).toBe("home");
12 expect(slugifyTitle("Getting Started")).toBe("getting-started");
13 });
14
15 it("strips punctuation", () => {
16 expect(slugifyTitle("Hello, World!")).toBe("hello-world");
17 expect(slugifyTitle("What's up?")).toBe("whats-up");
18 });
19
20 it("collapses consecutive spaces/dashes", () => {
21 expect(slugifyTitle("a b")).toBe("a-b");
22 expect(slugifyTitle("a---b")).toBe("a-b");
23 });
24
25 it("trims leading/trailing dashes", () => {
26 expect(slugifyTitle(" hi ")).toBe("hi");
27 expect(slugifyTitle("-hi-")).toBe("hi");
28 });
29
30 it("returns empty string when nothing usable", () => {
31 expect(slugifyTitle("")).toBe("");
32 expect(slugifyTitle("***")).toBe("");
33 });
34});
35
36describe("wikis — route smoke", () => {
37 it("GET /:owner/:repo/wiki on missing repo → 404", async () => {
38 const res = await app.request("/nobody/missing/wiki");
39 expect(res.status).toBe(404);
40 });
41
42 it("GET /:owner/:repo/wiki/new without auth → 302 /login", async () => {
43 const res = await app.request("/any/repo/wiki/new");
44 expect(res.status).toBe(302);
45 expect(res.headers.get("location") || "").toMatch(/^\/login/);
46 });
47
48 it("POST /:owner/:repo/wiki without auth → 302 /login", async () => {
49 const res = await app.request("/any/repo/wiki", {
50 method: "POST",
51 body: new URLSearchParams({ title: "Home", body: "welcome" }),
52 headers: { "content-type": "application/x-www-form-urlencoded" },
53 });
54 expect(res.status).toBe(302);
55 expect(res.headers.get("location") || "").toMatch(/^\/login/);
56 });
57
58 it("POST edit without auth → 302 /login", async () => {
59 const res = await app.request("/any/repo/wiki/home/edit", {
60 method: "POST",
61 body: new URLSearchParams({ title: "x", body: "y" }),
62 headers: { "content-type": "application/x-www-form-urlencoded" },
63 });
64 expect(res.status).toBe(302);
65 expect(res.headers.get("location") || "").toMatch(/^\/login/);
66 });
67
68 it("POST delete without auth → 302 /login", async () => {
69 const res = await app.request("/any/repo/wiki/home/delete", {
70 method: "POST",
71 });
72 expect(res.status).toBe(302);
73 expect(res.headers.get("location") || "").toMatch(/^\/login/);
74 });
75
76 it("POST revert without auth → 302 /login", async () => {
77 const res = await app.request("/any/repo/wiki/home/revert/1", {
78 method: "POST",
79 });
80 expect(res.status).toBe(302);
81 expect(res.headers.get("location") || "").toMatch(/^\/login/);
82 });
83});
Modifiedsrc/app.tsx+10−0View fileUnifiedSplit
4848import copilotRoutes from "./routes/copilot";
4949import depUpdaterRoutes from "./routes/dep-updater";
5050import semanticSearchRoutes from "./routes/semantic-search";
51import aiTestsRoutes from "./routes/ai-tests";
52import discussionRoutes from "./routes/discussions";
53import gistRoutes from "./routes/gists";
54import projectRoutes from "./routes/projects";
55import wikiRoutes from "./routes/wikis";
5156import webRoutes from "./routes/web";
5257
5358const app = new Hono();
177182app.route("/", copilotRoutes); // D9 — /api/copilot/completions
178183app.route("/", depUpdaterRoutes); // D2 — /:owner/:repo/settings/dep-updater
179184app.route("/", semanticSearchRoutes); // D1 — /:owner/:repo/search/semantic
185app.route("/", aiTestsRoutes); // D8 — /:owner/:repo/ai/tests
186app.route("/", discussionRoutes); // E2 — /:owner/:repo/discussions
187app.route("/", gistRoutes); // E4 — /gists, /gists/:slug, /:user/gists
188app.route("/", projectRoutes); // E1 — /:owner/:repo/projects
189app.route("/", wikiRoutes); // E3 — /:owner/:repo/wiki
180190
181191// Insights + milestones
182192app.route("/", insightsRoutes);
Modifiedsrc/db/schema.ts+268−0View fileUnifiedSplit
13431343
13441344export type CodebaseExplanation = typeof codebaseExplanations.$inferSelect;
13451345export type DepUpdateRun = typeof depUpdateRuns.$inferSelect;
1346
1347// ---------------------------------------------------------------------------
1348// Block E2 — Discussions (migration 0013)
1349// ---------------------------------------------------------------------------
1350
1351/**
1352 * Discussions — forum-style threaded conversations attached to a repo.
1353 * Similar to GitHub Discussions: categorised + pinnable + answerable.
1354 */
1355export const discussions = pgTable(
1356 "discussions",
1357 {
1358 id: uuid("id").primaryKey().defaultRandom(),
1359 number: serial("number"),
1360 repositoryId: uuid("repository_id")
1361 .notNull()
1362 .references(() => repositories.id, { onDelete: "cascade" }),
1363 authorId: uuid("author_id")
1364 .notNull()
1365 .references(() => users.id),
1366 // one of: "general" | "q-and-a" | "ideas" | "announcements" | "show-and-tell"
1367 category: text("category").notNull().default("general"),
1368 title: text("title").notNull(),
1369 body: text("body"),
1370 state: text("state").notNull().default("open"), // open, closed
1371 locked: boolean("locked").notNull().default(false),
1372 answerCommentId: uuid("answer_comment_id"),
1373 pinned: boolean("pinned").notNull().default(false),
1374 createdAt: timestamp("created_at").defaultNow().notNull(),
1375 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1376 },
1377 (table) => [
1378 index("discussions_repo").on(table.repositoryId),
1379 uniqueIndex("discussions_repo_number").on(
1380 table.repositoryId,
1381 table.number
1382 ),
1383 ]
1384);
1385
1386export const discussionComments = pgTable(
1387 "discussion_comments",
1388 {
1389 id: uuid("id").primaryKey().defaultRandom(),
1390 discussionId: uuid("discussion_id")
1391 .notNull()
1392 .references(() => discussions.id, { onDelete: "cascade" }),
1393 parentCommentId: uuid("parent_comment_id"),
1394 authorId: uuid("author_id")
1395 .notNull()
1396 .references(() => users.id),
1397 body: text("body").notNull(),
1398 isAnswer: boolean("is_answer").notNull().default(false),
1399 createdAt: timestamp("created_at").defaultNow().notNull(),
1400 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1401 },
1402 (table) => [
1403 index("discussion_comments_discussion").on(table.discussionId),
1404 ]
1405);
1406
1407export type Discussion = typeof discussions.$inferSelect;
1408export type DiscussionComment = typeof discussionComments.$inferSelect;
13461409export type CodeChunk = typeof codeChunks.$inferSelect;
1410
1411// ---------------------------------------------------------------------------
1412// Block E4 — Gists (migration 0014)
1413// ---------------------------------------------------------------------------
1414//
1415// User-owned small snippets/files that behave like tiny repos. DB-backed
1416// for v1 (no bare git repo): each gist owns a collection of gist_files and
1417// every edit appends a gist_revisions row with a JSON snapshot.
1418
1419export const gists = pgTable(
1420 "gists",
1421 {
1422 id: uuid("id").primaryKey().defaultRandom(),
1423 ownerId: uuid("owner_id")
1424 .notNull()
1425 .references(() => users.id, { onDelete: "cascade" }),
1426 // 8-char hex slug used in pretty URLs (e.g. /gists/a1b2c3d4).
1427 slug: text("slug").notNull().unique(),
1428 title: text("title").notNull().default(""),
1429 description: text("description").notNull().default(""),
1430 isPublic: boolean("is_public").default(true).notNull(),
1431 createdAt: timestamp("created_at").defaultNow().notNull(),
1432 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1433 },
1434 (table) => [index("gists_owner").on(table.ownerId)]
1435);
1436
1437export const gistFiles = pgTable(
1438 "gist_files",
1439 {
1440 id: uuid("id").primaryKey().defaultRandom(),
1441 gistId: uuid("gist_id")
1442 .notNull()
1443 .references(() => gists.id, { onDelete: "cascade" }),
1444 filename: text("filename").notNull(),
1445 // Optional explicit language override; falls back to filename detection.
1446 language: text("language"),
1447 content: text("content").notNull().default(""),
1448 sizeBytes: integer("size_bytes").default(0).notNull(),
1449 },
1450 (table) => [
1451 index("gist_files_gist").on(table.gistId),
1452 uniqueIndex("gist_files_gist_filename").on(table.gistId, table.filename),
1453 ]
1454);
1455
1456export const gistRevisions = pgTable(
1457 "gist_revisions",
1458 {
1459 id: uuid("id").primaryKey().defaultRandom(),
1460 gistId: uuid("gist_id")
1461 .notNull()
1462 .references(() => gists.id, { onDelete: "cascade" }),
1463 revision: integer("revision").notNull(),
1464 // JSON-encoded {filename: content} map capturing the full snapshot at
1465 // this revision. Stored as text to avoid requiring jsonb.
1466 snapshot: text("snapshot").notNull().default("{}"),
1467 authorId: uuid("author_id")
1468 .notNull()
1469 .references(() => users.id, { onDelete: "cascade" }),
1470 message: text("message"),
1471 createdAt: timestamp("created_at").defaultNow().notNull(),
1472 },
1473 (table) => [
1474 index("gist_revisions_gist_rev").on(table.gistId, table.revision),
1475 ]
1476);
1477
1478export const gistStars = pgTable(
1479 "gist_stars",
1480 {
1481 id: uuid("id").primaryKey().defaultRandom(),
1482 gistId: uuid("gist_id")
1483 .notNull()
1484 .references(() => gists.id, { onDelete: "cascade" }),
1485 userId: uuid("user_id")
1486 .notNull()
1487 .references(() => users.id, { onDelete: "cascade" }),
1488 createdAt: timestamp("created_at").defaultNow().notNull(),
1489 },
1490 (table) => [uniqueIndex("gist_stars_gist_user").on(table.gistId, table.userId)]
1491);
1492
1493export type Gist = typeof gists.$inferSelect;
1494export type GistFile = typeof gistFiles.$inferSelect;
1495export type GistRevision = typeof gistRevisions.$inferSelect;
1496export type GistStar = typeof gistStars.$inferSelect;
1497
1498// ---------------------------------------------------------------------------
1499// Block E1 — Projects / kanban (migration 0015)
1500// ---------------------------------------------------------------------------
1501
1502export const projects = pgTable(
1503 "projects",
1504 {
1505 id: uuid("id").primaryKey().defaultRandom(),
1506 number: serial("number"),
1507 repositoryId: uuid("repository_id")
1508 .notNull()
1509 .references(() => repositories.id, { onDelete: "cascade" }),
1510 ownerId: uuid("owner_id")
1511 .notNull()
1512 .references(() => users.id),
1513 title: text("title").notNull(),
1514 description: text("description").notNull().default(""),
1515 state: text("state").notNull().default("open"), // open | closed
1516 createdAt: timestamp("created_at").defaultNow().notNull(),
1517 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1518 },
1519 (table) => [
1520 index("projects_repo").on(table.repositoryId),
1521 uniqueIndex("projects_repo_number").on(table.repositoryId, table.number),
1522 ]
1523);
1524
1525export const projectColumns = pgTable(
1526 "project_columns",
1527 {
1528 id: uuid("id").primaryKey().defaultRandom(),
1529 projectId: uuid("project_id")
1530 .notNull()
1531 .references(() => projects.id, { onDelete: "cascade" }),
1532 name: text("name").notNull(),
1533 position: integer("position").notNull().default(0),
1534 createdAt: timestamp("created_at").defaultNow().notNull(),
1535 },
1536 (table) => [index("project_columns_project").on(table.projectId)]
1537);
1538
1539export const projectItems = pgTable(
1540 "project_items",
1541 {
1542 id: uuid("id").primaryKey().defaultRandom(),
1543 projectId: uuid("project_id")
1544 .notNull()
1545 .references(() => projects.id, { onDelete: "cascade" }),
1546 columnId: uuid("column_id")
1547 .notNull()
1548 .references(() => projectColumns.id, { onDelete: "cascade" }),
1549 position: integer("position").notNull().default(0),
1550 // "note" | "issue" | "pr" — application-level FK on itemId by type
1551 itemType: text("item_type").notNull().default("note"),
1552 itemId: uuid("item_id"),
1553 title: text("title").notNull().default(""),
1554 note: text("note").notNull().default(""),
1555 createdAt: timestamp("created_at").defaultNow().notNull(),
1556 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1557 },
1558 (table) => [
1559 index("project_items_project").on(table.projectId),
1560 index("project_items_column").on(table.columnId, table.position),
1561 ]
1562);
1563
1564export type Project = typeof projects.$inferSelect;
1565export type ProjectColumn = typeof projectColumns.$inferSelect;
1566export type ProjectItem = typeof projectItems.$inferSelect;
1567
1568// ---------------------------------------------------------------------------
1569// Block E3 — Wikis (migration 0016)
1570// ---------------------------------------------------------------------------
1571// DB-backed for v1; git-backed mirror is a future upgrade.
1572
1573export const wikiPages = pgTable(
1574 "wiki_pages",
1575 {
1576 id: uuid("id").primaryKey().defaultRandom(),
1577 repositoryId: uuid("repository_id")
1578 .notNull()
1579 .references(() => repositories.id, { onDelete: "cascade" }),
1580 slug: text("slug").notNull(),
1581 title: text("title").notNull(),
1582 body: text("body").notNull().default(""),
1583 revision: integer("revision").notNull().default(1),
1584 createdAt: timestamp("created_at").defaultNow().notNull(),
1585 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1586 updatedBy: uuid("updated_by").references(() => users.id),
1587 },
1588 (table) => [
1589 index("wiki_pages_repo").on(table.repositoryId),
1590 uniqueIndex("wiki_pages_repo_slug").on(table.repositoryId, table.slug),
1591 ]
1592);
1593
1594export const wikiRevisions = pgTable(
1595 "wiki_revisions",
1596 {
1597 id: uuid("id").primaryKey().defaultRandom(),
1598 pageId: uuid("page_id")
1599 .notNull()
1600 .references(() => wikiPages.id, { onDelete: "cascade" }),
1601 revision: integer("revision").notNull(),
1602 title: text("title").notNull(),
1603 body: text("body").notNull().default(""),
1604 message: text("message"),
1605 authorId: uuid("author_id")
1606 .notNull()
1607 .references(() => users.id),
1608 createdAt: timestamp("created_at").defaultNow().notNull(),
1609 },
1610 (table) => [index("wiki_revisions_page").on(table.pageId, table.revision)]
1611);
1612
1613export type WikiPage = typeof wikiPages.$inferSelect;
1614export type WikiRevision = typeof wikiRevisions.$inferSelect;
Modifiedsrc/hooks/post-receive.ts+22−0View fileUnifiedSplit
3232import { enqueueRun } from "../lib/workflow-runner";
3333import { onPagesPush } from "../lib/pages";
3434import { requiresApprovalFor } from "../lib/environments";
35import { onDeployFailure } from "../lib/ai-incident";
3536
3637interface PushRef {
3738 oldSha: string;
386387 })
387388 .where(eq(deployments.id, deployId));
388389 }
390 // D4: when Crontech returns a non-ok HTTP status, kick off the AI
391 // incident responder AFTER the deployment row is flipped to "failed".
392 if (!response.ok && deployId) {
393 void onDeployFailure({
394 repositoryId,
395 deploymentId: deployId,
396 ref: "refs/heads/main",
397 commitSha: sha,
398 target: "crontech",
399 errorMessage: `HTTP ${response.status}`,
400 }).catch((e) => console.error("[ai-incident]", e));
401 }
389402 } catch (err) {
390403 console.error(`[crontech] failed to trigger deploy:`, err);
391404 if (deployId) {
397410 completedAt: new Date(),
398411 })
399412 .where(eq(deployments.id, deployId));
413 // D4: fire-and-forget incident analysis AFTER marking the row failed.
414 void onDeployFailure({
415 repositoryId,
416 deploymentId: deployId,
417 ref: "refs/heads/main",
418 commitSha: sha,
419 target: "crontech",
420 errorMessage: (err as Error).message,
421 }).catch((e) => console.error("[ai-incident]", e));
400422 }
401423 }
402424}
Addedsrc/lib/ai-incident.ts+393−0View fileUnifiedSplit
1/**
2 * Block D4 — AI Incident Responder.
3 *
4 * When a deployment fails, this module automatically opens an issue with an
5 * AI-generated root-cause analysis. Invoked by the post-receive hook (from
6 * `triggerCrontechDeploy`) whenever the Crontech deploy call returns a non-2xx
7 * response or throws. Also retriggerable via the deployments route.
8 *
9 * Everything here degrades gracefully:
10 * - Without `ANTHROPIC_API_KEY` we still open an issue, just with a
11 * deterministic fallback body saying "AI analysis unavailable".
12 * - Any DB/git/network failure is caught; the function never throws and
13 * returns `{ issueNumber: null, reason: <err.message> }` instead.
14 */
15
16import { and, desc, eq } from "drizzle-orm";
17import { db } from "../db";
18import {
19 deployments,
20 issueLabels,
21 issues,
22 labels,
23 repositories,
24 users,
25} from "../db/schema";
26import { getDefaultBranch, listCommits } from "../git/repository";
27import {
28 MODEL_SONNET,
29 extractText,
30 getAnthropic,
31 isAiAvailable,
32 parseJsonResponse,
33} from "./ai-client";
34
35export interface IncidentAnalysis {
36 title: string;
37 likelyCause: string;
38 suspectedCommit: string | null;
39 remediation: string;
40}
41
42export interface OnDeployFailureArgs {
43 repositoryId: string;
44 deploymentId: string;
45 ref?: string | null;
46 commitSha?: string | null;
47 target?: string | null;
48 errorMessage?: string | null;
49}
50
51export interface OnDeployFailureResult {
52 issueNumber: number | null;
53 reason: string;
54}
55
56/**
57 * Format a list of commits for inclusion in the incident prompt / issue body.
58 * Pure helper — kept separate so it can be unit-tested without any I/O.
59 */
60export function summariseCommitsForIncident(
61 commits: { sha: string; message: string; author: string }[]
62): string {
63 return commits
64 .map((c) => {
65 const sha7 = (c.sha || "").slice(0, 7);
66 const subject = (c.message || "").split("\n")[0] || "";
67 const author = c.author || "unknown";
68 return `- ${sha7} ${subject}${author}`;
69 })
70 .join("\n");
71}
72
73function truncate(s: string | null | undefined, max: number): string {
74 if (!s) return "";
75 if (s.length <= max) return s;
76 return s.slice(0, max) + "\n…(truncated)";
77}
78
79function renderIssueBody(args: {
80 deploymentId: string;
81 ref: string;
82 shortSha: string;
83 target: string;
84 errorMessage: string;
85 likelyCause: string;
86 suspectedCommit: string | null;
87 remediation: string;
88}): string {
89 const suspected = args.suspectedCommit || "none";
90 const safeError = args.errorMessage || "(no error message captured)";
91 return [
92 `Automated by GlueCron incident responder after deployment ${args.deploymentId} failed.`,
93 "",
94 `**Ref:** \`${args.ref}\` (sha \`${args.shortSha}\`)`,
95 `**Target:** ${args.target}`,
96 "",
97 "## Error",
98 "```",
99 safeError,
100 "```",
101 "",
102 "## Likely cause",
103 args.likelyCause,
104 "",
105 "## Suspected commit",
106 suspected,
107 "",
108 "## Suggested remediation",
109 args.remediation,
110 "",
111 "---",
112 "_This issue was auto-generated. Edit or close if the analysis is off._",
113 ].join("\n");
114}
115
116async function askClaudeForAnalysis(
117 repoFullName: string,
118 ref: string,
119 shortSha: string,
120 errorMessage: string,
121 commitSummary: string
122): Promise<IncidentAnalysis | null> {
123 try {
124 const client = getAnthropic();
125 const message = await client.messages.create({
126 model: MODEL_SONNET,
127 max_tokens: 1024,
128 messages: [
129 {
130 role: "user",
131 content: `You are GlueCron's incident responder. A deployment just failed. Respond ONLY with JSON of the form:
132
133{"title": "...", "likelyCause": "...", "suspectedCommit": "<sha or null>", "remediation": "..."}
134
135Repository: ${repoFullName}
136Failing ref: ${ref} (sha ${shortSha})
137
138Error message:
139\`\`\`
140${truncate(errorMessage, 4000)}
141\`\`\`
142
143Recent commits (most recent first):
144${commitSummary || "(no commits available)"}
145
146Write a crisp issue title (prefixed with "Deploy failed:"), a plausible likelyCause (2-4 sentences), a suspectedCommit sha (or null if unclear), and concrete remediation steps (bullet list as a single string with \\n separators).`,
147 },
148 ],
149 });
150 const parsed = parseJsonResponse<IncidentAnalysis>(extractText(message));
151 if (!parsed) return null;
152 const suspected =
153 typeof parsed.suspectedCommit === "string" && parsed.suspectedCommit
154 ? parsed.suspectedCommit
155 : null;
156 return {
157 title:
158 typeof parsed.title === "string" && parsed.title.trim()
159 ? parsed.title.trim().slice(0, 200)
160 : `Deploy failed: ${repoFullName} @ ${shortSha}`,
161 likelyCause:
162 typeof parsed.likelyCause === "string" && parsed.likelyCause.trim()
163 ? parsed.likelyCause.trim()
164 : "Unknown — see raw error above.",
165 suspectedCommit: suspected,
166 remediation:
167 typeof parsed.remediation === "string" && parsed.remediation.trim()
168 ? parsed.remediation.trim()
169 : "Inspect logs manually and re-run the deployment.",
170 };
171 } catch (err) {
172 console.error("[ai-incident] analysis request failed:", err);
173 return null;
174 }
175}
176
177/**
178 * Entry point called by the post-receive hook and the retry-incident route.
179 * Never throws.
180 */
181export async function onDeployFailure(
182 args: OnDeployFailureArgs
183): Promise<OnDeployFailureResult> {
184 const ref = args.ref || "refs/heads/main";
185 const commitSha = args.commitSha || "";
186 const shortSha = commitSha ? commitSha.slice(0, 7) : "unknown";
187 const target = args.target || "unknown";
188 const errorMessage = args.errorMessage || "";
189
190 try {
191 // 1. Load repository + owner username for nice issue attribution.
192 const [repoRow] = await db
193 .select()
194 .from(repositories)
195 .where(eq(repositories.id, args.repositoryId))
196 .limit(1);
197 if (!repoRow) {
198 return { issueNumber: null, reason: "repository not found" };
199 }
200 const [ownerRow] = await db
201 .select()
202 .from(users)
203 .where(eq(users.id, repoRow.ownerId))
204 .limit(1);
205 if (!ownerRow) {
206 return { issueNumber: null, reason: "repository owner not found" };
207 }
208 const repoFullName = `${ownerRow.username}/${repoRow.name}`;
209
210 // 2. Load up to ~10 recent commits leading to commitSha, with a fallback
211 // to the default branch tip if the sha is missing / unresolvable.
212 let commits: Array<{ sha: string; message: string; author: string }> = [];
213 try {
214 const refForLog =
215 commitSha ||
216 (await getDefaultBranch(ownerRow.username, repoRow.name)) ||
217 repoRow.defaultBranch ||
218 "main";
219 const raw = await listCommits(
220 ownerRow.username,
221 repoRow.name,
222 refForLog,
223 10,
224 0
225 ).catch(() => [] as Awaited<ReturnType<typeof listCommits>>);
226 commits = raw.map((c) => ({
227 sha: c.sha,
228 message: c.message,
229 author: c.author,
230 }));
231 } catch {
232 commits = [];
233 }
234 const commitSummary = summariseCommitsForIncident(commits);
235
236 // 3. Ask Claude for an analysis, or fall back to a deterministic body.
237 let analysis: IncidentAnalysis;
238 if (isAiAvailable()) {
239 const ai = await askClaudeForAnalysis(
240 repoFullName,
241 ref,
242 shortSha,
243 errorMessage,
244 commitSummary
245 );
246 analysis = ai || {
247 title: `Deploy failed: ${repoFullName} @ ${shortSha}`,
248 likelyCause:
249 "AI analysis unavailable — inspect logs manually.\n\nRecent commits:\n" +
250 commitSummary,
251 suspectedCommit: commits[0]?.sha || null,
252 remediation:
253 "Inspect deployment logs and recent commits. Re-run the deploy once fixed.",
254 };
255 } else {
256 analysis = {
257 title: `Deploy failed: ${repoFullName} @ ${shortSha}`,
258 likelyCause:
259 "AI analysis unavailable — inspect logs manually.\n\nRecent commits:\n" +
260 (commitSummary || "(none)"),
261 suspectedCommit: commits[0]?.sha || null,
262 remediation:
263 "Inspect deployment logs and recent commits. Re-run the deploy once fixed.",
264 };
265 }
266
267 // 4. Render the Markdown issue body.
268 const body = renderIssueBody({
269 deploymentId: args.deploymentId,
270 ref,
271 shortSha,
272 target,
273 errorMessage,
274 likelyCause: analysis.likelyCause,
275 suspectedCommit: analysis.suspectedCommit,
276 remediation: analysis.remediation,
277 });
278
279 // 5. Insert the issue row. `issues.number` is a `serial()` — Postgres
280 // assigns the next number automatically, matching the pattern used
281 // in src/routes/issues.tsx.
282 let issueNumber: number | null = null;
283 try {
284 const [inserted] = await db
285 .insert(issues)
286 .values({
287 repositoryId: repoRow.id,
288 authorId: repoRow.ownerId,
289 title: analysis.title,
290 body,
291 state: "open",
292 })
293 .returning();
294 issueNumber = inserted?.number ?? null;
295
296 // Best-effort: attach the "incident" label if one exists for the repo.
297 if (inserted?.id) {
298 try {
299 const [incidentLabel] = await db
300 .select()
301 .from(labels)
302 .where(
303 and(
304 eq(labels.repositoryId, repoRow.id),
305 eq(labels.name, "incident")
306 )
307 )
308 .limit(1);
309 if (incidentLabel) {
310 await db
311 .insert(issueLabels)
312 .values({ issueId: inserted.id, labelId: incidentLabel.id })
313 .catch(() => {
314 /* ignore — the unique constraint may reject duplicates */
315 });
316 }
317 } catch {
318 /* best-effort */
319 }
320 }
321
322 // Bump the repo's issue count so the UI stays in sync.
323 try {
324 await db
325 .update(repositories)
326 .set({ issueCount: (repoRow.issueCount || 0) + 1 })
327 .where(eq(repositories.id, repoRow.id));
328 } catch {
329 /* best-effort */
330 }
331 } catch (err) {
332 return {
333 issueNumber: null,
334 reason: (err as Error).message || "issue insert failed",
335 };
336 }
337
338 // 6. Update the deployment's blockedReason to link the auto-issue, but
339 // only if the field is currently empty or looks like a raw error
340 // (i.e. NOT an admin-edited note).
341 if (issueNumber !== null) {
342 try {
343 const [depRow] = await db
344 .select()
345 .from(deployments)
346 .where(eq(deployments.id, args.deploymentId))
347 .limit(1);
348 const current = depRow?.blockedReason || "";
349 const looksAutoEditable =
350 !current ||
351 current === errorMessage ||
352 /^HTTP \d+/.test(current) ||
353 /^auto-issue #/.test(current);
354 if (depRow && looksAutoEditable) {
355 await db
356 .update(deployments)
357 .set({ blockedReason: `auto-issue #${issueNumber}` })
358 .where(eq(deployments.id, args.deploymentId));
359 }
360 } catch {
361 /* best-effort */
362 }
363 }
364
365 return {
366 issueNumber,
367 reason: issueNumber !== null ? "ok" : "issue number unavailable",
368 };
369 } catch (err) {
370 return {
371 issueNumber: null,
372 reason: (err as Error).message || "unknown failure",
373 };
374 }
375}
376
377// Re-exported for tests that want to inspect the most recent incident issue
378// for a repository without hitting the HTTP layer.
379export async function getLatestIncidentIssueForRepo(
380 repositoryId: string
381): Promise<{ number: number; title: string } | null> {
382 try {
383 const [row] = await db
384 .select({ number: issues.number, title: issues.title })
385 .from(issues)
386 .where(eq(issues.repositoryId, repositoryId))
387 .orderBy(desc(issues.createdAt))
388 .limit(1);
389 return row || null;
390 } catch {
391 return null;
392 }
393}
Addedsrc/lib/ai-tests.ts+354−0View fileUnifiedSplit
1/**
2 * Block D8 — AI-generated test suite helper.
3 *
4 * Given a source file from a repo, produces a *failing* test stub that
5 * exercises the public surface of the file using whatever test framework
6 * the repository appears to be using (bun:test, vitest, jest, pytest,
7 * go test, etc.).
8 *
9 * The HTTP glue lives in `routes/ai-tests.tsx`; this module only exposes
10 * pure helpers and an AI wrapper that NEVER throws. When Claude isn't
11 * available (no API key, transport error, etc.) `generateTestStub` returns
12 * an empty body and `framework: "fallback"` so the route can render a
13 * "couldn't generate" message without crashing.
14 */
15
16import {
17 MODEL_SONNET,
18 extractText,
19 getAnthropic,
20 isAiAvailable,
21} from "./ai-client";
22
23// ---------------------------------------------------------------------------
24// Language detection
25// ---------------------------------------------------------------------------
26
27export type TestLanguage =
28 | "typescript"
29 | "javascript"
30 | "python"
31 | "go"
32 | "rust"
33 | "java"
34 | "ruby"
35 | "other";
36
37/**
38 * Detect a coarse language bucket from a file path's extension.
39 * Unknown / non-code paths return "other".
40 */
41export function detectLanguage(path: string): TestLanguage {
42 const lower = path.toLowerCase();
43 const dot = lower.lastIndexOf(".");
44 if (dot < 0) return "other";
45 const ext = lower.slice(dot + 1);
46 switch (ext) {
47 case "ts":
48 case "tsx":
49 case "mts":
50 case "cts":
51 return "typescript";
52 case "js":
53 case "jsx":
54 case "mjs":
55 case "cjs":
56 return "javascript";
57 case "py":
58 return "python";
59 case "go":
60 return "go";
61 case "rs":
62 return "rust";
63 case "java":
64 case "kt":
65 return "java";
66 case "rb":
67 return "ruby";
68 default:
69 return "other";
70 }
71}
72
73// ---------------------------------------------------------------------------
74// Framework detection
75// ---------------------------------------------------------------------------
76
77/**
78 * Given a language bucket and a flat list of repository files (paths, not
79 * contents), return a short framework identifier that matches the
80 * conventions the repo already appears to be using.
81 *
82 * The returned string is what gets plumbed into Claude prompts and is used
83 * to compute the suggested test file path.
84 */
85export function detectTestFramework(
86 language: TestLanguage,
87 repoFiles: string[]
88): string {
89 const files = repoFiles.map((f) => f.toLowerCase());
90 const has = (needle: string | RegExp): boolean => {
91 if (typeof needle === "string") return files.some((f) => f === needle);
92 return files.some((f) => needle.test(f));
93 };
94
95 // Python first — it has the clearest signals.
96 if (language === "python") {
97 if (has("pytest.ini") || has("pyproject.toml") || has(/(^|\/)tests?\/.+test.*\.py$/))
98 return "pytest";
99 if (has(/(^|\/)test_.+\.py$/) || has(/_test\.py$/)) return "pytest";
100 return "pytest";
101 }
102
103 if (language === "go") return "go test";
104 if (language === "rust") return "cargo test";
105 if (language === "java") return "junit";
106 if (language === "ruby") return has("gemfile") ? "rspec" : "minitest";
107
108 // JavaScript / TypeScript — multiple competing frameworks.
109 if (language === "typescript" || language === "javascript" || language === "other") {
110 if (has(/vitest\.config\.(ts|js|mjs|cjs)$/) || has("vitest.config.ts"))
111 return "vitest";
112 if (has(/jest\.config(\..+)?$/)) return "jest";
113 if (has(/\.mocharc(\..+)?$/) || has("mocha.opts")) return "mocha";
114 if (has("playwright.config.ts") || has("playwright.config.js"))
115 return "playwright";
116
117 const usesBun =
118 has("bun.lockb") ||
119 has("bunfig.toml") ||
120 files.some((f) => f.endsWith("package.json"));
121 if (usesBun) return "bun:test";
122 }
123
124 return "bun:test";
125}
126
127// ---------------------------------------------------------------------------
128// Suggested test path
129// ---------------------------------------------------------------------------
130
131/**
132 * Compute a deterministic path for the generated test file. The rules are
133 * conventional rather than exhaustive — reviewers can always move the file
134 * after generation.
135 */
136export function suggestedTestPath(
137 sourcePath: string,
138 language: TestLanguage,
139 framework: string
140): string {
141 const parts = sourcePath.split("/");
142 const file = parts[parts.length - 1] || sourcePath;
143 const dir = parts.slice(0, -1).join("/");
144 const dotIdx = file.lastIndexOf(".");
145 const base = dotIdx > 0 ? file.slice(0, dotIdx) : file;
146 const ext = dotIdx > 0 ? file.slice(dotIdx) : "";
147
148 // Python: siblings `test_foo.py` is near-universal.
149 if (language === "python" || framework === "pytest") {
150 return dir ? `${dir}/test_${base}.py` : `test_${base}.py`;
151 }
152
153 // Go: `foo_test.go` adjacent to source.
154 if (language === "go" || framework === "go test") {
155 return dir ? `${dir}/${base}_test.go` : `${base}_test.go`;
156 }
157
158 // Rust: convention is `#[cfg(test)] mod tests` inline, but for a standalone
159 // stub we drop a file into `tests/`.
160 if (language === "rust" || framework === "cargo test") {
161 return `tests/${base}_test.rs`;
162 }
163
164 // Java / Kotlin
165 if (language === "java" || framework === "junit") {
166 const klass = base.charAt(0).toUpperCase() + base.slice(1);
167 return dir
168 ? `${dir.replace(/\/main\//, "/test/")}/${klass}Test${ext || ".java"}`
169 : `${klass}Test${ext || ".java"}`;
170 }
171
172 // Ruby
173 if (language === "ruby") {
174 if (framework === "rspec") {
175 return dir ? `spec/${dir}/${base}_spec.rb` : `spec/${base}_spec.rb`;
176 }
177 return dir ? `test/${dir}/${base}_test.rb` : `test/${base}_test.rb`;
178 }
179
180 // JS/TS with bun / jest / vitest — prefer `__tests__/<name>.test.<ext>`.
181 const testExt = ext === ".tsx" ? ".test.ts" : ext.replace(/^\./, ".test.");
182 const safeExt = testExt || ".test.ts";
183
184 if (framework === "bun:test") {
185 // If the source is already under src/, put tests under src/__tests__/.
186 if (dir.startsWith("src/")) {
187 return `src/__tests__/${base}${safeExt}`;
188 }
189 if (dir === "src") {
190 return `src/__tests__/${base}${safeExt}`;
191 }
192 if (dir) return `${dir}/__tests__/${base}${safeExt}`;
193 return `__tests__/${base}${safeExt}`;
194 }
195
196 // vitest / jest default to sibling `.test.` file.
197 if (dir) return `${dir}/${base}${safeExt}`;
198 return `${base}${safeExt}`;
199}
200
201// ---------------------------------------------------------------------------
202// Prompt
203// ---------------------------------------------------------------------------
204
205export interface TestGenOpts {
206 path: string;
207 language: string;
208 framework: string;
209 sourceCode: string;
210 apiHints?: string;
211}
212
213/**
214 * Build the user prompt that instructs Claude to emit a failing test stub.
215 * Returning the prompt as a pure function keeps it easy to test.
216 */
217export function buildTestsPrompt(opts: TestGenOpts): string {
218 const { path, language, framework, sourceCode, apiHints } = opts;
219 const trimmed = sourceCode.length > 40_000
220 ? sourceCode.slice(0, 40_000) + "\n// ... (truncated)"
221 : sourceCode;
222
223 return `You are writing an initial failing test suite for an open-source project.
224
225Source file path: \`${path}\`
226Detected language: ${language}
227Detected test framework: ${framework}
228
229Write a *failing* test stub — the tests should compile / import cleanly where possible, but assertions MUST fail (or use explicit \`fail()\` / \`todo\` / \`skip\` markers where the framework supports them) so a developer is forced to review, fill in expected values, and confirm the intended behaviour. Prefer realistic \`expect(...)\` calls with placeholder expected values that are obviously wrong (like \`TODO\`) rather than empty bodies.
230
231Rules:
232- Exercise every exported / public symbol you can see in the source.
233- Use only idioms native to "${framework}".
234- No explanations, no Markdown, no surrounding prose — return ONLY the test file body.
235- If imports are needed, compute paths relative to the source file at \`${path}\`.
236- Leave a top-of-file comment that this stub was generated by gluecron's AI test helper and MUST be reviewed before being committed.
237${apiHints ? `\nAdditional hints about the public API:\n${apiHints}\n` : ""}
238Source file contents:
239\`\`\`
240${trimmed}
241\`\`\`
242`;
243}
244
245// ---------------------------------------------------------------------------
246// Claude wrapper
247// ---------------------------------------------------------------------------
248
249export interface TestStubResult {
250 code: string;
251 suggestedPath: string;
252 framework: string;
253 language: string;
254}
255
256/**
257 * Strip a leading/trailing Markdown fence (```lang ... ```) that Claude will
258 * sometimes add around the returned body, even when told not to.
259 */
260function stripCodeFences(raw: string): string {
261 let text = raw.trim();
262 const fence = text.match(/^```[a-zA-Z0-9_+-]*\n?([\s\S]*?)\n?```\s*$/);
263 if (fence) return fence[1].trim();
264 // Partial fences (just the opener) — drop them.
265 if (text.startsWith("```")) {
266 const nl = text.indexOf("\n");
267 if (nl > -1) text = text.slice(nl + 1);
268 }
269 if (text.endsWith("```")) text = text.slice(0, -3);
270 return text.trim();
271}
272
273/**
274 * Ask Claude Sonnet to produce a failing test stub for the given source.
275 * Never throws. On any error (AI unavailable, network failure, empty
276 * response) returns `{ code: "", framework: "fallback", ... }`.
277 */
278export async function generateTestStub(
279 opts: TestGenOpts
280): Promise<TestStubResult> {
281 const lang = (opts.language as TestLanguage) || "other";
282 const suggestedPath = suggestedTestPath(opts.path, lang, opts.framework);
283
284 if (!isAiAvailable()) {
285 return {
286 code: "",
287 suggestedPath,
288 framework: "fallback",
289 language: opts.language,
290 };
291 }
292
293 try {
294 const client = getAnthropic();
295 const prompt = buildTestsPrompt(opts);
296 const message = await client.messages.create({
297 model: MODEL_SONNET,
298 max_tokens: 2048,
299 messages: [{ role: "user", content: prompt }],
300 });
301 const raw = extractText(message);
302 const code = stripCodeFences(raw);
303 if (!code) {
304 return {
305 code: "",
306 suggestedPath,
307 framework: "fallback",
308 language: opts.language,
309 };
310 }
311 return {
312 code,
313 suggestedPath,
314 framework: opts.framework,
315 language: opts.language,
316 };
317 } catch {
318 return {
319 code: "",
320 suggestedPath,
321 framework: "fallback",
322 language: opts.language,
323 };
324 }
325}
326
327// ---------------------------------------------------------------------------
328// Content types for ?format=raw
329// ---------------------------------------------------------------------------
330
331/**
332 * Return a suitable `Content-Type` header for a generated test file, based
333 * on the language bucket. Defaults to `text/plain; charset=utf-8`.
334 */
335export function contentTypeFor(language: string): string {
336 switch (language) {
337 case "typescript":
338 return "application/typescript; charset=utf-8";
339 case "javascript":
340 return "application/javascript; charset=utf-8";
341 case "python":
342 return "text/x-python; charset=utf-8";
343 case "go":
344 return "text/x-go; charset=utf-8";
345 case "rust":
346 return "text/x-rust; charset=utf-8";
347 case "java":
348 return "text/x-java-source; charset=utf-8";
349 case "ruby":
350 return "text/x-ruby; charset=utf-8";
351 default:
352 return "text/plain; charset=utf-8";
353 }
354}
Addedsrc/lib/branch-protection.ts+139−0View fileUnifiedSplit
1/**
2 * Block D5 — Branch-protection enforcement helpers.
3 *
4 * The `branch_protection` table lets owners configure per-pattern rules. Until
5 * now those rules were mostly advisory — `runAllGateChecks` read the repo-
6 * global `repoSettings` for enable flags, and the merge handler only rejected
7 * on gate-level hard failures. This module:
8 *
9 * 1. Matches a branch name against the list of protection rules for a repo
10 * (supports `*` / `**` globs via shared matcher).
11 * 2. Evaluates the matched rule against merge-time context (AI approval,
12 * human approvals, gate result) and returns a pass/fail decision with
13 * human-readable reasons.
14 *
15 * Kept minimal: no throwing, no side effects.
16 */
17
18import { and, eq } from "drizzle-orm";
19import { db } from "../db";
20import { branchProtection, prComments } from "../db/schema";
21import type { BranchProtection } from "../db/schema";
22import { matchGlob } from "./environments";
23
24export interface ProtectionEvalContext {
25 aiApproved: boolean;
26 humanApprovalCount: number;
27 gateResultGreen: boolean;
28 hasFailedGates: boolean;
29}
30
31export interface ProtectionDecision {
32 allowed: boolean;
33 rule: BranchProtection | null;
34 reasons: string[];
35}
36
37/**
38 * Find the most specific branch-protection rule that matches `branch`.
39 * Rules with exact string matches win over glob rules; among globs the first
40 * alphabetical pattern wins (deterministic). Returns null if nothing matches.
41 */
42export async function matchProtection(
43 repositoryId: string,
44 branch: string
45): Promise<BranchProtection | null> {
46 let rules: BranchProtection[];
47 try {
48 rules = await db
49 .select()
50 .from(branchProtection)
51 .where(eq(branchProtection.repositoryId, repositoryId));
52 } catch {
53 return null;
54 }
55 if (!rules || rules.length === 0) return null;
56
57 // Exact match wins.
58 const exact = rules.find((r) => r.pattern === branch);
59 if (exact) return exact;
60
61 // Otherwise first glob match (deterministic order).
62 const globs = rules
63 .filter((r) => r.pattern.includes("*"))
64 .sort((a, b) => a.pattern.localeCompare(b.pattern));
65 for (const rule of globs) {
66 if (matchGlob(branch, rule.pattern)) return rule;
67 }
68 return null;
69}
70
71/**
72 * Evaluate a protection rule against merge-time context. Does not block on
73 * a missing rule — callers can treat that as "no protection configured".
74 */
75export function evaluateProtection(
76 rule: BranchProtection | null,
77 ctx: ProtectionEvalContext
78): ProtectionDecision {
79 if (!rule) {
80 return { allowed: true, rule: null, reasons: [] };
81 }
82 const reasons: string[] = [];
83
84 if (rule.requireAiApproval && !ctx.aiApproved) {
85 reasons.push(
86 `Branch protection '${rule.pattern}' requires AI approval, but no AI review comment is approving this PR.`
87 );
88 }
89 if (rule.requireGreenGates && ctx.hasFailedGates) {
90 reasons.push(
91 `Branch protection '${rule.pattern}' requires green gates, but at least one gate is failing.`
92 );
93 }
94 if (rule.requireHumanReview && ctx.humanApprovalCount < 1) {
95 reasons.push(
96 `Branch protection '${rule.pattern}' requires at least one human review approval.`
97 );
98 }
99 if (
100 rule.requiredApprovals > 0 &&
101 ctx.humanApprovalCount < rule.requiredApprovals
102 ) {
103 reasons.push(
104 `Branch protection '${rule.pattern}' requires ${rule.requiredApprovals} approvals (have ${ctx.humanApprovalCount}).`
105 );
106 }
107
108 return { allowed: reasons.length === 0, rule, reasons };
109}
110
111/**
112 * Count human (non-AI) approving PR comments. "Approval" is defined as a
113 * comment containing LGTM / ":+1:" / "approved" tokens. Best-effort; callers
114 * should treat a zero here as "unknown", not "rejected".
115 */
116export async function countHumanApprovals(pullRequestId: string): Promise<number> {
117 try {
118 const comments = await db
119 .select({ body: prComments.body, isAi: prComments.isAiReview })
120 .from(prComments)
121 .where(
122 and(
123 eq(prComments.pullRequestId, pullRequestId),
124 eq(prComments.isAiReview, false)
125 )
126 );
127 return comments.filter((c) => {
128 const b = (c.body || "").toLowerCase();
129 return (
130 b.includes("lgtm") ||
131 b.includes(":+1:") ||
132 b.includes("approved") ||
133 b.includes("👍")
134 );
135 }).length;
136 } catch {
137 return 0;
138 }
139}
Addedsrc/routes/ai-tests.tsx+442−0View fileUnifiedSplit
1/**
2 * Block D8 — AI-generated test suite route.
3 *
4 * GET /:owner/:repo/ai/tests?path=...&ref=...
5 * Renders a form to pick a source file and generate a failing test
6 * stub for it. When `path` is provided the form is pre-filled with
7 * the currently-selected file so the user can "Generate" with one
8 * click.
9 *
10 * GET /:owner/:repo/ai/tests?path=...&format=raw
11 * Returns `c.text(result.code, 200, {"Content-Type": ...})` for CLI
12 * consumption (e.g. `curl | bat`). No HTML shell.
13 *
14 * POST /:owner/:repo/ai/tests/generate
15 * Auth required. Actually runs the model and renders the result
16 * page with highlighted source, highlighted test, a copy-to-clipboard
17 * button, a review warning, and a regenerate button.
18 */
19
20import { Hono } from "hono";
21import { html, raw } from "hono/html";
22import { and, eq } from "drizzle-orm";
23import { db } from "../db";
24import { repositories, users } from "../db/schema";
25import { Layout } from "../views/layout";
26import { RepoHeader } from "../views/components";
27import { IssueNav } from "./issues";
28import { softAuth, requireAuth } from "../middleware/auth";
29import type { AuthEnv } from "../middleware/auth";
30import {
31 getBlob,
32 getDefaultBranch,
33 getTree,
34 resolveRef,
35} from "../git/repository";
36import type { GitTreeEntry } from "../git/repository";
37import { highlightCode } from "../lib/highlight";
38import {
39 contentTypeFor,
40 detectLanguage,
41 detectTestFramework,
42 generateTestStub,
43} from "../lib/ai-tests";
44
45const aiTestsRoutes = new Hono<AuthEnv>();
46
47interface ResolvedRepo {
48 ownerId: string;
49 ownerUsername: string;
50 repoId: string;
51 repoName: string;
52}
53
54async function resolveRepo(
55 ownerName: string,
56 repoName: string
57): Promise<ResolvedRepo | null> {
58 try {
59 const [ownerRow] = await db
60 .select()
61 .from(users)
62 .where(eq(users.username, ownerName))
63 .limit(1);
64 if (!ownerRow) return null;
65 const [repoRow] = await db
66 .select()
67 .from(repositories)
68 .where(
69 and(
70 eq(repositories.ownerId, ownerRow.id),
71 eq(repositories.name, repoName)
72 )
73 )
74 .limit(1);
75 if (!repoRow) return null;
76 return {
77 ownerId: ownerRow.id,
78 ownerUsername: ownerRow.username,
79 repoId: repoRow.id,
80 repoName: repoRow.name,
81 };
82 } catch {
83 return null;
84 }
85}
86
87/**
88 * Shallow listing of source blobs reachable from the tree root and a couple
89 * of common top-level source directories. Kept intentionally small — the
90 * picker in the form is just a convenience, users can also type a path
91 * directly.
92 */
93async function listRepoFiles(
94 owner: string,
95 repo: string,
96 ref: string
97): Promise<string[]> {
98 const out: string[] = [];
99 let root: GitTreeEntry[] = [];
100 try {
101 root = await getTree(owner, repo, ref, "");
102 } catch {
103 root = [];
104 }
105 for (const entry of root) {
106 if (entry.type === "blob") {
107 out.push(entry.name);
108 }
109 }
110 const candidates = ["src", "lib", "app", "server", "pkg", "tests"];
111 for (const dir of candidates) {
112 const hit = root.find((e) => e.type === "tree" && e.name === dir);
113 if (!hit) continue;
114 let children: GitTreeEntry[] = [];
115 try {
116 children = await getTree(owner, repo, ref, dir);
117 } catch {
118 children = [];
119 }
120 for (const child of children) {
121 if (child.type === "blob") {
122 out.push(`${dir}/${child.name}`);
123 } else if (child.type === "tree") {
124 let grand: GitTreeEntry[] = [];
125 try {
126 grand = await getTree(owner, repo, ref, `${dir}/${child.name}`);
127 } catch {
128 grand = [];
129 }
130 for (const g of grand) {
131 if (g.type === "blob") {
132 out.push(`${dir}/${child.name}/${g.name}`);
133 }
134 }
135 }
136 }
137 if (out.length > 500) break;
138 }
139 return out;
140}
141
142function renderPicker(
143 ownerName: string,
144 repoName: string,
145 allFiles: string[],
146 currentPath: string,
147 ref: string
148) {
149 const trimmed = allFiles.slice(0, 200);
150 return (
151 <form
152 method="POST"
153 action={`/${ownerName}/${repoName}/ai/tests/generate`}
154 style="margin-top: 16px; display: flex; flex-direction: column; gap: 12px; max-width: 720px;"
155 >
156 <input type="hidden" name="ref" value={ref} />
157 <label style="display: flex; flex-direction: column; gap: 6px;">
158 <span style="font-weight: 600;">Source file</span>
159 <input
160 type="text"
161 name="path"
162 value={currentPath}
163 placeholder="src/lib/foo.ts"
164 required
165 style="padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text);"
166 />
167 </label>
168 {trimmed.length > 0 && (
169 <label style="display: flex; flex-direction: column; gap: 6px;">
170 <span style="font-weight: 600;">…or pick from the repo</span>
171 <select
172 name="pickPath"
173 onchange="this.form.path.value = this.value"
174 style="padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text);"
175 >
176 <option value="">— select a file —</option>
177 {trimmed.map((f) => (
178 <option value={f} selected={f === currentPath}>
179 {f}
180 </option>
181 ))}
182 </select>
183 </label>
184 )}
185 <div>
186 <button type="submit" class="star-btn" style="padding: 6px 14px;">
187 Generate tests
188 </button>
189 </div>
190 </form>
191 );
192}
193
194aiTestsRoutes.get("/:owner/:repo/ai/tests", softAuth, async (c) => {
195 const { owner, repo } = c.req.param();
196 const user = c.get("user");
197 const path = (c.req.query("path") || "").trim();
198 const reqRef = (c.req.query("ref") || "").trim();
199 const format = (c.req.query("format") || "").trim().toLowerCase();
200
201 const resolved = await resolveRepo(owner, repo);
202 if (!resolved) {
203 return c.html(
204 <Layout title="Not Found" user={user}>
205 <div class="empty-state">
206 <h2>Repository not found</h2>
207 </div>
208 </Layout>,
209 404
210 );
211 }
212
213 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
214 const ref = reqRef || defaultBranch;
215 const sha = (await resolveRef(owner, repo, ref)) || ref;
216
217 // Raw format: just emit the generated code (for CLI use).
218 if (format === "raw") {
219 if (!path) {
220 return c.text("missing ?path=", 400, {
221 "Content-Type": "text/plain; charset=utf-8",
222 });
223 }
224 const blob = await getBlob(owner, repo, sha, path).catch(() => null);
225 if (!blob || blob.isBinary) {
226 return c.text("file not found", 404, {
227 "Content-Type": "text/plain; charset=utf-8",
228 });
229 }
230 const language = detectLanguage(path);
231 const repoFiles = await listRepoFiles(owner, repo, sha);
232 const framework = detectTestFramework(language, repoFiles);
233 const result = await generateTestStub({
234 path,
235 language,
236 framework,
237 sourceCode: blob.content,
238 });
239 return c.text(result.code, 200, {
240 "Content-Type": contentTypeFor(result.language),
241 });
242 }
243
244 // HTML form mode.
245 const repoFiles = await listRepoFiles(owner, repo, sha);
246 const detectedLang = path ? detectLanguage(path) : "other";
247 const detectedFramework = detectTestFramework(detectedLang, repoFiles);
248
249 return c.html(
250 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
251 <RepoHeader owner={owner} repo={repo} />
252 <IssueNav owner={owner} repo={repo} active="code" />
253 <div style="margin: 16px 0;">
254 <h2 style="margin: 0 0 8px;">AI-generated tests</h2>
255 <p style="color: var(--text-muted); margin: 0;">
256 Pick a source file and gluecron will ask Claude to draft a{" "}
257 <strong>failing</strong> test stub that exercises its public surface.
258 Treat the output as a starting-point — always review before
259 committing.
260 </p>
261 {path && (
262 <p style="color: var(--text-muted); margin: 8px 0 0; font-size: 13px;">
263 Detected language: <code>{detectedLang}</code> · framework:{" "}
264 <code>{detectedFramework}</code>
265 </p>
266 )}
267 </div>
268 {renderPicker(owner, repo, repoFiles, path, ref)}
269 </Layout>
270 );
271});
272
273aiTestsRoutes.post(
274 "/:owner/:repo/ai/tests/generate",
275 requireAuth,
276 async (c) => {
277 const { owner, repo } = c.req.param();
278 const user = c.get("user")!;
279 const body = await c.req.parseBody().catch(() => ({} as Record<string, unknown>));
280 const path = String(body.path || "").trim();
281 const reqRef = String(body.ref || "").trim();
282
283 const resolved = await resolveRepo(owner, repo);
284 if (!resolved) {
285 return c.html(
286 <Layout title="Not Found" user={user}>
287 <div class="empty-state">
288 <h2>Repository not found</h2>
289 </div>
290 </Layout>,
291 404
292 );
293 }
294
295 if (!path) {
296 return c.redirect(`/${owner}/${repo}/ai/tests`);
297 }
298
299 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
300 const ref = reqRef || defaultBranch;
301 const sha = (await resolveRef(owner, repo, ref)) || ref;
302
303 const blob = await getBlob(owner, repo, sha, path).catch(() => null);
304 if (!blob || blob.isBinary) {
305 return c.html(
306 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
307 <RepoHeader owner={owner} repo={repo} />
308 <IssueNav owner={owner} repo={repo} active="code" />
309 <div class="empty-state">
310 <h2>Couldn't read that file</h2>
311 <p>
312 No such path at <code>{ref}</code>, or the file is binary.
313 </p>
314 <p>
315 <a href={`/${owner}/${repo}/ai/tests`}>Back to the picker</a>
316 </p>
317 </div>
318 </Layout>,
319 404
320 );
321 }
322
323 const language = detectLanguage(path);
324 const repoFiles = await listRepoFiles(owner, repo, sha);
325 const framework = detectTestFramework(language, repoFiles);
326
327 const result = await generateTestStub({
328 path,
329 language,
330 framework,
331 sourceCode: blob.content,
332 });
333
334 const sourceHl = highlightCode(blob.content, path);
335 const testHl = highlightCode(result.code || "", result.suggestedPath);
336
337 const aiFailed = result.framework === "fallback" || !result.code;
338
339 return c.html(
340 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
341 <RepoHeader owner={owner} repo={repo} />
342 <IssueNav owner={owner} repo={repo} active="code" />
343 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
344 <div>
345 <h2 style="margin: 0;">AI-generated tests for <code>{path}</code></h2>
346 <p style="color: var(--text-muted); margin: 4px 0 0; font-size: 13px;">
347 Detected language: <code>{language}</code> · framework:{" "}
348 <code>{aiFailed ? "fallback" : framework}</code> · ref{" "}
349 <code>{ref}</code>
350 </p>
351 </div>
352 <form
353 method="POST"
354 action={`/${owner}/${repo}/ai/tests/generate`}
355 style="display: inline;"
356 >
357 <input type="hidden" name="path" value={path} />
358 <input type="hidden" name="ref" value={ref} />
359 <button type="submit" class="star-btn">Regenerate</button>
360 </form>
361 </div>
362
363 <div
364 class="flash-warning"
365 style="border: 1px solid var(--border); background: rgba(210, 153, 34, 0.12); padding: 10px 14px; border-radius: 6px; margin-bottom: 16px; font-size: 14px;"
366 >
367 <strong>Review before committing.</strong> These tests are a
368 starting-point only — they are intentionally written to{" "}
369 <em>fail</em> so you are forced to supply real expected values.
370 Gluecron does not verify the behaviour is correct.
371 </div>
372
373 {aiFailed && (
374 <div
375 class="empty-state"
376 style="border: 1px dashed var(--border); padding: 16px; border-radius: 6px; margin-bottom: 16px;"
377 >
378 <p style="margin: 0;">
379 Couldn't generate a test stub. The AI backend may not be
380 configured, or the model returned an empty response. A suggested
381 path was still computed: <code>{result.suggestedPath}</code>.
382 </p>
383 </div>
384 )}
385
386 <section style="margin-bottom: 24px;">
387 <h3 style="margin: 0 0 8px; font-size: 14px; text-transform: uppercase; color: var(--text-muted);">
388 Source — <code>{path}</code>
389 </h3>
390 <pre class="hljs" style="padding: 12px; border: 1px solid var(--border); border-radius: 6px; overflow: auto;">
391 <code>{raw(sourceHl.html)}</code>
392 </pre>
393 </section>
394
395 <section>
396 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
397 <h3 style="margin: 0; font-size: 14px; text-transform: uppercase; color: var(--text-muted);">
398 Suggested test — <code>{result.suggestedPath}</code>
399 </h3>
400 <button
401 type="button"
402 class="star-btn"
403 id="copy-test-btn"
404 data-test-code-id="ai-test-code"
405 >
406 Copy
407 </button>
408 </div>
409 <pre
410 class="hljs"
411 id="ai-test-code"
412 style="padding: 12px; border: 1px solid var(--border); border-radius: 6px; overflow: auto; white-space: pre;"
413 >
414 <code>{result.code ? raw(testHl.html) : "// (no output)"}</code>
415 </pre>
416 </section>
417
418 {html`<script>
419 (function () {
420 var btn = document.getElementById('copy-test-btn');
421 if (!btn) return;
422 btn.addEventListener('click', function () {
423 var id = btn.getAttribute('data-test-code-id');
424 var el = id ? document.getElementById(id) : null;
425 var text = el ? el.innerText : '';
426 if (!text) return;
427 if (navigator.clipboard && navigator.clipboard.writeText) {
428 navigator.clipboard.writeText(text).then(function () {
429 var prev = btn.textContent;
430 btn.textContent = 'Copied';
431 setTimeout(function () { btn.textContent = prev; }, 1500);
432 });
433 }
434 });
435 })();
436 </script>`}
437 </Layout>
438 );
439 }
440);
441
442export default aiTestsRoutes;
Modifiedsrc/routes/deployments.tsx+70−1View fileUnifiedSplit
1414import { db } from "../db";
1515import { deployments, repositories, users } from "../db/schema";
1616import type { AuthEnv } from "../middleware/auth";
17import { softAuth } from "../middleware/auth";
17import { softAuth, requireAuth } from "../middleware/auth";
1818import { Layout } from "../views/layout";
1919import { RepoHeader } from "../views/components";
20import { onDeployFailure } from "../lib/ai-incident";
2021
2122const dep = new Hono<AuthEnv>();
2223
3132 .select({
3233 id: repositories.id,
3334 name: repositories.name,
35 ownerId: repositories.ownerId,
3436 })
3537 .from(repositories)
3638 .innerJoin(users, eq(users.id, repositories.ownerId))
4244 }
4345}
4446
47/** Parse "auto-issue #42" from a blockedReason string. Returns null if absent. */
48function parseAutoIssueNumber(blockedReason: string | null): number | null {
49 if (!blockedReason) return null;
50 const m = blockedReason.match(/auto-issue #(\d+)/);
51 return m ? parseInt(m[1], 10) : null;
52}
53
4554function statusBadgeClass(status: string): string {
4655 switch (status) {
4756 case "success":
295304 <td style="color: var(--red)">{row.blockedReason}</td>
296305 </tr>
297306 )}
307 {(() => {
308 const n = parseAutoIssueNumber(row.blockedReason);
309 return n !== null ? (
310 <tr>
311 <th>Incident issue</th>
312 <td>
313 <a href={`/${owner}/${repo}/issues/${n}`}>#{n}</a>
314 </td>
315 </tr>
316 ) : null;
317 })()}
298318 </tbody>
299319 </table>
320 {row.status === "failed" && (
321 <form
322 method="post"
323 action={`/${owner}/${repo}/deployments/${row.id}/retry-incident`}
324 style="margin-top: 16px"
325 >
326 <button type="submit" class="btn btn-secondary">
327 Re-run incident analysis
328 </button>
329 </form>
330 )}
300331 </div>
301332 </Layout>
302333 );
303334});
304335
336// D4: re-trigger the AI incident responder for a failed deployment. Owner-only.
337// Redirects back to the deployment detail page in all cases.
338dep.post(
339 "/:owner/:repo/deployments/:id/retry-incident",
340 requireAuth,
341 async (c) => {
342 const { owner, repo, id } = c.req.param();
343 const user = c.get("user")!;
344 const repoRow = await resolveRepo(owner, repo);
345 const back = `/${owner}/${repo}/deployments/${id}`;
346 if (!repoRow) return c.notFound();
347 if (repoRow.ownerId !== user.id) {
348 return c.redirect(back);
349 }
350 try {
351 const [depRow] = await db
352 .select()
353 .from(deployments)
354 .where(
355 and(eq(deployments.id, id), eq(deployments.repositoryId, repoRow.id))
356 )
357 .limit(1);
358 if (!depRow || depRow.status !== "failed") return c.redirect(back);
359 await onDeployFailure({
360 repositoryId: repoRow.id,
361 deploymentId: depRow.id,
362 ref: depRow.ref,
363 commitSha: depRow.commitSha,
364 target: depRow.target,
365 errorMessage: depRow.blockedReason,
366 });
367 } catch (err) {
368 console.error("[deployments] retry-incident:", err);
369 }
370 return c.redirect(back);
371 }
372);
373
305374export default dep;
Addedsrc/routes/discussions.tsx+674−0View fileUnifiedSplit
1/**
2 * Block E2 — Discussions: forum-style threaded conversations attached to a repo.
3 *
4 * Similar to GitHub Discussions: categorised, pinnable, answer-able threads
5 * that sit alongside issues but are conversational (Q&A, ideas, announcements).
6 *
7 * Never throws — all DB paths wrapped in try/catch; callers see a 500-like
8 * shell page or a redirect on any failure.
9 */
10
11import { Hono } from "hono";
12import { and, eq, desc, sql } from "drizzle-orm";
13import { db } from "../db";
14import {
15 discussions,
16 discussionComments,
17 repositories,
18 users,
19} from "../db/schema";
20import { Layout } from "../views/layout";
21import { RepoHeader, RepoNav } from "../views/components";
22import { renderMarkdown } from "../lib/markdown";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25
26const CATEGORIES = [
27 "general",
28 "q-and-a",
29 "ideas",
30 "announcements",
31 "show-and-tell",
32] as const;
33
34export function isValidCategory(c: string): boolean {
35 return (CATEGORIES as readonly string[]).includes(c);
36}
37
38const discussionRoutes = new Hono<AuthEnv>();
39
40async function resolveRepo(ownerName: string, repoName: string) {
41 try {
42 const [owner] = await db
43 .select()
44 .from(users)
45 .where(eq(users.username, ownerName))
46 .limit(1);
47 if (!owner) return null;
48 const [repo] = await db
49 .select()
50 .from(repositories)
51 .where(
52 and(
53 eq(repositories.ownerId, owner.id),
54 eq(repositories.name, repoName)
55 )
56 )
57 .limit(1);
58 if (!repo) return null;
59 return { owner, repo };
60 } catch {
61 return null;
62 }
63}
64
65function notFound(user: any, label = "Not found") {
66 return (
67 <Layout title={label} user={user}>
68 <div class="empty-state">
69 <h2>{label}</h2>
70 </div>
71 </Layout>
72 );
73}
74
75// List
76discussionRoutes.get("/:owner/:repo/discussions", softAuth, async (c) => {
77 const { owner: ownerName, repo: repoName } = c.req.param();
78 const user = c.get("user");
79 const category = c.req.query("category") || "";
80
81 const resolved = await resolveRepo(ownerName, repoName);
82 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
83 const { repo } = resolved;
84
85 let rows: any[] = [];
86 try {
87 const whereClause =
88 category && isValidCategory(category)
89 ? and(
90 eq(discussions.repositoryId, repo.id),
91 eq(discussions.category, category)
92 )
93 : eq(discussions.repositoryId, repo.id);
94 rows = await db
95 .select({
96 d: discussions,
97 author: { username: users.username },
98 commentCount: sql<number>`(SELECT count(*) FROM discussion_comments WHERE discussion_id = ${discussions.id})`,
99 })
100 .from(discussions)
101 .innerJoin(users, eq(discussions.authorId, users.id))
102 .where(whereClause)
103 .orderBy(desc(discussions.pinned), desc(discussions.updatedAt));
104 } catch {
105 rows = [];
106 }
107
108 return c.html(
109 <Layout title={`Discussions — ${ownerName}/${repoName}`} user={user}>
110 <RepoHeader owner={ownerName} repo={repoName} />
111 <div class="repo-nav">
112 <a href={`/${ownerName}/${repoName}`}>Code</a>
113 <a href={`/${ownerName}/${repoName}/issues`}>Issues</a>
114 <a href={`/${ownerName}/${repoName}/pulls`}>Pull Requests</a>
115 <a href={`/${ownerName}/${repoName}/discussions`} class="active">
116 Discussions
117 </a>
118 </div>
119 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
120 <div style="display: flex; gap: 8px;">
121 <a
122 href={`/${ownerName}/${repoName}/discussions`}
123 class={!category ? "active" : ""}
124 style="padding: 4px 10px; border-radius: 6px;"
125 >
126 All
127 </a>
128 {CATEGORIES.map((cat) => (
129 <a
130 href={`/${ownerName}/${repoName}/discussions?category=${cat}`}
131 class={cat === category ? "active" : ""}
132 style="padding: 4px 10px; border-radius: 6px;"
133 >
134 {cat}
135 </a>
136 ))}
137 </div>
138 {user && (
139 <a
140 href={`/${ownerName}/${repoName}/discussions/new`}
141 class="btn btn-primary"
142 >
143 New discussion
144 </a>
145 )}
146 </div>
147 {rows.length === 0 ? (
148 <div class="empty-state">
149 <p>No discussions yet.</p>
150 </div>
151 ) : (
152 <table class="file-table">
153 <tbody>
154 {rows.map((r) => (
155 <tr>
156 <td style="width: 40px; color: var(--text-muted);">
157 #{r.d.number}
158 </td>
159 <td>
160 {r.d.pinned && <span class="badge">📌 Pinned</span>}{" "}
161 <a
162 href={`/${ownerName}/${repoName}/discussions/${r.d.number}`}
163 >
164 <strong>{r.d.title}</strong>
165 </a>{" "}
166 <span class="badge">{r.d.category}</span>
167 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">
168 by @{r.author.username}
169 {r.d.state === "closed" ? " · closed" : ""}
170 {r.d.locked ? " · locked" : ""}
171 </div>
172 </td>
173 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
174 💬 {r.commentCount}
175 </td>
176 </tr>
177 ))}
178 </tbody>
179 </table>
180 )}
181 </Layout>
182 );
183});
184
185// New discussion form
186discussionRoutes.get(
187 "/:owner/:repo/discussions/new",
188 requireAuth,
189 async (c) => {
190 const { owner: ownerName, repo: repoName } = c.req.param();
191 const user = c.get("user");
192 const resolved = await resolveRepo(ownerName, repoName);
193 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
194 return c.html(
195 <Layout title="New discussion" user={user}>
196 <RepoHeader owner={ownerName} repo={repoName} />
197 <h2 style="margin-top: 20px;">Start a discussion</h2>
198 <form
199 method="POST"
200 action={`/${ownerName}/${repoName}/discussions`}
201 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
202 >
203 <input
204 type="text"
205 name="title"
206 placeholder="Title"
207 required
208 style="padding: 8px;"
209 />
210 <select name="category" style="padding: 8px;">
211 {CATEGORIES.map((c) => (
212 <option value={c}>{c}</option>
213 ))}
214 </select>
215 <textarea
216 name="body"
217 rows={10}
218 placeholder="Write your post (markdown supported)"
219 style="padding: 8px; font-family: inherit;"
220 ></textarea>
221 <button type="submit" class="btn btn-primary">
222 Start discussion
223 </button>
224 </form>
225 </Layout>
226 );
227 }
228);
229
230// Create
231discussionRoutes.post(
232 "/:owner/:repo/discussions",
233 requireAuth,
234 async (c) => {
235 const { owner: ownerName, repo: repoName } = c.req.param();
236 const user = c.get("user")!;
237 const resolved = await resolveRepo(ownerName, repoName);
238 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
239
240 const form = await c.req.formData();
241 const title = (form.get("title") as string || "").trim();
242 const body = (form.get("body") as string || "").trim();
243 const categoryRaw = (form.get("category") as string || "general").trim();
244 const category = isValidCategory(categoryRaw) ? categoryRaw : "general";
245
246 if (!title) {
247 return c.redirect(`/${ownerName}/${repoName}/discussions/new`);
248 }
249
250 try {
251 const [row] = await db
252 .insert(discussions)
253 .values({
254 repositoryId: resolved.repo.id,
255 authorId: user.id,
256 category,
257 title,
258 body,
259 })
260 .returning({ number: discussions.number });
261 return c.redirect(
262 `/${ownerName}/${repoName}/discussions/${row.number}`
263 );
264 } catch {
265 return c.redirect(`/${ownerName}/${repoName}/discussions`);
266 }
267 }
268);
269
270// Detail
271discussionRoutes.get(
272 "/:owner/:repo/discussions/:number",
273 softAuth,
274 async (c) => {
275 const { owner: ownerName, repo: repoName } = c.req.param();
276 const user = c.get("user");
277 const numParam = Number(c.req.param("number"));
278 const resolved = await resolveRepo(ownerName, repoName);
279 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
280
281 let discussion: any = null;
282 let comments: any[] = [];
283 try {
284 const [row] = await db
285 .select({ d: discussions, author: { username: users.username } })
286 .from(discussions)
287 .innerJoin(users, eq(discussions.authorId, users.id))
288 .where(
289 and(
290 eq(discussions.repositoryId, resolved.repo.id),
291 eq(discussions.number, numParam)
292 )
293 )
294 .limit(1);
295 if (row) discussion = row;
296 if (discussion) {
297 comments = await db
298 .select({
299 c: discussionComments,
300 author: { username: users.username },
301 })
302 .from(discussionComments)
303 .innerJoin(users, eq(discussionComments.authorId, users.id))
304 .where(eq(discussionComments.discussionId, discussion.d.id))
305 .orderBy(discussionComments.createdAt);
306 }
307 } catch {
308 // leave nulls
309 }
310
311 if (!discussion) return c.html(notFound(user, "Discussion not found"), 404);
312
313 const isOwner = user && user.id === resolved.repo.ownerId;
314 const isAuthor = user && user.id === discussion.d.authorId;
315 const canModerate = isOwner || isAuthor;
316
317 return c.html(
318 <Layout
319 title={`${discussion.d.title} · discussion #${discussion.d.number}`}
320 user={user}
321 >
322 <RepoHeader owner={ownerName} repo={repoName} />
323 <div style="margin-top: 16px;">
324 <div style="display: flex; justify-content: space-between; align-items: center;">
325 <h1 style="margin: 0;">
326 {discussion.d.title}{" "}
327 <span style="color: var(--text-muted);">
328 #{discussion.d.number}
329 </span>
330 </h1>
331 <div style="display: flex; gap: 8px;">
332 <span class="badge">{discussion.d.category}</span>
333 {discussion.d.state === "closed" && (
334 <span class="badge">closed</span>
335 )}
336 {discussion.d.locked && <span class="badge">🔒 locked</span>}
337 {discussion.d.pinned && <span class="badge">📌 pinned</span>}
338 </div>
339 </div>
340 <div style="color: var(--text-muted); font-size: 13px; margin-top: 4px;">
341 Started by @{discussion.author.username}
342 </div>
343 </div>
344 <article class="comment" style="margin-top: 16px;">
345 <div
346 // biome-ignore lint: rendered server-side from trusted markdown
347 dangerouslySetInnerHTML={{
348 __html: renderMarkdown(discussion.d.body || ""),
349 }}
350 />
351 </article>
352 <h3 style="margin-top: 32px;">{comments.length} Comments</h3>
353 {comments.map((com) => {
354 const isAnswer = com.c.id === discussion.d.answerCommentId;
355 return (
356 <article
357 class="comment"
358 style={`margin-top: 12px; ${isAnswer ? "border: 2px solid var(--green); padding: 12px;" : ""}`}
359 >
360 <div style="display: flex; justify-content: space-between;">
361 <div style="font-size: 13px; color: var(--text-muted);">
362 @{com.author.username}
363 {isAnswer && " · ✅ Answer"}
364 </div>
365 {isOwner &&
366 discussion.d.category === "q-and-a" &&
367 !isAnswer && (
368 <form
369 method="POST"
370 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/answer/${com.c.id}`}
371 style="display: inline;"
372 >
373 <button type="submit" class="btn">
374 Mark as answer
375 </button>
376 </form>
377 )}
378 </div>
379 <div
380 style="margin-top: 8px;"
381 dangerouslySetInnerHTML={{
382 __html: renderMarkdown(com.c.body || ""),
383 }}
384 />
385 </article>
386 );
387 })}
388 {user && !discussion.d.locked && discussion.d.state === "open" && (
389 <form
390 method="POST"
391 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/comment`}
392 style="margin-top: 24px; display: flex; flex-direction: column; gap: 8px;"
393 >
394 <textarea
395 name="body"
396 rows={5}
397 placeholder="Add a comment (markdown supported)"
398 required
399 style="padding: 8px; font-family: inherit;"
400 ></textarea>
401 <button type="submit" class="btn btn-primary">
402 Comment
403 </button>
404 </form>
405 )}
406 {user && (
407 <div style="margin-top: 24px; display: flex; gap: 8px;">
408 {canModerate && (
409 <form
410 method="POST"
411 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/close`}
412 style="display: inline;"
413 >
414 <button type="submit" class="btn">
415 {discussion.d.state === "open" ? "Close" : "Reopen"}
416 </button>
417 </form>
418 )}
419 {isOwner && (
420 <>
421 <form
422 method="POST"
423 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/lock`}
424 style="display: inline;"
425 >
426 <button type="submit" class="btn">
427 {discussion.d.locked ? "Unlock" : "Lock"}
428 </button>
429 </form>
430 <form
431 method="POST"
432 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/pin`}
433 style="display: inline;"
434 >
435 <button type="submit" class="btn">
436 {discussion.d.pinned ? "Unpin" : "Pin"}
437 </button>
438 </form>
439 </>
440 )}
441 </div>
442 )}
443 </Layout>
444 );
445 }
446);
447
448// Add comment
449discussionRoutes.post(
450 "/:owner/:repo/discussions/:number/comment",
451 requireAuth,
452 async (c) => {
453 const { owner: ownerName, repo: repoName } = c.req.param();
454 const user = c.get("user")!;
455 const numParam = Number(c.req.param("number"));
456 const resolved = await resolveRepo(ownerName, repoName);
457 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
458
459 const form = await c.req.formData();
460 const body = (form.get("body") as string || "").trim();
461 const parent = (form.get("parent_comment_id") as string) || null;
462 if (!body) {
463 return c.redirect(
464 `/${ownerName}/${repoName}/discussions/${numParam}`
465 );
466 }
467
468 try {
469 const [row] = await db
470 .select()
471 .from(discussions)
472 .where(
473 and(
474 eq(discussions.repositoryId, resolved.repo.id),
475 eq(discussions.number, numParam)
476 )
477 )
478 .limit(1);
479 if (!row || row.locked || row.state === "closed") {
480 return c.redirect(
481 `/${ownerName}/${repoName}/discussions/${numParam}`
482 );
483 }
484 await db.insert(discussionComments).values({
485 discussionId: row.id,
486 authorId: user.id,
487 body,
488 parentCommentId: parent || null,
489 });
490 await db
491 .update(discussions)
492 .set({ updatedAt: new Date() })
493 .where(eq(discussions.id, row.id));
494 } catch {
495 // swallow
496 }
497 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
498 }
499);
500
501// Toggle lock (owner)
502discussionRoutes.post(
503 "/:owner/:repo/discussions/:number/lock",
504 requireAuth,
505 async (c) => {
506 const { owner: ownerName, repo: repoName } = c.req.param();
507 const user = c.get("user")!;
508 const numParam = Number(c.req.param("number"));
509 const resolved = await resolveRepo(ownerName, repoName);
510 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
511 if (user.id !== resolved.repo.ownerId) {
512 return c.redirect(
513 `/${ownerName}/${repoName}/discussions/${numParam}`
514 );
515 }
516 try {
517 const [row] = await db
518 .select()
519 .from(discussions)
520 .where(
521 and(
522 eq(discussions.repositoryId, resolved.repo.id),
523 eq(discussions.number, numParam)
524 )
525 )
526 .limit(1);
527 if (row) {
528 await db
529 .update(discussions)
530 .set({ locked: !row.locked })
531 .where(eq(discussions.id, row.id));
532 }
533 } catch {
534 // swallow
535 }
536 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
537 }
538);
539
540// Toggle pin (owner)
541discussionRoutes.post(
542 "/:owner/:repo/discussions/:number/pin",
543 requireAuth,
544 async (c) => {
545 const { owner: ownerName, repo: repoName } = c.req.param();
546 const user = c.get("user")!;
547 const numParam = Number(c.req.param("number"));
548 const resolved = await resolveRepo(ownerName, repoName);
549 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
550 if (user.id !== resolved.repo.ownerId) {
551 return c.redirect(
552 `/${ownerName}/${repoName}/discussions/${numParam}`
553 );
554 }
555 try {
556 const [row] = await db
557 .select()
558 .from(discussions)
559 .where(
560 and(
561 eq(discussions.repositoryId, resolved.repo.id),
562 eq(discussions.number, numParam)
563 )
564 )
565 .limit(1);
566 if (row) {
567 await db
568 .update(discussions)
569 .set({ pinned: !row.pinned })
570 .where(eq(discussions.id, row.id));
571 }
572 } catch {
573 // swallow
574 }
575 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
576 }
577);
578
579// Mark answer (owner on q-and-a)
580discussionRoutes.post(
581 "/:owner/:repo/discussions/:number/answer/:commentId",
582 requireAuth,
583 async (c) => {
584 const { owner: ownerName, repo: repoName, commentId } = c.req.param();
585 const user = c.get("user")!;
586 const numParam = Number(c.req.param("number"));
587 const resolved = await resolveRepo(ownerName, repoName);
588 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
589
590 try {
591 const [row] = await db
592 .select()
593 .from(discussions)
594 .where(
595 and(
596 eq(discussions.repositoryId, resolved.repo.id),
597 eq(discussions.number, numParam)
598 )
599 )
600 .limit(1);
601 if (!row) {
602 return c.redirect(`/${ownerName}/${repoName}/discussions`);
603 }
604 const isOwner = user.id === resolved.repo.ownerId;
605 const isAuthor = user.id === row.authorId;
606 if (!isOwner && !isAuthor) {
607 return c.redirect(
608 `/${ownerName}/${repoName}/discussions/${numParam}`
609 );
610 }
611 if (row.category !== "q-and-a") {
612 return c.text(
613 "Only q-and-a discussions can have answers",
614 400
615 );
616 }
617 await db
618 .update(discussions)
619 .set({ answerCommentId: commentId })
620 .where(eq(discussions.id, row.id));
621 await db
622 .update(discussionComments)
623 .set({ isAnswer: true })
624 .where(eq(discussionComments.id, commentId));
625 } catch {
626 // swallow
627 }
628 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
629 }
630);
631
632// Toggle close (owner or author)
633discussionRoutes.post(
634 "/:owner/:repo/discussions/:number/close",
635 requireAuth,
636 async (c) => {
637 const { owner: ownerName, repo: repoName } = c.req.param();
638 const user = c.get("user")!;
639 const numParam = Number(c.req.param("number"));
640 const resolved = await resolveRepo(ownerName, repoName);
641 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
642 try {
643 const [row] = await db
644 .select()
645 .from(discussions)
646 .where(
647 and(
648 eq(discussions.repositoryId, resolved.repo.id),
649 eq(discussions.number, numParam)
650 )
651 )
652 .limit(1);
653 if (!row) {
654 return c.redirect(`/${ownerName}/${repoName}/discussions`);
655 }
656 const isOwner = user.id === resolved.repo.ownerId;
657 const isAuthor = user.id === row.authorId;
658 if (!isOwner && !isAuthor) {
659 return c.redirect(
660 `/${ownerName}/${repoName}/discussions/${numParam}`
661 );
662 }
663 await db
664 .update(discussions)
665 .set({ state: row.state === "open" ? "closed" : "open" })
666 .where(eq(discussions.id, row.id));
667 } catch {
668 // swallow
669 }
670 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
671 }
672);
673
674export default discussionRoutes;
Addedsrc/routes/gists.tsx+805−0View fileUnifiedSplit
1/**
2 * Block E4 — Gists: user-owned tiny multi-file repos.
3 *
4 * DB-backed v1 (no git bare repo). Each gist owns a collection of gist_files,
5 * and every edit appends a gist_revisions row with a JSON snapshot of the
6 * full file set at that revision.
7 *
8 * Never throws — all DB paths wrapped in try/catch; any failure redirects.
9 */
10
11import { Hono } from "hono";
12import { and, eq, desc, sql } from "drizzle-orm";
13import { randomBytes } from "crypto";
14import { db } from "../db";
15import {
16 gists,
17 gistFiles,
18 gistRevisions,
19 gistStars,
20 users,
21} from "../db/schema";
22import { Layout } from "../views/layout";
23import { highlightCode } from "../lib/highlight";
24import { softAuth, requireAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import { html } from "hono/html";
27
28export function generateSlug(): string {
29 return randomBytes(4).toString("hex");
30}
31
32export function snapshotOf(
33 files: { filename: string; content: string }[]
34): string {
35 const map: Record<string, string> = {};
36 for (const f of files) map[f.filename] = f.content;
37 return JSON.stringify(map);
38}
39
40const gistRoutes = new Hono<AuthEnv>();
41
42function notFound(user: any, label = "Gist not found") {
43 return (
44 <Layout title={label} user={user}>
45 <div class="empty-state">
46 <h2>{label}</h2>
47 </div>
48 </Layout>
49 );
50}
51
52// Discover / list public gists
53gistRoutes.get("/gists", softAuth, async (c) => {
54 const user = c.get("user");
55 const page = Math.max(1, Number(c.req.query("page")) || 1);
56 const limit = 30;
57 const offset = (page - 1) * limit;
58
59 let rows: any[] = [];
60 try {
61 rows = await db
62 .select({
63 g: gists,
64 owner: { username: users.username },
65 fileCount: sql<number>`(SELECT count(*) FROM gist_files WHERE gist_id = ${gists.id})`,
66 starCount: sql<number>`(SELECT count(*) FROM gist_stars WHERE gist_id = ${gists.id})`,
67 })
68 .from(gists)
69 .innerJoin(users, eq(gists.ownerId, users.id))
70 .where(eq(gists.isPublic, true))
71 .orderBy(desc(gists.updatedAt))
72 .limit(limit)
73 .offset(offset);
74 } catch {
75 rows = [];
76 }
77
78 return c.html(
79 <Layout title="Discover gists" user={user}>
80 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
81 <h1 style="margin: 0;">Discover gists</h1>
82 {user && (
83 <a href="/gists/new" class="btn btn-primary">
84 + New gist
85 </a>
86 )}
87 </div>
88 {rows.length === 0 ? (
89 <div class="empty-state">
90 <p>No public gists yet.</p>
91 </div>
92 ) : (
93 <div class="commit-list">
94 {rows.map((r) => (
95 <div class="commit-item">
96 <div>
97 <div class="commit-message">
98 <a href={`/gists/${r.g.slug}`}>
99 <strong>{r.g.title || r.g.slug}</strong>
100 </a>
101 </div>
102 <div class="commit-meta">
103 by <a href={`/${r.owner.username}`}>@{r.owner.username}</a>{" "}
104 · {r.fileCount} file{r.fileCount !== 1 ? "s" : ""} · ★{" "}
105 {r.starCount}
106 {r.g.description && ` · ${r.g.description}`}
107 </div>
108 </div>
109 <a href={`/gists/${r.g.slug}`} class="commit-sha">
110 {r.g.slug}
111 </a>
112 </div>
113 ))}
114 </div>
115 )}
116 {(rows.length === limit || page > 1) && (
117 <div style="margin-top: 16px;">
118 {page > 1 && <a href={`/gists?page=${page - 1}`}>← prev</a>}
119 {" "}
120 {rows.length === limit && (
121 <a href={`/gists?page=${page + 1}`}>next →</a>
122 )}
123 </div>
124 )}
125 </Layout>
126 );
127});
128
129// New gist form
130gistRoutes.get("/gists/new", requireAuth, async (c) => {
131 const user = c.get("user");
132 return c.html(
133 <Layout title="New gist" user={user}>
134 <h1 style="margin-top: 20px;">Create a gist</h1>
135 <form
136 method="POST"
137 action="/gists"
138 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
139 >
140 <input
141 type="text"
142 name="description"
143 placeholder="Gist description..."
144 style="padding: 8px;"
145 />
146 <div style="display: flex; gap: 16px;">
147 <label>
148 <input type="radio" name="is_public" value="true" checked />{" "}
149 Public
150 </label>
151 <label>
152 <input type="radio" name="is_public" value="false" /> Secret
153 </label>
154 </div>
155 <div id="files">
156 <div class="gist-file" style="border: 1px solid var(--border); padding: 12px; margin-bottom: 8px;">
157 <input
158 type="text"
159 name="filename[]"
160 placeholder="filename.ext"
161 required
162 style="padding: 6px; width: 300px;"
163 />
164 <textarea
165 name="content[]"
166 rows={12}
167 placeholder="File contents..."
168 required
169 style="width: 100%; padding: 8px; font-family: monospace; margin-top: 8px;"
170 ></textarea>
171 </div>
172 </div>
173 <button
174 type="button"
175 class="btn"
176 id="add-file"
177 style="align-self: flex-start;"
178 >
179 + Add file
180 </button>
181 <button type="submit" class="btn btn-primary">
182 Create gist
183 </button>
184 </form>
185 {html`
186 <script>
187 document.getElementById("add-file").addEventListener("click", () => {
188 const div = document.createElement("div");
189 div.className = "gist-file";
190 div.style.cssText = "border: 1px solid var(--border); padding: 12px; margin-bottom: 8px;";
191 div.innerHTML =
192 '<input type="text" name="filename[]" placeholder="filename.ext" required style="padding: 6px; width: 300px;" />' +
193 '<textarea name="content[]" rows="12" placeholder="File contents..." required style="width: 100%; padding: 8px; font-family: monospace; margin-top: 8px;"></textarea>';
194 document.getElementById("files").appendChild(div);
195 });
196 </script>
197 `}
198 </Layout>
199 );
200});
201
202// Create gist
203gistRoutes.post("/gists", requireAuth, async (c) => {
204 const user = c.get("user")!;
205 const form = await c.req.formData();
206 const description = (form.get("description") as string || "").trim();
207 const isPublic = (form.get("is_public") as string) !== "false";
208 const filenames = form.getAll("filename[]") as string[];
209 const contents = form.getAll("content[]") as string[];
210
211 const files = filenames
212 .map((fn, i) => ({
213 filename: (fn || "").trim(),
214 content: contents[i] || "",
215 }))
216 .filter((f) => f.filename && f.content);
217
218 if (files.length === 0) {
219 return c.text("At least one file is required", 400);
220 }
221
222 // Retry on unique slug collision
223 for (let attempt = 0; attempt < 5; attempt++) {
224 const slug = generateSlug();
225 try {
226 const [gist] = await db
227 .insert(gists)
228 .values({
229 ownerId: user.id,
230 slug,
231 title: files[0].filename,
232 description,
233 isPublic,
234 })
235 .returning({ id: gists.id });
236 await db.insert(gistFiles).values(
237 files.map((f) => ({
238 gistId: gist.id,
239 filename: f.filename,
240 content: f.content,
241 sizeBytes: new TextEncoder().encode(f.content).length,
242 }))
243 );
244 await db.insert(gistRevisions).values({
245 gistId: gist.id,
246 revision: 1,
247 snapshot: snapshotOf(files),
248 authorId: user.id,
249 message: "Initial",
250 });
251 return c.redirect(`/gists/${slug}`);
252 } catch (err: any) {
253 if (attempt === 4) {
254 return c.text("Could not create gist", 500);
255 }
256 // Otherwise assume slug collision, retry with fresh slug.
257 }
258 }
259 return c.redirect("/gists");
260});
261
262// View gist
263gistRoutes.get("/gists/:slug", softAuth, async (c) => {
264 const user = c.get("user");
265 const slug = c.req.param("slug");
266
267 let gist: any = null;
268 let files: any[] = [];
269 let starCount = 0;
270 let isStarred = false;
271 try {
272 const [row] = await db
273 .select({ g: gists, owner: { username: users.username } })
274 .from(gists)
275 .innerJoin(users, eq(gists.ownerId, users.id))
276 .where(eq(gists.slug, slug))
277 .limit(1);
278 if (row) {
279 gist = row;
280 files = await db
281 .select()
282 .from(gistFiles)
283 .where(eq(gistFiles.gistId, gist.g.id))
284 .orderBy(gistFiles.filename);
285 const [cnt] = await db
286 .select({ n: sql<number>`count(*)` })
287 .from(gistStars)
288 .where(eq(gistStars.gistId, gist.g.id));
289 starCount = Number(cnt?.n || 0);
290 if (user) {
291 const [has] = await db
292 .select()
293 .from(gistStars)
294 .where(
295 and(
296 eq(gistStars.gistId, gist.g.id),
297 eq(gistStars.userId, user.id)
298 )
299 )
300 .limit(1);
301 isStarred = !!has;
302 }
303 }
304 } catch {
305 // leave null
306 }
307
308 if (!gist) return c.html(notFound(user), 404);
309
310 const isOwner = user && user.id === gist.g.ownerId;
311 if (!gist.g.isPublic && !isOwner) {
312 return c.html(notFound(user), 404);
313 }
314
315 return c.html(
316 <Layout title={gist.g.title || slug} user={user}>
317 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
318 <div>
319 <h1 style="margin: 0;">
320 <a href={`/${gist.owner.username}`}>@{gist.owner.username}</a>{" "}
321 <span style="color: var(--text-muted);">/</span>{" "}
322 {gist.g.title || slug}
323 {!gist.g.isPublic && <span class="badge">Secret</span>}
324 </h1>
325 {gist.g.description && (
326 <div style="color: var(--text-muted); margin-top: 4px;">
327 {gist.g.description}
328 </div>
329 )}
330 </div>
331 <div style="display: flex; gap: 8px;">
332 {user && !isOwner && (
333 <form
334 method="POST"
335 action={`/gists/${slug}/star`}
336 style="display: inline;"
337 >
338 <button
339 type="submit"
340 class={`star-btn${isStarred ? " starred" : ""}`}
341 >
342 {isStarred ? "★" : "☆"} {starCount}
343 </button>
344 </form>
345 )}
346 {!user && (
347 <span class="star-btn">☆ {starCount}</span>
348 )}
349 <a href={`/gists/${slug}/revisions`} class="btn">
350 Revisions
351 </a>
352 {isOwner && (
353 <>
354 <a href={`/gists/${slug}/edit`} class="btn">
355 Edit
356 </a>
357 <form
358 method="POST"
359 action={`/gists/${slug}/delete`}
360 style="display: inline;"
361 onsubmit="return confirm('Delete this gist?')"
362 >
363 <button type="submit" class="btn">
364 Delete
365 </button>
366 </form>
367 </>
368 )}
369 </div>
370 </div>
371 {files.map((f) => {
372 const { html: highlighted } = highlightCode(f.content, f.filename);
373 return (
374 <div style="margin-top: 16px; border: 1px solid var(--border); border-radius: 6px;">
375 <div class="diff-file-header">{f.filename}</div>
376 <div class="blob-code">
377 <pre style="margin: 0; padding: 12px; font-size: 13px; line-height: 1.6; overflow-x: auto;">
378 {html([highlighted] as unknown as TemplateStringsArray)}
379 </pre>
380 </div>
381 </div>
382 );
383 })}
384 </Layout>
385 );
386});
387
388// Edit form
389gistRoutes.get("/gists/:slug/edit", requireAuth, async (c) => {
390 const user = c.get("user")!;
391 const slug = c.req.param("slug");
392
393 let gist: any = null;
394 let files: any[] = [];
395 try {
396 const [row] = await db
397 .select()
398 .from(gists)
399 .where(eq(gists.slug, slug))
400 .limit(1);
401 if (row && row.ownerId === user.id) {
402 gist = row;
403 files = await db
404 .select()
405 .from(gistFiles)
406 .where(eq(gistFiles.gistId, gist.id))
407 .orderBy(gistFiles.filename);
408 }
409 } catch {
410 // leave null
411 }
412
413 if (!gist) return c.html(notFound(user, "Not found or not yours"), 404);
414
415 return c.html(
416 <Layout title={`Edit ${gist.slug}`} user={user}>
417 <h1 style="margin-top: 20px;">Edit gist</h1>
418 <form
419 method="POST"
420 action={`/gists/${slug}/edit`}
421 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
422 >
423 <input
424 type="text"
425 name="description"
426 value={gist.description}
427 placeholder="Description"
428 style="padding: 8px;"
429 />
430 <input
431 type="text"
432 name="message"
433 placeholder="Revision message (optional)"
434 style="padding: 8px;"
435 />
436 <div id="files">
437 {files.map((f) => (
438 <div class="gist-file" style="border: 1px solid var(--border); padding: 12px; margin-bottom: 8px;">
439 <input
440 type="text"
441 name="filename[]"
442 value={f.filename}
443 required
444 style="padding: 6px; width: 300px;"
445 />
446 <textarea
447 name="content[]"
448 rows={12}
449 required
450 style="width: 100%; padding: 8px; font-family: monospace; margin-top: 8px;"
451 >
452 {f.content}
453 </textarea>
454 </div>
455 ))}
456 </div>
457 <button
458 type="button"
459 class="btn"
460 id="add-file"
461 style="align-self: flex-start;"
462 >
463 + Add file
464 </button>
465 <button type="submit" class="btn btn-primary">
466 Save revision
467 </button>
468 </form>
469 {html`
470 <script>
471 document.getElementById("add-file").addEventListener("click", () => {
472 const div = document.createElement("div");
473 div.className = "gist-file";
474 div.style.cssText = "border: 1px solid var(--border); padding: 12px; margin-bottom: 8px;";
475 div.innerHTML =
476 '<input type="text" name="filename[]" placeholder="filename.ext" required style="padding: 6px; width: 300px;" />' +
477 '<textarea name="content[]" rows="12" required style="width: 100%; padding: 8px; font-family: monospace; margin-top: 8px;"></textarea>';
478 document.getElementById("files").appendChild(div);
479 });
480 </script>
481 `}
482 </Layout>
483 );
484});
485
486// Save edit
487gistRoutes.post("/gists/:slug/edit", requireAuth, async (c) => {
488 const user = c.get("user")!;
489 const slug = c.req.param("slug");
490 const form = await c.req.formData();
491 const description = (form.get("description") as string || "").trim();
492 const message = (form.get("message") as string || "").trim();
493 const filenames = form.getAll("filename[]") as string[];
494 const contents = form.getAll("content[]") as string[];
495
496 const files = filenames
497 .map((fn, i) => ({
498 filename: (fn || "").trim(),
499 content: contents[i] || "",
500 }))
501 .filter((f) => f.filename && f.content);
502
503 if (files.length === 0) {
504 return c.text("At least one file is required", 400);
505 }
506
507 try {
508 const [row] = await db
509 .select()
510 .from(gists)
511 .where(eq(gists.slug, slug))
512 .limit(1);
513 if (!row || row.ownerId !== user.id) {
514 return c.redirect("/gists");
515 }
516 // Replace file set: delete all, re-insert.
517 await db.delete(gistFiles).where(eq(gistFiles.gistId, row.id));
518 await db.insert(gistFiles).values(
519 files.map((f) => ({
520 gistId: row.id,
521 filename: f.filename,
522 content: f.content,
523 sizeBytes: new TextEncoder().encode(f.content).length,
524 }))
525 );
526 // Bump revision.
527 const [last] = await db
528 .select({ r: sql<number>`max(${gistRevisions.revision})` })
529 .from(gistRevisions)
530 .where(eq(gistRevisions.gistId, row.id));
531 const nextRev = Number(last?.r || 0) + 1;
532 await db.insert(gistRevisions).values({
533 gistId: row.id,
534 revision: nextRev,
535 snapshot: snapshotOf(files),
536 authorId: user.id,
537 message: message || null,
538 });
539 await db
540 .update(gists)
541 .set({ description, updatedAt: new Date() })
542 .where(eq(gists.id, row.id));
543 } catch {
544 // swallow
545 }
546 return c.redirect(`/gists/${slug}`);
547});
548
549// Delete
550gistRoutes.post("/gists/:slug/delete", requireAuth, async (c) => {
551 const user = c.get("user")!;
552 const slug = c.req.param("slug");
553 try {
554 const [row] = await db
555 .select()
556 .from(gists)
557 .where(eq(gists.slug, slug))
558 .limit(1);
559 if (row && row.ownerId === user.id) {
560 await db.delete(gists).where(eq(gists.id, row.id));
561 }
562 } catch {
563 // swallow
564 }
565 return c.redirect("/gists");
566});
567
568// Toggle star
569gistRoutes.post("/gists/:slug/star", requireAuth, async (c) => {
570 const user = c.get("user")!;
571 const slug = c.req.param("slug");
572 try {
573 const [row] = await db
574 .select()
575 .from(gists)
576 .where(eq(gists.slug, slug))
577 .limit(1);
578 if (row && row.ownerId !== user.id) {
579 const [existing] = await db
580 .select()
581 .from(gistStars)
582 .where(
583 and(
584 eq(gistStars.gistId, row.id),
585 eq(gistStars.userId, user.id)
586 )
587 )
588 .limit(1);
589 if (existing) {
590 await db.delete(gistStars).where(eq(gistStars.id, existing.id));
591 } else {
592 await db.insert(gistStars).values({
593 gistId: row.id,
594 userId: user.id,
595 });
596 }
597 }
598 } catch {
599 // swallow
600 }
601 return c.redirect(`/gists/${slug}`);
602});
603
604// Revisions list
605gistRoutes.get("/gists/:slug/revisions", softAuth, async (c) => {
606 const user = c.get("user");
607 const slug = c.req.param("slug");
608
609 let gist: any = null;
610 let revs: any[] = [];
611 try {
612 const [row] = await db
613 .select()
614 .from(gists)
615 .where(eq(gists.slug, slug))
616 .limit(1);
617 if (row && (row.isPublic || (user && user.id === row.ownerId))) {
618 gist = row;
619 revs = await db
620 .select({
621 r: gistRevisions,
622 author: { username: users.username },
623 })
624 .from(gistRevisions)
625 .innerJoin(users, eq(gistRevisions.authorId, users.id))
626 .where(eq(gistRevisions.gistId, gist.id))
627 .orderBy(desc(gistRevisions.revision));
628 }
629 } catch {
630 // leave null
631 }
632
633 if (!gist) return c.html(notFound(user), 404);
634
635 return c.html(
636 <Layout title={`${gist.slug} — revisions`} user={user}>
637 <h1 style="margin-top: 16px;">
638 <a href={`/gists/${slug}`}>{gist.title || slug}</a> — revisions
639 </h1>
640 <div class="commit-list" style="margin-top: 16px;">
641 {revs.map((rv) => (
642 <div class="commit-item">
643 <div>
644 <div class="commit-message">
645 <a href={`/gists/${slug}/revisions/${rv.r.revision}`}>
646 <strong>Revision {rv.r.revision}</strong>
647 </a>
648 {rv.r.message ? ` — ${rv.r.message}` : ""}
649 </div>
650 <div class="commit-meta">
651 by @{rv.author.username}
652 </div>
653 </div>
654 <a
655 href={`/gists/${slug}/revisions/${rv.r.revision}`}
656 class="commit-sha"
657 >
658 r{rv.r.revision}
659 </a>
660 </div>
661 ))}
662 </div>
663 </Layout>
664 );
665});
666
667// Revision detail
668gistRoutes.get(
669 "/gists/:slug/revisions/:rev",
670 softAuth,
671 async (c) => {
672 const user = c.get("user");
673 const slug = c.req.param("slug");
674 const rev = Number(c.req.param("rev"));
675
676 let gist: any = null;
677 let snapshot: Record<string, string> | null = null;
678 try {
679 const [row] = await db
680 .select()
681 .from(gists)
682 .where(eq(gists.slug, slug))
683 .limit(1);
684 if (row && (row.isPublic || (user && user.id === row.ownerId))) {
685 gist = row;
686 const [rv] = await db
687 .select()
688 .from(gistRevisions)
689 .where(
690 and(
691 eq(gistRevisions.gistId, gist.id),
692 eq(gistRevisions.revision, rev)
693 )
694 )
695 .limit(1);
696 if (rv) {
697 try {
698 snapshot = JSON.parse(rv.snapshot);
699 } catch {
700 snapshot = {};
701 }
702 }
703 }
704 } catch {
705 // leave null
706 }
707
708 if (!gist || !snapshot)
709 return c.html(notFound(user, "Revision not found"), 404);
710
711 return c.html(
712 <Layout title={`${slug} @ r${rev}`} user={user}>
713 <h1 style="margin-top: 16px;">
714 <a href={`/gists/${slug}`}>{gist.title || slug}</a> @ revision {rev}
715 </h1>
716 {Object.entries(snapshot).map(([filename, content]) => {
717 const { html: highlighted } = highlightCode(content, filename);
718 return (
719 <div style="margin-top: 16px; border: 1px solid var(--border); border-radius: 6px;">
720 <div class="diff-file-header">{filename}</div>
721 <div class="blob-code">
722 <pre style="margin: 0; padding: 12px; font-size: 13px; line-height: 1.6; overflow-x: auto;">
723 {html([highlighted] as unknown as TemplateStringsArray)}
724 </pre>
725 </div>
726 </div>
727 );
728 })}
729 </Layout>
730 );
731 }
732);
733
734// User's public gists
735gistRoutes.get("/:username/gists", softAuth, async (c) => {
736 const user = c.get("user");
737 const username = c.req.param("username");
738
739 let ownerUser: any = null;
740 let rows: any[] = [];
741 try {
742 const [u] = await db
743 .select()
744 .from(users)
745 .where(eq(users.username, username))
746 .limit(1);
747 if (u) {
748 ownerUser = u;
749 const showPrivate = user && user.id === u.id;
750 rows = await db
751 .select({
752 g: gists,
753 fileCount: sql<number>`(SELECT count(*) FROM gist_files WHERE gist_id = ${gists.id})`,
754 })
755 .from(gists)
756 .where(
757 showPrivate
758 ? eq(gists.ownerId, u.id)
759 : and(eq(gists.ownerId, u.id), eq(gists.isPublic, true))
760 )
761 .orderBy(desc(gists.updatedAt));
762 }
763 } catch {
764 rows = [];
765 }
766
767 if (!ownerUser) return c.html(notFound(user, "User not found"), 404);
768
769 return c.html(
770 <Layout title={`@${username}'s gists`} user={user}>
771 <h1 style="margin-top: 16px;">
772 <a href={`/${username}`}>@{username}</a>'s gists
773 </h1>
774 {rows.length === 0 ? (
775 <div class="empty-state">
776 <p>No gists yet.</p>
777 </div>
778 ) : (
779 <div class="commit-list" style="margin-top: 16px;">
780 {rows.map((r) => (
781 <div class="commit-item">
782 <div>
783 <div class="commit-message">
784 <a href={`/gists/${r.g.slug}`}>
785 <strong>{r.g.title || r.g.slug}</strong>
786 </a>
787 {!r.g.isPublic && <span class="badge">Secret</span>}
788 </div>
789 <div class="commit-meta">
790 {r.fileCount} file{r.fileCount !== 1 ? "s" : ""}
791 {r.g.description && ` · ${r.g.description}`}
792 </div>
793 </div>
794 <a href={`/gists/${r.g.slug}`} class="commit-sha">
795 {r.g.slug}
796 </a>
797 </div>
798 ))}
799 </div>
800 )}
801 </Layout>
802 );
803});
804
805export default gistRoutes;
Addedsrc/routes/projects.tsx+601−0View fileUnifiedSplit
1/**
2 * Block E1 — Projects / Kanban boards scoped to a repo.
3 *
4 * Each project has ordered columns ("To Do" / "In Progress" / "Done" by
5 * default) and items (notes or linked issues/PRs). Items belong to exactly
6 * one column at a time. Simple v1: positions are recomputed via "max+1".
7 *
8 * Never throws — all DB paths wrapped in try/catch.
9 */
10
11import { Hono } from "hono";
12import { and, eq, desc, asc, sql } from "drizzle-orm";
13import { db } from "../db";
14import {
15 projects,
16 projectColumns,
17 projectItems,
18 repositories,
19 users,
20} from "../db/schema";
21import { Layout } from "../views/layout";
22import { RepoHeader } from "../views/components";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25
26const DEFAULT_COLUMNS = ["To Do", "In Progress", "Done"] as const;
27
28const projectRoutes = new Hono<AuthEnv>();
29
30async function resolveRepo(ownerName: string, repoName: string) {
31 try {
32 const [owner] = await db
33 .select()
34 .from(users)
35 .where(eq(users.username, ownerName))
36 .limit(1);
37 if (!owner) return null;
38 const [repo] = await db
39 .select()
40 .from(repositories)
41 .where(
42 and(
43 eq(repositories.ownerId, owner.id),
44 eq(repositories.name, repoName)
45 )
46 )
47 .limit(1);
48 if (!repo) return null;
49 return { owner, repo };
50 } catch {
51 return null;
52 }
53}
54
55function notFound(user: any, label = "Not found") {
56 return (
57 <Layout title={label} user={user}>
58 <div class="empty-state">
59 <h2>{label}</h2>
60 </div>
61 </Layout>
62 );
63}
64
65// List
66projectRoutes.get("/:owner/:repo/projects", softAuth, async (c) => {
67 const { owner: ownerName, repo: repoName } = c.req.param();
68 const user = c.get("user");
69 const resolved = await resolveRepo(ownerName, repoName);
70 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
71 const { repo } = resolved;
72
73 let rows: any[] = [];
74 try {
75 rows = await db
76 .select({
77 p: projects,
78 columnCount: sql<number>`(SELECT count(*) FROM project_columns WHERE project_id = ${projects.id})`,
79 itemCount: sql<number>`(SELECT count(*) FROM project_items WHERE project_id = ${projects.id})`,
80 })
81 .from(projects)
82 .where(eq(projects.repositoryId, repo.id))
83 .orderBy(desc(projects.updatedAt));
84 } catch {
85 rows = [];
86 }
87
88 return c.html(
89 <Layout title={`Projects — ${ownerName}/${repoName}`} user={user}>
90 <RepoHeader owner={ownerName} repo={repoName} />
91 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
92 <h2 style="margin: 0;">Projects</h2>
93 {user && (
94 <a
95 href={`/${ownerName}/${repoName}/projects/new`}
96 class="btn btn-primary"
97 >
98 New project
99 </a>
100 )}
101 </div>
102 {rows.length === 0 ? (
103 <div class="empty-state">
104 <p>No projects yet.</p>
105 </div>
106 ) : (
107 <table class="file-table">
108 <tbody>
109 {rows.map((r) => (
110 <tr>
111 <td style="width: 40px; color: var(--text-muted);">
112 #{r.p.number}
113 </td>
114 <td>
115 <a
116 href={`/${ownerName}/${repoName}/projects/${r.p.number}`}
117 >
118 <strong>{r.p.title}</strong>
119 </a>
120 {r.p.state === "closed" && <span class="badge">closed</span>}
121 {r.p.description && (
122 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">
123 {r.p.description}
124 </div>
125 )}
126 </td>
127 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
128 {r.columnCount} cols · {r.itemCount} items
129 </td>
130 </tr>
131 ))}
132 </tbody>
133 </table>
134 )}
135 </Layout>
136 );
137});
138
139// New form
140projectRoutes.get(
141 "/:owner/:repo/projects/new",
142 requireAuth,
143 async (c) => {
144 const { owner: ownerName, repo: repoName } = c.req.param();
145 const user = c.get("user");
146 const resolved = await resolveRepo(ownerName, repoName);
147 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
148 return c.html(
149 <Layout title="New project" user={user}>
150 <RepoHeader owner={ownerName} repo={repoName} />
151 <h2 style="margin-top: 20px;">Create a project</h2>
152 <form
153 method="POST"
154 action={`/${ownerName}/${repoName}/projects`}
155 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
156 >
157 <input
158 type="text"
159 name="title"
160 placeholder="Title"
161 required
162 style="padding: 8px;"
163 />
164 <textarea
165 name="description"
166 rows={4}
167 placeholder="Description (optional)"
168 style="padding: 8px; font-family: inherit;"
169 ></textarea>
170 <button type="submit" class="btn btn-primary">
171 Create
172 </button>
173 </form>
174 </Layout>
175 );
176 }
177);
178
179// Create
180projectRoutes.post(
181 "/:owner/:repo/projects",
182 requireAuth,
183 async (c) => {
184 const { owner: ownerName, repo: repoName } = c.req.param();
185 const user = c.get("user")!;
186 const resolved = await resolveRepo(ownerName, repoName);
187 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
188
189 const form = await c.req.formData();
190 const title = (form.get("title") as string || "").trim();
191 const description = (form.get("description") as string || "").trim();
192
193 if (!title) {
194 return c.redirect(`/${ownerName}/${repoName}/projects/new`);
195 }
196
197 try {
198 const [row] = await db
199 .insert(projects)
200 .values({
201 repositoryId: resolved.repo.id,
202 ownerId: user.id,
203 title,
204 description,
205 })
206 .returning({ id: projects.id, number: projects.number });
207 // Seed default columns
208 await db.insert(projectColumns).values(
209 DEFAULT_COLUMNS.map((name, i) => ({
210 projectId: row.id,
211 name,
212 position: i,
213 }))
214 );
215 return c.redirect(`/${ownerName}/${repoName}/projects/${row.number}`);
216 } catch {
217 return c.redirect(`/${ownerName}/${repoName}/projects`);
218 }
219 }
220);
221
222// Board view
223projectRoutes.get(
224 "/:owner/:repo/projects/:number",
225 softAuth,
226 async (c) => {
227 const { owner: ownerName, repo: repoName } = c.req.param();
228 const user = c.get("user");
229 const numParam = Number(c.req.param("number"));
230 const resolved = await resolveRepo(ownerName, repoName);
231 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
232
233 let project: any = null;
234 let columns: any[] = [];
235 let items: any[] = [];
236 try {
237 const [row] = await db
238 .select()
239 .from(projects)
240 .where(
241 and(
242 eq(projects.repositoryId, resolved.repo.id),
243 eq(projects.number, numParam)
244 )
245 )
246 .limit(1);
247 if (row) {
248 project = row;
249 columns = await db
250 .select()
251 .from(projectColumns)
252 .where(eq(projectColumns.projectId, row.id))
253 .orderBy(asc(projectColumns.position), asc(projectColumns.createdAt));
254 items = await db
255 .select()
256 .from(projectItems)
257 .where(eq(projectItems.projectId, row.id))
258 .orderBy(asc(projectItems.position));
259 }
260 } catch {
261 // leave nulls
262 }
263
264 if (!project) return c.html(notFound(user, "Project not found"), 404);
265
266 const isOwner = user && user.id === resolved.repo.ownerId;
267 const itemsByCol: Record<string, any[]> = {};
268 for (const col of columns) itemsByCol[col.id] = [];
269 for (const it of items) {
270 if (itemsByCol[it.columnId]) itemsByCol[it.columnId].push(it);
271 }
272
273 return c.html(
274 <Layout
275 title={`${project.title} — project #${project.number}`}
276 user={user}
277 >
278 <RepoHeader owner={ownerName} repo={repoName} />
279 <style>{`
280 .kanban { display: flex; gap: 16px; overflow-x: auto; padding: 16px 0; }
281 .kcol { background: var(--bg-soft); border: 1px solid var(--border); border-radius: 6px; min-width: 260px; flex-shrink: 0; padding: 12px; }
282 .kcol h4 { margin: 0 0 12px; display: flex; justify-content: space-between; }
283 .kcard { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 8px; margin-bottom: 8px; font-size: 13px; }
284 .kcard form { display: inline; }
285 `}</style>
286 <div style="display: flex; justify-content: space-between; align-items: center; margin-top: 16px;">
287 <h1 style="margin: 0;">
288 {project.title}{" "}
289 <span style="color: var(--text-muted);">#{project.number}</span>
290 {project.state === "closed" && <span class="badge">closed</span>}
291 </h1>
292 {user && (
293 <form
294 method="POST"
295 action={`/${ownerName}/${repoName}/projects/${project.number}/close`}
296 style="display: inline;"
297 >
298 <button type="submit" class="btn">
299 {project.state === "open" ? "Close" : "Reopen"}
300 </button>
301 </form>
302 )}
303 </div>
304 {project.description && (
305 <div style="color: var(--text-muted); margin-top: 4px;">
306 {project.description}
307 </div>
308 )}
309 <div class="kanban">
310 {columns.map((col) => (
311 <div class="kcol">
312 <h4>
313 <span>{col.name}</span>
314 <span style="color: var(--text-muted); font-size: 13px;">
315 {(itemsByCol[col.id] || []).length}
316 </span>
317 </h4>
318 {(itemsByCol[col.id] || []).map((it) => (
319 <div class="kcard">
320 <div>
321 <strong>{it.title || "(untitled)"}</strong>
322 </div>
323 {it.note && (
324 <div style="color: var(--text-muted); margin-top: 4px;">
325 {it.note}
326 </div>
327 )}
328 {user && (
329 <div style="margin-top: 8px; display: flex; gap: 4px; flex-wrap: wrap;">
330 {columns
331 .filter((oc) => oc.id !== col.id)
332 .map((oc) => (
333 <form
334 method="POST"
335 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/move`}
336 >
337 <input
338 type="hidden"
339 name="column_id"
340 value={oc.id}
341 />
342 <button
343 type="submit"
344 class="btn"
345 style="font-size: 11px; padding: 2px 6px;"
346 >
347 → {oc.name}
348 </button>
349 </form>
350 ))}
351 <form
352 method="POST"
353 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/delete`}
354 >
355 <button
356 type="submit"
357 class="btn"
358 style="font-size: 11px; padding: 2px 6px;"
359 >
360 ×
361 </button>
362 </form>
363 </div>
364 )}
365 </div>
366 ))}
367 {user && (
368 <form
369 method="POST"
370 action={`/${ownerName}/${repoName}/projects/${project.number}/items`}
371 style="margin-top: 8px; display: flex; flex-direction: column; gap: 4px;"
372 >
373 <input type="hidden" name="column_id" value={col.id} />
374 <input
375 type="text"
376 name="title"
377 placeholder="New card title"
378 required
379 style="padding: 4px; font-size: 12px;"
380 />
381 <button
382 type="submit"
383 class="btn"
384 style="font-size: 12px; padding: 4px;"
385 >
386 + Add card
387 </button>
388 </form>
389 )}
390 </div>
391 ))}
392 {user && (
393 <div class="kcol" style="background: transparent; border-style: dashed;">
394 <form
395 method="POST"
396 action={`/${ownerName}/${repoName}/projects/${project.number}/columns`}
397 style="display: flex; flex-direction: column; gap: 8px;"
398 >
399 <input
400 type="text"
401 name="name"
402 placeholder="New column"
403 required
404 style="padding: 6px;"
405 />
406 <button type="submit" class="btn">
407 + Add column
408 </button>
409 </form>
410 </div>
411 )}
412 </div>
413 </Layout>
414 );
415 }
416);
417
418// Add column
419projectRoutes.post(
420 "/:owner/:repo/projects/:number/columns",
421 requireAuth,
422 async (c) => {
423 const { owner: ownerName, repo: repoName } = c.req.param();
424 const numParam = Number(c.req.param("number"));
425 const resolved = await resolveRepo(ownerName, repoName);
426 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
427
428 const form = await c.req.formData();
429 const name = (form.get("name") as string || "").trim();
430 if (!name) {
431 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
432 }
433
434 try {
435 const [row] = await db
436 .select()
437 .from(projects)
438 .where(
439 and(
440 eq(projects.repositoryId, resolved.repo.id),
441 eq(projects.number, numParam)
442 )
443 )
444 .limit(1);
445 if (row) {
446 const [maxPos] = await db
447 .select({ p: sql<number>`coalesce(max(${projectColumns.position}), -1)` })
448 .from(projectColumns)
449 .where(eq(projectColumns.projectId, row.id));
450 await db.insert(projectColumns).values({
451 projectId: row.id,
452 name,
453 position: Number(maxPos?.p || -1) + 1,
454 });
455 }
456 } catch {
457 // swallow
458 }
459 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
460 }
461);
462
463// Add item
464projectRoutes.post(
465 "/:owner/:repo/projects/:number/items",
466 requireAuth,
467 async (c) => {
468 const { owner: ownerName, repo: repoName } = c.req.param();
469 const numParam = Number(c.req.param("number"));
470 const resolved = await resolveRepo(ownerName, repoName);
471 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
472
473 const form = await c.req.formData();
474 const columnId = (form.get("column_id") as string || "").trim();
475 const title = (form.get("title") as string || "").trim();
476 const note = (form.get("note") as string || "").trim();
477 if (!columnId || !title) {
478 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
479 }
480
481 try {
482 const [row] = await db
483 .select()
484 .from(projects)
485 .where(
486 and(
487 eq(projects.repositoryId, resolved.repo.id),
488 eq(projects.number, numParam)
489 )
490 )
491 .limit(1);
492 if (row) {
493 const [maxPos] = await db
494 .select({ p: sql<number>`coalesce(max(${projectItems.position}), -1)` })
495 .from(projectItems)
496 .where(eq(projectItems.columnId, columnId));
497 await db.insert(projectItems).values({
498 projectId: row.id,
499 columnId,
500 title,
501 note,
502 position: Number(maxPos?.p || -1) + 1,
503 });
504 }
505 } catch {
506 // swallow
507 }
508 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
509 }
510);
511
512// Move item
513projectRoutes.post(
514 "/:owner/:repo/projects/:number/items/:itemId/move",
515 requireAuth,
516 async (c) => {
517 const { owner: ownerName, repo: repoName, itemId } = c.req.param();
518 const numParam = Number(c.req.param("number"));
519 const resolved = await resolveRepo(ownerName, repoName);
520 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
521
522 const form = await c.req.formData();
523 const columnId = (form.get("column_id") as string || "").trim();
524 if (!columnId) {
525 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
526 }
527
528 try {
529 const [maxPos] = await db
530 .select({ p: sql<number>`coalesce(max(${projectItems.position}), -1)` })
531 .from(projectItems)
532 .where(eq(projectItems.columnId, columnId));
533 await db
534 .update(projectItems)
535 .set({
536 columnId,
537 position: Number(maxPos?.p || -1) + 1,
538 updatedAt: new Date(),
539 })
540 .where(eq(projectItems.id, itemId));
541 } catch {
542 // swallow
543 }
544 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
545 }
546);
547
548// Delete item
549projectRoutes.post(
550 "/:owner/:repo/projects/:number/items/:itemId/delete",
551 requireAuth,
552 async (c) => {
553 const { owner: ownerName, repo: repoName, itemId } = c.req.param();
554 const numParam = Number(c.req.param("number"));
555 try {
556 await db.delete(projectItems).where(eq(projectItems.id, itemId));
557 } catch {
558 // swallow
559 }
560 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
561 }
562);
563
564// Toggle close
565projectRoutes.post(
566 "/:owner/:repo/projects/:number/close",
567 requireAuth,
568 async (c) => {
569 const { owner: ownerName, repo: repoName } = c.req.param();
570 const numParam = Number(c.req.param("number"));
571 const resolved = await resolveRepo(ownerName, repoName);
572 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
573
574 try {
575 const [row] = await db
576 .select()
577 .from(projects)
578 .where(
579 and(
580 eq(projects.repositoryId, resolved.repo.id),
581 eq(projects.number, numParam)
582 )
583 )
584 .limit(1);
585 if (row) {
586 await db
587 .update(projects)
588 .set({
589 state: row.state === "open" ? "closed" : "open",
590 updatedAt: new Date(),
591 })
592 .where(eq(projects.id, row.id));
593 }
594 } catch {
595 // swallow
596 }
597 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
598 }
599);
600
601export default projectRoutes;
Modifiedsrc/routes/pulls.tsx+31−0View fileUnifiedSplit
3131import { mergeWithAutoResolve } from "../lib/merge-resolver";
3232import { runAllGateChecks, type GateCheckResult } from "../lib/gate";
3333import { labels as labelsTable } from "../db/schema";
34import {
35 matchProtection,
36 evaluateProtection,
37 countHumanApprovals,
38} from "../lib/branch-protection";
3439
3540const pulls = new Hono<AuthEnv>();
3641
816821 );
817822 }
818823
824 // D5 — Branch-protection enforcement. Looks up the matching rule for the
825 // base branch and blocks the merge if requireAiApproval / requireGreenGates
826 // / requireHumanReview / requiredApprovals are not satisfied. Independent
827 // of repo-global settings, so owners can lock specific branches down
828 // further than the repo default.
829 const protectionRule = await matchProtection(
830 resolved.repo.id,
831 pr.baseBranch
832 );
833 if (protectionRule) {
834 const humanApprovals = await countHumanApprovals(pr.id);
835 const decision = evaluateProtection(protectionRule, {
836 aiApproved,
837 humanApprovalCount: humanApprovals,
838 gateResultGreen: hardFailures.length === 0,
839 hasFailedGates: hardFailures.length > 0,
840 });
841 if (!decision.allowed) {
842 return c.redirect(
843 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
844 decision.reasons.join(" ")
845 )}`
846 );
847 }
848 }
849
819850 // Attempt the merge — with auto conflict resolution if needed
820851 const repoDir = getRepoPath(ownerName, repoName);
821852 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
Addedsrc/routes/wikis.tsx+752−0View fileUnifiedSplit
1/**
2 * Block E3 — Wikis: per-repo markdown page collection with revision history.
3 *
4 * v1 is DB-backed (no git bare repo). Each wiki_pages row holds the current
5 * title+body+revision counter; every edit appends a wiki_revisions row for
6 * history/diff/revert.
7 *
8 * Never throws.
9 */
10
11import { Hono } from "hono";
12import { and, eq, desc } from "drizzle-orm";
13import { db } from "../db";
14import {
15 wikiPages,
16 wikiRevisions,
17 repositories,
18 users,
19} from "../db/schema";
20import { Layout } from "../views/layout";
21import { RepoHeader } from "../views/components";
22import { renderMarkdown } from "../lib/markdown";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25
26/** lowercase-alphanumerics joined by single dashes, trimmed. */
27export function slugifyTitle(title: string): string {
28 return title
29 .toLowerCase()
30 .normalize("NFKD")
31 .replace(/[^a-z0-9\s-]/g, "")
32 .replace(/\s+/g, "-")
33 .replace(/-+/g, "-")
34 .replace(/^-+|-+$/g, "");
35}
36
37const wikiRoutes = new Hono<AuthEnv>();
38
39async function resolveRepo(ownerName: string, repoName: string) {
40 try {
41 const [owner] = await db
42 .select()
43 .from(users)
44 .where(eq(users.username, ownerName))
45 .limit(1);
46 if (!owner) return null;
47 const [repo] = await db
48 .select()
49 .from(repositories)
50 .where(
51 and(
52 eq(repositories.ownerId, owner.id),
53 eq(repositories.name, repoName)
54 )
55 )
56 .limit(1);
57 if (!repo) return null;
58 return { owner, repo };
59 } catch {
60 return null;
61 }
62}
63
64function notFound(user: any, label = "Page not found") {
65 return (
66 <Layout title={label} user={user}>
67 <div class="empty-state">
68 <h2>{label}</h2>
69 </div>
70 </Layout>
71 );
72}
73
74function WikiSidebar(props: {
75 ownerName: string;
76 repoName: string;
77 pages: { slug: string; title: string }[];
78 user: any;
79}) {
80 const { ownerName, repoName, pages, user } = props;
81 return (
82 <aside style="min-width: 220px; border-right: 1px solid var(--border); padding-right: 16px;">
83 <div style="font-weight: 600; margin-bottom: 8px;">Pages</div>
84 <ul style="list-style: none; padding: 0; margin: 0;">
85 {pages.map((p) => (
86 <li>
87 <a href={`/${ownerName}/${repoName}/wiki/${p.slug}`}>{p.title}</a>
88 </li>
89 ))}
90 </ul>
91 {user && (
92 <div style="margin-top: 16px;">
93 <a
94 href={`/${ownerName}/${repoName}/wiki/new`}
95 class="btn btn-primary"
96 >
97 + New page
98 </a>
99 </div>
100 )}
101 </aside>
102 );
103}
104
105async function listPages(repoId: string) {
106 try {
107 return await db
108 .select({ slug: wikiPages.slug, title: wikiPages.title })
109 .from(wikiPages)
110 .where(eq(wikiPages.repositoryId, repoId))
111 .orderBy(wikiPages.title);
112 } catch {
113 return [];
114 }
115}
116
117// Root — render "home" page if exists, else CTA
118wikiRoutes.get("/:owner/:repo/wiki", softAuth, async (c) => {
119 const { owner: ownerName, repo: repoName } = c.req.param();
120 const user = c.get("user");
121 const resolved = await resolveRepo(ownerName, repoName);
122 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
123 const pages = await listPages(resolved.repo.id);
124
125 let home: any = null;
126 try {
127 const [row] = await db
128 .select()
129 .from(wikiPages)
130 .where(
131 and(
132 eq(wikiPages.repositoryId, resolved.repo.id),
133 eq(wikiPages.slug, "home")
134 )
135 )
136 .limit(1);
137 if (row) home = row;
138 } catch {
139 // leave null
140 }
141
142 return c.html(
143 <Layout title={`Wiki — ${ownerName}/${repoName}`} user={user}>
144 <RepoHeader owner={ownerName} repo={repoName} />
145 <div style="display: flex; gap: 24px; margin-top: 16px;">
146 <WikiSidebar
147 ownerName={ownerName}
148 repoName={repoName}
149 pages={pages}
150 user={user}
151 />
152 <main style="flex: 1;">
153 {home ? (
154 <>
155 <h1>{home.title}</h1>
156 <div
157 dangerouslySetInnerHTML={{
158 __html: renderMarkdown(home.body || ""),
159 }}
160 />
161 </>
162 ) : (
163 <div class="empty-state">
164 <h2>No wiki yet</h2>
165 {user ? (
166 <a
167 href={`/${ownerName}/${repoName}/wiki/new`}
168 class="btn btn-primary"
169 >
170 Create the Home page
171 </a>
172 ) : (
173 <p>Nothing here yet.</p>
174 )}
175 </div>
176 )}
177 </main>
178 </div>
179 </Layout>
180 );
181});
182
183// All pages index
184wikiRoutes.get("/:owner/:repo/wiki/pages", softAuth, async (c) => {
185 const { owner: ownerName, repo: repoName } = c.req.param();
186 const user = c.get("user");
187 const resolved = await resolveRepo(ownerName, repoName);
188 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
189 let rows: any[] = [];
190 try {
191 rows = await db
192 .select()
193 .from(wikiPages)
194 .where(eq(wikiPages.repositoryId, resolved.repo.id))
195 .orderBy(wikiPages.title);
196 } catch {
197 rows = [];
198 }
199 return c.html(
200 <Layout title={`Wiki pages — ${ownerName}/${repoName}`} user={user}>
201 <RepoHeader owner={ownerName} repo={repoName} />
202 <h2 style="margin-top: 16px;">Wiki pages</h2>
203 {rows.length === 0 ? (
204 <div class="empty-state">
205 <p>No pages.</p>
206 </div>
207 ) : (
208 <table class="file-table">
209 <tbody>
210 {rows.map((p) => (
211 <tr>
212 <td>
213 <a href={`/${ownerName}/${repoName}/wiki/${p.slug}`}>
214 {p.title}
215 </a>
216 </td>
217 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
218 r{p.revision}
219 </td>
220 </tr>
221 ))}
222 </tbody>
223 </table>
224 )}
225 </Layout>
226 );
227});
228
229// New page form
230wikiRoutes.get("/:owner/:repo/wiki/new", requireAuth, async (c) => {
231 const { owner: ownerName, repo: repoName } = c.req.param();
232 const user = c.get("user");
233 const resolved = await resolveRepo(ownerName, repoName);
234 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
235 return c.html(
236 <Layout title="New wiki page" user={user}>
237 <RepoHeader owner={ownerName} repo={repoName} />
238 <h2 style="margin-top: 20px;">New wiki page</h2>
239 <form
240 method="POST"
241 action={`/${ownerName}/${repoName}/wiki`}
242 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
243 >
244 <input
245 type="text"
246 name="title"
247 placeholder="Page title"
248 required
249 style="padding: 8px;"
250 />
251 <textarea
252 name="body"
253 rows={16}
254 placeholder="Markdown body"
255 style="padding: 8px; font-family: monospace;"
256 ></textarea>
257 <button type="submit" class="btn btn-primary">
258 Create page
259 </button>
260 </form>
261 </Layout>
262 );
263});
264
265// Create
266wikiRoutes.post("/:owner/:repo/wiki", requireAuth, async (c) => {
267 const { owner: ownerName, repo: repoName } = c.req.param();
268 const user = c.get("user")!;
269 const resolved = await resolveRepo(ownerName, repoName);
270 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
271
272 const form = await c.req.formData();
273 const title = (form.get("title") as string || "").trim();
274 const body = (form.get("body") as string || "").trim();
275 if (!title) {
276 return c.redirect(`/${ownerName}/${repoName}/wiki/new`);
277 }
278 const slug = slugifyTitle(title) || "page";
279
280 try {
281 const [page] = await db
282 .insert(wikiPages)
283 .values({
284 repositoryId: resolved.repo.id,
285 slug,
286 title,
287 body,
288 revision: 1,
289 updatedBy: user.id,
290 })
291 .returning({ id: wikiPages.id });
292 await db.insert(wikiRevisions).values({
293 pageId: page.id,
294 revision: 1,
295 title,
296 body,
297 message: "Initial",
298 authorId: user.id,
299 });
300 } catch {
301 // likely unique-violation on slug; redirect to the existing page
302 }
303 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
304});
305
306// View page
307wikiRoutes.get("/:owner/:repo/wiki/:slug", softAuth, async (c) => {
308 const { owner: ownerName, repo: repoName, slug } = c.req.param();
309 const user = c.get("user");
310 const resolved = await resolveRepo(ownerName, repoName);
311 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
312
313 let page: any = null;
314 try {
315 const [row] = await db
316 .select()
317 .from(wikiPages)
318 .where(
319 and(
320 eq(wikiPages.repositoryId, resolved.repo.id),
321 eq(wikiPages.slug, slug)
322 )
323 )
324 .limit(1);
325 if (row) page = row;
326 } catch {
327 // leave null
328 }
329 if (!page) return c.html(notFound(user, "Page not found"), 404);
330 const pages = await listPages(resolved.repo.id);
331 const isOwner = user && user.id === resolved.repo.ownerId;
332
333 return c.html(
334 <Layout title={`${page.title} — wiki`} user={user}>
335 <RepoHeader owner={ownerName} repo={repoName} />
336 <div style="display: flex; gap: 24px; margin-top: 16px;">
337 <WikiSidebar
338 ownerName={ownerName}
339 repoName={repoName}
340 pages={pages}
341 user={user}
342 />
343 <main style="flex: 1;">
344 <div style="display: flex; justify-content: space-between; align-items: center;">
345 <h1 style="margin: 0;">{page.title}</h1>
346 <div style="display: flex; gap: 8px;">
347 <a
348 href={`/${ownerName}/${repoName}/wiki/${slug}/history`}
349 class="btn"
350 >
351 History
352 </a>
353 {user && (
354 <a
355 href={`/${ownerName}/${repoName}/wiki/${slug}/edit`}
356 class="btn"
357 >
358 Edit
359 </a>
360 )}
361 {isOwner && (
362 <form
363 method="POST"
364 action={`/${ownerName}/${repoName}/wiki/${slug}/delete`}
365 style="display: inline;"
366 onsubmit="return confirm('Delete this page?')"
367 >
368 <button type="submit" class="btn">
369 Delete
370 </button>
371 </form>
372 )}
373 </div>
374 </div>
375 <div
376 style="margin-top: 16px;"
377 dangerouslySetInnerHTML={{
378 __html: renderMarkdown(page.body || ""),
379 }}
380 />
381 </main>
382 </div>
383 </Layout>
384 );
385});
386
387// Edit form
388wikiRoutes.get(
389 "/:owner/:repo/wiki/:slug/edit",
390 requireAuth,
391 async (c) => {
392 const { owner: ownerName, repo: repoName, slug } = c.req.param();
393 const user = c.get("user");
394 const resolved = await resolveRepo(ownerName, repoName);
395 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
396
397 let page: any = null;
398 try {
399 const [row] = await db
400 .select()
401 .from(wikiPages)
402 .where(
403 and(
404 eq(wikiPages.repositoryId, resolved.repo.id),
405 eq(wikiPages.slug, slug)
406 )
407 )
408 .limit(1);
409 if (row) page = row;
410 } catch {
411 // leave null
412 }
413 if (!page) return c.html(notFound(user, "Page not found"), 404);
414
415 return c.html(
416 <Layout title={`Edit ${page.title}`} user={user}>
417 <RepoHeader owner={ownerName} repo={repoName} />
418 <h2 style="margin-top: 20px;">Edit "{page.title}"</h2>
419 <form
420 method="POST"
421 action={`/${ownerName}/${repoName}/wiki/${slug}/edit`}
422 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
423 >
424 <input
425 type="text"
426 name="title"
427 value={page.title}
428 required
429 style="padding: 8px;"
430 />
431 <textarea
432 name="body"
433 rows={16}
434 style="padding: 8px; font-family: monospace;"
435 >
436 {page.body}
437 </textarea>
438 <input
439 type="text"
440 name="message"
441 placeholder="Revision message (optional)"
442 style="padding: 8px;"
443 />
444 <button type="submit" class="btn btn-primary">
445 Save
446 </button>
447 </form>
448 </Layout>
449 );
450 }
451);
452
453// Save edit
454wikiRoutes.post(
455 "/:owner/:repo/wiki/:slug/edit",
456 requireAuth,
457 async (c) => {
458 const { owner: ownerName, repo: repoName, slug } = c.req.param();
459 const user = c.get("user")!;
460 const resolved = await resolveRepo(ownerName, repoName);
461 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
462
463 const form = await c.req.formData();
464 const title = (form.get("title") as string || "").trim();
465 const body = (form.get("body") as string || "").trim();
466 const message = (form.get("message") as string || "").trim();
467 if (!title) {
468 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}/edit`);
469 }
470
471 try {
472 const [page] = await db
473 .select()
474 .from(wikiPages)
475 .where(
476 and(
477 eq(wikiPages.repositoryId, resolved.repo.id),
478 eq(wikiPages.slug, slug)
479 )
480 )
481 .limit(1);
482 if (page) {
483 const nextRev = page.revision + 1;
484 await db
485 .update(wikiPages)
486 .set({
487 title,
488 body,
489 revision: nextRev,
490 updatedAt: new Date(),
491 updatedBy: user.id,
492 })
493 .where(eq(wikiPages.id, page.id));
494 await db.insert(wikiRevisions).values({
495 pageId: page.id,
496 revision: nextRev,
497 title,
498 body,
499 message: message || null,
500 authorId: user.id,
501 });
502 }
503 } catch {
504 // swallow
505 }
506 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
507 }
508);
509
510// Delete
511wikiRoutes.post(
512 "/:owner/:repo/wiki/:slug/delete",
513 requireAuth,
514 async (c) => {
515 const { owner: ownerName, repo: repoName, slug } = c.req.param();
516 const user = c.get("user")!;
517 const resolved = await resolveRepo(ownerName, repoName);
518 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
519 if (user.id !== resolved.repo.ownerId) {
520 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
521 }
522 try {
523 await db
524 .delete(wikiPages)
525 .where(
526 and(
527 eq(wikiPages.repositoryId, resolved.repo.id),
528 eq(wikiPages.slug, slug)
529 )
530 );
531 } catch {
532 // swallow
533 }
534 return c.redirect(`/${ownerName}/${repoName}/wiki`);
535 }
536);
537
538// History
539wikiRoutes.get(
540 "/:owner/:repo/wiki/:slug/history",
541 softAuth,
542 async (c) => {
543 const { owner: ownerName, repo: repoName, slug } = c.req.param();
544 const user = c.get("user");
545 const resolved = await resolveRepo(ownerName, repoName);
546 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
547
548 let page: any = null;
549 let revs: any[] = [];
550 try {
551 const [row] = await db
552 .select()
553 .from(wikiPages)
554 .where(
555 and(
556 eq(wikiPages.repositoryId, resolved.repo.id),
557 eq(wikiPages.slug, slug)
558 )
559 )
560 .limit(1);
561 if (row) {
562 page = row;
563 revs = await db
564 .select({
565 r: wikiRevisions,
566 author: { username: users.username },
567 })
568 .from(wikiRevisions)
569 .innerJoin(users, eq(wikiRevisions.authorId, users.id))
570 .where(eq(wikiRevisions.pageId, page.id))
571 .orderBy(desc(wikiRevisions.revision));
572 }
573 } catch {
574 // leave null
575 }
576 if (!page) return c.html(notFound(user, "Page not found"), 404);
577
578 return c.html(
579 <Layout title={`${page.title} — history`} user={user}>
580 <RepoHeader owner={ownerName} repo={repoName} />
581 <h2 style="margin-top: 20px;">
582 <a href={`/${ownerName}/${repoName}/wiki/${slug}`}>{page.title}</a>
583 history
584 </h2>
585 <table class="file-table">
586 <tbody>
587 {revs.map((rv) => (
588 <tr>
589 <td>
590 <a
591 href={`/${ownerName}/${repoName}/wiki/${slug}/revisions/${rv.r.revision}`}
592 >
593 Revision {rv.r.revision}
594 </a>
595 {rv.r.message && (
596 <span style="color: var(--text-muted);">
597 {" "}
598 — {rv.r.message}
599 </span>
600 )}
601 </td>
602 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
603 by @{rv.author.username}
604 {user && user.id === resolved.repo.ownerId &&
605 rv.r.revision !== page.revision && (
606 <>
607 {" "}
608 ·{" "}
609 <form
610 method="POST"
611 action={`/${ownerName}/${repoName}/wiki/${slug}/revert/${rv.r.revision}`}
612 style="display: inline;"
613 >
614 <button type="submit" class="btn" style="font-size: 11px;">
615 Revert to this
616 </button>
617 </form>
618 </>
619 )}
620 </td>
621 </tr>
622 ))}
623 </tbody>
624 </table>
625 </Layout>
626 );
627 }
628);
629
630// View revision
631wikiRoutes.get(
632 "/:owner/:repo/wiki/:slug/revisions/:rev",
633 softAuth,
634 async (c) => {
635 const { owner: ownerName, repo: repoName, slug } = c.req.param();
636 const user = c.get("user");
637 const rev = Number(c.req.param("rev"));
638 const resolved = await resolveRepo(ownerName, repoName);
639 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
640
641 let rv: any = null;
642 try {
643 const [page] = await db
644 .select()
645 .from(wikiPages)
646 .where(
647 and(
648 eq(wikiPages.repositoryId, resolved.repo.id),
649 eq(wikiPages.slug, slug)
650 )
651 )
652 .limit(1);
653 if (page) {
654 const [r] = await db
655 .select()
656 .from(wikiRevisions)
657 .where(
658 and(
659 eq(wikiRevisions.pageId, page.id),
660 eq(wikiRevisions.revision, rev)
661 )
662 )
663 .limit(1);
664 if (r) rv = r;
665 }
666 } catch {
667 // leave null
668 }
669 if (!rv) return c.html(notFound(user, "Revision not found"), 404);
670
671 return c.html(
672 <Layout title={`${rv.title} @ r${rev}`} user={user}>
673 <RepoHeader owner={ownerName} repo={repoName} />
674 <div style="margin-top: 16px; color: var(--text-muted);">
675 Viewing revision {rev} of{" "}
676 <a href={`/${ownerName}/${repoName}/wiki/${slug}`}>{rv.title}</a>
677 </div>
678 <h1>{rv.title}</h1>
679 <div
680 dangerouslySetInnerHTML={{ __html: renderMarkdown(rv.body || "") }}
681 />
682 </Layout>
683 );
684 }
685);
686
687// Revert
688wikiRoutes.post(
689 "/:owner/:repo/wiki/:slug/revert/:rev",
690 requireAuth,
691 async (c) => {
692 const { owner: ownerName, repo: repoName, slug } = c.req.param();
693 const rev = Number(c.req.param("rev"));
694 const user = c.get("user")!;
695 const resolved = await resolveRepo(ownerName, repoName);
696 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
697 if (user.id !== resolved.repo.ownerId) {
698 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
699 }
700 try {
701 const [page] = await db
702 .select()
703 .from(wikiPages)
704 .where(
705 and(
706 eq(wikiPages.repositoryId, resolved.repo.id),
707 eq(wikiPages.slug, slug)
708 )
709 )
710 .limit(1);
711 if (!page) {
712 return c.redirect(`/${ownerName}/${repoName}/wiki`);
713 }
714 const [target] = await db
715 .select()
716 .from(wikiRevisions)
717 .where(
718 and(
719 eq(wikiRevisions.pageId, page.id),
720 eq(wikiRevisions.revision, rev)
721 )
722 )
723 .limit(1);
724 if (target) {
725 const nextRev = page.revision + 1;
726 await db
727 .update(wikiPages)
728 .set({
729 title: target.title,
730 body: target.body,
731 revision: nextRev,
732 updatedAt: new Date(),
733 updatedBy: user.id,
734 })
735 .where(eq(wikiPages.id, page.id));
736 await db.insert(wikiRevisions).values({
737 pageId: page.id,
738 revision: nextRev,
739 title: target.title,
740 body: target.body,
741 message: `Reverted to revision ${rev}`,
742 authorId: user.id,
743 });
744 }
745 } catch {
746 // swallow
747 }
748 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
749 }
750);
751
752export default wikiRoutes;
Modifiedsrc/views/components.tsx+15−1View fileUnifiedSplit
7171 | "insights"
7272 | "explain"
7373 | "changelog"
74 | "semantic";
74 | "semantic"
75 | "wiki"
76 | "projects";
7577}> = ({ owner, repo, active }) => (
7678 <div class="repo-nav">
7779 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
8385 >
8486 Issues
8587 </a>
88 <a
89 href={`/${owner}/${repo}/wiki`}
90 class={active === "wiki" ? "active" : ""}
91 >
92 Wiki
93 </a>
8694 <a
8795 href={`/${owner}/${repo}/pulls`}
8896 class={active === "pulls" ? "active" : ""}
8997 >
9098 Pull Requests
9199 </a>
100 <a
101 href={`/${owner}/${repo}/projects`}
102 class={active === "projects" ? "active" : ""}
103 >
104 Projects
105 </a>
92106 <a
93107 href={`/${owner}/${repo}/commits`}
94108 class={active === "commits" ? "active" : ""}
95109