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

feat(BLOCK-J): J7 closing keywords auto-close issues on PR merge

feat(BLOCK-J): J7 closing keywords auto-close issues on PR merge

GitHub-style "closes #123" / "fixes #456" / "resolves #789" auto-close.
Pure parser in src/lib/close-keywords.ts — case-insensitive, word-boundary
safe (rejects "disclose", "unresolved"), skips cross-repo refs, tolerates
optional colon / hyphen / whitespace between verb and ref.

Wired into the PR merge handler: after a successful merge, scan PR title
+ body, look up each referenced open issue in the same repo, close it,
post a "Closed by pull request #N" comment. Failures never block the
merge redirect.

14 new tests. Total suite: 749/749 passing.

https://claude.ai/code/session_01U6sj9ezKCpmiwx7cXGZmS6
Claude committed on April 15, 2026Parent: 9ff7128
4 files changed+1910d62fb368b1207996ac4b0c8f97e038e01d8270ff
4 changed files+191−0
ModifiedBUILD_BIBLE.md+2−0View fileUnifiedSplit
289289- **J4** — User following + personalised feed → ✅ shipped. `drizzle/0031_user_follows.sql` adds `user_follows` (composite PK on `(follower_id, following_id)`, CHECK constraint rejecting self-follows, reverse-lookup index on `following_id`). `src/lib/follows.ts` exposes `followUser/unfollowUser/isFollowing/listFollowers/listFollowing/followCounts` + `feedForUser(userId, limit)` which joins `activity_feed` against the follow set (bounded to 200 edges) and filters out private repos the viewer doesn't own. `src/routes/follows.tsx` serves `POST /:user/follow` + `/:user/unfollow` (auth-gated, audit-logged), public `GET /:user/followers` + `/:user/following`, and `GET /feed` (personalised timeline). Follow button + follower/following counts added to the user profile page via `src/routes/web.tsx`. Reserved-name set protects fixed paths (`login`, `settings`, `feed`, etc.). 8 new tests (verb table + route auth).
290290- **J5** — Profile READMEs → ✅ shipped. User profile page at `/:owner` now attempts to render `<user>/<user>/README.md` (GitHub convention) or `<user>/.github/README.md` (org-style fallback) as the hero panel above the repo list. No schema changes — reuses `getReadme` / `renderMarkdown` + `repoExists` from the git layer. Failures are silent; missing repo just hides the panel. 2 smoke tests.
291291- **J6** — Repository rulesets (push policy engine) → ✅ shipped. `drizzle/0032_repo_rulesets.sql` adds `repo_rulesets` (unique on `(repository_id, name)`, enforcement enum active/evaluate/disabled) + `ruleset_rules` (JSON params). `src/lib/rulesets.ts` exposes six rule types (`commit_message_pattern`, `branch_name_pattern`, `tag_name_pattern`, `blocked_file_paths`, `max_file_size`, `forbid_force_push`) + the pure evaluator `evaluatePush(rulesets, ctx) → {allowed, violations}`. Helpers: glob-lite matcher (`globToRegex`), defensive `parseParams`. CRUD: `listRulesetsForRepo`, `getRuleset`, `createRuleset`, `updateRulesetEnforcement`, `deleteRuleset`, `addRule`, `deleteRule`. `src/routes/rulesets.tsx` serves owner-only UI at `/:owner/:repo/settings/rulesets` (list + create), `/:id` (detail, enforcement toggle, add rule), `/:id/delete`, `/:id/rules/:ruleId/delete`. 23 new tests covering each rule type, enforcement modes, glob edge cases, and route-auth redirects.
292- **J7** — Closing keywords auto-close issues on PR merge → ✅ shipped. `src/lib/close-keywords.ts` exports pure `extractClosingRefs(text)` and `extractClosingRefsMulti(sources[])` — scans for `(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[:|-]? #N` with case-insensitive, punctuation-tolerant, word-boundary-respecting matching. Rejects cross-repo refs (`owner/repo#N`), embedded-in-word verbs (`disclose`, `unresolved`), and non-positive numbers. Wired into the PR merge handler in `src/routes/pulls.tsx` — after a successful merge, scans `pr.title + pr.body`, looks up each referenced open issue in the same repo, closes it, and posts a "Closed by pull request #N" comment. Wrapped in try/catch so close-keyword failures never block the merge redirect. 14 new tests covering verb forms, punctuation variants, de-dup+sort, cross-repo rejection, embedded-word rejection, case-insensitivity, and multi-source merging. Total suite 749/749.
292293
293294### BLOCK H — Marketplace
294295- **H1** — App marketplace → ✅ shipped. `src/routes/marketplace.tsx` + `src/lib/marketplace.ts` + `drizzle/0021_marketplace_and_apps.sql` (5 tables: `apps`, `app_installations`, `app_bots`, `app_install_tokens`, `app_events`). Routes: `GET /marketplace` (public directory with search), `GET /marketplace/:slug` (detail + install CTA), `POST /marketplace/:slug/install` (user-target install in v1), `POST /marketplace/installations/:id/uninstall`, `GET /settings/apps` (personal list), `GET+POST /developer/apps-new` (register), `GET /developer/apps/:slug/manage` (event log + install count), `POST /developer/apps/:slug/tokens/new` (show-once token). Install idempotent via soft-update on existing non-uninstalled row.
466467- `src/routes/mirrors.tsx` (Block I9) — owner-only config at `/:owner/:repo/settings/mirror` (GET form + recent-runs panel, POST save, POST delete, POST sync-now). Site-admin `POST /admin/mirrors/sync-all` iterates due mirrors. All mutations `audit()`-logged.
467468- `src/lib/rulesets.ts` (Block J6) — exports `RULE_TYPES` (6 types), pure `globToRegex`, defensive `parseParams`, pure evaluator `evaluatePush(rulesets, ctx) → {allowed, violations}`. CRUD: `listRulesetsForRepo`, `getRuleset`, `createRuleset`, `updateRulesetEnforcement`, `deleteRuleset`, `addRule`, `deleteRule`. `__internal = { evalRule, globToRegex, parseParams }` for tests. Every rule variant no-ops on malformed params or bad regex rather than throwing.
468469- `src/routes/rulesets.tsx` (Block J6) — owner-only UI at `/:owner/:repo/settings/rulesets` (list + create), `/:id` (detail + enforcement toggle + add rule), `/:id/delete`, `/:id/rules/:ruleId/delete`. All mutations `audit()`-logged via the existing `gate(c)` owner pattern.
470- `src/lib/close-keywords.ts` (Block J7) — pure parser. Exports `extractClosingRefs(text)` + `extractClosingRefsMulti(sources[])`. Verbs: close/closes/closed/fix/fixes/fixed/resolve/resolves/resolved. Case-insensitive, word-boundary-respecting, ignores cross-repo `owner/repo#N` refs, rejects non-positive numbers, returns sorted de-duped list. Tolerates `:` / `-` / whitespace between verb and ref.
469471
470472### 4.7 Views (locked contracts)
471473- `src/views/layout.tsx``Layout` accepts `title`, `user`, `notificationCount`
Addedsrc/__tests__/close-keywords.test.ts+100−0View fileUnifiedSplit
1/**
2 * Block J7 — Closing-keyword parser tests. Pure function so the whole spec
3 * lives here.
4 */
5
6import { describe, it, expect } from "bun:test";
7import {
8 extractClosingRefs,
9 extractClosingRefsMulti,
10} from "../lib/close-keywords";
11
12describe("close-keywords — extractClosingRefs", () => {
13 it("returns [] for null / empty / undefined", () => {
14 expect(extractClosingRefs(null)).toEqual([]);
15 expect(extractClosingRefs(undefined)).toEqual([]);
16 expect(extractClosingRefs("")).toEqual([]);
17 });
18
19 it("matches all close/fix/resolve forms", () => {
20 expect(extractClosingRefs("closes #1")).toEqual([1]);
21 expect(extractClosingRefs("Close #2")).toEqual([2]);
22 expect(extractClosingRefs("closed #3")).toEqual([3]);
23 expect(extractClosingRefs("fix #4")).toEqual([4]);
24 expect(extractClosingRefs("Fixes #5")).toEqual([5]);
25 expect(extractClosingRefs("fixed #6")).toEqual([6]);
26 expect(extractClosingRefs("resolve #7")).toEqual([7]);
27 expect(extractClosingRefs("Resolves #8")).toEqual([8]);
28 expect(extractClosingRefs("resolved #9")).toEqual([9]);
29 });
30
31 it("handles colon / hyphen / punctuation between verb and ref", () => {
32 expect(extractClosingRefs("Fixes: #12")).toEqual([12]);
33 expect(extractClosingRefs("Closes:#13")).toEqual([13]);
34 expect(extractClosingRefs("Fixes - #14")).toEqual([14]);
35 expect(extractClosingRefs("Closes #15.")).toEqual([15]);
36 expect(extractClosingRefs("Closes #16, thanks")).toEqual([16]);
37 });
38
39 it("de-dupes and sorts", () => {
40 expect(extractClosingRefs("closes #2 fixes #1 resolves #2")).toEqual([1, 2]);
41 });
42
43 it("picks up multiple refs in a body", () => {
44 const body =
45 "This PR tidies up the UI.\n\nFixes #10\nCloses #11\nNot related: #99\nResolves #12";
46 expect(extractClosingRefs(body)).toEqual([10, 11, 12]);
47 });
48
49 it("ignores bare #N without a closing verb", () => {
50 expect(extractClosingRefs("See #5 for context")).toEqual([]);
51 expect(extractClosingRefs("#123 is the root cause")).toEqual([]);
52 });
53
54 it("ignores cross-repo refs (owner/repo#N)", () => {
55 expect(extractClosingRefs("Closes alice/widgets#42")).toEqual([]);
56 expect(extractClosingRefs("Fixes foo/bar#99 and fixes #7")).toEqual([7]);
57 });
58
59 it("does not match verbs embedded in larger words", () => {
60 expect(extractClosingRefs("disclose #1")).toEqual([]);
61 expect(extractClosingRefs("prefix #2")).toEqual([]);
62 expect(extractClosingRefs("unresolved #3")).toEqual([]);
63 });
64
65 it("does not match when # is spaced away from the number", () => {
66 expect(extractClosingRefs("Closes # 1")).toEqual([]);
67 });
68
69 it("is case insensitive on the verb", () => {
70 expect(extractClosingRefs("CLOSES #1 FIXES #2 Resolves #3")).toEqual([
71 1, 2, 3,
72 ]);
73 });
74
75 it("tolerates whitespace runs", () => {
76 expect(extractClosingRefs("Closes #1")).toEqual([1]);
77 expect(extractClosingRefs("fixes\t#2")).toEqual([2]);
78 });
79
80 it("rejects non-positive numbers", () => {
81 expect(extractClosingRefs("Closes #0")).toEqual([]);
82 // "#-1" is not a valid match under the parser either.
83 expect(extractClosingRefs("Closes #-1")).toEqual([]);
84 });
85});
86
87describe("close-keywords — extractClosingRefsMulti", () => {
88 it("merges + de-dupes across sources", () => {
89 const body = "Fixes #1\nCloses #2";
90 const title = "Resolves #2: cleanup";
91 expect(extractClosingRefsMulti([title, body])).toEqual([1, 2]);
92 });
93
94 it("skips nullish sources gracefully", () => {
95 expect(extractClosingRefsMulti([null, undefined, "Closes #7"])).toEqual([
96 7,
97 ]);
98 expect(extractClosingRefsMulti([])).toEqual([]);
99 });
100});
Addedsrc/lib/close-keywords.ts+54−0View fileUnifiedSplit
1/**
2 * Block J7 — Closing keywords.
3 *
4 * Parses PR body + commit messages for GitHub-style "closes #N" phrases and
5 * returns the de-duplicated set of issue numbers that should auto-close when
6 * the PR merges. Pure; zero IO.
7 *
8 * Accepted verbs (case-insensitive):
9 * close(s|d), fix(es|ed), resolve(s|d)
10 *
11 * Optional punctuation between the verb and the issue ref ("Fixes: #12",
12 * "Closes #12.") is tolerated. Refs must be bare `#<number>` — cross-repo
13 * refs like `owner/repo#12` are intentionally ignored for v1 because
14 * cross-repo auto-close requires authorisation we don't track yet.
15 */
16
17const VERB = "close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved";
18
19// Word boundary on the verb, optional colon / hyphen / whitespace, then a
20// bare `#<number>`. Ignore matches preceded by `/` (owner/repo#N cross-repo).
21const CLOSE_RE = new RegExp(
22 `(^|[^a-z0-9/])(${VERB})\\s*[:\\-]?\\s*#(\\d+)`,
23 "gi"
24);
25
26/**
27 * Extract the sorted de-duplicated list of issue numbers referenced with a
28 * closing verb in the supplied text. Returns [] on empty / no match.
29 */
30export function extractClosingRefs(text: string | null | undefined): number[] {
31 if (!text) return [];
32 const out = new Set<number>();
33 for (const m of text.matchAll(CLOSE_RE)) {
34 const n = parseInt(m[3], 10);
35 if (Number.isFinite(n) && n > 0) out.add(n);
36 }
37 return [...out].sort((a, b) => a - b);
38}
39
40/**
41 * Extract closing refs from N source strings (e.g. PR body + commit messages)
42 * and return the merged de-duped list.
43 */
44export function extractClosingRefsMulti(
45 sources: Array<string | null | undefined>
46): number[] {
47 const all = new Set<number>();
48 for (const s of sources) {
49 for (const n of extractClosingRefs(s)) all.add(n);
50 }
51 return [...all].sort((a, b) => a - b);
52}
53
54export const __internal = { CLOSE_RE };
Modifiedsrc/routes/pulls.tsx+35−0View fileUnifiedSplit
1010 prComments,
1111 repositories,
1212 users,
13 issues,
14 issueComments,
1315} from "../db/schema";
1416import { Layout } from "../views/layout";
1517import { RepoHeader, DiffView } from "../views/components";
926928 })
927929 .where(eq(pullRequests.id, pr.id));
928930
931 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
932 // and auto-close each matching open issue with a back-link comment. Bounded
933 // to the same repo for v1 (cross-repo refs ignored). Failures never block
934 // the merge redirect.
935 try {
936 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
937 const refs = extractClosingRefsMulti([pr.title, pr.body]);
938 for (const n of refs) {
939 const [issue] = await db
940 .select()
941 .from(issues)
942 .where(
943 and(
944 eq(issues.repositoryId, resolved.repo.id),
945 eq(issues.number, n)
946 )
947 )
948 .limit(1);
949 if (!issue || issue.state !== "open") continue;
950 await db
951 .update(issues)
952 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
953 .where(eq(issues.id, issue.id));
954 await db.insert(issueComments).values({
955 issueId: issue.id,
956 authorId: user.id,
957 body: `Closed by pull request #${pr.number}.`,
958 });
959 }
960 } catch {
961 // Never block the merge on close-keyword failures.
962 }
963
929964 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
930965 }
931966);
932967