Commit8ec29ffunknown_key
feat(BLOCK-J): J13 pinned repositories on user profile
feat(BLOCK-J): J13 pinned repositories on user profile GitHub-parity feature: users select up to 6 repos to feature at the top of their profile. - drizzle/0035_pinned_repos.sql: pinned_repositories table, unique on (user_id, repository_id), (user_id, position) index for ordered listing. - src/lib/pinned-repos.ts: MAX_PINS=6, pure sanitisePinIds (trim + de-dup + clamp), listPinnedForUser (position-ordered, owner joined), setPinsForUser (delete-then-insert, filters private repos the viewer doesn't own), listPinCandidates. - src/routes/pinned-repos.tsx: GET/POST /settings/pins with checkbox grid + saved flash. requireAuth. - src/routes/web.tsx: Pinned section rendered above the repo grid on user profile when pins exist; private pins hidden from other viewers. - 9 new tests. Total: 853 passing.
8 files changed+495−18ec29ff4193f7bd429aecc8a5424478bb1c747d6
8 changed files+495−1
ModifiedBUILD_BIBLE.md+6−1View fileUnifiedSplit
@@ -135,6 +135,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
135135| AI PR triage | ✅ | D3 — Claude Haiku suggests labels/reviewers/priority as an AI comment on PR create; `triagePullRequest` in `src/lib/ai-generators.ts`, wired in `src/routes/pulls.tsx` |
136136| CODEOWNERS auto-assign reviewers | ✅ | J11 — on PR open, `git diff --numstat base...head` → CODEOWNERS rule match → user IDs → `pr_review_requests` rows + `review_requested` notifications. PR detail page renders a Reviewers panel with state pills (pending/approved/changes_requested/dismissed), manual `@username` add, and dismiss. `src/lib/review-requests.ts` + `drizzle/0034_pr_review_requests.sql`. |
137137| Community profile (health standards) | ✅ | J12 — `GET /:owner/:repo/community` scores the repo on 8 items (description, README, LICENSE — required; CODE_OF_CONDUCT, CONTRIBUTING, issue templates, PR template, topics — recommended). Pure `checklistFromInputs` + `buildReport`, git-layer `computeHealth`. One-click "Add <path>" links route to the web editor. `src/lib/community.ts` + `src/routes/community.tsx`. |
138| Pinned repositories on profile | ✅ | J13 — users pin up to 6 repos ordered explicitly; `drizzle/0035_pinned_repos.sql` adds `pinned_repositories`. Manage at `/settings/pins`. Profile page renders "Pinned" grid above the repo list with viewer-aware private filtering. `src/lib/pinned-repos.ts` + `src/routes/pinned-repos.tsx`. |
138139| GitHub Actions equivalent (workflow runner) | ✅ | `src/lib/workflow-parser.ts`, `src/lib/workflow-runner.ts`, `src/routes/workflows.tsx`; `.gluecron/workflows/*.yml` auto-discovered on push; Bun subprocess executor, per-step timeouts, size-capped logs |
139140| Dependabot equivalent (AI dep bumper) | ✅ | D2 — `dep_update_runs` table, npm registry fetch, plan + apply bumps, creates `gluecron/dep-update-*` branch + PR row via git plumbing. `src/lib/dep-updater.ts`, `src/routes/dep-updater.tsx`, settings UI at `/:owner/:repo/settings/dep-updater`. |
140141| Code scanning UI | ✅ | I5 — `src/routes/code-scanning.tsx`, `GET /:owner/:repo/security`. Aggregates last-100 `gate_runs` matching `%scan%`/`%security%`, rolls up latest status per gate, shows failed/repaired/total cards + scanner status list + recent runs. |
@@ -295,6 +296,7 @@ This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
295296- **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.
296297- **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.
297298- **J8** — Commit status API (external CI signals) → ✅ shipped. `drizzle/0033_commit_statuses.sql` adds `commit_statuses` (unique on `(repository_id, commit_sha, context)`, state vocabulary pending/success/failure/error). `src/lib/commit-statuses.ts` exposes pure helpers (`isValidSha`, `isValidState`, `sanitiseContext`, `reduceCombined`) and DB helpers (`setStatus` with delete-then-insert upsert, `listStatuses`, `combinedStatus`). `src/routes/commit-statuses.ts` serves `POST /api/v1/repos/:owner/:repo/statuses/:sha` (requireAuth + owner check), `GET /api/v1/repos/:owner/:repo/commits/:sha/statuses` (list, private-repo visibility), `GET /api/v1/repos/:owner/:repo/commits/:sha/status` (combined rollup). Commit detail view now renders a "Checks" pill row when statuses exist, colour-coded per state with clickable target URLs. 18 new tests covering the pure helpers + route auth + invalid-sha rejection. Total suite 767/767.
299- **J13** — Pinned repositories on user profile → ✅ shipped. `drizzle/0035_pinned_repos.sql` adds `pinned_repositories` (unique on `(user_id, repository_id)`, `position` int for explicit ordering). `src/lib/pinned-repos.ts` exposes `MAX_PINS=6`, pure `sanitisePinIds` (de-dup + clamp + trim), `listPinnedForUser` (ordered with owner username joined), `setPinsForUser` (delete-then-insert, filters out private repos the viewer doesn't own), `listPinCandidates`. `src/routes/pinned-repos.tsx` serves `GET/POST /settings/pins` (requireAuth) with a checkbox grid preview. The profile page in `src/routes/web.tsx` now renders a "Pinned" section above the repo grid when the user has any pinned repos; private pins hidden from other viewers. 9 new tests. Total suite 853/853.
298300- **J12** — Community profile / health scorecard → ✅ shipped. `GET /:owner/:repo/community` renders a GitHub-parity "Community standards" page scoring the repo on 8 checklist items: description, README, LICENSE (all required), CODE_OF_CONDUCT, CONTRIBUTING, issue templates, PR template, topics (recommended). `src/lib/community.ts` exposes pure matchers (`isReadme`, `isLicense`, `isCodeOfConduct`, `isContributing`, `isPrTemplate`), pure `checklistFromInputs` (drives all the I/O-free unit tests), `buildReport` (→ percent + required breakdown), and `computeHealth` (reads default-branch root tree + `.github/` subtree + repo metadata + topics). Each missing item offers a one-click "Add <path>" or "Edit settings" link. `src/routes/community.tsx` degrades to a zero-score report on git/DB failure — never 500s. 21 new tests. Total suite 844/844.
299301- **J11** — PR auto-assign reviewers from CODEOWNERS + requested-reviewers tracking → ✅ shipped. `drizzle/0034_pr_review_requests.sql` adds `pr_review_requests` (unique on `(pull_request_id, reviewer_id)`, source enum `codeowners|manual|ai`, state enum `pending|approved|changes_requested|dismissed`). `src/lib/review-requests.ts` exposes pure helpers (`isValidSource`, `isValidState`, `nextState`, `sanitiseCandidates`) and DB helpers (`requestReviewers` idempotent, `listForPr` with username join, `dismissRequest`, `recordReviewOutcome`, `autoAssignFromCodeowners`, `countPendingForUser`). On PR creation, `src/routes/pulls.tsx` runs `git diff --numstat base...head` to extract changed paths, calls `reviewersForChangedFiles` (Block B3 CODEOWNERS parser), resolves usernames → user IDs, excludes the PR author, and fires `review_requested` notifications. PR detail page renders a `ReviewersPanel` with per-reviewer state pills, source labels, and dismiss + manual-add forms for owner/author. Auto-assign runs fire-and-forget — CODEOWNERS failures never block PR creation. 17 new tests. Total suite 823/823.
300302- **J10** — Repository status badges (shields.io-style SVG) → ✅ shipped. `src/lib/badge.ts` renders shields.io-style flat two-segment badges with zero IO — exports `renderBadge`, `escapeXml`, `estimateTextWidth` (Verdana-11 heuristic), `colorForState`. Named colour table (green/red/yellow/blue/grey/orange) + hex-literal passthrough. Label + value clamped to 64 chars. `src/routes/badges.ts` serves `/:o/:r/badge/gates.svg` (latest 20 gate_runs rollup → passing/running/failing), `/issues.svg` + `/prs.svg` (open counts), `/status.svg` (combined commit status on default-branch HEAD), `/status/:context.svg` (single named context). Every handler wrapped in try/catch and returns a grey "unknown" badge on DB or git failure — never 500. `image/svg+xml; charset=utf-8`, `Cache-Control: public, max-age=60, stale-while-revalidate=300`. `softAuth` so public-repo badges don't require cookies. 21 new tests. Total suite 806/806.
@@ -314,7 +316,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
314316- `src/app.tsx` — route composition, middleware order, error handlers
315317- `src/index.ts` — Bun server entry
316318- `src/lib/config.ts` — env getters (late-binding)
317- `src/db/schema.ts` — 96 tables. New tables only via new migration.
319- `src/db/schema.ts` — 97 tables. New tables only via new migration.
318320- `src/db/index.ts` — lazy proxy DB connection
319321- `src/db/migrate.ts` — migration runner
320322- `drizzle/0000_initial.sql`, `drizzle/0001_green_ecosystem.sql` — migrations
@@ -349,6 +351,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
349351- `drizzle/0032_repo_rulesets.sql` (Block J6) — migration, never edited in place. Adds `repo_rulesets` (unique on `(repository_id, name)`, enforcement enum) + `ruleset_rules` (JSON params).
350352- `drizzle/0033_commit_statuses.sql` (Block J8) — migration, never edited in place. Adds `commit_statuses` (unique on `(repository_id, commit_sha, context)`, state vocabulary pending/success/failure/error).
351353- `drizzle/0034_pr_review_requests.sql` (Block J11) — migration, never edited in place. Adds `pr_review_requests` (unique on `(pull_request_id, reviewer_id)`, source enum codeowners/manual/ai, state enum pending/approved/changes_requested/dismissed, reviewer+state index for inbox queries).
354- `drizzle/0035_pinned_repos.sql` (Block J13) — migration, never edited in place. Adds `pinned_repositories` (unique on `(user_id, repository_id)`, `(user_id, position)` index for ordered listing).
352355
353356### 4.2 Git layer (locked)
354357- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
@@ -485,6 +488,8 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
485488- `src/lib/badge.ts` (Block J10) — zero-IO SVG badge renderer. Exports `renderBadge({label, value, color, labelColor})`, `escapeXml`, `estimateTextWidth` (Verdana-11 per-char heuristic), `colorForState` (success/passed→green, pending→yellow, failure/failed/error→red, else grey). Named colour table (green/red/yellow/blue/grey/orange) + hex literals accepted. Label + value clamped to 64 chars each before rendering. Shields.io flat style with `<title>` + `aria-label` + shadow/main text pairs.
486489- `src/routes/badges.ts` (Block J10) — serves `/:owner/:repo/badge/gates.svg` (latest 20 gate_runs rollup), `/issues.svg` (open count), `/prs.svg` (open count), `/status.svg` (combined commit status on default-branch HEAD), `/status/:context.svg` (single context on HEAD). Every handler wrapped in try/catch and returns a grey "unknown" SVG on DB or git failure — never 500. `image/svg+xml; charset=utf-8`, `Cache-Control: public, max-age=60, stale-while-revalidate=300`. `softAuth` so public-repo badges don't require cookies.
487490- `src/lib/community.ts` (Block J12) — pure + git-layer community-health helpers. Exports `CHECKLIST` (8 items, 3 required), pure matchers (`isReadme`, `isLicense`, `isCodeOfConduct`, `isContributing`, `isPrTemplate`), pure `checklistFromInputs({rootEntries, githubEntries, issueTemplateDirExists, description, topics})`, `buildReport` (percent + required breakdown), and `computeHealth({owner, repo, description, topics})` which walks default-branch root + `.github/`. Always returns a zero-score report on git/DB failure; never throws.
491- `src/lib/pinned-repos.ts` (Block J13) — pinned-repo helpers. Exports `MAX_PINS=6`, pure `sanitisePinIds` (trim + de-dup + clamp to MAX_PINS), `listPinnedForUser` (position-ordered, owner-username joined), `setPinsForUser` (delete-then-insert, filters out private repos the viewer doesn't own), `listPinCandidates` (owned repos). Always returns safe defaults on DB failure.
492- `src/routes/pinned-repos.tsx` (Block J13) — serves `GET/POST /settings/pins` (requireAuth). Checkbox grid over own repos; the first MAX_PINS ticked are stored in position order. `?saved=1` flash on success.
488493- `src/routes/community.tsx` (Block J12) — serves `/:owner/:repo/community`. softAuth. Renders progress bar (green ≥80%, yellow ≥50%, else red) + per-item row with required badge + "Add <path>" or "Edit settings" CTA.
489494- `src/lib/review-requests.ts` (Block J11) — PR review-request lifecycle helpers. Pure: `isValidSource`, `isValidState`, `nextState` (state machine; `dismissed` terminal, `commented` is no-op), `sanitiseCandidates` (de-dup + drop author). DB: `requestReviewers` (idempotent, skips existing (pr, reviewer) rows), `listForPr` (joins `users` for username), `dismissRequest`, `recordReviewOutcome`, `autoAssignFromCodeowners` (diff paths → CODEOWNERS → user IDs → review requests + `review_requested` notifications), `countPendingForUser` (for inbox badges). Every DB helper swallows errors and returns safe defaults — never throws.
490495
Addeddrizzle/0035_pinned_repos.sql+18−0View fileUnifiedSplit
@@ -0,0 +1,18 @@
1-- Block J13 — Pinned repositories on user profile.
2--
3-- Users can pin up to 6 repositories that appear at the top of their
4-- profile page. Ordering is explicit via `position` (0-indexed).
5
6CREATE TABLE IF NOT EXISTS "pinned_repositories" (
7 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
8 "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
9 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
10 "position" integer NOT NULL DEFAULT 0,
11 "pinned_at" timestamp NOT NULL DEFAULT now()
12);
13
14CREATE UNIQUE INDEX IF NOT EXISTS "pinned_repositories_user_repo_unique"
15 ON "pinned_repositories" ("user_id", "repository_id");
16
17CREATE INDEX IF NOT EXISTS "pinned_repositories_user_position_idx"
18 ON "pinned_repositories" ("user_id", "position");
Addedsrc/__tests__/pinned-repos.test.ts+65−0View fileUnifiedSplit
@@ -0,0 +1,65 @@
1/**
2 * Block J13 — Pinned repos. Pure helpers + route smoke.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7import { MAX_PINS, __internal } from "../lib/pinned-repos";
8
9describe("pinned-repos — MAX_PINS", () => {
10 it("is capped at 6 (GitHub parity)", () => {
11 expect(MAX_PINS).toBe(6);
12 });
13});
14
15describe("pinned-repos — sanitisePinIds", () => {
16 const { sanitisePinIds } = __internal;
17
18 it("returns empty for no input", () => {
19 expect(sanitisePinIds([])).toEqual([]);
20 });
21
22 it("drops null / undefined / empty / whitespace-only", () => {
23 expect(sanitisePinIds([null, undefined, "", " ", "a"])).toEqual(["a"]);
24 });
25
26 it("preserves first-seen order and de-dupes", () => {
27 expect(sanitisePinIds(["a", "b", "a", "c", "b"])).toEqual(["a", "b", "c"]);
28 });
29
30 it("clamps to MAX_PINS", () => {
31 const many = Array.from({ length: 20 }, (_, i) => `id-${i}`);
32 const out = sanitisePinIds(many);
33 expect(out.length).toBe(MAX_PINS);
34 expect(out[0]).toBe("id-0");
35 expect(out[MAX_PINS - 1]).toBe(`id-${MAX_PINS - 1}`);
36 });
37
38 it("trims whitespace but preserves interior case", () => {
39 expect(sanitisePinIds([" Abc ", "abc"])).toEqual(["Abc", "abc"]);
40 });
41});
42
43describe("pinned-repos — routes", () => {
44 it("GET /settings/pins requires auth (redirects)", async () => {
45 const res = await app.request("/settings/pins");
46 expect([302, 401].includes(res.status)).toBe(true);
47 });
48
49 it("POST /settings/pins requires auth", async () => {
50 const res = await app.request("/settings/pins", {
51 method: "POST",
52 body: "",
53 });
54 expect([302, 401].includes(res.status)).toBe(true);
55 });
56
57 it("POST with invalid bearer → 401", async () => {
58 const res = await app.request("/settings/pins", {
59 method: "POST",
60 headers: { authorization: "Bearer glc_garbage" },
61 body: "",
62 });
63 expect(res.status).toBe(401);
64 });
65});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -77,6 +77,7 @@ import rulesetsRoutes from "./routes/rulesets";
7777import commitStatusesRoutes from "./routes/commit-statuses";
7878import badgesRoutes from "./routes/badges";
7979import communityRoutes from "./routes/community";
80import pinnedReposRoutes from "./routes/pinned-repos";
8081import webRoutes from "./routes/web";
8182
8283const app = new Hono();
@@ -270,6 +271,9 @@ app.route("/", badgesRoutes);
270271// Community profile / health scorecard — /:owner/:repo/community (Block J12)
271272app.route("/", communityRoutes);
272273
274// Pinned repositories on user profile — /settings/pins (Block J13)
275app.route("/", pinnedReposRoutes);
276
273277// Insights + milestones
274278app.route("/", insightsRoutes);
275279
Modifiedsrc/db/schema.ts+34−0View fileUnifiedSplit
@@ -2400,3 +2400,37 @@ export const prReviewRequests = pgTable(
24002400);
24012401
24022402export type PrReviewRequest = typeof prReviewRequests.$inferSelect;
2403
2404// ============================================================================
2405// PINNED REPOSITORIES (Block J13)
2406// ============================================================================
2407/**
2408 * User-selected repositories (max 6) shown at the top of a profile. Ordering
2409 * is explicit via `position`.
2410 */
2411export const pinnedRepositories = pgTable(
2412 "pinned_repositories",
2413 {
2414 id: uuid("id").primaryKey().defaultRandom(),
2415 userId: uuid("user_id")
2416 .notNull()
2417 .references(() => users.id, { onDelete: "cascade" }),
2418 repositoryId: uuid("repository_id")
2419 .notNull()
2420 .references(() => repositories.id, { onDelete: "cascade" }),
2421 position: integer("position").default(0).notNull(),
2422 pinnedAt: timestamp("pinned_at").defaultNow().notNull(),
2423 },
2424 (table) => [
2425 uniqueIndex("pinned_repositories_user_repo_unique").on(
2426 table.userId,
2427 table.repositoryId
2428 ),
2429 index("pinned_repositories_user_position_idx").on(
2430 table.userId,
2431 table.position
2432 ),
2433 ]
2434);
2435
2436export type PinnedRepository = typeof pinnedRepositories.$inferSelect;
Addedsrc/lib/pinned-repos.ts+165−0View fileUnifiedSplit
@@ -0,0 +1,165 @@
1/**
2 * Block J13 — Pinned repositories on user profile.
3 *
4 * Users select up to 6 repositories to feature on their profile. Pure helpers
5 * here are driven by the route handler + unit tests; DB helpers swallow
6 * errors and return safe defaults.
7 */
8
9import { and, asc, eq, inArray } from "drizzle-orm";
10import { db } from "../db";
11import {
12 pinnedRepositories,
13 repositories,
14 users,
15} from "../db/schema";
16
17export const MAX_PINS = 6;
18
19/**
20 * Sanitise an incoming pin list: de-dup, preserve first-seen order, clamp
21 * to MAX_PINS, drop empty strings. Pure; used by the form handler.
22 */
23export function sanitisePinIds(ids: Array<string | null | undefined>): string[] {
24 const seen = new Set<string>();
25 const out: string[] = [];
26 for (const id of ids) {
27 if (!id) continue;
28 const trimmed = id.trim();
29 if (!trimmed) continue;
30 if (seen.has(trimmed)) continue;
31 seen.add(trimmed);
32 out.push(trimmed);
33 if (out.length >= MAX_PINS) break;
34 }
35 return out;
36}
37
38/**
39 * List a user's pinned repos in display order, joined to the owning user so
40 * the profile can render "owner/name" links. Returns at most MAX_PINS rows.
41 */
42export async function listPinnedForUser(userId: string): Promise<
43 Array<{
44 repositoryId: string;
45 name: string;
46 ownerUsername: string;
47 description: string | null;
48 starCount: number;
49 forkCount: number;
50 isPrivate: boolean;
51 position: number;
52 }>
53> {
54 try {
55 const rows = await db
56 .select({
57 repositoryId: pinnedRepositories.repositoryId,
58 position: pinnedRepositories.position,
59 name: repositories.name,
60 description: repositories.description,
61 starCount: repositories.starCount,
62 forkCount: repositories.forkCount,
63 isPrivate: repositories.isPrivate,
64 ownerUsername: users.username,
65 })
66 .from(pinnedRepositories)
67 .innerJoin(
68 repositories,
69 eq(repositories.id, pinnedRepositories.repositoryId)
70 )
71 .innerJoin(users, eq(users.id, repositories.ownerId))
72 .where(eq(pinnedRepositories.userId, userId))
73 .orderBy(asc(pinnedRepositories.position))
74 .limit(MAX_PINS);
75 return rows;
76 } catch (err) {
77 console.error("[pinned-repos] listPinnedForUser failed:", err);
78 return [];
79 }
80}
81
82/**
83 * Replace a user's entire pin set with the given ordered list of repo IDs.
84 * - Ignores IDs the user can't pin (private repos they don't own).
85 * - Uses delete-then-insert for atomicity-lite.
86 * Returns the resulting canonical list of pinned repo IDs.
87 */
88export async function setPinsForUser(
89 userId: string,
90 repoIds: string[]
91): Promise<string[]> {
92 const clean = sanitisePinIds(repoIds);
93 if (clean.length === 0) {
94 try {
95 await db
96 .delete(pinnedRepositories)
97 .where(eq(pinnedRepositories.userId, userId));
98 } catch (err) {
99 console.error("[pinned-repos] clear failed:", err);
100 }
101 return [];
102 }
103 try {
104 const rows = await db
105 .select({
106 id: repositories.id,
107 ownerId: repositories.ownerId,
108 isPrivate: repositories.isPrivate,
109 })
110 .from(repositories)
111 .where(inArray(repositories.id, clean));
112 const byId = new Map(rows.map((r) => [r.id, r]));
113 // Filter: must exist; if private, viewer must be the owner.
114 const allowed = clean.filter((id) => {
115 const r = byId.get(id);
116 if (!r) return false;
117 if (r.isPrivate && r.ownerId !== userId) return false;
118 return true;
119 });
120 await db
121 .delete(pinnedRepositories)
122 .where(eq(pinnedRepositories.userId, userId));
123 if (allowed.length === 0) return [];
124 await db.insert(pinnedRepositories).values(
125 allowed.map((repositoryId, position) => ({
126 userId,
127 repositoryId,
128 position,
129 }))
130 );
131 return allowed;
132 } catch (err) {
133 console.error("[pinned-repos] setPinsForUser failed:", err);
134 return [];
135 }
136}
137
138/**
139 * List candidate repositories for the pin-chooser UI — repos the user owns
140 * (public + private) and repos they've starred (public only here; we don't
141 * want a user to pin someone else's private repo). Capped to 50 rows each.
142 */
143export async function listPinCandidates(userId: string): Promise<
144 Array<{ id: string; name: string; ownerUsername: string; isPrivate: boolean }>
145> {
146 try {
147 const owned = await db
148 .select({
149 id: repositories.id,
150 name: repositories.name,
151 ownerUsername: users.username,
152 isPrivate: repositories.isPrivate,
153 })
154 .from(repositories)
155 .innerJoin(users, eq(users.id, repositories.ownerId))
156 .where(eq(repositories.ownerId, userId))
157 .limit(50);
158 return owned;
159 } catch (err) {
160 console.error("[pinned-repos] listPinCandidates failed:", err);
161 return [];
162 }
163}
164
165export const __internal = { MAX_PINS, sanitisePinIds };
Addedsrc/routes/pinned-repos.tsx+142−0View fileUnifiedSplit
@@ -0,0 +1,142 @@
1/**
2 * Block J13 — Pinned repos management UI.
3 *
4 * GET /settings/pins — pick up to 6 repos to feature on your profile
5 * POST /settings/pins — save the selection (replaces prior set)
6 */
7
8import { Hono } from "hono";
9import { Layout } from "../views/layout";
10import { softAuth, requireAuth } from "../middleware/auth";
11import type { AuthEnv } from "../middleware/auth";
12import {
13 MAX_PINS,
14 listPinCandidates,
15 listPinnedForUser,
16 setPinsForUser,
17} from "../lib/pinned-repos";
18
19const pins = new Hono<AuthEnv>();
20
21pins.get("/settings/pins", softAuth, requireAuth, async (c) => {
22 const user = c.get("user")!;
23 const [pinned, candidates] = await Promise.all([
24 listPinnedForUser(user.id),
25 listPinCandidates(user.id),
26 ]);
27 const pinnedIds = new Set(pinned.map((p) => p.repositoryId));
28 const error = c.req.query("error");
29 const saved = c.req.query("saved") === "1";
30
31 return c.html(
32 <Layout title="Pinned repositories" user={user}>
33 <h2>Pinned repositories</h2>
34 <p style="color: var(--text-muted); max-width: 620px">
35 Choose up to {MAX_PINS} repositories to feature at the top of your
36 profile. They appear in the order you select them.
37 </p>
38
39 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
40 {saved && (
41 <div
42 style="padding: 10px 12px; background: rgba(63, 185, 80, 0.1); border: 1px solid var(--green); color: var(--green); border-radius: var(--radius); margin: 12px 0"
43 >
44 Saved.
45 </div>
46 )}
47
48 <form method="POST" action="/settings/pins" style="margin-top: 16px">
49 {candidates.length === 0 ? (
50 <div class="empty-state">
51 <p>You don't own any repositories yet.</p>
52 </div>
53 ) : (
54 <ul
55 style="list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px"
56 data-testid="pin-candidates"
57 >
58 {candidates.map((c) => (
59 <li
60 style="border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; background: var(--bg-secondary)"
61 >
62 <label
63 style="display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 13px"
64 >
65 <input
66 type="checkbox"
67 name="repoId"
68 value={c.id}
69 checked={pinnedIds.has(c.id)}
70 />
71 <span style="flex: 1">
72 <strong>{c.name}</strong>
73 {c.isPrivate && (
74 <span
75 style="margin-left: 6px; font-size: 10px; padding: 1px 6px; border-radius: 10px; background: rgba(139, 148, 158, 0.15); color: var(--text-muted); text-transform: uppercase"
76 >
77 Private
78 </span>
79 )}
80 </span>
81 </label>
82 </li>
83 ))}
84 </ul>
85 )}
86
87 <div style="margin-top: 16px; display: flex; gap: 8px; align-items: center">
88 <button type="submit" class="btn btn-primary">
89 Save pinned repositories
90 </button>
91 <span style="color: var(--text-muted); font-size: 12px">
92 The first {MAX_PINS} you tick are kept.
93 </span>
94 </div>
95 </form>
96
97 {pinned.length > 0 && (
98 <div style="margin-top: 28px">
99 <h3>Current pins (preview)</h3>
100 <ul
101 style="list-style: none; padding: 0; margin: 8px 0 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 8px"
102 >
103 {pinned.map((p) => (
104 <li
105 style="border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px"
106 >
107 <div>
108 <a href={`/${p.ownerUsername}/${p.name}`}>
109 <strong>{p.ownerUsername}/{p.name}</strong>
110 </a>
111 </div>
112 {p.description && (
113 <div style="color: var(--text-muted); font-size: 12px; margin-top: 4px">
114 {p.description}
115 </div>
116 )}
117 <div style="color: var(--text-muted); font-size: 11px; margin-top: 6px">
118 {p.starCount} {"\u2605"} · {p.forkCount} forks
119 </div>
120 </li>
121 ))}
122 </ul>
123 </div>
124 )}
125 </Layout>
126 );
127});
128
129pins.post("/settings/pins", softAuth, requireAuth, async (c) => {
130 const user = c.get("user")!;
131 const form = await c.req.parseBody({ all: true });
132 const raw = form.repoId;
133 const ids: string[] = Array.isArray(raw)
134 ? raw.map((x) => String(x))
135 : raw != null
136 ? [String(raw)]
137 : [];
138 await setPinsForUser(user.id, ids);
139 return c.redirect("/settings/pins?saved=1");
140});
141
142export default pins;
Modifiedsrc/routes/web.tsx+61−0View fileUnifiedSplit
@@ -274,6 +274,23 @@ web.get("/:owner", async (c) => {
274274 profileReadmeHtml = null;
275275 }
276276
277 // Block J13 — pinned repositories. Viewer-aware (private repos filtered
278 // to owner in the lib helper). Always safe on DB failure.
279 let pinnedRepos: Awaited<
280 ReturnType<typeof import("../lib/pinned-repos").listPinnedForUser>
281 > = [];
282 if (ownerUser) {
283 try {
284 const { listPinnedForUser } = await import("../lib/pinned-repos");
285 const all = await listPinnedForUser(ownerUser.id);
286 pinnedRepos = all.filter(
287 (p) => !p.isPrivate || user?.id === ownerUser.id
288 );
289 } catch {
290 pinnedRepos = [];
291 }
292 }
293
277294 // Block J9 — contribution heatmap. 52-week activity grid sourced from
278295 // activity_feed rows authored by this user. Failures fall through silently.
279296 let heatmap: Awaited<
@@ -417,6 +434,50 @@ web.get("/:owner", async (c) => {
417434 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
418435 />
419436 )}
437 {pinnedRepos.length > 0 && (
438 <div style="margin-bottom: 24px" data-testid="pinned-repos">
439 <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px">
440 <h3 style="margin: 0">Pinned</h3>
441 {user && ownerUser && user.id === ownerUser.id && (
442 <a href="/settings/pins" style="font-size: 12px; color: var(--text-muted)">
443 Customize pins
444 </a>
445 )}
446 </div>
447 <div
448 style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px"
449 >
450 {pinnedRepos.map((p) => (
451 <div
452 style="border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; background: var(--bg-secondary)"
453 >
454 <div style="display: flex; align-items: center; gap: 6px">
455 <a href={`/${p.ownerUsername}/${p.name}`}>
456 <strong>{p.name}</strong>
457 </a>
458 {p.isPrivate && (
459 <span
460 style="font-size: 10px; padding: 1px 6px; border-radius: 10px; background: rgba(139, 148, 158, 0.15); color: var(--text-muted); text-transform: uppercase"
461 >
462 Private
463 </span>
464 )}
465 </div>
466 {p.description && (
467 <div
468 style="color: var(--text-muted); font-size: 12px; margin-top: 6px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical"
469 >
470 {p.description}
471 </div>
472 )}
473 <div style="margin-top: 8px; font-size: 11px; color: var(--text-muted)">
474 {p.starCount} {"\u2605"} · {p.forkCount} forks
475 </div>
476 </div>
477 ))}
478 </div>
479 </div>
480 )}
420481 <h3 style="margin-bottom: 16px">Repositories</h3>
421482 {repos.length === 0 ? (
422483 <p style="color: var(--text-muted)">No repositories yet.</p>
423484