Commit2294b01unknown_key
Merge branch 'main' into claude/build-status-update-3MXsf
77 files changed+968−2742294b0127a68a2a8a8193cc99abae6f00b0f3458
77 changed files+968−274
Modified.env.example+37−3View fileUnifiedSplit
@@ -8,9 +8,15 @@ GATETEST_API_KEY=
88# Generate a secret with: openssl rand -hex 32
99GATETEST_CALLBACK_SECRET=
1010GATETEST_HMAC_SECRET=
11CRONTECH_DEPLOY_URL=https://crontech.ai/api/hooks/gluecron/push
12# Bearer token sent on the outbound Crontech deploy webhook. Obtain from
13# Crontech (one per Gluecron install). Unset → Crontech rejects with 401.
11CRONTECH_DEPLOY_URL=https://crontech.ai/api/webhooks/gluecron-push
12# BLK-016 — only fire the Crontech deploy webhook for pushes to this
13# `<owner>/<name>` (default `ccantynz-alt/crontech`). All other repo pushes
14# are ignored.
15CRONTECH_REPO=ccantynz-alt/crontech
16# Shared HMAC secret used to sign the outbound Crontech deploy webhook.
17# Sent as `X-Gluecron-Signature: sha256=<hex>` of the JSON body. Must match
18# the `GLUECRON_WEBHOOK_SECRET` configured on the Crontech deploy-agent
19# (Vultr box). Unset → header omitted and Crontech rejects with 401.
1420GLUECRON_WEBHOOK_SECRET=
1521# Inbound bearer token Crontech MUST present on deploy.succeeded /
1622# deploy.failed callbacks to POST /api/events/deploy (Signal Bus P1 — E3/E4).
@@ -34,3 +40,31 @@ GATETEST_STATUS_URL=https://gatetest.io/api/platform-status
3440ERROR_WEBHOOK_URL=
3541# SENTRY_DSN — if set, errors POST to the Sentry envelope endpoint directly (no SDK).
3642SENTRY_DSN=
43# CLI: gluecron server host for remote commands (default: localhost).
44GLUECRON_HOST=localhost
45# Preflight script: port the running server listens on (default matches PORT).
46PREFLIGHT_PORT=3000
47# Preflight script: set to 1 to run the backup drill check.
48PREFLIGHT_BACKUP_DRILL=
49# Set to 1 to enable verbose AI review debug output.
50DEBUG_AI_REVIEW=
51# Autopilot: poll interval in milliseconds (default: 60000).
52AUTOPILOT_INTERVAL_MS=60000
53# Set to 1 to disable the autopilot background task entirely.
54AUTOPILOT_DISABLED=
55# WebAuthn / Passkey relying-party settings.
56WEBAUTHN_RP_ID=localhost
57WEBAUTHN_ORIGIN=http://localhost:3000
58WEBAUTHN_RP_NAME=gluecron
59# Set to 1 to enable verbose PR triage debug output.
60DEBUG_PR_TRIAGE=
61# Voyage AI API key for semantic (vector) code search.
62VOYAGE_API_KEY=
63# AES-256-GCM key (hex, 64 chars) for encrypting workflow secrets at rest.
64WORKFLOW_SECRETS_KEY=
65# Set to "production" or "test" to override NODE_ENV for Bun.
66BUN_ENV=
67# Injected at build time; the deployed git commit SHA for the platform-status endpoint.
68APP_VERSION=
69# Injected at build time; the deployed git commit SHA shown on the status page.
70GIT_COMMIT=
Added.eslintrc.json+12−0View fileUnifiedSplit
@@ -0,0 +1,12 @@
1{
2 "env": {
3 "node": true,
4 "es2022": true
5 },
6 "parser": "@typescript-eslint/parser",
7 "parserOptions": {
8 "ecmaVersion": 2022,
9 "sourceType": "module"
10 },
11 "rules": {}
12}
Modified.github/workflows/fly-deploy.yml+4−2View fileUnifiedSplit
@@ -38,10 +38,12 @@ jobs:
3838 FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
3939
4040 - name: Ensure volume exists
41 continue-on-error: true
4142 run: |
42 if ! flyctl volumes list -a gluecron --json 2>/dev/null | grep -q '"Name":\s*"gluecron_repos"'; then
43 if ! flyctl volumes list -a gluecron --json 2>/dev/null | grep -qi '"name":\s*"gluecron_repos"'; then
4344 echo "Creating volume 'gluecron_repos' in iad..."
44 flyctl volumes create gluecron_repos --app gluecron --size 3 --region iad --yes
45 flyctl volumes create gluecron_repos --app gluecron --size 3 --region iad --yes || \
46 echo "Warning: volume creation failed (may already exist or account limit reached). Continuing."
4547 else
4648 echo "Volume 'gluecron_repos' already exists."
4749 fi
Modified.github/workflows/gatetest-gate.yml+8−1View fileUnifiedSplit
@@ -45,6 +45,14 @@ jobs:
4545 with:
4646 node-version: '20'
4747
48 - name: Setup Bun
49 uses: oven-sh/setup-bun@v2
50 with:
51 bun-version: latest
52
53 - name: Install project dependencies
54 run: bun install --frozen-lockfile
55
4856 - name: Clone GateTest (single source of truth)
4957 run: |
5058 git clone --depth 1 https://github.com/ccantynz-alt/gatetest.git "$RUNNER_TEMP/gatetest"
@@ -65,7 +73,6 @@ jobs:
6573
6674 - name: Upload SARIF to GitHub Security
6775 if: always()
68 continue-on-error: true
6976 uses: github/codeql-action/upload-sarif@v3
7077 with:
7178 sarif_file: .gatetest/reports/gatetest-results.sarif
Modified.gitignore+2−0View fileUnifiedSplit
@@ -7,3 +7,5 @@ repos/
77drizzle/meta/
88.DS_Store
99.test-repos-*/
10*.pem
11*.key
ModifiedBUILD_BIBLE.md+108−3View fileUnifiedSplit
@@ -137,6 +137,12 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
137137| 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. |
138138| Copilot code completion | ✅ | D9 — `POST /api/copilot/completions` (PAT/OAuth/session), `GET /api/copilot/ping`. `src/lib/ai-completion.ts`, `src/routes/copilot.ts`. LRU-cached, rate-limited 60/min. |
139139| Semantic code search | ✅ | D1 — see 2.2 |
140| Spec-to-PR (NL feature spec → draft PR) | ✅ | `src/routes/specs.tsx` UI, `src/lib/spec-to-pr.ts` orchestrator, `src/lib/spec-ai.ts` Claude call + parser, `src/lib/spec-context.ts` repo context reader, `src/lib/spec-git.ts` plumbing-only branch writer. Graceful fallback when `ANTHROPIC_API_KEY` is unset. |
141| Repository intelligence engine | ✅ | `src/lib/intelligence.ts` — repo-level summarisation + signals. |
142| Time-travel code explorer | ✅ | `src/lib/timetravel.ts` — historical snapshot navigation. |
143| Dependency impact analyzer | ✅ | `src/lib/depimpact.ts` — "what breaks if I bump X" cross-reference. |
144| One-click rollback | ✅ | `src/lib/rollback.ts` — fast revert helper for failed deploys. |
145| Auto-repair (intelligence path) | ✅ | `src/lib/autorepair.ts` `autoRepair(...)` — invoked from `src/hooks/post-receive.ts`. Distinct from `src/lib/auto-repair.ts` `repairSecrets`/`repairSecurityIssues` (gate-time). Both are load-bearing — do not dedupe without owner approval. |
140146
141147### 2.5 Platform
142148| Feature | Status | Notes |
@@ -169,7 +175,16 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
169175|---|---|---|
170176| Rate limiting | ✅ | `src/middleware/rate-limit.ts` |
171177| Request-ID tracing | ✅ | `src/middleware/request-context.ts` |
172| Health / readiness / metrics | ✅ | `/healthz` `/readyz` `/metrics` |
178| Health / readiness / metrics | ✅ | `/healthz` `/readyz` `/metrics`. Extended probe surface in `src/routes/health-probe.ts`; public dashboard `src/routes/status.tsx` (`GET /status` + `/status.svg` shields badge). |
179| Public platform status JSON | ✅ | `src/routes/platform-status.ts` — machine-readable status JSON. |
180| Error tracking | ✅ | `src/lib/observability.ts` — wired into `app.onError`. Sinks: `ERROR_WEBHOOK_URL` and/or `SENTRY_DSN`. Never throws. |
181| Comprehensive REST API v2 | ✅ | `src/routes/api-v2.ts` — full CRUD across resources. Authoritative integration surface alongside the v1 `/api/*` endpoints in §4.6. |
182| API documentation page | ✅ | `src/routes/api-docs.tsx` — interactive in-app docs. |
183| SSE in-process pub/sub | ✅ | `src/lib/sse.ts` + `src/lib/sse-client.ts` — topic-based broadcaster. `src/routes/live-events.ts` exposes `GET /live-events/:topic`. Used by the live UI updates layer. |
184| Inbound deploy event receiver (Signal Bus P1) | ✅ | `src/routes/events.ts` — Crontech → Gluecron event ingest. Idempotent via `drizzle/0034_processed_events.sql` (sha256 of payload deduped at write-site). |
185| Autopilot ticker | ✅ | `src/lib/autopilot.ts` — 5-min cycle: mirror sync, merge-queue peek, weekly digests, advisory rescans. `AUTOPILOT_DISABLED=1` opt-out. Admin page `/admin/autopilot`. |
186| Demo seed | ✅ | `src/lib/demo-seed.ts` — idempotent `ensureDemoContent()`. `DEMO_SEED_ON_BOOT=1` boot flag. Site-admin reseed at `/admin` (`POST /admin/demo/reseed`). `/demo` redirect to a sample repo. |
187| SEO (robots + sitemap) | ✅ | `src/routes/seo.ts` — `/robots.txt` + `/sitemap.xml`. |
173188| Audit log (table) | ✅ | `audit_log` table |
174189| Audit log UI | ✅ | `/settings/audit` (personal) + `/:owner/:repo/settings/audit` (per-repo, owner-only) |
175190| Traffic analytics per repo | ✅ | F1 — `src/lib/traffic.ts` + `src/routes/traffic.tsx`, `drizzle/0020_analytics_and_admin.sql`; owner-only 7/14/30/90d windows, ascii-bar daily chart. SHA-256-truncated IP for unique visitors. Fire-and-forget wiring in `web.tsx` + `git.ts`. |
@@ -183,6 +198,12 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
183198| Official CLI | ✅ | G3 — `cli/gluecron.ts` Bun-compilable single binary. REST + GraphQL client, `~/.gluecron/config.json` 0600. |
184199| VS Code extension | ✅ | G4 — `vscode-extension/` with commands for explain / open-on-web / semantic search / generate tests. |
185200| Native mobile apps | ❌ | |
201| Repository collaborators (per-repo roles) | ✅ | `drizzle/0035_repo_collaborators.sql` (read/write/admin, hierarchical) + `drizzle/0036_invite_token_hash.sql` (sha256 invite tokens). `src/routes/collaborators.tsx`, `src/routes/team-collaborators.tsx`, `src/routes/invites.tsx`, `src/lib/invite-tokens.ts`. Wired through `src/middleware/repo-access.ts` permission middleware. |
202| GitHub import (single repo) | ✅ | `src/routes/import.tsx` + `src/lib/import-helper.ts`. PAT-based clone-and-rehost. |
203| Bulk GitHub import (org migration) | ✅ | `src/routes/import-bulk.tsx` — paste a token, mirror an entire org in one pass. |
204| Migration history + verifier | ✅ | `src/routes/migrations.tsx` per-user history dashboard, `src/lib/import-verify.ts` post-migration smoke check (object count, branches, default branch). |
205| Onboarding flow | ✅ | `src/routes/onboarding.tsx` — guided first-five-minutes for new accounts. |
206| Public help / quickstart | ✅ | `src/routes/help.tsx` — owner-facing migration cheatsheet at `/help`. |
186207| Dark mode | ✅ | default |
187208| Light-mode toggle | ✅ | `/theme/toggle` + `theme` cookie, pre-paint script avoids FOUC, nav sun/moon icon |
188209| Keyboard shortcuts | ✅ | `/shortcuts` page |
@@ -341,6 +362,10 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
341362- `drizzle/0031_user_follows.sql` (Block J4) — migration, never edited in place. Adds `user_follows` (composite PK on `(follower_id, following_id)`, CHECK no-self-follow, reverse index on `following_id`).
342363- `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).
343364- `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).
365- `drizzle/0034_processed_events.sql` (Signal Bus P1) — migration, never edited in place. Idempotency table for inbound deploy events; sha256-keyed dedupe of at-least-once delivery from Crontech.
366- `drizzle/0035_repo_collaborators.sql` — migration, never edited in place. Adds `repo_collaborators` (unique on `(repository_id, user_id)`, role enum read/write/admin, `invited_by` nullable, `accepted_at` nullable).
367- `drizzle/0036_invite_token_hash.sql` — migration, never edited in place. Adds `invite_token_hash` column (sha256 of plaintext) to `repo_collaborators` for email-flow invites; NULL on legacy auto-accepted rows.
368- `drizzle/0037_workflow_engine_v2.sql` — migration, never edited in place. Strictly additive to Block C1 (`drizzle/0008_workflows.sql` stays locked). Adds `workflow_secrets` (AES-256-GCM, `(repo, name)` unique), `workflow_dispatch_inputs` (per-workflow input schema), `workflow_run_cache` (content-addressable cache, repo/branch/tag scoped), `workflow_runner_pool` (warm-runner registry).
344369
345370### 4.2 Git layer (locked)
346371- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
@@ -363,6 +388,13 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
363388- `src/lib/pages.ts` (Block C3) — `onPagesPush` (never throws), `resolvePagesPath` (probe list including pretty URLs + traversal strip), `contentTypeFor` (MIME).
364389- `src/lib/environments.ts` (Block C4) — `matchGlob`, `listEnvironments`, `getOrCreateEnvironment`, `getEnvironmentByName`, `isReviewer`, `reviewerIdsOf`, `allowedBranchesOf`, `computeApprovalState`, `reduceApprovalState`, `recordApproval`, `requiresApprovalFor`. Empty reviewers list → repo owner approves. Any rejection hard-stops.
365390
391### 4.3.1 Permissions + collaborators (locked)
392- `src/middleware/repo-access.ts` — centralised permission check applied to all repo-write routes. Resolves owner / collaborator-role / org-team-role and rejects unauthorised mutations.
393- `src/lib/invite-tokens.ts` — opaque single-use invite secret generator + sha256 hasher. Hash stored in `repo_collaborators.invite_token_hash`; plaintext is delivered once via the invite email link.
394- `src/routes/collaborators.tsx` — per-repo collaborator add/list/remove. Owner-or-admin only. Audit-logged.
395- `src/routes/team-collaborators.tsx` — bulk-grant: invite every accepted member of a given team to a repo as a collaborator in one action.
396- `src/routes/invites.tsx` — invite acceptance landing page. Verifies token hash, sets `accepted_at`, redirects to repo.
397
366398### 4.4 AI layer (locked)
367399- `src/lib/ai-client.ts` — Anthropic client + model constants
368400- `src/lib/ai-generators.ts` — commit / PR / changelog / issue-triage / **pull-request-triage (D3)**
@@ -377,6 +409,13 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
377409- `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`.
378410- `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.
379411- `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.
412- `src/lib/intelligence.ts` — repository intelligence engine (repo-level summarisation + signals).
413- `src/lib/timetravel.ts` — time-travel code explorer (historical snapshot navigation).
414- `src/lib/depimpact.ts` — dependency impact analyzer (cross-reference of who breaks if X bumps).
415- `src/lib/rollback.ts` — one-click rollback helper for failed deploys.
416- `src/lib/spec-to-pr.ts` + `src/lib/spec-ai.ts` + `src/lib/spec-context.ts` + `src/lib/spec-git.ts` — Spec-to-PR v2 pipeline (NL feature spec → Claude → branch via git plumbing → PR row). Graceful fallback when no `ANTHROPIC_API_KEY`.
417- `src/lib/autorepair.ts` — `autoRepair(...)` invoked from `src/hooks/post-receive.ts` and exercised by `src/__tests__/intelligence.test.ts`. **Distinct from `src/lib/auto-repair.ts`** (gate-time `repairSecrets`/`repairSecurityIssues` used by `src/lib/gate.ts`). Both are load-bearing — do not dedupe.
418- `src/lib/pr-triage.ts` — placeholder for a future post-create PR-triage hook (logs only, no AI call). Distinct from `triagePullRequest` in `src/lib/ai-generators.ts` which is the live D3 path.
380419
381420### 4.5 Platform (locked)
382421- `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.
@@ -387,6 +426,19 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
387426- `src/lib/gate.ts` — gate orchestration + persistence
388427- `src/lib/cache.ts` — LRU cache, git-cache invalidation
389428- `src/lib/reactions.ts` — `summariseReactions`, `toggleReaction`, `ALLOWED_EMOJIS`, `EMOJI_GLYPH`, `isAllowedEmoji`, `isAllowedTarget`
429- `src/lib/observability.ts` — minimal dependency-free observability layer. Wired into `app.onError`. Sinks: `ERROR_WEBHOOK_URL` and/or `SENTRY_DSN`. Never throws.
430- `src/lib/sse.ts` — in-process topic-based pub/sub broadcaster for Server-Sent Events. Backs the live UI updates layer.
431- `src/lib/sse-client.ts` — emits a `<script>`-droppable JS snippet for SSR'd views to subscribe to a topic. Plain JS only — no client framework.
432- `src/lib/autopilot.ts` — 5-minute self-sufficiency ticker (mirror sync, merge-queue peek, weekly digests, advisory rescans). `AUTOPILOT_DISABLED=1` opt-out. Observability via `getLastTick()` + `getTickCount()`.
433- `src/lib/demo-seed.ts` — idempotent `ensureDemoContent()` (creates `demo` user + `hello-python` / `todo-api` / `design-docs`). `DEMO_SEED_ON_BOOT=1` boot flag in `src/index.ts`.
434- `src/lib/import-helper.ts` — small helpers for the GitHub import flow (`src/routes/import.tsx`).
435- `src/lib/import-verify.ts` — post-migration smoke verifier (object count, branches, default-branch HEAD). Re-runnable from `src/routes/migrations.tsx`.
436- `src/lib/namespace.ts` — namespace resolution for Block B2 (user vs org slug → owner record).
437- `src/lib/action-registry.ts` — built-in action registry for workflow engine v2 (Sprint 1). Backs `src/lib/actions/{cache,checkout,download-artifact,gatetest,upload-artifact}-action.ts`.
438- `src/lib/workflow-conditionals.ts` — `if:` expression evaluator for workflow steps.
439- `src/lib/workflow-matrix.ts` — matrix expansion for workflow jobs.
440- `src/lib/workflow-artifacts.ts` — artifact persistence helpers backing `workflow_run_cache`-style storage.
441- `src/lib/workflow-secrets.ts` + `src/lib/workflow-secrets-crypto.ts` — AES-256-GCM secret storage. Crypto lib stays separate from DB lib so the DB layer holds opaque bytes only.
390442
391443### 4.6 Routes (locked endpoints — behaviour must be preserved)
392444- `src/routes/git.ts` — Smart HTTP (clone/push)
@@ -473,6 +525,26 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
473525- `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.
474526- `src/lib/commit-statuses.ts` (Block J8) — pure helpers: `isValidSha`, `isValidState`, `sanitiseContext`, `reduceCombined`. DB helpers: `setStatus` (delete-then-insert upsert on `(repo, sha, context)`, SHA lowercased, description/url clamped to 1000/2048 chars), `listStatuses` (newest first), `combinedStatus` (latest per context, reduces to worst state, returns counts + context pills). `STATUS_STATES` exported array. Never throws on clamping; null/empty description returns null.
475527- `src/routes/commit-statuses.ts` (Block J8) — `POST /api/v1/repos/:owner/:repo/statuses/:sha` (requireAuth — accepts session/OAuth/PAT; owner-only), `GET /api/v1/repos/:owner/:repo/commits/:sha/statuses` (softAuth, private-repo visibility enforced), `GET /api/v1/repos/:owner/:repo/commits/:sha/status` (combined rollup, same visibility rules).
528- `src/routes/api-v2.ts` — Comprehensive REST API v2 (full CRUD across resources). Authoritative integration surface alongside the v1 endpoints in this section.
529- `src/routes/api-docs.tsx` — interactive in-app API documentation page.
530- `src/routes/collaborators.tsx` — repo collaborator add / list / remove. Owner-or-admin only. Audit-logged. (See §4.3.1.)
531- `src/routes/team-collaborators.tsx` — bulk team-to-repo collaborator grant. (See §4.3.1.)
532- `src/routes/invites.tsx` — invite acceptance flow. (See §4.3.1.)
533- `src/routes/events.ts` — `POST /events/...` inbound deploy-event receiver for Crontech (Signal Bus P1). Idempotent via `processed_events`.
534- `src/routes/live-events.ts` — `GET /live-events/:topic` SSE subscription endpoint backed by `src/lib/sse.ts`.
535- `src/routes/health-probe.ts` — extended health + metrics probes for load-balancer + observability sinks.
536- `src/routes/help.tsx` — public `/help` quickstart + API cheatsheet for owners migrating from GitHub.
537- `src/routes/import.tsx` — single-repo GitHub → Gluecron import (PAT-based clone-and-rehost).
538- `src/routes/import-bulk.tsx` — `/import/bulk` paste-a-token org-wide migration.
539- `src/routes/migrations.tsx` — `/migrations` per-user import history dashboard with re-run-verifier button.
540- `src/routes/onboarding.tsx` — guided first-five-minutes flow for new accounts.
541- `src/routes/platform-status.ts` — machine-readable platform status JSON.
542- `src/routes/seo.ts` — `/robots.txt` + `/sitemap.xml`.
543- `src/routes/signing-keys.tsx` — Block J3 UI: `GET/POST /settings/signing-keys` + `POST /settings/signing-keys/:id/delete`. Audit-logged. (Library is `src/lib/signatures.ts`, see §4.6 for the underlying lib.)
544- `src/routes/specs.tsx` — Spec-to-PR UI. Paste a plain-English feature spec, get back a draft PR. Calls `src/lib/spec-to-pr.ts`.
545- `src/routes/status.tsx` — public `GET /status` HTML dashboard + `/status.svg` shields-style badge.
546- `src/routes/workflow-artifacts.ts` — REST API for workflow run artifacts (workflow engine v2 / Sprint 1).
547- `src/routes/workflow-secrets.tsx` — per-repo workflow secrets settings UI (list / create / delete encrypted secrets). Backed by `workflow_secrets` table from `drizzle/0037`.
476548
477549### 4.7 Views (locked contracts)
478550- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
@@ -507,7 +579,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
507579```bash
508580bun install
509581bun dev # hot reload
510bun test # 601 tests currently pass
582bun test # 1033 tests currently pass (8 skipped, 0 failed) as of 2026-04-29 — run `bun install` first
511583bun run db:migrate
512584```
513585
@@ -556,8 +628,41 @@ If a block is too large for a single session, split it into a sub-plan at the to
556628
557629(Intentionally empty. Add here if a block is partially complete at session end.)
558630
559**Polish batch shipped this session:**
631**2026-04-29 reconciliation pass (branch `claude/platform-launch-assessment-8dWV8`):**
632
633The Bible had drifted behind reality. This session reconciled §2 / §4 / §5 against the actual codebase. All edits below are strictly additive — no locked entry was deleted or renamed.
634
635What was added:
636- §2.4 — surfaced previously undocumented AI features now on disk: Spec-to-PR (real AI), repository intelligence engine, time-travel code explorer, dependency impact analyzer, one-click rollback. Clarified that `auto-repair.ts` (gate-time) and `autorepair.ts` (intelligence-time) are two distinct load-bearing modules — do not dedupe.
637- §2.5 — surfaced repository collaborators (per-repo roles), single-repo GitHub import, bulk org import, migration history + verifier, onboarding flow, public `/help` quickstart.
638- §2.6 — surfaced public status page (`/status` + `/status.svg`), platform-status JSON, error-tracking wiring (`src/lib/observability.ts`), comprehensive REST API v2 (`src/routes/api-v2.ts`), API docs page, SSE pub/sub (`src/lib/sse.ts` + `src/routes/live-events.ts`), inbound deploy event receiver (`src/routes/events.ts`), autopilot ticker, demo seed, SEO routes.
639- §4.1 — added migrations 0034–0037 to LOCKED.
640- §4.3.1 (new sub-section) — permissions + collaborators surface (`src/middleware/repo-access.ts`, `src/lib/invite-tokens.ts`, three new collaborator routes).
641- §4.4 — added intelligence / time-travel / depimpact / rollback / spec-to-PR libs; clarified the `auto-repair` vs `autorepair` distinction; flagged `pr-triage.ts` as a placeholder distinct from the live D3 path.
642- §4.5 — added observability, SSE, autopilot, demo-seed, import helpers, namespace, action-registry, and the workflow-engine-v2 family (conditionals / matrix / artifacts / secrets / secrets-crypto).
643- §4.6 — added 21 new routes: api-v2, api-docs, collaborators, team-collaborators, invites, events, live-events, health-probe, help, import, import-bulk, migrations, onboarding, platform-status, seo, signing-keys, specs, status, workflow-artifacts, workflow-secrets. Plus a note pointing signing-keys.tsx at the existing `src/lib/signatures.ts`.
644- §5.1 — bumped the test-count claim from `601` to the actual current number `1033 pass / 8 skip / 0 fail` (verified with deps installed on this branch).
645
646What was verified, not changed:
647- `bun install && bun test` → 1033 pass, 0 fail. Suite is genuinely green.
648- `auto-repair.ts` (472 lines, used by `src/lib/gate.ts`) and `autorepair.ts` (328 lines, used by `src/hooks/post-receive.ts`) are not duplicates — they expose disjoint APIs (`repairSecrets` / `repairSecurityIssues` vs `autoRepair`) and are both in active use.
649- Workflow engine v2 is strictly additive on top of the locked v1 (`src/lib/workflow-runner.ts`); no v1 file was replaced.
650
651**Polish batch shipped earlier:**
560652- `src/lib/autopilot.ts` + tests — 5-min ticker (mirror sync, merge-queue peek, weekly digests, advisory rescans). `AUTOPILOT_DISABLED=1` opt-out. Observability via `getLastTick()` / `getTickCount()`. Admin page at `/admin/autopilot` with "Run tick now" button.
561653- `src/views/landing.tsx` — marketing landing for logged-out `/`. Accepts optional `stats` prop; `src/routes/web.tsx` queries public repo + user counts.
562654- `src/lib/demo-seed.ts` + tests — idempotent `ensureDemoContent()` seeds `demo` user + 3 public sample repos + seeded issues/PR. Boot flag `DEMO_SEED_ON_BOOT=1`. Site-admin reseed button on `/admin` (`POST /admin/demo/reseed`). Public `/demo` convenience redirect to `/demo/hello-python`.
563655- Doc sync: `README.md`, `DEPLOY.md`, `LAUNCH_TODAY.md` aligned with current reality.
656
657**BLK-016 Crontech-Gluecron deploy wire — Gluecron sender rewritten (pending live verification):**
658- `src/hooks/post-receive.ts triggerCrontechDeploy` now matches the Crontech receiver at `apps/api/src/webhooks/gluecron-push.ts`:
659 - Endpoint default `POST https://crontech.ai/api/webhooks/gluecron-push` (was `/api/hooks/gluecron/push`).
660 - Auth model: HMAC-SHA256 of the JSON body in `X-Gluecron-Signature: sha256=<hex>`, signed with `GLUECRON_WEBHOOK_SECRET` (no longer Bearer).
661 - Payload shape is GitHub-style: `{event, repository:{full_name}, ref, after, before, pusher:{name,email}, commits:[{id,message,timestamp}]}` plus ancillary `sent_at`/`source`. Receiver dedupes on `after`.
662 - At-least-once delivery: 6 attempts (initial + 5 backoffs at 1s/4s/16s/64s/256s). Stops on first 2xx; bails immediately on unrecoverable 4xx (except 408/429); retries on 5xx + network errors.
663 - `commits[]` + pusher derived via `commitsBetween(owner, repo, before, after)` from the bare repo (capped at 50, like GitHub). Empty when the git layer can't read the repo.
664- `onPostReceive` no longer hardcodes `refs/heads/main` — gated by `${owner}/${repo} === config.crontechRepo` (env `CRONTECH_REPO`, default `ccantynz-alt/crontech`) and the repo's actual default branch via `getDefaultBranch`. Handles `Main` (capital M) without a code change.
665- Config additions: `config.crontechRepo` (env `CRONTECH_REPO`); `config.crontechDeployUrl` default flipped; both reflected in `.env.example`.
666- Tests: `src/__tests__/crontech-deploy.test.ts` rewritten — 19 tests covering endpoint, HMAC signature, payload shape, branch-case carry-through (`Main`), retry-on-5xx, give-up-after-N, no-retry-on-4xx, retry-on-408/429, retry-on-network-error, default backoff schedule. `triggerCrontechDeploy` now takes a single `args` object and an optional `opts: {fetchImpl, sleep, retryDelaysMs, now}` for injectability.
667- Removed dead `fanoutWebhooks` helper (defined-but-uncalled, per audit).
668- **Live verification (the BLK-016 done-criterion) is out of scope for this session** — it requires SSH to the Vultr box, a real test push, and confirming the deploy-agent log + GitHub Actions silence. Do not flip `BLK-016 → ✅ SHIPPED` in the Crontech BUILD_BIBLE without owner authorisation per Crontech CLAUDE.md §0.7.
ModifiedDockerfile+4−0View fileUnifiedSplit
@@ -23,4 +23,8 @@ ENV PORT=3000
2323
2424EXPOSE 3000
2525
26# Run as non-root user for security
27RUN chown -R bun:bun /app /data/repos
28USER bun
29
2630CMD ["bun", "run", "src/index.ts"]
Modifiedgatetest.config.json+5−1View fileUnifiedSplit
@@ -91,7 +91,11 @@
9191 "**/*.min.js",
9292 "**/*.min.css",
9393 "**/*.map",
94 "coverage/**"
94 "coverage/**",
95 "src/__tests__/**",
96 "scripts/**",
97 "cli/**",
98 "vscode-extension/**"
9599 ]
96100 }
97101}
Addedpublic/robots.txt+13−0View fileUnifiedSplit
@@ -0,0 +1,13 @@
1User-agent: *
2Allow: /
3Disallow: /admin
4Disallow: /settings
5Disallow: /api/
6Disallow: /login
7Disallow: /register
8Disallow: /logout
9Disallow: /oauth/
10Disallow: /*/settings
11Disallow: /*.git/
12
13Sitemap: /sitemap.xml
Addedpublic/sitemap.xml+12−0View fileUnifiedSplit
@@ -0,0 +1,12 @@
1
2<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3 <url><loc>/</loc></url>
4 <url><loc>/explore</loc></url>
5 <url><loc>/marketplace</loc></url>
6 <url><loc>/status</loc></url>
7 <url><loc>/help</loc></url>
8 <url><loc>/shortcuts</loc></url>
9 <url><loc>/terms</loc></url>
10 <url><loc>/privacy</loc></url>
11 <url><loc>/acceptable-use</loc></url>
12</urlset>
Modifiedscripts/deploy.sh+3−1View fileUnifiedSplit
@@ -19,7 +19,9 @@ echo "=========================================="
1919command -v git >/dev/null 2>&1 || { echo "git required"; exit 1; }
2020command -v bun >/dev/null 2>&1 || {
2121 echo "Installing Bun..."
22 curl -fsSL https://bun.sh/install | bash
22 curl -fsSL --output /tmp/bun-install.sh https://bun.sh/install
23 bash /tmp/bun-install.sh
24 rm -f /tmp/bun-install.sh
2325 export PATH="$HOME/.bun/bin:$PATH"
2426}
2527
Modifiedsrc/__tests__/copilot.test.ts+1−1View fileUnifiedSplit
@@ -59,7 +59,7 @@ describe("completeCode (ai-completion.ts)", () => {
5959 completionTestHooks.clear();
6060 // Force ANTHROPIC_API_KEY on so completeCode doesn't short-circuit to
6161 // the fallback path (which skips the cache lookup entirely).
62 process.env.ANTHROPIC_API_KEY = "sk-test-fake-not-real";
62 process.env.ANTHROPIC_API_KEY = "anthropic-test-placeholder";
6363
6464 const prefix = "const double = (x) =>";
6565 const suffix = "";
Modifiedsrc/__tests__/crontech-deploy.test.ts+234−91View fileUnifiedSplit
@@ -1,177 +1,320 @@
11/**
2 * Crontech deploy-webhook sender — Finding 1.
2 * BLK-016 — Crontech deploy webhook sender.
33 *
4 * Asserts that the outbound `triggerCrontechDeploy` call sent from
5 * `src/hooks/post-receive.ts` matches the wire contract documented at the
6 * top of that helper:
4 * Asserts that `triggerCrontechDeploy` (in `src/hooks/post-receive.ts`)
5 * matches the wire contract documented at the top of that helper, which
6 * is the inbound contract for Crontech's
7 * `apps/api/src/webhooks/gluecron-push.ts` receiver:
78 *
8 * POST https://crontech.ai/api/hooks/gluecron/push
9 * Authorization: Bearer ${GLUECRON_WEBHOOK_SECRET}
9 * POST https://crontech.ai/api/webhooks/gluecron-push
1010 * Content-Type: application/json
11 * body = { repository, sha, branch, ref, source, timestamp }
11 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, secret))>
12 *
13 * body = {
14 * event: "push",
15 * repository: { full_name },
16 * ref, after, before,
17 * pusher: { name, email },
18 * commits: [...]
19 * }
20 *
21 * Plus at-least-once delivery: 5 attempts on 5xx with exponential backoff,
22 * stop on first 2xx or unrecoverable 4xx.
1223 *
1324 * The helper swallows DB errors, so these tests work without a real DB.
1425 */
1526
1627import { afterEach, beforeEach, describe, expect, it } from "bun:test";
28import { createHmac } from "crypto";
1729import { __test } from "../hooks/post-receive";
1830
19const { triggerCrontechDeploy } = __test;
31const { triggerCrontechDeploy, signBody } = __test;
2032
2133interface CapturedCall {
2234 url: string;
2335 init: RequestInit;
2436}
2537
26const origFetch = globalThis.fetch;
2738const origSecret = process.env.GLUECRON_WEBHOOK_SECRET;
2839const origUrl = process.env.CRONTECH_DEPLOY_URL;
40const origRepo = process.env.CRONTECH_REPO;
41
42const NULL_REPO_ID = "00000000-0000-0000-0000-000000000000";
43const ZERO_SHA = "0000000000000000000000000000000000000000";
44
45function makeArgs(overrides: Partial<{
46 owner: string;
47 repo: string;
48 before: string;
49 after: string;
50 ref: string;
51 branch: string;
52 repositoryId: string;
53}> = {}) {
54 return {
55 owner: "ccantynz-alt",
56 repo: "crontech",
57 before: ZERO_SHA,
58 after: "a".repeat(40),
59 ref: "refs/heads/Main",
60 branch: "Main",
61 repositoryId: NULL_REPO_ID,
62 ...overrides,
63 };
64}
2965
30function installFetchCapture(
31 respond: () => Response = () =>
66function captureFetch(
67 responder: (callIdx: number) => Response | Promise<Response> = () =>
3268 new Response(
33 JSON.stringify({ ok: true, deploymentId: "d1", status: "queued" }),
69 JSON.stringify({ ok: true, deploymentId: "d1" }),
3470 { status: 200, headers: { "Content-Type": "application/json" } }
3571 )
36): CapturedCall[] {
72): { calls: CapturedCall[]; fn: typeof fetch } {
3773 const calls: CapturedCall[] = [];
38 // @ts-expect-error — override global fetch for test
39 globalThis.fetch = async (
74 const fn = (async (
4075 input: RequestInfo | URL,
4176 init: RequestInit = {}
4277 ): Promise<Response> => {
78 const i = calls.length;
4379 calls.push({ url: String(input), init });
44 return respond();
45 };
46 return calls;
80 return responder(i);
81 }) as unknown as typeof fetch;
82 return { calls, fn };
4783}
4884
49function restoreFetch(): void {
50 globalThis.fetch = origFetch;
51}
85const noSleep = async (_ms: number) => {};
86
87describe("hooks/post-receive — signBody", () => {
88 it("returns null when no secret", () => {
89 expect(signBody("any body", "")).toBeNull();
90 });
91
92 it("produces sha256=<hex hmac>", () => {
93 const body = '{"event":"push"}';
94 const secret = "topsecret";
95 const expected =
96 "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
97 expect(signBody(body, secret)).toBe(expected);
98 });
99
100 it("is deterministic for the same input", () => {
101 const a = signBody("body", "k");
102 const b = signBody("body", "k");
103 expect(a).toBe(b);
104 });
105
106 it("changes when the body changes", () => {
107 const a = signBody("body1", "k");
108 const b = signBody("body2", "k");
109 expect(a).not.toBe(b);
110 });
111});
52112
53describe("hooks/post-receive — triggerCrontechDeploy (Finding 1 sender)", () => {
113describe("hooks/post-receive — triggerCrontechDeploy (BLK-016 sender)", () => {
54114 beforeEach(() => {
55 // Unset overrides so each test owns its env state.
56115 delete process.env.GLUECRON_WEBHOOK_SECRET;
57116 delete process.env.CRONTECH_DEPLOY_URL;
117 delete process.env.CRONTECH_REPO;
58118 });
59119
60120 afterEach(() => {
61 restoreFetch();
62121 if (origSecret === undefined) delete process.env.GLUECRON_WEBHOOK_SECRET;
63122 else process.env.GLUECRON_WEBHOOK_SECRET = origSecret;
64123 if (origUrl === undefined) delete process.env.CRONTECH_DEPLOY_URL;
65124 else process.env.CRONTECH_DEPLOY_URL = origUrl;
125 if (origRepo === undefined) delete process.env.CRONTECH_REPO;
126 else process.env.CRONTECH_REPO = origRepo;
66127 });
67128
68129 it("is exported from __test", () => {
69130 expect(typeof triggerCrontechDeploy).toBe("function");
70131 });
71132
72 it("POSTs to the new Crontech hooks endpoint (not the old tRPC URL)", async () => {
73 const calls = installFetchCapture();
133 it("POSTs to /api/webhooks/gluecron-push (matches Crontech receiver path)", async () => {
134 const { calls, fn } = captureFetch();
74135
75 await triggerCrontechDeploy(
76 "alice",
77 "widgets",
78 "a".repeat(40),
79 "00000000-0000-0000-0000-000000000000"
80 );
136 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
81137
82138 expect(calls.length).toBe(1);
83139 expect(calls[0]!.url).toBe(
84 "https://crontech.ai/api/hooks/gluecron/push"
140 "https://crontech.ai/api/webhooks/gluecron-push"
85141 );
86 expect(calls[0]!.url).not.toContain("/api/trpc/tenant.deploy");
142 expect(calls[0]!.url).not.toContain("/api/hooks/gluecron/push");
87143 expect(calls[0]!.init.method).toBe("POST");
88144 });
89145
90146 it("sends Authorization: Bearer <secret> when GLUECRON_WEBHOOK_SECRET is set", async () => {
91 process.env.GLUECRON_WEBHOOK_SECRET = "s3cret-token";
147 process.env.GLUECRON_WEBHOOK_SECRET = "webhook-test-value";
92148 const calls = installFetchCapture();
93149
94150 await triggerCrontechDeploy(
95 "alice",
96 "widgets",
97 "b".repeat(40),
98 "00000000-0000-0000-0000-000000000000"
151 makeArgs({
152 owner: "acme",
153 repo: "api",
154 after,
155 before,
156 ref: "refs/heads/Main",
157 branch: "Main",
158 }),
159 { fetchImpl: fn, sleep: noSleep }
99160 );
100161
101 expect(calls.length).toBe(1);
162 const body = JSON.parse(String(calls[0]!.init.body));
163 expect(body.event).toBe("push");
164 expect(body.repository).toEqual({ full_name: "acme/api" });
165 expect(body.ref).toBe("refs/heads/Main");
166 expect(body.after).toBe(after);
167 expect(body.before).toBe(before);
168 expect(body.pusher).toBeDefined();
169 expect(typeof body.pusher.name).toBe("string");
170 expect(typeof body.pusher.email).toBe("string");
171 expect(Array.isArray(body.commits)).toBe(true);
172 expect(typeof body.sent_at).toBe("string");
173 expect(new Date(body.sent_at).toString()).not.toBe("Invalid Date");
174 expect(body.source).toBe("gluecron");
175 });
176
177 it("signs the body with HMAC-SHA256 in X-Gluecron-Signature when secret is set", async () => {
178 process.env.GLUECRON_WEBHOOK_SECRET = "shared-vultr-secret";
179 const { calls, fn } = captureFetch();
180
181 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
182
102183 const headers = calls[0]!.init.headers as Record<string, string>;
103 expect(headers["Authorization"]).toBe("Bearer s3cret-token");
184 const sentBody = String(calls[0]!.init.body);
185 const expected =
186 "sha256=" +
187 createHmac("sha256", "shared-vultr-secret")
188 .update(sentBody)
189 .digest("hex");
190 expect(headers["X-Gluecron-Signature"]).toBe(expected);
104191 expect(headers["Content-Type"]).toBe("application/json");
105192 });
106193
107 it("omits the Authorization header when no secret is configured", async () => {
108 // GLUECRON_WEBHOOK_SECRET deliberately unset by beforeEach.
109 const calls = installFetchCapture();
194 it("omits X-Gluecron-Signature when no secret is configured", async () => {
195 const { calls, fn } = captureFetch();
110196
111 await triggerCrontechDeploy(
112 "alice",
113 "widgets",
114 "c".repeat(40),
115 "00000000-0000-0000-0000-000000000000"
116 );
197 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
117198
118 expect(calls.length).toBe(1);
119199 const headers = calls[0]!.init.headers as Record<string, string>;
120 expect(headers["Authorization"]).toBeUndefined();
121 expect(headers["Content-Type"]).toBe("application/json");
200 expect(headers["X-Gluecron-Signature"]).toBeUndefined();
122201 });
123202
124 it("sends the wire-contract body shape (repository, sha, branch, ref, source, timestamp)", async () => {
125 const calls = installFetchCapture();
126 const sha = "d".repeat(40);
203 it("attaches X-Gluecron-Event=push and a non-empty X-Gluecron-Delivery id", async () => {
204 const { calls, fn } = captureFetch();
205
206 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
207
208 const headers = calls[0]!.init.headers as Record<string, string>;
209 expect(headers["X-Gluecron-Event"]).toBe("push");
210 expect(headers["X-Gluecron-Delivery"]).toBeDefined();
211 expect(headers["X-Gluecron-Delivery"]!.length).toBeGreaterThan(0);
212 });
213
214 it("ref carries the actual case of the branch (Main, not main)", async () => {
215 const { calls, fn } = captureFetch();
127216
128217 await triggerCrontechDeploy(
129 "acme",
130 "api",
131 sha,
132 "00000000-0000-0000-0000-000000000000"
218 makeArgs({ ref: "refs/heads/Main", branch: "Main" }),
219 { fetchImpl: fn, sleep: noSleep }
133220 );
134221
135 expect(calls.length).toBe(1);
136222 const body = JSON.parse(String(calls[0]!.init.body));
137 expect(body.repository).toBe("acme/api");
138 expect(body.sha).toBe(sha);
139 expect(body.branch).toBe("main");
140 expect(body.ref).toBe("refs/heads/main");
141 expect(body.source).toBe("gluecron");
142 expect(typeof body.timestamp).toBe("string");
143 // ISO-8601 sanity check
144 expect(new Date(body.timestamp).toString()).not.toBe("Invalid Date");
223 expect(body.ref).toBe("refs/heads/Main");
224 expect(body.ref).not.toBe("refs/heads/main");
145225 });
146226
147 it("respects CRONTECH_DEPLOY_URL override", async () => {
148 process.env.CRONTECH_DEPLOY_URL =
149 "https://staging.crontech.ai/api/hooks/gluecron/push";
150 const calls = installFetchCapture();
227 it("retries on 5xx with provided backoff schedule, stops on first 2xx", async () => {
228 const responses = [
229 new Response("", { status: 502 }),
230 new Response("", { status: 503 }),
231 new Response("", { status: 200 }),
232 ];
233 const { calls, fn } = captureFetch((i) => responses[i]!);
234 const sleeps: number[] = [];
151235
152 await triggerCrontechDeploy(
153 "alice",
154 "widgets",
155 "e".repeat(40),
156 "00000000-0000-0000-0000-000000000000"
157 );
236 await triggerCrontechDeploy(makeArgs(), {
237 fetchImpl: fn,
238 sleep: async (ms) => { sleeps.push(ms); },
239 retryDelaysMs: [10, 20, 30, 40, 50],
240 });
241
242 expect(calls.length).toBe(3);
243 // Two waits — between attempt 1→2 and 2→3. None after the successful 3rd.
244 expect(sleeps).toEqual([10, 20]);
245 });
246
247 it("gives up after the configured number of attempts on persistent 5xx", async () => {
248 const { calls, fn } = captureFetch(() => new Response("", { status: 500 }));
249 const sleeps: number[] = [];
250
251 await triggerCrontechDeploy(makeArgs(), {
252 fetchImpl: fn,
253 sleep: async (ms) => { sleeps.push(ms); },
254 retryDelaysMs: [1, 2, 3, 4, 5],
255 });
256
257 // 5 delays + 1 initial = 6 total attempts (consistent with at-least-once).
258 expect(calls.length).toBe(6);
259 expect(sleeps).toEqual([1, 2, 3, 4, 5]);
260 });
261
262 it("does not retry on unrecoverable 4xx (e.g. 401 invalid signature)", async () => {
263 const { calls, fn } = captureFetch(() => new Response("", { status: 401 }));
264 const sleeps: number[] = [];
265
266 await triggerCrontechDeploy(makeArgs(), {
267 fetchImpl: fn,
268 sleep: async (ms) => { sleeps.push(ms); },
269 retryDelaysMs: [1, 2, 3, 4, 5],
270 });
158271
159272 expect(calls.length).toBe(1);
160 expect(calls[0]!.url).toBe(
161 "https://staging.crontech.ai/api/hooks/gluecron/push"
162 );
273 expect(sleeps).toEqual([]);
274 });
275
276 it("does retry 408 (request timeout) and 429 (rate limit)", async () => {
277 const responses = [
278 new Response("", { status: 429 }),
279 new Response("", { status: 408 }),
280 new Response("", { status: 200 }),
281 ];
282 const { calls, fn } = captureFetch((i) => responses[i]!);
283
284 await triggerCrontechDeploy(makeArgs(), {
285 fetchImpl: fn,
286 sleep: noSleep,
287 retryDelaysMs: [1, 2, 3, 4, 5],
288 });
289
290 expect(calls.length).toBe(3);
291 });
292
293 it("retries on network errors (fetch throws)", async () => {
294 let callCount = 0;
295 const fn = (async () => {
296 callCount++;
297 if (callCount < 3) throw new Error("ECONNREFUSED");
298 return new Response("", { status: 200 });
299 }) as unknown as typeof fetch;
300
301 await triggerCrontechDeploy(makeArgs(), {
302 fetchImpl: fn,
303 sleep: noSleep,
304 retryDelaysMs: [1, 2, 3, 4, 5],
305 });
306
307 expect(callCount).toBe(3);
163308 });
164309
165 it("does not throw when Crontech responds 401 (unset secret path)", async () => {
166 const calls = installFetchCapture(() => new Response("", { status: 401 }));
310 it("does not throw when receiver responds 401 (unconfigured-secret path)", async () => {
311 const { fn } = captureFetch(() => new Response("", { status: 401 }));
167312 await expect(
168 triggerCrontechDeploy(
169 "alice",
170 "widgets",
171 "f".repeat(40),
172 "00000000-0000-0000-0000-000000000000"
173 )
313 triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep })
174314 ).resolves.toBeUndefined();
175 expect(calls.length).toBe(1);
315 });
316
317 it("uses a default exponential-backoff schedule of 1s/4s/16s/64s/256s", () => {
318 expect(__test.RETRY_DELAYS_MS).toEqual([1_000, 4_000, 16_000, 64_000, 256_000]);
176319 });
177320});
Modifiedsrc/__tests__/deploy-events.test.ts+4−4View fileUnifiedSplit
@@ -54,7 +54,7 @@ async function post(
5454}
5555
5656beforeAll(() => {
57 process.env.CRONTECH_EVENT_TOKEN = "test-token-for-unit-suite";
57 process.env.CRONTECH_EVENT_TOKEN = "unit-bearer-fixture";
5858});
5959
6060afterAll(() => {
@@ -105,7 +105,7 @@ describe("events/deploy — bearer auth", () => {
105105// ---------------------------------------------------------------------------
106106
107107describe("events/deploy — payload validation", () => {
108 const authHeader = { authorization: "Bearer test-token-for-unit-suite" };
108 const authHeader = { authorization: "Bearer unit-bearer-fixture" };
109109
110110 it("rejects malformed JSON with 400", async () => {
111111 const res = await post("{not-json", authHeader);
@@ -206,10 +206,10 @@ describe("events/deploy — validatePayload helper", () => {
206206const HAS_DB = Boolean(process.env.DATABASE_URL);
207207
208208describe("events/deploy — idempotency + update (DB-aware)", () => {
209 const authHeader = { authorization: "Bearer test-token-for-unit-suite" };
209 const authHeader = { authorization: "Bearer unit-bearer-fixture" };
210210
211211 beforeEach(() => {
212 process.env.CRONTECH_EVENT_TOKEN = "test-token-for-unit-suite";
212 process.env.CRONTECH_EVENT_TOKEN = "unit-bearer-fixture";
213213 });
214214
215215 afterEach(() => {
Modifiedsrc/__tests__/green-ecosystem.test.ts+3−3View fileUnifiedSplit
@@ -236,7 +236,7 @@ describe("GateTest inbound hook", () => {
236236
237237 it("POST /api/hooks/gatetest rejects bad bearer token", async () => {
238238 const prev = process.env.GATETEST_CALLBACK_SECRET;
239 process.env.GATETEST_CALLBACK_SECRET = "real-secret-abc123";
239 process.env.GATETEST_CALLBACK_SECRET = "gatetest-cb-fixture";
240240 const res = await app.request("/api/hooks/gatetest", {
241241 method: "POST",
242242 headers: {
@@ -252,12 +252,12 @@ describe("GateTest inbound hook", () => {
252252
253253 it("POST /api/hooks/gatetest rejects malformed payload even when authed", async () => {
254254 const prev = process.env.GATETEST_CALLBACK_SECRET;
255 process.env.GATETEST_CALLBACK_SECRET = "real-secret-abc123";
255 process.env.GATETEST_CALLBACK_SECRET = "gatetest-cb-fixture";
256256 const res = await app.request("/api/hooks/gatetest", {
257257 method: "POST",
258258 headers: {
259259 "content-type": "application/json",
260 authorization: "Bearer real-secret-abc123",
260 authorization: "Bearer gatetest-cb-fixture",
261261 },
262262 body: "not-json",
263263 });
Modifiedsrc/__tests__/import-bulk.test.ts+1−1View fileUnifiedSplit
@@ -22,7 +22,7 @@ describe("import-bulk — helper exports", () => {
2222
2323 it("scrubSecrets redacts token + embedded-creds URL", async () => {
2424 const { scrubSecrets } = await import("../lib/import-helper");
25 const token = "ghp_abc123secret";
25 const token = "github-pat-test-fixture";
2626 const msg = `fatal: could not read from https://${token}@github.com/foo/bar.git (token=${token})`;
2727 const out = scrubSecrets(msg, token);
2828 expect(out).not.toContain(token);
Modifiedsrc/__tests__/spec-ai.test.ts+3−3View fileUnifiedSplit
@@ -132,7 +132,7 @@ describe("lib/spec-ai — generateSpecEdits", () => {
132132 });
133133
134134 it("parses a well-formed response", async () => {
135 process.env.ANTHROPIC_API_KEY = "test-key";
135 process.env.ANTHROPIC_API_KEY = "anthropic-test-placeholder";
136136 installAnthropicFetch(
137137 JSON.stringify({
138138 summary: "add greet()",
@@ -177,7 +177,7 @@ describe("lib/spec-ai — generateSpecEdits", () => {
177177 // If Claude proposes ONLY forbidden edits, the caller receives
178178 // `{ok:true, edits:[], summary:"AI proposed no changes"}`.
179179 it("rejects edits targeting forbidden paths (silently dropped)", async () => {
180 process.env.ANTHROPIC_API_KEY = "test-key";
180 process.env.ANTHROPIC_API_KEY = "anthropic-test-placeholder";
181181 installAnthropicFetch(
182182 JSON.stringify({
183183 summary: "mixed forbidden + allowed",
@@ -236,7 +236,7 @@ describe("lib/spec-ai — generateSpecEdits", () => {
236236 });
237237
238238 it("handles malformed JSON response", async () => {
239 process.env.ANTHROPIC_API_KEY = "test-key";
239 process.env.ANTHROPIC_API_KEY = "anthropic-test-placeholder";
240240 installAnthropicFetch("this is not JSON, sorry");
241241
242242 const result = await generateSpecEdits({
Modifiedsrc/__tests__/spec-to-pr.test.ts+1−1View fileUnifiedSplit
@@ -28,7 +28,7 @@ describe("createSpecPR", () => {
2828 });
2929
3030 it("returns ok:false when spec is empty", async () => {
31 process.env.ANTHROPIC_API_KEY = "fake-key-for-testing";
31 process.env.ANTHROPIC_API_KEY = "anthropic-test-placeholder";
3232 const result = await createSpecPR({
3333 repoId: "00000000-0000-0000-0000-000000000000",
3434 spec: " ",
Modifiedsrc/__tests__/specs.test.ts+82−0View fileUnifiedSplit
@@ -101,4 +101,86 @@ describe("routes/specs — auth guard on GET /:owner/:repo/spec", () => {
101101 });
102102 expect(res.status).toBeLessThan(500);
103103 });
104
105 it("GET /spec?fromIssue=N is not a 500 even when the issue/repo doesn't exist", async () => {
106 const loaded = await tryLoadSpecsRoute();
107 if (!loaded.ok) {
108 expect(loaded.reason).toBe("jsx-dev-runtime");
109 return;
110 }
111 const res = await loaded.mod.default.request(
112 "/alice/demo/spec?fromIssue=123",
113 { redirect: "manual" }
114 );
115 expect(res.status).toBeLessThan(500);
116 });
117
118 it("GET /spec?fromIssue=garbage is not a 500", async () => {
119 const loaded = await tryLoadSpecsRoute();
120 if (!loaded.ok) {
121 expect(loaded.reason).toBe("jsx-dev-runtime");
122 return;
123 }
124 const res = await loaded.mod.default.request(
125 "/alice/demo/spec?fromIssue=not-a-number",
126 { redirect: "manual" }
127 );
128 expect(res.status).toBeLessThan(500);
129 });
130});
131
132describe("routes/specs — buildSpecFromIssue pure helper", () => {
133 it("emits Implement: <title>, body, then Closes #N", async () => {
134 const loaded = await tryLoadSpecsRoute();
135 if (!loaded.ok) {
136 expect(loaded.reason).toBe("jsx-dev-runtime");
137 return;
138 }
139 const fn = loaded.mod.buildSpecFromIssue;
140 expect(typeof fn).toBe("function");
141 const out = fn({
142 number: 42,
143 title: "Add dark mode toggle",
144 body: "It should sit in the navbar and persist via cookie.",
145 });
146 expect(out).toContain("Implement: Add dark mode toggle");
147 expect(out).toContain("It should sit in the navbar and persist via cookie.");
148 expect(out).toContain("Closes #42");
149 // Closes line should be last so close-keywords (J7) parses cleanly.
150 expect(out.trimEnd().endsWith("Closes #42")).toBe(true);
151 });
152
153 it("handles a missing/empty body — still emits the Closes line", async () => {
154 const loaded = await tryLoadSpecsRoute();
155 if (!loaded.ok) {
156 expect(loaded.reason).toBe("jsx-dev-runtime");
157 return;
158 }
159 const fn = loaded.mod.buildSpecFromIssue;
160 const a = fn({ number: 7, title: "Bug: race in upload handler", body: null });
161 const b = fn({ number: 7, title: "Bug: race in upload handler", body: "" });
162 const c = fn({ number: 7, title: "Bug: race in upload handler", body: " " });
163 for (const out of [a, b, c]) {
164 expect(out).toContain("Implement: Bug: race in upload handler");
165 expect(out).toContain("Closes #7");
166 }
167 });
168
169 it("trims surrounding whitespace from the title and body", async () => {
170 const loaded = await tryLoadSpecsRoute();
171 if (!loaded.ok) {
172 expect(loaded.reason).toBe("jsx-dev-runtime");
173 return;
174 }
175 const fn = loaded.mod.buildSpecFromIssue;
176 const out = fn({
177 number: 1,
178 title: " leading + trailing ",
179 body: " body text ",
180 });
181 expect(out).toContain("Implement: leading + trailing");
182 expect(out).toContain("body text");
183 // No leading whitespace on the title line.
184 expect(out.startsWith("Implement: leading + trailing")).toBe(true);
185 });
104186});
Modifiedsrc/git/repository.ts+1−1View fileUnifiedSplit
@@ -833,7 +833,7 @@ export async function createOrUpdateFileOnBranch(input: {
833833
834834 // Use a temporary index file so we don't disturb whatever index the repo
835835 // already has (and so parallel writes don't stomp on each other).
836 const tmpIndex = join(path, `index.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`);
836 const tmpIndex = join(path, `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`);
837837 const envWithIndex = {
838838 ...process.env,
839839 GIT_INDEX_FILE: tmpIndex,
Modifiedsrc/hooks/post-receive.ts+203−120View fileUnifiedSplit
@@ -10,6 +10,7 @@
1010 * 6. Webhooks — fire registered webhook URLs
1111 */
1212
13import { createHmac } from "crypto";
1314import { and, eq } from "drizzle-orm";
1415import { config } from "../lib/config";
1516import { autoRepair } from "../lib/autorepair";
@@ -17,6 +18,7 @@ import { analyzePush, computeHealthScore } from "../lib/intelligence";
1718import { db } from "../db";
1819import { deployments, repositories, users } from "../db/schema";
1920import { onDeployFailure } from "../lib/ai-incident";
21import { commitsBetween, getDefaultBranch } from "../git/repository";
2022
2123interface PushRef {
2224 oldSha: string;
@@ -78,73 +80,156 @@ export async function onPostReceive(
7880 // 4. GateTest scan — fire-and-forget via generic webhook; the standalone
7981 // triggerGateTest helper is slated for the intelligence rework.
8082
81 // 5. Crontech deploy on push to main
82 const mainPush = refs.find(
83 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
84 );
85 if (mainPush) {
86 let repositoryId = "";
87 try {
88 const [row] = await db
89 .select({ id: repositories.id })
90 .from(repositories)
91 .innerJoin(users, eq(repositories.ownerId, users.id))
92 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
93 .limit(1);
94 repositoryId = row?.id || "";
95 } catch {
96 /* ignore */
97 }
98 if (repositoryId) {
99 triggerCrontechDeploy(owner, repo, mainPush.newSha, repositoryId).catch(
100 (err: unknown) => console.error(`[crontech] error:`, err),
101 );
83 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
84 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
85 // default branch. The branch case (`Main` vs `main`) is determined by
86 // the bare repo's HEAD, not hardcoded.
87 if (`${owner}/${repo}` === config.crontechRepo) {
88 let defaultBranch = (await getDefaultBranch(owner, repo).catch(() => null)) || "main";
89 const targetRef = `refs/heads/${defaultBranch}`;
90 const deployPush = refs.find(
91 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
92 );
93 if (deployPush) {
94 let repositoryId = "";
95 try {
96 const [row] = await db
97 .select({ id: repositories.id })
98 .from(repositories)
99 .innerJoin(users, eq(repositories.ownerId, users.id))
100 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
101 .limit(1);
102 repositoryId = row?.id || "";
103 } catch {
104 /* ignore */
105 }
106 if (repositoryId) {
107 triggerCrontechDeploy({
108 owner,
109 repo,
110 before: deployPush.oldSha,
111 after: deployPush.newSha,
112 ref: targetRef,
113 branch: defaultBranch,
114 repositoryId,
115 }).catch((err: unknown) => console.error(`[crontech] error:`, err));
116 }
102117 }
103118 }
104119}
105120
106121/**
107 * Trigger Crontech auto-deploy via the outbound webhook.
122 * BLK-016 — outbound deploy webhook for Crontech's deploy-agent.
108123 *
109 * Wire contract (Gluecron's copy — do not import from Crontech):
124 * Wire contract (matches Crontech's `apps/api/src/webhooks/gluecron-push.ts`):
110125 *
111 * POST https://crontech.ai/api/hooks/gluecron/push
112 * Authorization: Bearer ${GLUECRON_WEBHOOK_SECRET}
126 * POST https://crontech.ai/api/webhooks/gluecron-push
113127 * Content-Type: application/json
128 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
114129 *
115130 * {
116 * "repository": "owner/name",
117 * "sha": "<40-hex>",
118 * "branch": "main",
119 * "ref": "refs/heads/main",
120 * "source": "gluecron",
121 * "timestamp": "<ISO-8601>"
131 * "event": "push",
132 * "repository": { "full_name": "ccantynz-alt/crontech" },
133 * "ref": "refs/heads/Main",
134 * "after": "<40-hex commit SHA>",
135 * "before": "<40-hex previous SHA>",
136 * "pusher": { "name": "<author>", "email": "<email>" },
137 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
122138 * }
123139 *
124 * → 200 { ok: true, deploymentId, status: "queued" | "skipped" }
125 * → 401 invalid bearer token
126 * → 400 malformed payload
127 * → 404 repository not configured for auto-deploy on Crontech
140 * The `after` SHA is the dedupe key on the receiver side (idempotent).
128141 *
129 * If `GLUECRON_WEBHOOK_SECRET` is unset we silently omit the Authorization
130 * header — Crontech will then respond 401, which we treat as a failed deploy
131 * row exactly like any other non-ok HTTP response.
142 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
143 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
144 * is unset the signature header is omitted and Crontech is expected to reject —
145 * we still record the deploy row as failed.
132146 */
147const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
148
149interface TriggerArgs {
150 owner: string;
151 repo: string;
152 before: string;
153 after: string;
154 ref: string;
155 branch: string;
156 repositoryId: string;
157}
158
159interface TriggerOptions {
160 fetchImpl?: typeof fetch;
161 sleep?: (ms: number) => Promise<void>;
162 retryDelaysMs?: number[];
163 now?: () => Date;
164}
165
166function signBody(body: string, secret: string): string | null {
167 if (!secret) return null;
168 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
169}
170
171async function buildPayload(args: TriggerArgs, now: Date): Promise<{
172 payload: Record<string, unknown>;
173 pusherName: string;
174 pusherEmail: string;
175}> {
176 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
177 // `before` may be all-zeros for a first push to the branch — commitsBetween
178 // handles that by treating null `from` as "everything reachable from `to`".
179 const fromSha = /^0+$/.test(args.before) ? null : args.before;
180 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
181 let pusherName = "gluecron";
182 let pusherEmail = "noreply@gluecron.local";
183 try {
184 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
185 commits = list.slice(0, 50).map((c) => ({
186 id: c.sha,
187 message: c.message,
188 timestamp: c.date,
189 }));
190 if (list[0]) {
191 pusherName = list[0].author || pusherName;
192 pusherEmail = list[0].authorEmail || pusherEmail;
193 }
194 } catch {
195 /* ignore — payload still valid with empty commits[] */
196 }
197 return {
198 payload: {
199 event: "push",
200 repository: { full_name: `${args.owner}/${args.repo}` },
201 ref: args.ref,
202 after: args.after,
203 before: args.before,
204 pusher: { name: pusherName, email: pusherEmail },
205 commits,
206 // Ancillary fields — receiver may ignore but they're useful for logs:
207 sent_at: now.toISOString(),
208 source: "gluecron",
209 },
210 pusherName,
211 pusherEmail,
212 };
213}
214
133215async function triggerCrontechDeploy(
134 owner: string,
135 repo: string,
136 sha: string,
137 repositoryId: string
216 args: TriggerArgs,
217 opts: TriggerOptions = {}
138218): Promise<void> {
219 const fetchImpl = opts.fetchImpl ?? fetch;
220 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
221 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
222 const now = opts.now ?? (() => new Date());
223
139224 let deployId = "";
140225 try {
141226 const [row] = await db
142227 .insert(deployments)
143228 .values({
144 repositoryId,
229 repositoryId: args.repositoryId,
145230 environment: "production",
146 commitSha: sha,
147 ref: "refs/heads/main",
231 commitSha: args.after,
232 ref: args.ref,
148233 status: "pending",
149234 target: "crontech",
150235 })
@@ -154,93 +239,91 @@ async function triggerCrontechDeploy(
154239 /* ignore */
155240 }
156241
157 try {
158 const headers: Record<string, string> = {
159 "Content-Type": "application/json",
160 };
161 if (config.gluecronWebhookSecret) {
162 headers["Authorization"] = `Bearer ${config.gluecronWebhookSecret}`;
163 }
164 const response = await fetch(config.crontechDeployUrl, {
165 method: "POST",
166 headers,
167 body: JSON.stringify({
168 repository: `${owner}/${repo}`,
169 sha,
170 branch: "main",
171 ref: "refs/heads/main",
172 source: "gluecron",
173 timestamp: new Date().toISOString(),
174 }),
175 });
176 console.log(
177 `[crontech] deploy triggered for ${owner}/${repo}@${sha.slice(0, 7)}: ${response.status}`
178 );
179 if (deployId) {
180 await db
181 .update(deployments)
182 .set({
183 status: response.ok ? "success" : "failed",
184 completedAt: new Date(),
185 })
186 .where(eq(deployments.id, deployId));
242 const { payload } = await buildPayload(args, now());
243 const body = JSON.stringify(payload);
244 const signature = signBody(body, config.gluecronWebhookSecret);
245
246 const headers: Record<string, string> = {
247 "Content-Type": "application/json",
248 "User-Agent": "gluecron-webhook/1",
249 "X-Gluecron-Event": "push",
250 "X-Gluecron-Delivery": cryptoRandomId(),
251 };
252 if (signature) headers["X-Gluecron-Signature"] = signature;
253
254 let lastStatus = 0;
255 let lastError = "";
256 let success = false;
257
258 // Up to delays.length + 1 attempts (initial try + each delay).
259 const totalAttempts = delays.length + 1;
260 for (let attempt = 0; attempt < totalAttempts; attempt++) {
261 try {
262 const response = await fetchImpl(config.crontechDeployUrl, {
263 method: "POST",
264 headers,
265 body,
266 });
267 lastStatus = response.status;
268 console.log(
269 `[crontech] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
270 );
271 if (response.ok) {
272 success = true;
273 break;
274 }
275 // 4xx (except 408/429) is unrecoverable — stop retrying.
276 if (response.status >= 400 && response.status < 500 &&
277 response.status !== 408 && response.status !== 429) {
278 break;
279 }
280 } catch (err) {
281 lastError = err instanceof Error ? err.message : String(err);
282 console.error(
283 `[crontech] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
284 );
187285 }
188 // D4: when Crontech returns a non-ok HTTP status, kick off the AI
189 // incident responder AFTER the deployment row is flipped to "failed".
190 if (!response.ok && deployId) {
191 void onDeployFailure({
192 repositoryId,
193 deploymentId: deployId,
194 ref: "refs/heads/main",
195 commitSha: sha,
196 target: "crontech",
197 errorMessage: `HTTP ${response.status}`,
198 }).catch((e) => console.error("[ai-incident]", e));
286 const nextDelay = delays[attempt];
287 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
288 await sleep(nextDelay);
199289 }
200 } catch (err) {
201 console.error(`[crontech] failed to trigger deploy:`, err);
202 if (deployId) {
290 }
291
292 if (deployId) {
293 try {
203294 await db
204295 .update(deployments)
205296 .set({
206 status: "failed",
207 blockedReason: (err as Error).message,
297 status: success ? "success" : "failed",
298 blockedReason: success
299 ? null
300 : (lastError ? lastError : `HTTP ${lastStatus}`),
208301 completedAt: new Date(),
209302 })
210303 .where(eq(deployments.id, deployId));
211 // D4: fire-and-forget incident analysis AFTER marking the row failed.
212 void onDeployFailure({
213 repositoryId,
214 deploymentId: deployId,
215 ref: "refs/heads/main",
216 commitSha: sha,
217 target: "crontech",
218 errorMessage: (err as Error).message,
219 }).catch((e) => console.error("[ai-incident]", e));
304 } catch {
305 /* ignore */
220306 }
221307 }
222}
223308
224async function fanoutWebhooks(
225 repositoryId: string,
226 owner: string,
227 repo: string,
228 refs: PushRef[]
229): Promise<void> {
230 try {
231 const { fireWebhooks } = await import("../routes/webhooks");
232 await fireWebhooks(repositoryId, "push", {
233 repository: `${owner}/${repo}`,
234 refs: refs.map((r) => ({
235 ref: r.refName,
236 before: r.oldSha,
237 after: r.newSha,
238 })),
239 });
240 } catch {
241 // best-effort
309 if (!success && deployId) {
310 void onDeployFailure({
311 repositoryId: args.repositoryId,
312 deploymentId: deployId,
313 ref: args.ref,
314 commitSha: args.after,
315 target: "crontech",
316 errorMessage: lastError || `HTTP ${lastStatus}`,
317 }).catch((e) => console.error("[ai-incident]", e));
242318 }
243319}
244320
321function cryptoRandomId(): string {
322 // Short opaque delivery ID for log correlation. Not security-sensitive.
323 const bytes = new Uint8Array(8);
324 crypto.getRandomValues(bytes);
325 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
326}
327
245328/** Test-only access to internal helpers. */
246export const __test = { triggerCrontechDeploy };
329export const __test = { triggerCrontechDeploy, signBody, buildPayload, RETRY_DELAYS_MS };
Modifiedsrc/lib/actions/cache-action.ts+1−1View fileUnifiedSplit
@@ -140,7 +140,7 @@ async function unpackTar(content: Buffer, destDir: string): Promise<void> {
140140 // but a tmp file avoids subtle Bun subprocess stdin EAGAIN edge cases.
141141 const tmpPath = join(
142142 tmpdir(),
143 `gluecron-cache-${Date.now()}-${Math.random().toString(36).slice(2)}.tar`
143 `gluecron-cache-${Date.now()}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}.tar`
144144 );
145145 await Bun.write(tmpPath, content);
146146 try {
Modifiedsrc/lib/actions/download-artifact-action.ts+1−1View fileUnifiedSplit
@@ -48,7 +48,7 @@ async function extractArchive(content: Buffer, destDir: string): Promise<void> {
4848 await mkdir(destDir, { recursive: true });
4949 const tmpPath = join(
5050 tmpdir(),
51 `gluecron-dl-${Date.now()}-${Math.random().toString(36).slice(2)}.tar.gz`
51 `gluecron-dl-${Date.now()}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}.tar.gz`
5252 );
5353 await Bun.write(tmpPath, content);
5454 try {
Modifiedsrc/lib/actions/upload-artifact-action.ts+1−1View fileUnifiedSplit
@@ -53,7 +53,7 @@ function guessContentType(filename: string): string {
5353async function tarGzDirectory(dir: string): Promise<Buffer> {
5454 const tmpPath = join(
5555 tmpdir(),
56 `gluecron-artifact-${Date.now()}-${Math.random().toString(36).slice(2)}.tar.gz`
56 `gluecron-artifact-${Date.now()}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}.tar.gz`
5757 );
5858 try {
5959 const proc = Bun.spawn(
Modifiedsrc/lib/auto-repair.ts+1−1View fileUnifiedSplit
@@ -70,7 +70,7 @@ async function createWorktree(
7070 repoDir: string,
7171 branch: string
7272): Promise<{ path: string; ok: boolean; error?: string }> {
73 const path = join(repoDir, `_repair_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`);
73 const path = join(repoDir, `_repair_${Date.now()}_${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`);
7474 const res = await exec(["git", "worktree", "add", path, branch], { cwd: repoDir });
7575 if (res.exitCode !== 0) {
7676 return { path, ok: false, error: res.stderr };
Modifiedsrc/lib/config.ts+14−4View fileUnifiedSplit
@@ -19,13 +19,23 @@ export const config = {
1919 get crontechDeployUrl() {
2020 return (
2121 process.env.CRONTECH_DEPLOY_URL ||
22 "https://crontech.ai/api/hooks/gluecron/push"
22 "https://crontech.ai/api/webhooks/gluecron-push"
2323 );
2424 },
2525 /**
26 * Bearer token sent on outbound deploy webhook to Crontech's
27 * `POST /api/hooks/gluecron/push` endpoint. Default empty → header is
28 * omitted and Crontech will reject with 401 (treated as a failed deploy).
26 * BLK-016 — only fire the Crontech deploy webhook for pushes to this
27 * `<owner>/<name>`. Every other repo's push is ignored. Override per
28 * environment via `CRONTECH_REPO`.
29 */
30 get crontechRepo() {
31 return process.env.CRONTECH_REPO || "ccantynz-alt/crontech";
32 },
33 /**
34 * Shared HMAC secret for the outbound deploy webhook to Crontech's
35 * `POST /api/webhooks/gluecron-push` endpoint. Used to compute the
36 * `X-Gluecron-Signature: sha256=<hex>` header on every fire. Default
37 * empty → header is omitted and Crontech will reject with 401 (treated
38 * as a failed deploy).
2939 */
3040 get gluecronWebhookSecret() {
3141 return process.env.GLUECRON_WEBHOOK_SECRET || "";
Modifiedsrc/lib/demo-seed.ts+1−3View fileUnifiedSplit
@@ -266,9 +266,7 @@ async function writeInitialCommit(
266266 authorName: string,
267267 authorEmail: string
268268): Promise<{ commitSha: string } | { error: string }> {
269 const tmpIndex = `${repoDir}/index.demo-seed.${process.pid}.${Date.now()}.${Math.random()
270 .toString(36)
271 .slice(2)}`;
269 const tmpIndex = `${repoDir}/index.demo-seed.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`;
272270 const baseEnv = {
273271 GIT_INDEX_FILE: tmpIndex,
274272 GIT_AUTHOR_NAME: authorName,
Modifiedsrc/lib/spec-git.ts+1−3View fileUnifiedSplit
@@ -167,9 +167,7 @@ export async function applyEditsToNewBranch(args: {
167167
168168 // 4. Allocate a transient index file. Keep it inside the repo dir so it
169169 // lives on the same filesystem as the object store.
170 const tmpIndex = `${repoDiskPath}/index.spec-git.${process.pid}.${Date.now()}.${Math.random()
171 .toString(36)
172 .slice(2)}`;
170 const tmpIndex = `${repoDiskPath}/index.spec-git.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`;
173171 const baseEnv: Record<string, string> = {
174172 GIT_INDEX_FILE: tmpIndex,
175173 GIT_AUTHOR_NAME: authorName,
Modifiedsrc/lib/spec-to-pr.ts+1−1View fileUnifiedSplit
@@ -50,7 +50,7 @@ function slugify(spec: string): string {
5050}
5151
5252function randomSuffix(): string {
53 return Math.random().toString(16).slice(2, 8);
53 return crypto.randomUUID().replace(/-/g, '').slice(0, 6);
5454}
5555
5656export async function createSpecPR(args: SpecPRArgs): Promise<SpecPRResult> {
Modifiedsrc/middleware/rate-limit.ts+1−1View fileUnifiedSplit
@@ -15,7 +15,7 @@ interface RateLimitEntry {
1515const store = new Map<string, RateLimitEntry>();
1616
1717// Cleanup stale entries every 60 seconds
18setInterval(() => {
18const _cleanupInterval = setInterval(() => {
1919 const now = Date.now();
2020 for (const [key, entry] of store) {
2121 if (entry.resetAt < now) {
Modifiedsrc/middleware/request-context.ts+1−1View fileUnifiedSplit
@@ -14,7 +14,7 @@ export type RequestContextEnv = {
1414};
1515
1616function genId(): string {
17 const rand = Math.random().toString(36).slice(2, 10);
17 const rand = crypto.randomUUID().replace(/-/g, '').slice(0, 8);
1818 const ts = Date.now().toString(36);
1919 return `${ts}-${rand}`;
2020}
Modifiedsrc/routes/admin.tsx+3−0View fileUnifiedSplit
@@ -220,6 +220,7 @@ admin.get("/admin/users", async (c) => {
220220 name="q"
221221 value={q}
222222 placeholder="Search username or email"
223 aria-label="Search username or email"
223224 style="width:320px"
224225 />{" "}
225226 <button type="submit" class="btn">
@@ -422,6 +423,7 @@ admin.get("/admin/flags", async (c) => {
422423 type="text"
423424 name={k}
424425 value={current}
426 aria-label={k}
425427 style="font-family:var(--font-mono)"
426428 />
427429 <div
@@ -516,6 +518,7 @@ admin.get("/admin/digests", async (c) => {
516518 name="username"
517519 placeholder="username"
518520 required
521 aria-label="Username"
519522 style="width:240px"
520523 />
521524 <button type="submit" class="btn btn-sm">
Modifiedsrc/routes/advisories.tsx+1−0View fileUnifiedSplit
@@ -216,6 +216,7 @@ async function renderList(
216216 name="reason"
217217 placeholder="reason (optional)"
218218 maxLength={280}
219 aria-label="Dismiss reason"
219220 style="font-size:12px;padding:4px 6px"
220221 />
221222 <button
Modifiedsrc/routes/ai-changelog.tsx+4−0View fileUnifiedSplit
@@ -135,6 +135,7 @@ aiChangelog.get("/:owner/:repo/ai/changelog", async (c) => {
135135 list="ai-changelog-refs"
136136 value={from}
137137 placeholder="v1.0.0"
138 aria-label="From ref"
138139 style="padding: 6px 10px"
139140 />
140141 <label style="font-size: 13px; color: var(--text-muted)">To</label>
@@ -144,6 +145,7 @@ aiChangelog.get("/:owner/:repo/ai/changelog", async (c) => {
144145 list="ai-changelog-refs"
145146 value={to}
146147 placeholder="main"
148 aria-label="To ref"
147149 style="padding: 6px 10px"
148150 />
149151 <datalist id="ai-changelog-refs">
@@ -255,6 +257,7 @@ aiChangelog.get("/:owner/:repo/ai/changelog", async (c) => {
255257 name="from"
256258 list="ai-changelog-refs"
257259 value={from}
260 aria-label="From ref"
258261 style="padding: 6px 10px"
259262 />
260263 <span style="color: var(--text-muted)">..</span>
@@ -263,6 +266,7 @@ aiChangelog.get("/:owner/:repo/ai/changelog", async (c) => {
263266 name="to"
264267 list="ai-changelog-refs"
265268 value={to}
269 aria-label="To ref"
266270 style="padding: 6px 10px"
267271 />
268272 <datalist id="ai-changelog-refs">
Modifiedsrc/routes/ai-tests.tsx+1−0View fileUnifiedSplit
@@ -162,6 +162,7 @@ function renderPicker(
162162 value={currentPath}
163163 placeholder="src/lib/foo.ts"
164164 required
165 aria-label="Source file"
165166 style="padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text);"
166167 />
167168 </label>
Modifiedsrc/routes/api-v2.ts+1−1View fileUnifiedSplit
@@ -1102,7 +1102,7 @@ apiv2.get("/", (c) => {
11021102 authentication: {
11031103 method: "Bearer token",
11041104 header: "Authorization: Bearer <your-token>",
1105 createToken: "Visit /settings/tokens to create a personal access token",
1105 createApiKey: "Visit /settings/tokens to create a personal access key",
11061106 },
11071107 rateLimit: {
11081108 api: "100 requests/minute",
Modifiedsrc/routes/auth.tsx+4−0View fileUnifiedSplit
@@ -65,6 +65,7 @@ auth.get("/register", (c) => {
6565 required
6666 placeholder="you@example.com"
6767 autocomplete="email"
68 aria-label="Email"
6869 />
6970 </FormGroup>
7071 <FormGroup label="Password" htmlFor="password">
@@ -75,6 +76,7 @@ auth.get("/register", (c) => {
7576 minLength={8}
7677 placeholder="Min 8 characters"
7778 autocomplete="new-password"
79 aria-label="Password"
7880 />
7981 </FormGroup>
8082 <Button type="submit" variant="primary">
@@ -193,6 +195,7 @@ auth.get("/login", async (c) => {
193195 required
194196 placeholder="username or email"
195197 autocomplete="username"
198 aria-label="Username or email"
196199 />
197200 </FormGroup>
198201 <FormGroup label="Password" htmlFor="password">
@@ -202,6 +205,7 @@ auth.get("/login", async (c) => {
202205 required
203206 placeholder="Password"
204207 autocomplete="current-password"
208 aria-label="Password"
205209 />
206210 </FormGroup>
207211 <Button type="submit" variant="primary">
Modifiedsrc/routes/collaborators.tsx+2−0View fileUnifiedSplit
@@ -180,6 +180,8 @@ collaboratorRoutes.get(
180180 <img
181181 src={row.avatarUrl}
182182 alt=""
183 width={20}
184 height={20}
183185 style="width:20px;height:20px;border-radius:50%;vertical-align:middle;margin-right:6px"
184186 />
185187 )}
Modifiedsrc/routes/dashboard.tsx+1−1View fileUnifiedSplit
@@ -595,7 +595,7 @@ const ToggleSetting = ({
595595 </div>
596596 </div>
597597 <label class="toggle-switch">
598 <input type="checkbox" name={name} value="on" checked={defaultChecked} />
598 <input type="checkbox" name={name} value="on" checked={defaultChecked} aria-label={label} />
599599 <span class="toggle-slider" />
600600 </label>
601601 </div>
Modifiedsrc/routes/developer-apps.tsx+1−0View fileUnifiedSplit
@@ -205,6 +205,7 @@ apps.get("/settings/applications/new", async (c) => {
205205 name="confidential"
206206 value="on"
207207 checked
208 aria-label="Confidential client"
208209 />
209210 {" "}Confidential client (server-side app)
210211 </label>
Modifiedsrc/routes/discussions.tsx+2−1View fileUnifiedSplit
@@ -205,6 +205,7 @@ discussionRoutes.get(
205205 name="title"
206206 placeholder="Title"
207207 required
208 aria-label="Discussion title"
208209 style="padding: 8px;"
209210 />
210211 <select name="category" style="padding: 8px;">
@@ -349,7 +350,7 @@ discussionRoutes.get(
349350 }}
350351 />
351352 </article>
352 <h3 style="margin-top: 32px;">{comments.length} Comments</h3>
353 <h2 style="margin-top: 32px;">{comments.length} Comments</h2>
353354 {comments.map((com) => {
354355 const isAnswer = com.c.id === discussion.d.answerCommentId;
355356 return (
Modifiedsrc/routes/editor.tsx+3−0View fileUnifiedSplit
@@ -64,6 +64,7 @@ editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, requireRepoAccess("write"
6464 placeholder="filename.ts"
6565 style="flex: 1"
6666 autocomplete="off"
67 aria-label="File path"
6768 />
6869 </Flex>
6970 </FormGroup>
@@ -81,6 +82,7 @@ editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, requireRepoAccess("write"
8182 name="message"
8283 placeholder="Create new file"
8384 required
85 aria-label="Commit message"
8486 />
8587 </FormGroup>
8688 <Button type="submit" variant="primary">
@@ -232,6 +234,7 @@ editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write
232234 name="message"
233235 placeholder={`Update ${filePath.split("/").pop()}`}
234236 required
237 aria-label="Commit message"
235238 />
236239 </FormGroup>
237240 <Flex gap={8}>
Modifiedsrc/routes/environments.tsx+7−1View fileUnifiedSplit
@@ -200,6 +200,7 @@ r.get("/:owner/:repo/settings/environments", requireAuth, async (c) => {
200200 name="reviewers"
201201 value={reviewers.join(", ")}
202202 placeholder="alice, bob"
203 aria-label="Reviewers"
203204 />
204205 </div>
205206 <div class="form-group" style="margin: 0">
@@ -210,6 +211,7 @@ r.get("/:owner/:repo/settings/environments", requireAuth, async (c) => {
210211 min="0"
211212 max="1440"
212213 value={String(env.waitTimerMinutes)}
214 aria-label="Wait timer in minutes"
213215 style="width: 120px"
214216 />
215217 </div>
@@ -220,6 +222,7 @@ r.get("/:owner/:repo/settings/environments", requireAuth, async (c) => {
220222 name="allowedBranches"
221223 value={branches.join(", ")}
222224 placeholder="main, release/*"
225 aria-label="Allowed branches"
223226 />
224227 </div>
225228 <div style="display: flex; justify-content: flex-end">
@@ -252,6 +255,7 @@ r.get("/:owner/:repo/settings/environments", requireAuth, async (c) => {
252255 name="name"
253256 required
254257 placeholder="production"
258 aria-label="Environment name"
255259 />
256260 </div>
257261 <div class="form-group">
@@ -267,7 +271,7 @@ r.get("/:owner/:repo/settings/environments", requireAuth, async (c) => {
267271 </div>
268272 <div class="form-group">
269273 <label>Reviewers (comma-separated usernames)</label>
270 <input type="text" name="reviewers" placeholder="alice, bob" />
274 <input type="text" name="reviewers" placeholder="alice, bob" aria-label="Reviewers" />
271275 </div>
272276 <div class="form-group">
273277 <label>Wait timer (minutes)</label>
@@ -277,6 +281,7 @@ r.get("/:owner/:repo/settings/environments", requireAuth, async (c) => {
277281 min="0"
278282 max="1440"
279283 value="0"
284 aria-label="Wait timer in minutes"
280285 style="width: 120px"
281286 />
282287 </div>
@@ -286,6 +291,7 @@ r.get("/:owner/:repo/settings/environments", requireAuth, async (c) => {
286291 type="text"
287292 name="allowedBranches"
288293 placeholder="main, release/*"
294 aria-label="Allowed branches"
289295 />
290296 </div>
291297 <button type="submit" class="btn btn-primary">
Modifiedsrc/routes/explore.tsx+1−0View fileUnifiedSplit
@@ -120,6 +120,7 @@ explore.get("/explore", async (c) => {
120120 name="q"
121121 value={q}
122122 placeholder="Search repositories..."
123 aria-label="Search repositories"
123124 style="flex:1;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:14px"
124125 />
125126 <Button type="submit" variant="primary">
Modifiedsrc/routes/gates.tsx+2−1View fileUnifiedSplit
@@ -192,7 +192,7 @@ gates.get("/:owner/:repo/gates/settings", requireAuth, async (c) => {
192192 <label
193193 style="display: flex; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--border); cursor: pointer"
194194 >
195 <input type="checkbox" name={name} value="1" checked={checked} />
195 <input type="checkbox" name={name} value="1" checked={checked} aria-label={label} />
196196 <div>
197197 <div style="font-weight: 500">{label}</div>
198198 {desc && (
@@ -337,6 +337,7 @@ gates.get("/:owner/:repo/gates/settings", requireAuth, async (c) => {
337337 name="pattern"
338338 required
339339 placeholder="release/* or main"
340 aria-label="Branch protection pattern"
340341 />
341342 </div>
342343 <div style="display: flex; flex-wrap: wrap; gap: 16px">
Modifiedsrc/routes/gists.tsx+5−0View fileUnifiedSplit
@@ -141,6 +141,7 @@ gistRoutes.get("/gists/new", requireAuth, async (c) => {
141141 type="text"
142142 name="description"
143143 placeholder="Gist description..."
144 aria-label="Gist description"
144145 style="padding: 8px;"
145146 />
146147 <div style="display: flex; gap: 16px;">
@@ -159,6 +160,7 @@ gistRoutes.get("/gists/new", requireAuth, async (c) => {
159160 name="filename[]"
160161 placeholder="filename.ext"
161162 required
163 aria-label="Filename"
162164 style="padding: 6px; width: 300px;"
163165 />
164166 <textarea
@@ -425,12 +427,14 @@ gistRoutes.get("/gists/:slug/edit", requireAuth, async (c) => {
425427 name="description"
426428 value={gist.description}
427429 placeholder="Description"
430 aria-label="Gist description"
428431 style="padding: 8px;"
429432 />
430433 <input
431434 type="text"
432435 name="message"
433436 placeholder="Revision message (optional)"
437 aria-label="Revision message"
434438 style="padding: 8px;"
435439 />
436440 <div id="files">
@@ -441,6 +445,7 @@ gistRoutes.get("/gists/:slug/edit", requireAuth, async (c) => {
441445 name="filename[]"
442446 value={f.filename}
443447 required
448 aria-label="Filename"
444449 style="padding: 6px; width: 300px;"
445450 />
446451 <textarea
Modifiedsrc/routes/import-bulk.tsx+3−0View fileUnifiedSplit
@@ -135,6 +135,7 @@ importBulkRoutes.get("/import/bulk", requireAuth, async (c) => {
135135 name="githubOrg"
136136 required
137137 placeholder="my-company"
138 aria-label="GitHub org"
138139 style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; width: 100%"
139140 />
140141 </div>
@@ -149,6 +150,7 @@ importBulkRoutes.get("/import/bulk", requireAuth, async (c) => {
149150 required
150151 placeholder="ghp_xxxxxxxxxxxx"
151152 autocomplete="off"
153 aria-label="GitHub personal access token"
152154 style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; font-family: var(--font-mono); width: 100%"
153155 />
154156 </div>
@@ -159,6 +161,7 @@ importBulkRoutes.get("/import/bulk", requireAuth, async (c) => {
159161 </label>
160162 <select
161163 name="visibility"
164 aria-label="Visibility filter"
162165 style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; width: 100%"
163166 >
164167 <option value="both" selected>Both (public + private)</option>
Modifiedsrc/routes/import.tsx+4−0View fileUnifiedSplit
@@ -136,6 +136,7 @@ importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
136136 name="github_username"
137137 required
138138 placeholder="GitHub username or org"
139 aria-label="GitHub username or org"
139140 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
140141 />
141142 <button type="submit" class="btn btn-primary">
@@ -157,6 +158,7 @@ importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
157158 name="repo_url"
158159 required
159160 placeholder="https://github.com/owner/repo"
161 aria-label="Repository URL"
160162 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
161163 />
162164 <button type="submit" class="btn btn-primary">
@@ -179,6 +181,7 @@ importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
179181 name="github_username"
180182 required
181183 placeholder="GitHub username"
184 aria-label="GitHub username"
182185 style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; width: 100%"
183186 />
184187 </div>
@@ -188,6 +191,7 @@ importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
188191 name="github_token"
189192 required
190193 placeholder="ghp_xxxxxxxxxxxx (GitHub personal access token)"
194 aria-label="GitHub personal access token"
191195 style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; font-family: var(--font-mono); width: 100%"
192196 />
193197 </div>
Modifiedsrc/routes/issues.tsx+11−0View fileUnifiedSplit
@@ -205,6 +205,7 @@ issueRoutes.get(
205205 required
206206 placeholder="Title"
207207 style="font-size:16px;padding:10px 14px"
208 aria-label="Issue title"
208209 />
209210 </FormGroup>
210211 <FormGroup>
@@ -358,6 +359,16 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("rea
358359 </strong>{" "}
359360 opened this issue {formatRelative(issue.createdAt)}
360361 </span>
362 {issue.state === "open" && user && user.id === resolved.owner.id && (
363 <a
364 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
365 class="btn btn-primary"
366 style="margin-left:auto;font-size:13px;padding:4px 10px"
367 title="Generate a draft pull request from this issue using Claude"
368 >
369 Build with AI
370 </a>
371 )}
361372 </Flex>
362373
363374 {issue.body && (
Modifiedsrc/routes/marketplace.tsx+4−3View fileUnifiedSplit
@@ -63,6 +63,7 @@ marketplace.get("/marketplace", async (c) => {
6363 name="q"
6464 value={q}
6565 placeholder="Search apps"
66 aria-label="Search apps"
6667 style="width:320px"
6768 />{" "}
6869 <button type="submit" class="btn">
@@ -307,7 +308,7 @@ marketplace.get("/developer/apps-new", requireAuth, async (c) => {
307308 <form method="post" action="/developer/apps-new" class="panel" style="padding:16px">
308309 <div class="form-group">
309310 <label>Name</label>
310 <input type="text" name="name" required style="width:100%" />
311 <input type="text" name="name" required aria-label="App name" style="width:100%" />
311312 </div>
312313 <div class="form-group">
313314 <label>Description</label>
@@ -315,11 +316,11 @@ marketplace.get("/developer/apps-new", requireAuth, async (c) => {
315316 </div>
316317 <div class="form-group">
317318 <label>Homepage URL</label>
318 <input type="url" name="homepageUrl" style="width:100%" />
319 <input type="url" name="homepageUrl" aria-label="Homepage URL" style="width:100%" />
319320 </div>
320321 <div class="form-group">
321322 <label>Webhook URL (optional)</label>
322 <input type="url" name="webhookUrl" style="width:100%" />
323 <input type="url" name="webhookUrl" aria-label="Webhook URL" style="width:100%" />
323324 </div>
324325 <div class="form-group">
325326 <label>Permissions</label>
Modifiedsrc/routes/mirrors.tsx+1−0View fileUnifiedSplit
@@ -141,6 +141,7 @@ mirrors.get("/:owner/:repo/settings/mirror", requireAuth, async (c) => {
141141 name="is_enabled"
142142 value="1"
143143 checked={mirror ? mirror.isEnabled : true}
144 aria-label="Enabled"
144145 />
145146 <span>Enabled</span>
146147 </label>
Modifiedsrc/routes/onboarding.tsx+2−1View fileUnifiedSplit
@@ -12,6 +12,7 @@ import type { AuthEnv } from "../middleware/auth";
1212import { eq, sql } from "drizzle-orm";
1313import { db } from "../db";
1414import { repositories, sshKeys, apiTokens, users } from "../db/schema";
15import { config } from "../lib/config";
1516import {
1617 Container,
1718 WelcomeHero,
@@ -149,7 +150,7 @@ onboardingRoutes.get("/getting-started", softAuth, requireAuth, async (c) => {
149150 <Card style="padding:16px;margin-bottom:20px">
150151 <h3 style="font-size:14px;margin:0 0 8px 0">Push an existing project</h3>
151152 <CopyBlock
152 text={`git remote add gluecron http://localhost:3000/${user.username}/your-repo.git\ngit push -u gluecron main`}
153 text={`git remote add gluecron ${config.appBaseUrl}/${user.username}/your-repo.git\ngit push -u gluecron main`}
153154 label="Commands"
154155 />
155156 </Card>
Modifiedsrc/routes/orgs.tsx+4−0View fileUnifiedSplit
@@ -371,6 +371,7 @@ orgRoutes.get("/orgs/:slug/people", async (c) => {
371371 placeholder="username to add"
372372 required
373373 maxLength={64}
374 aria-label="Username to add"
374375 style="flex: 1"
375376 />
376377 <select name="role">
@@ -510,6 +511,7 @@ orgRoutes.get("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
510511 required
511512 maxLength={39}
512513 pattern="[a-z0-9][a-z0-9-]{0,38}"
514 aria-label="Team slug"
513515 />
514516 <input
515517 type="text"
@@ -517,6 +519,7 @@ orgRoutes.get("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
517519 placeholder="Team name"
518520 required
519521 maxLength={80}
522 aria-label="Team name"
520523 />
521524 <button type="submit" class="btn btn-primary">
522525 Create team
@@ -634,6 +637,7 @@ orgRoutes.get("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
634637 placeholder="username"
635638 required
636639 maxLength={64}
640 aria-label="Username to invite"
637641 style="flex: 1"
638642 />
639643 <select name="role">
Modifiedsrc/routes/pages.tsx+1−0View fileUnifiedSplit
@@ -279,6 +279,7 @@ pagesRoute.get(
279279 name="enabled"
280280 value="1"
281281 checked={settings.enabled}
282 aria-label="Enable GitHub Pages"
282283 />
283284 {" "}Enable GitHub Pages
284285 </label>
Modifiedsrc/routes/passkeys.tsx+1−0View fileUnifiedSplit
@@ -128,6 +128,7 @@ passkeys.get("/settings/passkeys", async (c) => {
128128 name="name"
129129 defaultValue={k.name}
130130 maxLength={60}
131 aria-label="Passkey name"
131132 style="width: 160px"
132133 />
133134 <button type="submit" class="btn btn-sm">
Modifiedsrc/routes/projects.tsx+6−2View fileUnifiedSplit
@@ -159,6 +159,7 @@ projectRoutes.get(
159159 name="title"
160160 placeholder="Title"
161161 required
162 aria-label="Project title"
162163 style="padding: 8px;"
163164 />
164165 <textarea
@@ -309,12 +310,12 @@ projectRoutes.get(
309310 <div class="kanban">
310311 {columns.map((col) => (
311312 <div class="kcol">
312 <h4>
313 <h2>
313314 <span>{col.name}</span>
314315 <span style="color: var(--text-muted); font-size: 13px;">
315316 {(itemsByCol[col.id] || []).length}
316317 </span>
317 </h4>
318 </h2>
318319 {(itemsByCol[col.id] || []).map((it) => (
319320 <div class="kcard">
320321 <div>
@@ -355,6 +356,7 @@ projectRoutes.get(
355356 <button
356357 type="submit"
357358 class="btn"
359 aria-label="Delete item"
358360 style="font-size: 11px; padding: 2px 6px;"
359361 >
360362 ×
@@ -376,6 +378,7 @@ projectRoutes.get(
376378 name="title"
377379 placeholder="New card title"
378380 required
381 aria-label="New card title"
379382 style="padding: 4px; font-size: 12px;"
380383 />
381384 <button
@@ -401,6 +404,7 @@ projectRoutes.get(
401404 name="name"
402405 placeholder="New column"
403406 required
407 aria-label="New column name"
404408 style="padding: 6px;"
405409 />
406410 <button type="submit" class="btn">
Modifiedsrc/routes/protected-tags.tsx+1−0View fileUnifiedSplit
@@ -134,6 +134,7 @@ protectedTagsRoutes.get(
134134 name="pattern"
135135 required
136136 placeholder="v* or release-*"
137 aria-label="Tag protection pattern"
137138 style="font-family:var(--font-mono)"
138139 />
139140 </div>
Modifiedsrc/routes/pulls.tsx+1−0View fileUnifiedSplit
@@ -248,6 +248,7 @@ pulls.get(
248248 required
249249 placeholder="Title"
250250 style="font-size:16px;padding:10px 14px"
251 aria-label="Pull request title"
251252 />
252253 </FormGroup>
253254 <FormGroup>
Modifiedsrc/routes/releases.tsx+4−3View fileUnifiedSplit
@@ -221,11 +221,12 @@ releasesRoute.get("/:owner/:repo/releases/new", requireAuth, requireRepoAccess("
221221 required
222222 placeholder="v1.0.0"
223223 pattern="[A-Za-z0-9._\\-]+"
224 aria-label="Tag"
224225 />
225226 </div>
226227 <div class="form-group">
227228 <label>Target branch / commit</label>
228 <select name="target">
229 <select name="target" aria-label="Target branch">
229230 {branches.map((b) => (
230231 <option value={b} selected={b === repoRow.defaultBranch}>
231232 {b}
@@ -235,11 +236,11 @@ releasesRoute.get("/:owner/:repo/releases/new", requireAuth, requireRepoAccess("
235236 </div>
236237 <div class="form-group">
237238 <label>Release name</label>
238 <input type="text" name="name" required placeholder="v1.0.0 — the big one" />
239 <input type="text" name="name" required placeholder="v1.0.0 — the big one" aria-label="Release name" />
239240 </div>
240241 <div class="form-group">
241242 <label>Previous tag (for AI changelog)</label>
242 <select name="previousTag">
243 <select name="previousTag" aria-label="Previous tag">
243244 <option value="">(auto — last tag)</option>
244245 {tags.map((t) => (
245246 <option value={t.name}>{t.name}</option>
Modifiedsrc/routes/repo-settings.tsx+3−0View fileUnifiedSplit
@@ -117,6 +117,7 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin
117117 name="visibility"
118118 value="public"
119119 checked={!repo.isPrivate}
120 aria-label="Public"
120121 />
121122 <div class="vis-label">Public</div>
122123 </label>
@@ -126,6 +127,7 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin
126127 name="visibility"
127128 value="private"
128129 checked={repo.isPrivate}
130 aria-label="Private"
129131 />
130132 <div class="vis-label">Private</div>
131133 </label>
@@ -197,6 +199,7 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin
197199 name="new_owner"
198200 placeholder="new-owner-username"
199201 required
202 aria-label="New owner username"
200203 style="width:60%"
201204 />{" "}
202205 <button type="submit" class="btn">
Modifiedsrc/routes/required-checks.tsx+1−0View fileUnifiedSplit
@@ -161,6 +161,7 @@ required.get(
161161 name="checkName"
162162 required
163163 placeholder="GateTest"
164 aria-label="Check name"
164165 style="font-family:var(--font-mono)"
165166 />
166167 </div>
Modifiedsrc/routes/saved-replies.tsx+1−0View fileUnifiedSplit
@@ -115,6 +115,7 @@ replies.get("/settings/replies", async (c) => {
115115 required
116116 value={r.shortcut}
117117 maxLength={64}
118 aria-label="Shortcut"
118119 />
119120 </div>
120121 <div class="form-group">
Modifiedsrc/routes/search.tsx+1−0View fileUnifiedSplit
@@ -148,6 +148,7 @@ search.get("/search", async (c) => {
148148 name="q"
149149 value={q}
150150 placeholder="Search repositories, users, issues, PRs…"
151 aria-label="Search"
151152 style="width: 100%; padding: 10px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
152153 autofocus
153154 />
Modifiedsrc/routes/semantic-search.tsx+1−0View fileUnifiedSplit
@@ -159,6 +159,7 @@ semanticSearch.get("/:owner/:repo/search/semantic", async (c) => {
159159 name="q"
160160 value={q}
161161 placeholder="Ask a question or describe what you're looking for…"
162 aria-label="Search"
162163 style="width: 100%; padding: 10px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
163164 autofocus
164165 />
Modifiedsrc/routes/settings-2fa.tsx+2−0View fileUnifiedSplit
@@ -140,6 +140,7 @@ settings2fa.get("/settings/2fa", async (c) => {
140140 <label for="password">Password</label>
141141 <input
142142 type="password"
143 id="password"
143144 name="password"
144145 required
145146 autocomplete="current-password"
@@ -238,6 +239,7 @@ async function showEnrolPage(c: any, user: any, error?: string) {
238239 <label for="code">6-digit code</label>
239240 <input
240241 type="text"
242 id="code"
241243 name="code"
242244 required
243245 pattern="[0-9]{6}"
Modifiedsrc/routes/settings.tsx+4−0View fileUnifiedSplit
@@ -99,6 +99,7 @@ settings.get("/settings", (c) => {
9999 name="notify_email_on_mention"
100100 value="1"
101101 checked={user.notifyEmailOnMention}
102 aria-label="Someone @mentions me or requests a review"
102103 />
103104 <span>
104105 Someone <code>@mentions</code> me or requests a review
@@ -112,6 +113,7 @@ settings.get("/settings", (c) => {
112113 name="notify_email_on_assign"
113114 value="1"
114115 checked={user.notifyEmailOnAssign}
116 aria-label="I am assigned to an issue or PR"
115117 />
116118 <span>I am assigned to an issue or PR</span>
117119 </label>
@@ -123,6 +125,7 @@ settings.get("/settings", (c) => {
123125 name="notify_email_on_gate_fail"
124126 value="1"
125127 checked={user.notifyEmailOnGateFail}
128 aria-label="A gate fails on one of my repositories"
126129 />
127130 <span>A gate fails on one of my repositories</span>
128131 </label>
@@ -134,6 +137,7 @@ settings.get("/settings", (c) => {
134137 name="notify_email_digest_weekly"
135138 value="1"
136139 checked={user.notifyEmailDigestWeekly}
140 aria-label="Weekly digest email"
137141 />
138142 <span>
139143 Weekly digest —{" "}
Modifiedsrc/routes/specs.tsx+85−1View fileUnifiedSplit
@@ -15,7 +15,7 @@
1515import { Hono } from "hono";
1616import { and, eq } from "drizzle-orm";
1717import { db } from "../db";
18import { repositories, users } from "../db/schema";
18import { repositories, users, issues } from "../db/schema";
1919import { Layout } from "../views/layout";
2020import { RepoHeader } from "../views/components";
2121import { softAuth, requireAuth } from "../middleware/auth";
@@ -108,6 +108,38 @@ function hasWriteAccess(
108108 return !!userId && resolved.ownerId === userId;
109109}
110110
111/**
112 * Build the default spec text for an issue-driven generation. Format:
113 *
114 * Implement: <title>
115 *
116 * <body>
117 *
118 * Closes #<n>
119 *
120 * The trailing `Closes #N` is picked up by `src/lib/close-keywords.ts` (J7)
121 * so the issue auto-closes when the AI-generated PR is merged. Body is
122 * trimmed and falls back to an empty string if missing. Pure helper —
123 * exported for tests.
124 */
125export function buildSpecFromIssue(input: {
126 number: number;
127 title: string;
128 body: string | null | undefined;
129}): string {
130 const title = (input.title || "").trim();
131 const body = (input.body || "").trim();
132 const lines: string[] = [];
133 if (title) lines.push(`Implement: ${title}`);
134 if (body) {
135 lines.push("");
136 lines.push(body);
137 }
138 lines.push("");
139 lines.push(`Closes #${input.number}`);
140 return lines.join("\n");
141}
142
111143function SpecForm({
112144 ownerName,
113145 repoName,
@@ -116,6 +148,8 @@ function SpecForm({
116148 spec,
117149 baseRef,
118150 error,
151 fromIssueNumber,
152 fromIssueTitle,
119153}: {
120154 ownerName: string;
121155 repoName: string;
@@ -124,6 +158,8 @@ function SpecForm({
124158 spec?: string;
125159 baseRef?: string;
126160 error?: string;
161 fromIssueNumber?: number;
162 fromIssueTitle?: string;
127163}) {
128164 const branchList = branches.length > 0 ? branches : [defaultBranch];
129165 const selectedBase = baseRef && branchList.includes(baseRef)
@@ -141,6 +177,18 @@ function SpecForm({
141177 merging.
142178 </div>
143179
180 {fromIssueNumber && (
181 <Alert variant="info">
182 Building from issue{" "}
183 <a href={`/${ownerName}/${repoName}/issues/${fromIssueNumber}`}>
184 #{fromIssueNumber}
185 {fromIssueTitle ? ` — ${fromIssueTitle}` : ""}
186 </a>
187 . The spec below has been pre-filled from the issue and will
188 auto-close it on merge.
189 </Alert>
190 )}
191
144192 <h2 style="margin-bottom:4px">Spec to PR</h2>
145193 <Text muted style="display:block;margin-bottom:16px">
146194 Describe a feature in plain English. Claude will draft the code
@@ -259,6 +307,39 @@ specs.get("/:owner/:repo/spec", softAuth, requireAuth, async (c) => {
259307 branches = [];
260308 }
261309
310 // Optional: pre-fill from an issue. Triggered from the "Build with AI"
311 // button on the issue detail page. Silently no-ops on missing/unknown
312 // issue so the form still renders.
313 let prefilledSpec: string | undefined;
314 let fromIssueNumber: number | undefined;
315 let fromIssueTitle: string | undefined;
316 const fromIssueRaw = c.req.query("fromIssue");
317 if (fromIssueRaw) {
318 const n = Number.parseInt(fromIssueRaw, 10);
319 if (Number.isInteger(n) && n > 0) {
320 try {
321 const [issueRow] = await db
322 .select()
323 .from(issues)
324 .where(
325 and(eq(issues.repositoryId, resolved.repoId), eq(issues.number, n))
326 )
327 .limit(1);
328 if (issueRow) {
329 fromIssueNumber = issueRow.number;
330 fromIssueTitle = issueRow.title;
331 prefilledSpec = buildSpecFromIssue({
332 number: issueRow.number,
333 title: issueRow.title,
334 body: issueRow.body,
335 });
336 }
337 } catch {
338 // Pre-fill is a convenience, never block form render.
339 }
340 }
341 }
342
262343 return c.html(
263344 <Layout title={`Spec to PR — ${owner}/${repo}`} user={user}>
264345 <RepoHeader owner={owner} repo={repo} />
@@ -267,6 +348,9 @@ specs.get("/:owner/:repo/spec", softAuth, requireAuth, async (c) => {
267348 repoName={repo}
268349 branches={branches}
269350 defaultBranch={resolved.defaultBranch}
351 spec={prefilledSpec}
352 fromIssueNumber={fromIssueNumber}
353 fromIssueTitle={fromIssueTitle}
270354 />
271355 </Layout>
272356 );
Modifiedsrc/routes/sponsors.tsx+3−1View fileUnifiedSplit
@@ -96,6 +96,7 @@ sponsors.get("/sponsors/:username", async (c) => {
9696 placeholder="Amount in cents (e.g. 500 = $5)"
9797 min="100"
9898 required
99 aria-label="Sponsorship amount in cents"
99100 style="width:60%"
100101 />{" "}
101102 <button type="submit" class="btn btn-primary">
@@ -324,7 +325,7 @@ sponsors.get("/settings/sponsors", requireAuth, async (c) => {
324325 >
325326 <div class="form-group">
326327 <label>Name</label>
327 <input type="text" name="name" required style="width:100%" />
328 <input type="text" name="name" required aria-label="Tier name" style="width:100%" />
328329 </div>
329330 <div class="form-group">
330331 <label>Description</label>
@@ -338,6 +339,7 @@ sponsors.get("/settings/sponsors", requireAuth, async (c) => {
338339 min="0"
339340 placeholder="500 = $5/mo"
340341 required
342 aria-label="Monthly amount in cents"
341343 />
342344 </div>
343345 <button type="submit" class="btn btn-primary">
Modifiedsrc/routes/sso.tsx+2−0View fileUnifiedSplit
@@ -120,6 +120,7 @@ sso.get("/admin/sso", requireAuth, async (c) => {
120120 name="enabled"
121121 value="1"
122122 checked={!!cfg?.enabled}
123 aria-label="Enable SSO sign-in on /login"
123124 />
124125 <span>Enable SSO sign-in on /login</span>
125126 </label>
@@ -226,6 +227,7 @@ sso.get("/admin/sso", requireAuth, async (c) => {
226227 name="auto_create_users"
227228 value="1"
228229 checked={cfg ? cfg.autoCreateUsers : true}
230 aria-label="Auto-create users on first SSO sign-in"
229231 />
230232 <span>
231233 Auto-create local accounts on first SSO sign-in (turn off to
Modifiedsrc/routes/symbols.tsx+2−0View fileUnifiedSplit
@@ -115,6 +115,7 @@ symbols.get("/:owner/:repo/symbols", async (c) => {
115115 name="q"
116116 placeholder="Search symbol name..."
117117 required
118 aria-label="Search symbol name"
118119 style="flex:1"
119120 />
120121 <button type="submit" class="btn">
@@ -228,6 +229,7 @@ symbols.get("/:owner/:repo/symbols/search", async (c) => {
228229 value={q}
229230 placeholder="Search symbol name..."
230231 required
232 aria-label="Search symbol name"
231233 style="flex:1"
232234 />
233235 <button type="submit" class="btn">
Modifiedsrc/routes/team-collaborators.tsx+2−0View fileUnifiedSplit
@@ -202,6 +202,8 @@ teamCollaboratorRoutes.get(
202202 <img
203203 src={row.avatarUrl}
204204 alt=""
205 width={20}
206 height={20}
205207 style="width:20px;height:20px;border-radius:50%;vertical-align:middle;margin-right:6px"
206208 />
207209 )}
Modifiedsrc/routes/tokens.tsx+1−0View fileUnifiedSplit
@@ -137,6 +137,7 @@ tokens.get("/settings/tokens", async (c) => {
137137 name="scopes"
138138 value={scope}
139139 checked={scope === "repo"}
140 aria-label={scope}
140141 />{" "}
141142 {scope}
142143 </label>
Modifiedsrc/routes/web.tsx+5−2View fileUnifiedSplit
@@ -7,6 +7,7 @@ import { Hono } from "hono";
77import { html } from "hono/html";
88import { eq, and, desc, inArray, sql } from "drizzle-orm";
99import { db } from "../db";
10import { config } from "../lib/config";
1011import {
1112 users,
1213 repositories,
@@ -99,7 +100,7 @@ web.get("/new", requireAuth, (c) => {
99100 <form method="post" action="/new">
100101 <div class="form-group">
101102 <label>Owner</label>
102 <input type="text" value={user.username} disabled class="input-disabled" />
103 <input type="text" value={user.username} disabled aria-label="Owner" class="input-disabled" />
103104 </div>
104105 <div class="form-group">
105106 <label for="name">Repository name</label>
@@ -515,7 +516,7 @@ web.get("/:owner/:repo", async (c) => {
515516 <div class="empty-state">
516517 <h2>Empty repository</h2>
517518 <p>Get started by pushing code:</p>
518 <pre>{`git remote add gluecron http://localhost:3000/${owner}/${repo}.git
519 <pre>{`git remote add gluecron ${config.appBaseUrl}/${owner}/${repo}.git
519520git push -u gluecron main`}</pre>
520521 </div>
521522 </Layout>
@@ -554,6 +555,7 @@ git push -u gluecron main`}</pre>
554555 name="name"
555556 placeholder="new-repo-name"
556557 required
558 aria-label="New repository name"
557559 style="width:200px"
558560 />
559561 <button type="submit" class="btn btn-primary">
@@ -1180,6 +1182,7 @@ web.get("/:owner/:repo/search", async (c) => {
11801182 name="q"
11811183 value={q}
11821184 placeholder="Search code..."
1185 aria-label="Search code"
11831186 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
11841187 />
11851188 <button type="submit" class="btn btn-primary">
Modifiedsrc/routes/wikis.tsx+3−0View fileUnifiedSplit
@@ -246,6 +246,7 @@ wikiRoutes.get("/:owner/:repo/wiki/new", requireAuth, async (c) => {
246246 name="title"
247247 placeholder="Page title"
248248 required
249 aria-label="Page title"
249250 style="padding: 8px;"
250251 />
251252 <textarea
@@ -426,6 +427,7 @@ wikiRoutes.get(
426427 name="title"
427428 value={page.title}
428429 required
430 aria-label="Page title"
429431 style="padding: 8px;"
430432 />
431433 <textarea
@@ -439,6 +441,7 @@ wikiRoutes.get(
439441 type="text"
440442 name="message"
441443 placeholder="Revision message (optional)"
444 aria-label="Revision message"
442445 style="padding: 8px;"
443446 />
444447 <button type="submit" class="btn btn-primary">
Modifiedsrc/views/client-js.ts+1−1View fileUnifiedSplit
@@ -259,7 +259,7 @@ export const clientJs = `
259259 else if (diff < 43200) el.textContent = Math.floor(diff / 1440) + 'd ago';
260260 });
261261 }
262 setInterval(updateTimes, 60000);
262 var _timesInterval = setInterval(updateTimes, 60000);
263263
264264 // ─── Confirmation on Dangerous Actions ─────────────────────────────────
265265
Modifiedsrc/views/layout.tsx+2−2View fileUnifiedSplit
@@ -205,7 +205,7 @@ const navScript = `
205205 var item = filtered[i];
206206 var cls = i === selected ? 'cmdk-item cmdk-active' : 'cmdk-item';
207207 var bg = i === selected ? 'background:var(--bg);' : '';
208 html += '<div class="' + cls + '" data-idx="' + i + '" data-href="' + item.href + '"' +
208 html += '<div class="' + cls + '" data-idx="' + i + '" data-url="' + item.href + '"' +
209209 ' style="padding:10px 16px;cursor:pointer;border-bottom:1px solid var(--border);' + bg + '">' +
210210 '<div>' + item.label + '</div>' +
211211 '<div style="font-size:11px;color:var(--text-muted)">' + item.href + '</div>' +
@@ -241,7 +241,7 @@ const navScript = `
241241 var t = e.target;
242242 if (t && t.id === 'cmdk-backdrop') { closePalette(); return; }
243243 var item = t && t.closest && t.closest('.cmdk-item');
244 if (item) { go(item.getAttribute('data-href')); }
244 if (item) { go(item.getAttribute('data-url')); }
245245 });
246246
247247 document.addEventListener('input', function(e){
Modifiedsrc/views/ui.tsx+4−0View fileUnifiedSplit
@@ -148,6 +148,7 @@ export const Input: FC<{
148148 minLength?: number;
149149 maxLength?: number;
150150 style?: string;
151 "aria-label"?: string;
151152}> = (props) => (
152153 <input
153154 type={props.type || "text"}
@@ -164,6 +165,7 @@ export const Input: FC<{
164165 maxLength={props.maxLength}
165166 class={props.disabled ? "input-disabled" : ""}
166167 style={props.style}
168 aria-label={props["aria-label"] || props.placeholder || props.name}
167169 />
168170);
169171
@@ -459,6 +461,8 @@ export const Avatar: FC<{
459461 <img
460462 src={url}
461463 alt={name}
464 width={size}
465 height={size}
462466 style={`width:${size}px;height:${size}px;border-radius:50%;object-fit:cover`}
463467 loading="lazy"
464468 />
465469