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

feat(BLOCK-J): J12 community profile / health scorecard

feat(BLOCK-J): J12 community profile / health scorecard

GitHub-parity 'Community standards' checklist at /:owner/:repo/community.

- src/lib/community.ts: pure matchers (isReadme, isLicense,
  isCodeOfConduct, isContributing, isPrTemplate), pure
  checklistFromInputs driver (keeps tests IO-free), buildReport
  (percent + required breakdown), computeHealth (reads default-branch
  root tree + .github/ subtree). Returns a zero-score report on git
  or DB failure — never throws.
- src/routes/community.tsx: softAuth, colour-coded progress bar
  (green >=80, yellow >=50, else red), per-item row with required
  badge + one-click 'Add <path>' or 'Edit settings' CTA.
- 8 items: description / README / LICENSE required; CODE_OF_CONDUCT,
  CONTRIBUTING, issue templates, PR template, topics recommended.
- 21 new tests. Total: 844 passing.
Claude committed on April 15, 2026Parent: 3247f79
5 files changed+6880f9e7c01b3e5c23d4be08f98ad64813166047ee14
5 changed files+688−0
ModifiedBUILD_BIBLE.md+4−0View fileUnifiedSplit
134134| AI explain-this-codebase | ✅ | D6 — per-commit cached markdown, `GET /:owner/:repo/explain`, `src/lib/ai-explain.ts` + `src/routes/ai-explain.tsx` |
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`. |
137| 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`. |
137138| 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 |
138139| 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`. |
139140| 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. |
294295- **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.
295296- **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.
296297- **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.
298- **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.
297299- **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.
298300- **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.
299301- **J9** — GitHub-style contribution heatmap on user profile → ✅ shipped. `src/lib/contribution-heatmap.ts` exposes pure `buildHeatmap(activities, windowDays=365, today?)` that returns a 53-week Sunday-aligned grid of `{date, count, level 0-4, dow}` cells plus `totalContributions`, `maxDayCount`, `longestStreak`, `currentStreak`, and window start/end dates. `levelFor(count, max)` buckets into 5 GitHub-style quartiles. Wired into the profile handler in `src/routes/web.tsx` — queries `activity_feed` rows authored by the user over the last 365 days and renders a scrollable 11×11 px cell grid with hover titles, a legend, and streak counters. No schema changes — reuses existing `activity_feed` rows. 18 new tests. Total suite 785/785.
482484- `src/lib/contribution-heatmap.ts` (Block J9) — pure heatmap builder. Exports `buildHeatmap(activities, windowDays=365, today?)` returning 53-week Sunday-aligned grid + rollup stats. Helpers: `levelFor(count, max)` (5-level quartile bucket), `formatDateKey` (UTC YYYY-MM-DD), `startOfUtcDay`, `daysBetween`. `__internal` re-exports for tests. Silently ignores invalid dates + activity outside the window.
483485- `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.
484486- `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.
487- `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.
488- `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.
485489- `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.
486490
487491### 4.7 Views (locked contracts)
Addedsrc/__tests__/community.test.ts+242−0View fileUnifiedSplit
1/**
2 * Block J12 — Community health scorecard tests.
3 *
4 * Pure helpers + route smoke.
5 */
6
7import { describe, it, expect } from "bun:test";
8import app from "../app";
9import {
10 CHECKLIST,
11 buildReport,
12 checklistFromInputs,
13 isCodeOfConduct,
14 isContributing,
15 isLicense,
16 isPrTemplate,
17 isReadme,
18} from "../lib/community";
19
20describe("community — name matchers", () => {
21 it("isReadme matches all common spellings", () => {
22 expect(isReadme("README")).toBe(true);
23 expect(isReadme("readme.md")).toBe(true);
24 expect(isReadme("Readme.MD")).toBe(true);
25 expect(isReadme("README.txt")).toBe(true);
26 expect(isReadme("README.rst")).toBe(true);
27 expect(isReadme("readme.markdown")).toBe(true);
28 });
29
30 it("isReadme rejects look-alikes", () => {
31 expect(isReadme("readme.html")).toBe(false);
32 expect(isReadme("not-readme.md")).toBe(false);
33 expect(isReadme("READMEFIRST.md")).toBe(false);
34 });
35
36 it("isLicense matches LICENSE / LICENCE / COPYING variants", () => {
37 expect(isLicense("LICENSE")).toBe(true);
38 expect(isLicense("License.md")).toBe(true);
39 expect(isLicense("LICENCE.txt")).toBe(true);
40 expect(isLicense("COPYING")).toBe(true);
41 });
42
43 it("isLicense rejects unrelated files", () => {
44 expect(isLicense("licensing.md")).toBe(false);
45 expect(isLicense("license-check.md")).toBe(false);
46 });
47
48 it("isCodeOfConduct matches common separators", () => {
49 expect(isCodeOfConduct("CODE_OF_CONDUCT.md")).toBe(true);
50 expect(isCodeOfConduct("code-of-conduct.md")).toBe(true);
51 expect(isCodeOfConduct("CodeOfConduct")).toBe(true);
52 });
53
54 it("isContributing matches case-insensitively", () => {
55 expect(isContributing("CONTRIBUTING.md")).toBe(true);
56 expect(isContributing("contributing")).toBe(true);
57 expect(isContributing("contributing.txt")).toBe(true);
58 expect(isContributing("contributors.md")).toBe(false);
59 });
60
61 it("isPrTemplate matches expected spellings", () => {
62 expect(isPrTemplate("pull_request_template.md")).toBe(true);
63 expect(isPrTemplate("PULL_REQUEST_TEMPLATE")).toBe(true);
64 expect(isPrTemplate("pull-request-template.md")).toBe(true);
65 expect(isPrTemplate("PR_TEMPLATE.md")).toBe(false);
66 });
67});
68
69describe("community — CHECKLIST table", () => {
70 it("exposes eight items with unique keys", () => {
71 expect(CHECKLIST.length).toBe(8);
72 const keys = new Set(CHECKLIST.map((c) => c.key));
73 expect(keys.size).toBe(8);
74 });
75
76 it("marks description/readme/license required; others recommended", () => {
77 const required = new Set(
78 CHECKLIST.filter((i) => i.required).map((i) => i.key)
79 );
80 expect(required.has("description")).toBe(true);
81 expect(required.has("readme")).toBe(true);
82 expect(required.has("license")).toBe(true);
83 expect(required.has("code_of_conduct")).toBe(false);
84 expect(required.has("pr_template")).toBe(false);
85 });
86});
87
88describe("community — checklistFromInputs", () => {
89 const empty = {
90 rootEntries: [],
91 githubEntries: [],
92 issueTemplateDirExists: false,
93 description: null,
94 topics: [],
95 };
96
97 it("all-zero input → all checks false", () => {
98 const r = checklistFromInputs(empty);
99 for (const key of Object.keys(r) as Array<keyof typeof r>) {
100 expect(r[key]).toBe(false);
101 }
102 });
103
104 it("detects README at root + README in .github", () => {
105 expect(
106 checklistFromInputs({ ...empty, rootEntries: ["README.md"] }).readme
107 ).toBe(true);
108 expect(
109 checklistFromInputs({ ...empty, githubEntries: ["README.md"] }).readme
110 ).toBe(true);
111 });
112
113 it("LICENSE variants all count", () => {
114 for (const n of ["LICENSE", "License.md", "LICENCE.txt", "COPYING"]) {
115 expect(
116 checklistFromInputs({ ...empty, rootEntries: [n] }).license
117 ).toBe(true);
118 }
119 });
120
121 it("description requires non-empty trimmed text", () => {
122 expect(checklistFromInputs({ ...empty, description: "" }).description).toBe(false);
123 expect(
124 checklistFromInputs({ ...empty, description: " " }).description
125 ).toBe(false);
126 expect(checklistFromInputs({ ...empty, description: "x" }).description).toBe(true);
127 });
128
129 it("topics requires at least one", () => {
130 expect(checklistFromInputs({ ...empty, topics: [] }).topics).toBe(false);
131 expect(checklistFromInputs({ ...empty, topics: ["ai"] }).topics).toBe(true);
132 });
133
134 it("issue_template is present if dir exists OR a single file", () => {
135 expect(
136 checklistFromInputs({ ...empty, issueTemplateDirExists: true })
137 .issue_template
138 ).toBe(true);
139 expect(
140 checklistFromInputs({
141 ...empty,
142 rootEntries: ["ISSUE_TEMPLATE.md"],
143 }).issue_template
144 ).toBe(true);
145 expect(
146 checklistFromInputs({
147 ...empty,
148 githubEntries: ["ISSUE_TEMPLATE.md"],
149 }).issue_template
150 ).toBe(true);
151 });
152
153 it("pr_template — accepts .github/ and root spellings", () => {
154 expect(
155 checklistFromInputs({
156 ...empty,
157 githubEntries: ["pull_request_template.md"],
158 }).pr_template
159 ).toBe(true);
160 expect(
161 checklistFromInputs({
162 ...empty,
163 rootEntries: ["PULL_REQUEST_TEMPLATE.md"],
164 }).pr_template
165 ).toBe(true);
166 });
167});
168
169describe("community — buildReport", () => {
170 it("all-false → 0% / meetsRequired false", () => {
171 const r = buildReport({
172 description: false,
173 readme: false,
174 license: false,
175 code_of_conduct: false,
176 contributing: false,
177 issue_template: false,
178 pr_template: false,
179 topics: false,
180 });
181 expect(r.score).toBe(0);
182 expect(r.passed).toBe(0);
183 expect(r.total).toBe(8);
184 expect(r.requiredPassed).toBe(0);
185 expect(r.requiredTotal).toBe(3);
186 expect(r.meetsRequired).toBe(false);
187 });
188
189 it("all-true → 100% / meetsRequired true", () => {
190 const r = buildReport({
191 description: true,
192 readme: true,
193 license: true,
194 code_of_conduct: true,
195 contributing: true,
196 issue_template: true,
197 pr_template: true,
198 topics: true,
199 });
200 expect(r.score).toBe(100);
201 expect(r.meetsRequired).toBe(true);
202 });
203
204 it("required-only → meetsRequired true, score partial", () => {
205 const r = buildReport({
206 description: true,
207 readme: true,
208 license: true,
209 code_of_conduct: false,
210 contributing: false,
211 issue_template: false,
212 pr_template: false,
213 topics: false,
214 });
215 expect(r.meetsRequired).toBe(true);
216 expect(r.score).toBe(Math.round((3 / 8) * 100));
217 });
218
219 it("preserves CHECKLIST ordering + annotates `present`", () => {
220 const r = buildReport({
221 description: true,
222 readme: false,
223 license: false,
224 code_of_conduct: false,
225 contributing: false,
226 issue_template: false,
227 pr_template: false,
228 topics: false,
229 });
230 expect(r.items[0].key).toBe("description");
231 expect(r.items[0].present).toBe(true);
232 expect(r.items[1].key).toBe("readme");
233 expect(r.items[1].present).toBe(false);
234 });
235});
236
237describe("community — route", () => {
238 it("GET /:o/:r/community on missing repo returns 404", async () => {
239 const res = await app.request("/alice/nope/community");
240 expect(res.status).toBe(404);
241 });
242});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
7676import rulesetsRoutes from "./routes/rulesets";
7777import commitStatusesRoutes from "./routes/commit-statuses";
7878import badgesRoutes from "./routes/badges";
79import communityRoutes from "./routes/community";
7980import webRoutes from "./routes/web";
8081
8182const app = new Hono();
266267// Shields.io-style SVG badges — /:owner/:repo/badge/*.svg (Block J10)
267268app.route("/", badgesRoutes);
268269
270// Community profile / health scorecard — /:owner/:repo/community (Block J12)
271app.route("/", communityRoutes);
272
269273// Insights + milestones
270274app.route("/", insightsRoutes);
271275
Addedsrc/lib/community.ts+243−0View fileUnifiedSplit
1/**
2 * Block J12 — Community profile / health scorecard.
3 *
4 * Pure + git-layer helpers that score a repo on its community health files
5 * (README, LICENSE, CODE_OF_CONDUCT, CONTRIBUTING, issue templates, PR
6 * template) plus repo metadata (description, topics). Mirrors GitHub's
7 * "Community Standards" checklist.
8 *
9 * The check list itself is a pure table so unit tests can verify each rule
10 * without touching git or the DB.
11 */
12
13import { getDefaultBranch, getTree } from "../git/repository";
14import type { GitTreeEntry } from "../git/repository";
15
16export type ChecklistKey =
17 | "description"
18 | "readme"
19 | "license"
20 | "code_of_conduct"
21 | "contributing"
22 | "issue_template"
23 | "pr_template"
24 | "topics";
25
26export interface ChecklistItem {
27 key: ChecklistKey;
28 label: string;
29 description: string;
30 /** Path suggested in the "add this file" UI if it's missing. */
31 suggestedPath?: string;
32 required: boolean;
33}
34
35export const CHECKLIST: ChecklistItem[] = [
36 {
37 key: "description",
38 label: "Description",
39 description: "A one-line summary so people know what the repo is for.",
40 required: true,
41 },
42 {
43 key: "readme",
44 label: "README",
45 description:
46 "README tells people what the project is and how to use it. Accepted names: README, README.md, README.txt, README.rst.",
47 suggestedPath: "README.md",
48 required: true,
49 },
50 {
51 key: "license",
52 label: "License",
53 description:
54 "Without a license file, the default is 'all rights reserved'. Accepted names: LICENSE, LICENSE.md, LICENSE.txt, COPYING.",
55 suggestedPath: "LICENSE",
56 required: true,
57 },
58 {
59 key: "code_of_conduct",
60 label: "Code of Conduct",
61 description:
62 "A Code of Conduct clarifies expectations for behaviour in the community.",
63 suggestedPath: "CODE_OF_CONDUCT.md",
64 required: false,
65 },
66 {
67 key: "contributing",
68 label: "Contributing guidelines",
69 description: "Tells potential contributors how to propose changes.",
70 suggestedPath: "CONTRIBUTING.md",
71 required: false,
72 },
73 {
74 key: "issue_template",
75 label: "Issue templates",
76 description:
77 "Templates under .github/ISSUE_TEMPLATE/ help reporters file useful bugs.",
78 suggestedPath: ".github/ISSUE_TEMPLATE/bug_report.md",
79 required: false,
80 },
81 {
82 key: "pr_template",
83 label: "Pull-request template",
84 description:
85 "A PR template nudges contributors to summarise their change. Accepted names: .github/pull_request_template.md, PULL_REQUEST_TEMPLATE.md.",
86 suggestedPath: ".github/pull_request_template.md",
87 required: false,
88 },
89 {
90 key: "topics",
91 label: "Topics",
92 description:
93 "Add topics so people can discover your project on the Explore page.",
94 required: false,
95 },
96];
97
98export type HealthResult = Record<ChecklistKey, boolean>;
99
100export interface HealthReport {
101 items: Array<ChecklistItem & { present: boolean }>;
102 passed: number;
103 total: number;
104 requiredPassed: number;
105 requiredTotal: number;
106 /** 0 – 100 integer percentage of items passed overall. */
107 score: number;
108 /** true when all `required` items are present. */
109 meetsRequired: boolean;
110}
111
112const README_RE = /^readme(\.(md|txt|rst|markdown))?$/i;
113const LICENSE_RE = /^(license|licence|copying)(\.(md|txt|rst))?$/i;
114const COC_RE = /^code[_-]?of[_-]?conduct(\.(md|txt))?$/i;
115const CONTRIBUTING_RE = /^contributing(\.(md|txt))?$/i;
116const PR_TEMPLATE_RE = /^pull[_-]?request[_-]?template(\.(md|txt))?$/i;
117
118/** Pure name-matcher helpers — exported for unit tests. */
119export function isReadme(name: string): boolean {
120 return README_RE.test(name);
121}
122export function isLicense(name: string): boolean {
123 return LICENSE_RE.test(name);
124}
125export function isCodeOfConduct(name: string): boolean {
126 return COC_RE.test(name);
127}
128export function isContributing(name: string): boolean {
129 return CONTRIBUTING_RE.test(name);
130}
131export function isPrTemplate(name: string): boolean {
132 return PR_TEMPLATE_RE.test(name);
133}
134
135/**
136 * Given a set of pure inputs (what files exist at the root, what files
137 * exist in `.github/`, whether the issue-template directory exists, and the
138 * repo metadata), compute the checklist result. No IO — unit tests drive
139 * this directly.
140 */
141export function checklistFromInputs(opts: {
142 rootEntries: string[];
143 githubEntries: string[];
144 issueTemplateDirExists: boolean;
145 description: string | null | undefined;
146 topics: string[];
147}): HealthResult {
148 const root = opts.rootEntries.map((n) => n);
149 const gh = opts.githubEntries.map((n) => n);
150
151 const anywhere = (pred: (n: string) => boolean) =>
152 root.some(pred) || gh.some(pred);
153
154 return {
155 description: !!(opts.description && opts.description.trim().length > 0),
156 readme: anywhere(isReadme),
157 license: anywhere(isLicense),
158 code_of_conduct: anywhere(isCodeOfConduct),
159 contributing: anywhere(isContributing),
160 issue_template:
161 opts.issueTemplateDirExists ||
162 // Fallback: single `ISSUE_TEMPLATE.md` at root or .github
163 anywhere((n) => /^issue[_-]?template(\.(md|txt))?$/i.test(n)),
164 pr_template: anywhere(isPrTemplate),
165 topics: opts.topics.length > 0,
166 };
167}
168
169/** Turn raw booleans into a sorted + annotated report. */
170export function buildReport(result: HealthResult): HealthReport {
171 const items = CHECKLIST.map((item) => ({ ...item, present: result[item.key] }));
172 const passed = items.filter((i) => i.present).length;
173 const required = items.filter((i) => i.required);
174 const requiredPassed = required.filter((i) => i.present).length;
175 const score = items.length === 0 ? 0 : Math.round((passed / items.length) * 100);
176 return {
177 items,
178 passed,
179 total: items.length,
180 requiredPassed,
181 requiredTotal: required.length,
182 score,
183 meetsRequired: requiredPassed === required.length,
184 };
185}
186
187/**
188 * Walk the default branch of a repo and produce the community health
189 * report. Returns a safe all-false report on git/DB failure — never throws.
190 */
191export async function computeHealth(opts: {
192 owner: string;
193 repo: string;
194 description: string | null | undefined;
195 topics: string[];
196}): Promise<HealthReport> {
197 try {
198 const branch =
199 (await getDefaultBranch(opts.owner, opts.repo)) || "main";
200 const rootTree = await safeTree(opts.owner, opts.repo, branch, "");
201 const githubTree = await safeTree(opts.owner, opts.repo, branch, ".github");
202 const rootNames = rootTree.map((e) => e.name);
203 const githubNames = githubTree.map((e) => e.name);
204 const issueTemplateDir = githubTree.some(
205 (e) => e.type === "tree" && /^ISSUE_TEMPLATE$/i.test(e.name)
206 );
207 const result = checklistFromInputs({
208 rootEntries: rootNames,
209 githubEntries: githubNames,
210 issueTemplateDirExists: issueTemplateDir,
211 description: opts.description,
212 topics: opts.topics,
213 });
214 return buildReport(result);
215 } catch {
216 const zero: HealthResult = {
217 description: false,
218 readme: false,
219 license: false,
220 code_of_conduct: false,
221 contributing: false,
222 issue_template: false,
223 pr_template: false,
224 topics: false,
225 };
226 return buildReport(zero);
227 }
228}
229
230async function safeTree(
231 owner: string,
232 repo: string,
233 ref: string,
234 treePath: string
235): Promise<GitTreeEntry[]> {
236 try {
237 return await getTree(owner, repo, ref, treePath);
238 } catch {
239 return [];
240 }
241}
242
243export const __internal = { README_RE, LICENSE_RE, COC_RE, CONTRIBUTING_RE, PR_TEMPLATE_RE };
Addedsrc/routes/community.tsx+195−0View fileUnifiedSplit
1/**
2 * Block J12 — Community profile / health scorecard route.
3 *
4 * GET /:owner/:repo/community
5 *
6 * Renders GitHub-parity "Community standards" checklist: README, LICENSE,
7 * CODE_OF_CONDUCT, CONTRIBUTING, issue + PR templates, description, topics.
8 * Scored in percentage and broken down by required vs recommended.
9 *
10 * Never 500s — falls back to a zero-score report when git or DB reads fail.
11 */
12
13import { Hono } from "hono";
14import { and, eq } from "drizzle-orm";
15import { db } from "../db";
16import { repositories, repoTopics, users } from "../db/schema";
17import { Layout } from "../views/layout";
18import { RepoHeader, RepoNav } from "../views/components";
19import { softAuth } from "../middleware/auth";
20import type { AuthEnv } from "../middleware/auth";
21import { computeHealth, type HealthReport } from "../lib/community";
22import { getUnreadCount } from "../lib/unread";
23
24const community = new Hono<AuthEnv>();
25
26community.get("/:owner/:repo/community", softAuth, async (c) => {
27 const user = c.get("user");
28 const { owner: ownerName, repo: repoName } = c.req.param();
29
30 let repoRow: {
31 id: string;
32 description: string | null;
33 starCount: number;
34 forkCount: number;
35 } | null = null;
36 try {
37 const rows = await db
38 .select({
39 id: repositories.id,
40 description: repositories.description,
41 starCount: repositories.starCount,
42 forkCount: repositories.forkCount,
43 })
44 .from(repositories)
45 .innerJoin(users, eq(repositories.ownerId, users.id))
46 .where(
47 and(
48 eq(users.username, ownerName),
49 eq(repositories.name, repoName)
50 )
51 )
52 .limit(1);
53 repoRow = rows[0] || null;
54 } catch {
55 repoRow = null;
56 }
57 if (!repoRow) return c.notFound();
58
59 let topics: string[] = [];
60 try {
61 const rows = await db
62 .select({ topic: repoTopics.topic })
63 .from(repoTopics)
64 .where(eq(repoTopics.repositoryId, repoRow.id));
65 topics = rows.map((r) => r.topic);
66 } catch {
67 topics = [];
68 }
69
70 const report: HealthReport = await computeHealth({
71 owner: ownerName,
72 repo: repoName,
73 description: repoRow.description,
74 topics,
75 });
76
77 const unread = user ? await getUnreadCount(user.id) : 0;
78
79 const barColor =
80 report.score >= 80
81 ? "var(--green)"
82 : report.score >= 50
83 ? "var(--yellow)"
84 : "var(--red)";
85
86 return c.html(
87 <Layout
88 title={`Community standards — ${ownerName}/${repoName}`}
89 user={user}
90 notificationCount={unread}
91 >
92 <RepoHeader
93 owner={ownerName}
94 repo={repoName}
95 starCount={repoRow.starCount}
96 forkCount={repoRow.forkCount}
97 currentUser={user?.username || null}
98 />
99 <RepoNav owner={ownerName} repo={repoName} active="insights" />
100 <h2>Community standards</h2>
101 <p style="color: var(--text-muted); max-width: 640px">
102 Healthy projects set expectations and onboard new contributors. Here's
103 how this repo scores on GlueCron's community profile checklist.
104 </p>
105
106 <div
107 style="margin: 16px 0 24px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)"
108 data-testid="community-score"
109 >
110 <div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px">
111 <strong style="font-size: 18px">{report.score}%</strong>
112 <span style="color: var(--text-muted); font-size: 13px">
113 {report.passed} of {report.total} items present · {report.requiredPassed}/{report.requiredTotal} required
114 </span>
115 </div>
116 <div style="height: 8px; background: var(--bg); border-radius: 4px; overflow: hidden">
117 <div
118 style={`width: ${report.score}%; height: 100%; background: ${barColor}; transition: width 0.3s`}
119 />
120 </div>
121 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
122 {report.meetsRequired
123 ? "All required items present — nice work."
124 : "Add the required items to reach the minimum community profile."}
125 </div>
126 </div>
127
128 <ul style="list-style: none; padding: 0; margin: 0">
129 {report.items.map((item) => (
130 <li
131 style="display: flex; align-items: flex-start; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border)"
132 data-testid={`community-item-${item.key}`}
133 >
134 <span
135 style={`font-size: 20px; color: ${item.present ? "var(--green)" : item.required ? "var(--red)" : "var(--text-muted)"}; line-height: 1`}
136 aria-label={item.present ? "present" : "missing"}
137 >
138 {item.present ? "\u2713" : item.required ? "\u2717" : "\u25CB"}
139 </span>
140 <div style="flex: 1">
141 <div style="display: flex; align-items: center; gap: 8px">
142 <strong>{item.label}</strong>
143 {item.required && (
144 <span
145 style="font-size: 10px; padding: 1px 6px; border-radius: 10px; background: rgba(248, 81, 73, 0.1); color: var(--red); text-transform: uppercase; letter-spacing: 0.5px"
146 >
147 Required
148 </span>
149 )}
150 </div>
151 <div style="color: var(--text-muted); font-size: 13px; margin-top: 2px">
152 {item.description}
153 </div>
154 {!item.present && item.suggestedPath && (
155 <div style="margin-top: 6px">
156 <a
157 href={`/${ownerName}/${repoName}/new/main?path=${encodeURIComponent(item.suggestedPath)}`}
158 class="btn"
159 style="font-size: 12px; padding: 4px 10px"
160 >
161 Add {item.suggestedPath}
162 </a>
163 </div>
164 )}
165 {!item.present && item.key === "description" && (
166 <div style="margin-top: 6px">
167 <a
168 href={`/${ownerName}/${repoName}/settings`}
169 class="btn"
170 style="font-size: 12px; padding: 4px 10px"
171 >
172 Edit description
173 </a>
174 </div>
175 )}
176 {!item.present && item.key === "topics" && (
177 <div style="margin-top: 6px">
178 <a
179 href={`/${ownerName}/${repoName}/settings`}
180 class="btn"
181 style="font-size: 12px; padding: 4px 10px"
182 >
183 Add topics
184 </a>
185 </div>
186 )}
187 </div>
188 </li>
189 ))}
190 </ul>
191 </Layout>
192 );
193});
194
195export default community;
0196