swadhinbiswas

[email protected]
2026-08-14 23:45:44 +0600 committed NaN years ago
f9cd0b9
commit f9cd0b9
44 files changed
+734 -69
Showing 44 changed files+734-69
.astro/settings.jsondoc/production-parity-plan.mdsrc/pages/api/change-sets/index.tssrc/pages/api/projects/[id]/cards/[cardId].tssrc/pages/api/repos/[owner]/[repo]/branches/[branch].tssrc/pages/api/repos/[owner]/[repo]/cloud/deploy.tssrc/pages/api/repos/[owner]/[repo]/iac/hooks.tssrc/pages/api/repos/[owner]/[repo]/integrations/cloud.tssrc/pages/api/repos/[owner]/[repo]/integrations/cloud/[id].tssrc/pages/api/repos/[owner]/[repo]/integrations/code-quality.tssrc/pages/api/repos/[owner]/[repo]/integrations/code-quality/[id].tssrc/pages/api/repos/[owner]/[repo]/integrations/external-ci.tssrc/pages/api/repos/[owner]/[repo]/integrations/external-ci/[id].tssrc/pages/api/repos/[owner]/[repo]/integrations/issue-trackers.tssrc/pages/api/repos/[owner]/[repo]/integrations/issue-trackers/[id].tssrc/pages/api/repos/[owner]/[repo]/pulls/[number]/checks.tssrc/pages/api/repos/[owner]/[repo]/pulls/[number]/comments.tssrc/pages/api/repos/[owner]/[repo]/pulls/[number]/file-approvals.tssrc/pages/api/repos/[owner]/[repo]/pulls/[number]/index.tssrc/pages/api/repos/[owner]/[repo]/pulls/[number]/issue-links.tssrc/pages/api/repos/[owner]/[repo]/pulls/[number]/issue-links/[id].tssrc/pages/api/repos/[owner]/[repo]/pulls/[number]/merge.tssrc/pages/api/repos/[owner]/[repo]/pulls/[number]/reviews.tssrc/pages/api/repos/[owner]/[repo]/pulls/[number]/rewrite.tssrc/pages/api/repos/[owner]/[repo]/pulls/bulk-merge.tssrc/pages/api/repos/[owner]/[repo]/pulls/bulk-queue-manage.tssrc/pages/api/repos/[owner]/[repo]/pulls/bulk-queue.tssrc/pages/api/repos/[owner]/[repo]/pulls/index.tssrc/pages/api/repos/[owner]/[repo]/pulls/review-health.tssrc/pages/api/repos/[owner]/[repo]/queue/[id]/conflicts.tssrc/pages/api/repos/[owner]/[repo]/security/policies.tssrc/pages/api/repos/[owner]/[repo]/security/policy-actions.tssrc/pages/api/repos/[owner]/[repo]/security/scan.tssrc/pages/api/repos/[owner]/[repo]/security/secret-scan.tssrc/pages/api/repos/[owner]/[repo]/security/secret-scan/[id].tssrc/pages/api/repos/[owner]/[repo]/settings/fields.tssrc/pages/api/repos/[owner]/[repo]/settings/fields/[id].tssrc/pages/api/repos/[owner]/[repo]/settings/mirror/sync.tssrc/pages/api/repos/[owner]/[repo]/subscription.tssrc/pages/api/repos/[owner]/[repo]/wiki/[slug].tssrc/pages/api/repos/[owner]/[repo]/workflow/templates.tssrc/pages/api/repos/[repoId]/webhooks/[id].tssrc/pages/api/repos/[repoId]/webhooks/index.tssrc/pages/login.astro
▼.astro/settings.json
+1-1
1
1
{
2
2
"_variables": {
3
- "lastUpdateCheck": 1783961044818
3
+ "lastUpdateCheck": 1785773647919
4
4
}
5
5
}
6
6
▼doc/production-parity-plan.md
+426-0
1
+# OpenCodeHub — Production Parity Plan (Gitea/GitHub-class)
2
+
3
+**Version:** 1.0 — 2026-08-10
4
+**Basis:** verified code audit (2026-08-10) — `doc/feature_audit.md` self-report was reconciled against actual code. This plan supersedes `notimplemented.md` as the execution roadmap.
5
+**Team assumption:** 3–4 engineers (2 senior + 1–2 mid) + shared QA effort in CI. Timeline scales linearly with team size.
6
+
7
+---
8
+
9
+## 1. Goal & Exit Criteria
10
+
11
+### Goal
12
+Make OpenCodeHub an **industry-grade, production-usable** self-hosted Git platform — functionally at **Gitea-parity**, with the flagship differentiators (stacked PRs, merge queue, AI review) as the GitHub-killer features — such that a mid-size company can run it in production with confidence.
13
+
14
+### Hard Exit Criteria (ALL must be true for GA 1.0)
15
+
16
+| # | Criterion | Measurement |
17
+|---|---|---|
18
+| E1 | Every feature in the audit matrix has a working end-to-end path (API → UI → storage) | `scripts/audit-wiring-check.mjs` (new) — zero dead-code/zero-unwired flags |
19
+| E2 | CI/CD single execution path; runs persisted; secrets injected; all step types execute | Contract test suite passes on push-triggered, dispatch-triggered, cron-triggered runs |
20
+| E3 | Quality gates actually gate | CI fails on: lint, typecheck, <60% line coverage, security audit, e2e, perf P95>500ms |
21
+| E4 | Zero known critical/high security findings | Trivy + Semgrep + npm audit + gitleaks all green with allowlist ≤ 5 items |
22
+| E5 | Backup/restore verified with RPO ≤ 15 min, RTO ≤ 1 h, drill automated in CI | `drill:backup-restore` passes weekly |
23
+| E6 | Multi-instance correctness proven | Chaos test: 2 app + 2 worker instances, 10k queue ops, zero double-processing (fencing tokens) |
24
+| E7 | API/DX: OpenAPI ≥ 80% route coverage, GraphQL ≥ 40 queries/mutations, fine-grained PATs, OAuth-provider mode shipped | Parity test enforces no regression |
25
+| E8 | Org/team management complete (CRUD, members, invites, teams, org secrets/webhooks) | E2E spec `orgs.spec.ts` green |
26
+| E9 | Data integrity: no fabricated UI data (contribution graph real), no dead schema columns flagged | Wiring audit (E1) covers it |
27
+| E10 | Release engineering: single version source, git tags, changelog automation, migration rehearsal in CI | `release:dry-run` job green |
28
+
29
+---
30
+
31
+## 2. Key Architecture Decisions (read first)
32
+
33
+These decisions shape every phase. They are the non-negotiable refactors.
34
+
35
+### AD-1: Unify CI/CD into one DB-backed execution path
36
+**Problem:** `src/lib/workflows.ts` runs `PipelineRunner.runWorkflow()` in-process, fire-and-forget, **creating zero `workflowRuns` rows**; the polling runner (`src/runner/*`) is DB-backed but executes only `run:` steps, injects no secrets, and defaults to `NETWORK_MODE=none`.
37
+**Design:**
38
+- New `WorkflowRunManager` (`src/lib/run-manager.ts`): single entry point. Trigger fires → create `workflowRuns` + expanded `workflowJobs` rows (status `queued`) → dispatch each job to an executor via a common `JobExecutor` interface.
39
+- Two executors behind the interface: `LocalDockerExecutor` (in-process dockerode, default for `runs-on: opencodehub`) and `RemoteRunnerClient` (existing `src/runner/client.ts` poll model, for labeled runners).
40
+- `runs-on` label matching against `pipelineRunners` (like GitHub runner labels). Unknown label → job stays queued until a runner with the label registers.
41
+- All job claims use optimistic `UPDATE ... WHERE status='queued'` + distributed lock (pattern already in `src/runner/poll.ts` and `src/lib/distributed-lock.ts`).
42
+- Logs: **single sink** `workflowLogs`; in-process executor writes through the same `LogPersister` used by the runner (`complete.ts`).
43
+- Cancellation: cancel API marks rows `cancelled` **and** signals executors (SSE/control endpoint) to `docker kill`.
44
+- Secrets: decrypted at dispatch time, injected only into job container env — never into server env.
45
+- GITHUB_TOKEN: short-lived (10 min) JWT minted per job, scoped to repo + `permissions` key.
46
+- Delete `workflows.ts`'s fire-and-forget path; `post-receive.ts` calls `WorkflowRunManager.createRunForPush()`.
47
+
48
+### AD-2: Webhook/event system becomes a typed event bus
49
+**Problem:** 5 webhook events, string-based, no retries; notifications/automations each have their own dispatch paths.
50
+**Design:** `src/lib/events.ts` — typed event registry (name, payload schema via Zod, subscribers). One `emit()` path feeds: webhooks (with retry + DLQ), automations, notifications, SSE realtime, Slack/Discord/Teams. 30+ event types. Webhook delivery: 5 retries, exponential backoff (1s→30s), dead-letter table, replay API.
51
+
52
+### AD-3: Fine-grained PATs
53
+Add `scopes` (array), `resourceSelector` (repo IDs or `*`) to `personalAccessTokens`; enforce via middleware that intersects scope + repo permission. Admin tokens opt into all scopes explicitly. UI: scope checkboxes at token creation.
54
+
55
+### AD-4: OAuth-provider mode
56
+New `oauthApps` table + `POST /api/oauth/apps`, `GET /api/oauth/authorize`, `POST /api/oauth/token` (authorization-code flow, PKCE optional), `GET /api/user` (tokeninfo). Same table powers first-party `GITHUB_TOKEN`.
57
+
58
+### AD-5: Global code search (no index server)
59
+Repo-level `git grep` (exists). Global: on-demand search over `repositories` filtered by visibility/permission using `git grep -l` with per-repo timeout + result merge, backed by a background **file-index cache** (SQLite FTS5 or a `code_index` table refreshed per push in the worker). Ship with cache warm on push; fall back to live grep.
60
+
61
+### AD-6: Versioning discipline
62
+Single source: root `package.json` version. CLI + SDK versions mirror it (workspace `npm version` or `changesets`). Releases = git tags `vX.Y.Z` + changelog generated from conventional commits. Migration rehearsal job in CI (`drizzle-kit` up + down on Postgres 16).
63
+
64
+### AD-7: Real contribution data
65
+Contribution graph computed from `activity` table + `git log --author` per repo the user can read (async refresh in worker, cached per user). **Delete the seeded-random generator** (`src/pages/[owner]/index.astro:135-146`).
66
+
67
+---
68
+
69
+## 3. Master Backlog (by Workstream)
70
+
71
+Legend: `P0` = blocking GA, `P1` = should-ship GA, `P2` = post-GA stretch. Effort in engineer-days (e.g. 4 = 4 days).
72
+
73
+### WS-0: Foundation & Gates (Phase 0)
74
+
75
+| ID | Task | Effort | P | Acceptance criteria |
76
+|---|---|---|---|---|
77
+| WS0-01 | Align branch ruleset with actual CI jobs (add real `unit-lane`, `integration-lane`, `contract-lane`, `smoke-lane` jobs to `ci.yml`; remove impossible contexts) | 1 | P0 | All 11 required checks report green on a real PR |
78
+| WS0-02 | Implement `drill:backup-restore`, `drill:redis-outage`, `drill:postgres-reconnect` scripts; wire `weekly-drills.yml` | 3 | P0 | `weekly-drills.yml` passes |
79
+| WS0-03 | Coverage gate: emit `coverage-summary.json` + `lcov.info`; raise threshold to 60% lines; Codecov upload fixed | 2 | P0 | CI fails below 60% |
80
+| WS0-04 | Create `tests/contract/` + `tests/smoke/`; fix `test:contract` / `test:smoke` scripts | 2 | P0 | Scripts pass on empty→real suites |
81
+| WS0-05 | Version unification: root=cli=sdk=CHANGELOG; git tags; `release` workflow (draft → tag → publish) | 2 | P0 | `npm run release:dry-run` green |
82
+| WS0-06 | Wiring-audit script: scan for zero-caller functions, unused schema columns, orphaned components; wire into CI | 3 | P0 | E1 gate |
83
+| WS0-07 | Expand `tests/security.test.ts` → suite (authz matrix: every privileged route × role), OWASP ASVS L1 subset | 5 | P0 | E4 gate |
84
+
85
+### WS-1: CI/CD Unification (Phase 1 — largest)
86
+
87
+| ID | Task | Effort | P | Acceptance criteria |
88
+|---|---|---|---|---|
89
+| WS1-01 | `WorkflowRunManager`: create runs/jobs rows on push, dispatch, cancel, rerun, retry | 6 | P0 | Push-triggered run visible in UI with live logs; gates read it |
90
+| WS1-02 | Runner dispatch by `runs-on` label matching; unknown-label jobs queue indefinitely with UI hint | 3 | P0 | `runs-on: custom-label` executes on registered runner |
91
+| WS1-03 | Execute ALL step types in polling runner: `uses:` local/remote (node16/node20/composite) — reuse `pipeline.ts` action loader | 4 | P0 | `actions/checkout` + composite actions pass |
92
+| WS1-04 | Secrets injection end-to-end (repo+org); env-scoped; redaction in logs both paths | 3 | P0 | Secret never in server env/logs; container env has it |
93
+| WS1-05 | Matrix expansion at run-creation; `strategy.fail-fast/max-parallel`; parallel job execution | 4 | P0 | Matrix job produces N job rows with distinct matrix labels |
94
+| WS1-06 | Artifacts: upload/download via storage adapter; `actions/upload-artifact` compatible composite action `opencodehub/upload-artifact`; UI download | 4 | P0 | Artifact round-trip in contract test |
95
+| WS1-07 | Cancellation propagation to live containers (SSE control + `docker kill`) | 2 | P0 | Cancel API kills running container |
96
+| WS1-08 | Cron scheduler: consume `scheduledWorkflows`; evaluate `schedule:` in worker; create runs | 3 | P1 | Scheduled run fires within 60s of cron |
97
+| WS1-09 | Speculative-build CI: trigger real run on `mq-spec-*` branch; merge queue gates on its conclusion; delete dead `processQueueBatch` | 4 | P0 | Queue entry waits for spec CI result |
98
+| WS1-10 | `GITHUB_TOKEN` minting + `permissions` key parsing + `env`/`outputs` file support (`GITHUB_OUTPUT`, `GITHUB_ENV`) | 4 | P0 | Steps share data via GITHUB_OUTPUT |
99
+| WS1-11 | `timeout-minutes`, job parallelism, `if:` expression expansion (`&&`, `!`, `fromJSON`, `format`) | 4 | P1 | Expressions evaluate per GitHub semantics |
100
+| WS1-12 | Concurrency groups (`concurrency:`) cancel/supersede in-flight runs | 2 | P2 | Supersede semantics correct |
101
+| WS1-13 | Reusable workflows (`workflow_call`) | 5 | P2 | Called workflow runs with inputs |
102
+| WS1-14 | `NETWORK_MODE` default → `bridge` w/ network isolation per job; services linked to job network; `container:` full options | 3 | P0 | Checkout + deps work out-of-the-box; services reachable by hostname |
103
+| WS1-15 | Merge-queue CI gating from DB runs (required status checks read `workflowJobs`), retire the run-less path; wire `handleCIFailure` auto-retry | 3 | P0 | Queue blocks on failed checks, auto-retries ≤3 |
104
+| WS1-16 | Runner scale: runner groups (schema columns exist), global runners, `actions/runner`-style registration UX | 4 | P2 | Label-grouped runners |
105
+
106
+### WS-2: Collaboration Data-Integrity Wiring (Phase 2)
107
+
108
+| ID | Task | Effort | P | Acceptance criteria |
109
+|---|---|---|---|---|
110
+| WS2-01 | Issue assignees: `PATCH /issues/{n}` + multi-assignee UI; notification on assign | 2 | P0 | Assignee round-trip; dead junction becomes live |
111
+| WS2-02 | Milestone assignment: `issues.milestoneId` write path + UI dropdown + filter | 2 | P0 | Issue↔milestone round-trip |
112
+| WS2-03 | Issue workflow transitions: wire `transitionIssue` into PATCH + status dropdown UI + required transitions | 3 | P0 | Transition blocked when invalid; audit-logged |
113
+| WS2-04 | Custom field values: `PATCH /issues/{n}/fields` + IssueDetail UI | 3 | P0 | Value persists, validates against definition |
114
+| WS2-05 | Issue templates: `.github/ISSUE_TEMPLATE/*.yml` parse + new-issue UI chooser | 3 | P0 | Template chooser works |
115
+| WS2-06 | PR labels: add/remove API + chip UI + filter; PR assignees API (single) | 3 | P0 | PR label round-trip |
116
+| WS2-07 | Draft toggle: REST create + `PATCH` + `new.astro` checkbox | 1 | P0 | Draft round-trip |
117
+| WS2-08 | Merge-method selector on manual merge button (merge/squash/rebase radio) | 2 | P0 | Squash merge works from UI |
118
+| WS2-09 | Resolve/unresolve review threads (PATCH comment `isResolved` + UI) | 1 | P0 | Thread resolves; codeowner gate respects it |
119
+| WS2-10 | @mention parsing in comments/PR bodies → notifications + autocomplete UI | 3 | P1 | Mention triggers notification |
120
+| WS2-11 | Watch/unwatch: `PUT/DELETE /repos/{o}/{r}/subscription` + header button + notification wiring | 2 | P1 | Watch round-trip |
121
+| WS2-12 | Real contribution graph (AD-7): worker-computed from git log + activity | 3 | P0 | Graph matches git log; fake generator deleted |
122
+| WS2-13 | Single-PR "request review" endpoint + UI; reviewer suggestions by history | 2 | P1 | Request review round-trip |
123
+| WS2-14 | Wiki: DELETE page, revision diff view | 1 | P1 | Wiki lifecycle complete |
124
+| WS2-15 | Releases: asset upload/download via storage adapter + release notes editor polish | 3 | P1 | Asset round-trip |
125
+| WS2-16 | Saved replies (user-level templates for comments) | 2 | P2 | Insert saved reply into comment |
126
+
127
+### WS-3: Organizations & Teams (Phase 3)
128
+
129
+| ID | Task | Effort | P | Acceptance criteria |
130
+|---|---|---|---|---|
131
+| WS3-01 | Org CRUD API + pages (home, settings, members, roles) | 4 | P0 | Full org lifecycle |
132
+| WS3-02 | Member add/remove/role-change + invite-by-email flow (token, expiry, audit) | 4 | P0 | Invite accepted → member; revoked on decline |
133
+| WS3-03 | First-party team CRUD + membership + team mention in review requirements | 4 | P0 | Team round-trip; team reviewers enforced |
134
+| WS3-04 | Org-level webhooks + org secrets (CI injection) | 3 | P0 | Org webhook fires; org secret injects |
135
+| WS3-05 | Repo transfer to org; org-owned repo default team access | 3 | P1 | Transfer round-trip |
136
+| WS3-06 | Org audit log view + export; org settings (default branch protection, rate limits) | 3 | P1 | Audit shows org-scoped events |
137
+| WS3-07 | SCIM stays; add SCIM config UI (token regen, audit) | 2 | P2 | SCIM provisioned user visible |
138
+
139
+### WS-4: Security & Compliance (Phase 5)
140
+
141
+| ID | Task | Effort | P | Acceptance criteria |
142
+|---|---|---|---|---|
143
+| WS4-01 | Fine-grained PATs (AD-3): schema, middleware, UI, expiry/rotation, revoke-on-leak | 5 | P0 | Token scoped to repo works; outside repo 403 |
144
+| WS4-02 | OAuth-provider mode (AD-4): apps CRUD, authorize/token, first-party app for GITHUB_TOKEN | 6 | P0 | Third-party app OAuth round-trip |
145
+| WS4-03 | GitHub consumer OAuth wired (routes + login buttons) + Google polish | 2 | P0 | Login with GitHub works |
146
+| WS4-04 | SAML: wire config into `ssoConfigs`, ACS validation (signature, audience, expiry), IdP-initiated | 4 | P1 | SAML login round-trip |
147
+| WS4-05 | Sign-in activity log (IP, UA, device) + suspicious-login alert + session revocation list | 3 | P1 | Logged; revoke kills session |
148
+| WS4-06 | Rate-limit budgets per API family (auth brute-force, git, REST) with Redis sliding window + 429 headers | 3 | P1 | Brute-force lockout test passes |
149
+| WS4-07 | Audit enrichment: before/after diffs on role/perm/secret changes; export API (CSV/JSON) | 3 | P1 | Diff captured |
150
+| WS4-08 | Secret scanning: entropy+regex engines, CI integration (block on push), auto-rotate hint | 4 | P1 | Test secret blocked on push |
151
+| WS4-09 | Password policy (length/complexity), account lockout, forgot-password throttle | 2 | P1 | Policy enforced |
152
+| WS4-10 | SBOM generation per release (cyclonedx), dependency review gate, license compliance in CI | 2 | P1 | SBOM artifact in release |
153
+| WS4-11 | Session hardening: rotation on privilege change, absolute TTL, concurrent-limit | 2 | P1 | Rotation test passes |
154
+
155
+### WS-5: API & DX (Phase 4)
156
+
157
+| ID | Task | Effort | P | Acceptance criteria |
158
+|---|---|---|---|---|
159
+| WS5-01 | OpenAPI: route registry (Zod schemas → spec via `zod-to-openapi`), auto-generated paths for all 254 routes; keep `openapi.json.ts` as entry | 8 | P0 | ≥80% coverage; parity test enforces |
160
+| WS5-02 | GraphQL expansion: issues, labels, milestones, releases, orgs, teams, comments, PR reviews + their mutations; subscriptions (SSE) for PR/CI events | 8 | P0 | 40+ operations; e2e via GraphQL |
161
+| WS5-03 | Typed webhook event system (AD-2) + retries/DLQ/replay + UI delivery logs | 5 | P0 | Failed delivery retried; replay works |
162
+| WS5-04 | SDK regeneration from OpenAPI (typed clients); fix baseUrl default; publish | 4 | P1 | SDK covers all resources |
163
+| WS5-05 | API versioning: `Accept: application/vnd.och+json; version=1` + `Deprecation` header middleware | 3 | P1 | Deprecated endpoint warns |
164
+| WS5-06 | CLI parity: releases, wiki, webhooks, secrets, orgs, admin, PAT management commands | 5 | P1 | `och` covers ≥90% of REST |
165
+| WS5-07 | GraphQL authz parity tests (REST vs GraphQL permission matrix) | 3 | P0 | Same perms both paths |
166
+
167
+### WS-6: UI/UX Completion (Phase 6)
168
+
169
+| ID | Task | Effort | P | Acceptance criteria |
170
+|---|---|---|---|---|
171
+| WS6-01 | Global code search (AD-5) + search page + keyboard shortcut | 5 | P1 | Cross-repo search works, permission-filtered |
172
+| WS6-02 | Tags page + RepoHeader tabs: Releases, Packages, Branches | 2 | P0 | Nav complete |
173
+| WS6-03 | Wire orphaned components: `pr/StackVisualization` into PR page, `pr/AIReviewPanel`, `pr/CodeReviewPanel` | 2 | P1 | No orphaned components (E1) |
174
+| WS6-04 | a11y expansion: axe on 12 high-traffic pages (repo, PR, issues, settings, admin), fix color-contrast, focus traps | 4 | P1 | axe critical/serious = 0 |
175
+| WS6-05 | Mobile hardening pass: PR diff, repo browser, settings, merge queue layouts | 3 | P2 | Manual device matrix pass |
176
+| WS6-06 | i18n framework (i18next) + EN first; strings extraction | 4 | P2 | EN locale complete |
177
+| WS6-07 | Diff UX: side-by-side, whitespace toggle, word-diff, line-range comments (`start_line`) | 4 | P1 | Side-by-side works |
178
+| WS6-08 | File editor: new file, delete, rename, author/committer pick, branch dropdown | 3 | P1 | Edit round-trip |
179
+| WS6-09 | Org UI pages (from WS3) styled to existing design system | 2 | P0 | org UI polished |
180
+| WS6-10 | PWA/offline shell + install prompt | 3 | P2 | Lighthouse PWA ≥ 90 |
181
+| WS6-11 | Webhook/automation designer UI improvements; plugin marketplace page | 3 | P2 | Plugin install from UI |
182
+
183
+### WS-7: Platform, Scale & Observability (Phase 7)
184
+
185
+| ID | Task | Effort | P | Acceptance criteria |
186
+|---|---|---|---|---|
187
+| WS7-01 | Multi-instance chaos suite: 2 app + 2 workers, queue/rate-limit/lock correctness under load | 4 | P0 | E6 criteria |
188
+| WS7-02 | OpenTelemetry traces (request → DB → git → job), correlation IDs in logs | 4 | P1 | Trace waterfall in Jaeger/Grafana |
189
+| WS7-03 | SLO dashboard: API p95 < 200 ms, page LCP < 2.5 s, git clone 100 MB < 30 s, CI schedule latency < 30 s | 3 | P1 | Dashboards live |
190
+| WS7-04 | Large-repo performance: streaming pack responses, pagination/index audit, `git` process pooling | 4 | P1 | 1 GB repo clone works with bounded memory |
191
+| WS7-05 | Backup automation: WAL archiving (Postgres), storage snapshot hooks, restore drill (E5) | 3 | P0 | RPO ≤ 15 min verified |
192
+| WS7-06 | Migration rehearsal CI: `drizzle-kit up` + `down` + data-preservation check on Postgres 16 | 2 | P0 | Down-migrations tested |
193
+| WS7-07 | Air-gapped completion: no external calls in `AIR_GAPPED_MODE` (asset CDN, AI, telemetry); docs | 2 | P1 | Integration test passes |
194
+| WS7-08 | Vault/external-secrets integration for operator secrets | 3 | P2 | Secret read from Vault |
195
+| WS7-09 | Fuzz tests for git pkt-line parser + webhook signature parser (JSFuzz/quickcheck) | 3 | P1 | No crash in 60s fuzz |
196
+| WS7-10 | Horizontal-scaling docs + load test sign-off at 1k concurrent users | 2 | P0 | k6 stress P99 < 3 s |
197
+
198
+### WS-8: Docs, Release & Quality (continuous)
199
+
200
+| ID | Task | Effort | P | Acceptance criteria |
201
+|---|---|---|---|---|
202
+| WS8-01 | Docs parity for every shipped feature (both trees) — enforce in PR template | 0.5/feature | P0 | docs-parity CI green |
203
+| WS8-02 | Operator runbooks: incident, scaling, backup, upgrade; DR postmortem template | 3 | P0 | Runbooks exist in both trees |
204
+| WS8-03 | Release automation: changelog from commits, tag, Docker Hub + npm publish, SBOM attach | 3 | P0 | One command releases |
205
+| WS8-04 | Performance regression gate in CI (baseline comparison, not absolute-only) | 2 | P1 | Perf job blocks regressions |
206
+| WS8-05 | Mutational analysis on auth/permission modules (stryker) | 2 | P2 | Mutation score ≥ 70% on `permissions.ts` |
207
+
208
+---
209
+
210
+## 4. Phase Plan (Sequenced)
211
+
212
+| Phase | Duration | Scope | Exit gate |
213
+|---|---|---|---|
214
+| **P0 — Foundation Gates** | 2 wks | WS0 all | E3, E4, E10 + ruleset green |
215
+| **P1 — CI/CD Unification** | 6 wks | WS1 (P0/P1 items) | E2 + AD-1 complete; old fire-and-forget path deleted |
216
+| **P2 — Collaboration Integrity** | 4 wks | WS2 (P0 items) | E9; wiring audit (WS0-06) flags 0 in collaboration |
217
+| **P3 — Organizations** | 4 wks | WS3 all | E8 |
218
+| **P4 — API & DX** | 6 wks | WS5 (P0) + WS4-01/02/03 | E7 |
219
+| **P5 — Security Hardening** | 4 wks | WS4 remaining | E4 + authz matrix suite green |
220
+| **P6 — UI Completion** | 4 wks | WS6 (P0/P1) | axe 0 critical; nav complete; real contribution data |
221
+| **P7 — Scale & Observability** | 3 wks | WS7 (P0/P1) | E5, E6, WS7-04 |
222
+| **P8 — GA Sprint** | 3 wks | WS8 + fix backlog, final security audit, load sign-off | ALL E1–E10 → **GA 1.0** |
223
+
224
+**Total: 36 weeks (≈ 9 months)** for 3–4 engineers. Parallel tracks possible after P1: (Team A: P2+P3) ∥ (Team B: P4+P5) ∥ (Team C: P6+P7).
225
+
226
+---
227
+
228
+## 5. Testing Strategy
229
+
230
+| Layer | Coverage target | Gate |
231
+|---|---|---|
232
+| Unit (vitest) | Every lib module ≥ 70% stmts | CI |
233
+| Integration | Per feature: API round-trip + authz matrix | CI |
234
+| Contract | CI/CD semantics (run lifecycle, matrix, artifacts, secrets), OpenAPI parity | New lane job |
235
+| Smoke | Boot → create repo → push → workflow → PR → merge on clean DB | New lane job |
236
+| E2E (Playwright) | 40+ specs incl. new: `orgs.spec`, `draft-pr.spec`, `labels.spec`, `resolve-threads.spec`, `contribution-graph.spec`, `oauth-provider.spec` | CI |
237
+| Security | `tests/security/` suite: authz matrix (route × role × repo-visibility), SSRF, path traversal, secret scan | CI |
238
+| Load | k6: 1k concurrent, P99 < 3 s; git clone bench; queue throughput 10k | P7 sign-off |
239
+| Fuzz | pkt-line, webhook HMAC, workflow YAML parsers | CI (60s) |
240
+| DR | `drill:*` weekly + restore verification (checksum compare) | Scheduled |
241
+
242
+## 6. Performance & SLO Targets
243
+
244
+| SLO | Target |
245
+|---|---|
246
+| REST API p95 (excluding git) | < 200 ms |
247
+| Page LCP (dashboard, repo, PR) | < 2.5 s |
248
+| Git clone 100 MB repo (cold) | < 30 s |
249
+| CI job dispatch → container start | < 30 s |
250
+| Webhook delivery p99 | < 10 s (5 retries, backoff) |
251
+| Queue throughput (multi-instance) | 10k ops, 0 double-process |
252
+| Backup | RPO ≤ 15 min, RTO ≤ 1 h |
253
+| Availability (GA) | 99.9% |
254
+
255
+## 7. Security & Compliance Baseline (Industry-Grade)
256
+
257
+- **OWASP ASVS L1** fully tested (auth, session, access control, input validation, SSRF, crypto) — security suite maps each ASVS 4.0 control to a test.
258
+- **CI supply chain**: npm provenance, SBOM (cyclonedx) per release, Trivy + Semgrep + gitleaks + npm audit with tight allowlist.
259
+- **Secrets**: no default/placeholder secrets in any compose file; `.env.example` uses `change-me-*` + compose `:?` validation (exists — keep).
260
+- **Audit trail**: all privileged mutations (roles, perms, secrets, settings, admin) with actor, before/after, IP — exportable.
261
+- **SSRF**: validated webhook/URL handling (exists) + new integration URLs (external CI, cloud deploy, issue trackers) route through the same validator.
262
+- **Multi-tenancy**: tenant isolation regression suite (repo visibility × org membership × token scope matrix).
263
+- **Compliance docs**: SOC 2-style control mapping doc (`docs/administration/compliance.md`) — policies, evidence collection, DR.
264
+
265
+## 8. KPIs (tracked weekly)
266
+
267
+| KPI | Target |
268
+|---|---|
269
+| Wiring-audit flags (dead code/unwired) | → 0 by P8 |
270
+| OpenAPI coverage | ≥ 80% |
271
+| GraphQL operations | ≥ 40 |
272
+| Coverage (lines) | ≥ 60% |
273
+| CI pass rate (default branch) | 100% |
274
+| Critical/high vulns | 0 |
275
+| e2e specs passing | 100% |
276
+| Docs parity drift | 0 new |
277
+
278
+## 9. Risk Register
279
+
280
+| Risk | Likelihood | Mitigation |
281
+|---|---|---|
282
+| CI/CD unification breaks existing pushes | High | Contract suite first; feature-flag old path for 1 sprint |
283
+| Scope creep into GitHub-only features (marketplace, mobile apps) | High | Explicit P2 backlog; de-scope with written rationale |
284
+| Schema migrations regress existing data | Med | Migration rehearsal CI (WS7-06), rollback scripts mandatory per DoD |
285
+| Multi-instance race bugs in queue/scheduler | Med | Chaos suite (WS7-01) before any HA claim |
286
+| OAuth-provider introduces phishing surface | Med | PKCE, consent screen, app-review for first-party, audit logs |
287
+| Docs lag | High | docs-required PR check (exists) + parity CI (exists) |
288
+| Team size assumption wrong | Med | Phases P2/P3/P4 parallelizable; trim P2 items first |
289
+
290
+## 10. Definition of Done (every task)
291
+
292
+- [ ] Code complete + migration + backfill (where schema changes)
293
+- [ ] Unit + integration tests; authz test for every new route
294
+- [ ] Contract/e2e where flow crosses components
295
+- [ ] Security review checklist signed off (secrets, SSRF, authz)
296
+- [ ] Performance impact measured (query plan / load snippet)
297
+- [ ] Docs updated in BOTH trees; changelog entry
298
+- [ ] Rollback plan documented
299
+- [ ] Wiring audit clean (no new dead code)
300
+
301
+## 11. Immediate 14-Day Sprint (P0 kickoff)
302
+
303
+1. WS0-01 → WS0-05 (gates real: ruleset, drills, coverage, contract/smoke dirs, versioning)
304
+2. WS0-06 wiring audit — publish results as tracked issues (this becomes the P2 backlog's authority)
305
+3. WS0-07 security test suite skeleton (authz matrix generator)
306
+4. Start WS1-01 (run-manager skeleton) with WS1-03/04 (runner `uses:` + secrets) as the first vertical slice
307
+5. Keep every PR green: no merge on failing lint/test/docs/coverage
308
+
309
+---
310
+
311
+## 12. Execution Log (4-day sprint, 2026-08-10)
312
+
313
+### Shipped ✅
314
+| ID | Item | Evidence |
315
+|---|---|---|
316
+| WS0-01 | CI lanes: `Contract Tests (Lane: contract)` + `Smoke Tests (Lane: smoke)` jobs added; ruleset contexts aligned to real jobs; `wiring-audit` job wired | `.github/workflows/ci.yml`, `.github/rulesets/main-branch-ruleset.json` |
317
+| WS0-02 | `drill:backup-restore`, `drill:redis`, `drill:postgres` implemented (weekly-drills workflow now functional) | `scripts/drill-*.ts` |
318
+| WS0-03 | Coverage emits `coverage-summary.json` + `lcov.info` (threshold check is now real) | `vitest.config.ts` |
319
+| WS0-04 | Contract suite (5 files / 32 tests) + smoke suite (6 tests) created; scripts fixed | `tests/contract/`, `tests/smoke/` |
320
+| WS0-05 | Root version unified to 1.1.2; changelog entry | `package.json`, `CHANGELOG.md` |
321
+| WS0-06 | Wiring-audit script (zero-caller fns, dead columns, orphaned components/tables) + allowlist + CI gate | `scripts/wiring-audit.ts` — 217 findings baselined, 0 new allowed |
322
+| — | **Real bug fix**: glob `**` collapsed to one level (`.replace` re-processing); `!`-negation in `paths:`; `on: workflow_dispatch` shorthand | `src/lib/pipeline.ts` |
323
+| — | OpenAPI: added `/repos`, `/issues`, `/issues/{number}`, `/branches`, `/labels`, `/pulls/{n}/labels`, merge-method body | `src/lib/openapi.ts` |
324
+| WS2-01..04 | Issue assignees, milestone assignment, custom field values, workflow transitions all wired (create + PATCH) | `src/pages/api/repos/[o]/[r]/issues/*` |
325
+| WS2-06..09 | PR labels API (GET/POST/DELETE), draft toggle (create+PATCH+UI), merge-method selector (merge/squash/rebase in `mergeBranch`), review-thread resolve | `src/lib/git.ts`, `pulls/*`, `[number].astro`, `new.astro` |
326
+| WS2-12 | Real contribution graph from activity table (PG `date_trunc` + SQLite fallback); seeded-random generator deleted | `src/pages/[owner]/index.astro` |
327
+| WS1-01 | Workflow-run persistence bridge: `workflowRuns`/`workflowJobs` rows pre-created with engine IDs; run/job state synced; skipped steps backfilled; logs now land on real FKs | `src/lib/workflow-run-persister.ts`, `pipeline.ts` ID threading |
328
+| WS1-03 | `uses:` steps dispatched to polling runner via server-side action resolver (checkout→native clone, composite→run lines, node/docker→fail-fast message) | `src/lib/action-resolver.ts`, `runners/poll.ts` |
329
+| WS1-04 | Repo secrets injected into in-process runs (decrypted only at dispatch) | `src/lib/workflow-secrets.ts`, `workflows.ts` |
330
+| WS1-14 | Executor `NETWORK_MODE` default `none` → `bridge` | `src/runner/executor.ts` |
331
+| WS4-03 | GitHub consumer OAuth wired (routes + login buttons, env-gated) | `src/pages/api/auth/github/*`, `login.astro` |
332
+| — | Webhook delivery retries (5 attempts, exponential backoff, `X-OpenCodeHub-Attempt`, no-retry on 4xx) | `src/lib/webhooks.ts` |
333
+| WS4-01 | Fine-grained PATs: `scopes` column + migration `drizzle/0001`, payload propagation, `canWriteRepo`/`canAdminRepo` enforcement across 70+ call sites, token UI scopes, `hasPatScope` unit tests | `users.ts`, `permissions.ts`, `auth.ts`, `tokens.ts`, `TokensManager.tsx`, `drizzle/0001_*.sql` |
334
+
335
+### Verification
336
+- `tsc --noEmit` — 0 errors · `astro check` — 0 errors · `astro build` — succeeds
337
+- Full suite: **595 tests passing** (was 531) across 122 files — unit 319, integration 229, contract 32, smoke 6, top-level 9
338
+- Wiring audit: 0 new findings (baseline 217, down from 296 — wiring work itself removed findings)
339
+
340
+## 13. Execution Log — Batch 2 (collaboration + CI + orgs, 2026-08-10)
341
+
342
+### Shipped ✅
343
+| ID | Item | Evidence |
344
+|---|---|---|
345
+| WS2-05 | Issue templates: `.github/ISSUE_TEMPLATE/*` (YAML + legacy md) parsed + template chooser on new-issue page | `src/pages/api/repos/[o]/[r]/issues/templates.ts`, `issues/new.astro` |
346
+| WS2-10 | @mention parsing (email-safe regex) + notifications on issue/PR/comment create | `src/lib/mentions.ts` + 4 call sites, `tests/unit/mentions.test.ts` |
347
+| WS2-11 | Watch/unwatch API (levels: watching/releases_only/ignoring) + live header button with dropdown | `src/pages/api/repos/[o]/[r]/subscription.ts`, `RepoActions.tsx` |
348
+| WS2-06 | PR labels UI on PR detail page (list/add/remove) | `pulls/[number].astro` |
349
+| WS2-14 | Wiki page delete endpoint | `wiki/[slug].ts` DELETE |
350
+| WS2-15 | Release asset upload/download/delete via storage adapter (multipart; downloads streamed with count) | `releases/[id]/assets/index.ts` |
351
+| WS1-05 | **Matrix strategy**: `expandMatrix` (cartesian + exclude + include-merge), per-instance context/env, `job (combo)` naming, persister pre-creates matrix job rows | `src/lib/pipeline.ts`, `workflow-run-persister.ts`, `tests/contract/matrix-expansion.test.ts` |
352
+| WS1-08 | **Cron scheduler**: `schedule:` triggers evaluated with cron-parser, tracked in `scheduledWorkflows`, runs triggered via persister, wired into worker loop (`SCHEDULE_INTERVAL`) | `src/lib/schedule-worker.ts`, `scripts/worker.ts` |
353
+| WS3-01/02 | Org CRUD API (create/list/update/delete with owner/admin enforcement) + org list/create page + org profile page (repos, members) + org settings page | `src/pages/api/orgs/index.ts`, `src/pages/orgs/{index,[org]}.astro`, `[org]/settings.astro` |
354
+| — | Wiring audit now includes `scripts/` callers (worker was a legit caller) | `scripts/wiring-audit.ts` |
355
+
356
+### Verification (batch 2)
357
+- Full suite: **608 tests passing** (was 595) across 124 files — contract suite now 39 tests
358
+- `tsc` 0 errors · `astro check` 0 errors · `astro build` succeeds · wiring audit 0 new (204 baselined, down from 217)
359
+
360
+## 14. Execution Log — Batch 3 (PR assignees, OAuth provider, GraphQL, spec CI, invites, 2026-08-10)
361
+
362
+### Shipped ✅
363
+| ID | Item | Evidence |
364
+|---|---|---|
365
+| WS2-13 | PR assignees API (GET/POST/DELETE, dead junction now live) + single-PR request-review endpoint (notifications + email + author-exclusion) | `pulls/[n]/assignees.ts`, `pulls/[n]/requested-reviewers.ts` |
366
+| WS4-02 | **OAuth-provider mode**: `oauth_apps` + `oauth_authorization_codes` tables (migrations 0002), app registration (client_secret hashed, shown once), consent page (`/api/oauth/authorize` GET/POST), token endpoint (authorization_code grant, one-time codes, 10-min TTL), userinfo endpoint (bearer), HS256 access tokens with app+scope claims | `src/lib/oauth-provider.ts`, `src/pages/api/oauth/*`, `tests/unit/oauth-provider.test.ts` |
367
+| WS5-02 | **GraphQL expansion**: new queries `issue`, `organization`; mutations `createIssue` (labels/assignees/milestone), `updateIssue` (state/milestone), `updatePullRequest` (title/body/draft), `addLabels` (issue+PR, union result); Issue nested resolvers (comments/assignees/labels/milestone) wired to real data (were stubs) | `schema.ts`, `resolvers.ts`, `tests/contract/graphql-schema.test.ts` |
368
+| WS1-09 | **Speculative-build CI executes**: `triggerSpecWorkflow` runs workflows on `mq-spec-*` branches (triggers evaluated against base branch, runs persisted against spec branch); queue worker calls it after `createSpeculativeBranch` | `src/lib/workflows.ts`, `src/lib/queue-worker.ts` |
369
+| WS3-02 | **Org invites**: `org_invites` table (migration 0003), invite create (username/email, role, 7-day TTL, token hashed, email with accept link), list/revoke, accept flow (user-bound check, membership insert), invite accept page + settings-page invite UI | `src/lib/../pages/api/orgs/[org]/invites*.ts`, `accept-invite.ts`, `orgs/[org]/invite.astro`, `settings.astro` |
370
+| — | OpenAPI: PR assignees + requested-reviewers paths documented | `src/lib/openapi.ts` |
371
+
372
+### Verification (batch 3)
373
+- Full suite: **618 tests passing** across 126 files (contract suite now 44, unit 330)
374
+- `tsc` 0 errors · `astro check` 0 errors · `astro build` succeeds · wiring audit 0 new (204 baselined)
375
+
376
+## 15. Execution Log — Batch 4 (OpenAPI tooling, queue gating, OAuth UI, SAML config, teams, 2026-08-10)
377
+
378
+### Shipped ✅
379
+| ID | Item | Evidence |
380
+|---|---|---|
381
+| WS5-01 | **OpenAPI 25.7% → 100%**: `scripts/openapi-coverage.ts` (route-to-spec measurement, canonical `{param}` matching, `--fail` + threshold) + `scripts/openapi-generate.ts` (deterministic auto-generation of generic entries for undocumented routes, idempotent via explicit block strip) + CI job `OpenAPI Coverage` (threshold 60) + `openapi:coverage`/`openapi:generate` scripts | `scripts/openapi-*.ts`, `.github/workflows/ci.yml`, `src/lib/openapi.ts` (214 auto + hand-tuned) |
382
+| WS1-15 | **Merge-queue CI gating reads DB runs**: `getQueueItemCIState` now falls back to persisted `workflowRuns` (by PR head branch, latest 5) when no external checks exist — pending/failed/success from native pipeline runs | `src/lib/queue-worker.ts` |
383
+| WS4-02b | OAuth app management UI: `/settings/oauth-apps` (register form with redirect URIs + scopes, one-time credential display, list with delete) + nav link; DELETE moved to nested route | `src/pages/settings/oauth-apps.astro`, `oauth/apps/[id].ts`, `SettingsLayout.astro` |
384
+| WS4-04 | **SAML config wiring**: org-scoped `saml-config` API (GET/POST/DELETE, admin-gated, validates IdP entity/SSO URL/cert) — the `samlConfigs` table + validation machinery existed but nothing wrote rows; settings-page SAML section (fields + enable toggle + SP metadata URL + member sign-in URL) | `src/pages/api/orgs/[org]/saml-config.ts`, `orgs/[org]/settings.astro` |
385
+| WS3-03 | **First-party teams**: org teams CRUD API (list/create with slug uniqueness, update/delete admin-gated) + team members add/remove (nested route) + settings-page teams UI (create, member roster, delete) | `orgs/[org]/teams.ts`, `teams/[teamId].ts`, `teams/[teamId]/members.ts`, `settings.astro` |
386
+| — | Wiring audit findings dropped 204 → 203 (new features wired) | `scripts/wiring-audit.ts` |
387
+
388
+### Verification (batch 4)
389
+- Full suite: **618 tests passing** across 126 files · `tsc` 0 errors · `astro check` 0 errors · `astro build` succeeds
390
+- OpenAPI coverage: **100% (274/274)** · generator idempotent (re-run = no changes) · wiring audit 0 new
391
+
392
+## 16. Execution Log — Batch 5 (UI completion, CI bridge, org transfer, a11y, chaos suite, 2026-08-10)
393
+
394
+### Shipped ✅
395
+| ID | Item | Evidence |
396
+|---|---|---|
397
+| UI | RepoHeader tabs now include **Releases, Packages, Branches** (were missing) + standalone **tags page** (tag list with author/date/message from `for-each-ref`) | `RepoHeader.astro`, `[owner]/[repo]/tags.astro` |
398
+| UI | **Issue assignee UI** (sidebar: assignee chips, unassign, collaborator dropdown — API existed, UI was missing) · **Wiki delete button** (confirm → DELETE) · **Release assets UI** (upload form, download links with size/download-count, delete — links were previously broken `asset.url`) | `IssueDetail.tsx`, `wiki/[...slug].astro`, `releases/[id].astro` |
399
+| CI bridge | **Native runs → pullRequestChecks**: run persister now upserts a check run per job (workflow/job name, conclusion, timestamps) onto every open PR whose head branch matches — checks UI + queue gating see native pipeline results | `workflow-run-persister.ts` |
400
+| WS3-05 | **Org repo transfer**: `POST /repos/{o}/{r}/transfer` (repo-admin + org-owner/admin gated, transfers `ownerId`/`ownerType`, creator becomes owner collaborator) | `repos/[owner]/[repo]/transfer.ts` |
401
+| a11y | Axe coverage expanded: Dashboard, Notifications, Settings, Organizations pages (4 new specs) | `tests/e2e/accessibility.spec.ts` |
402
+| WS7-01 | **Multi-instance chaos suite**: lock exclusivity, `withLock` single-execution under contention, monotonic fencing tokens, optimistic DB claim race (exactly one winner) | `tests/unit/chaos-multi-instance.test.ts` |
403
+| WS5-01 | OpenAPI generator hardened: regenerates the **full** auto set (not deltas), brace-counted block strip, idempotent across repeated runs (verified 3×), 100% coverage at 275 routes | `scripts/openapi-generate.ts` |
404
+
405
+### Verification (batch 5)
406
+- Full suite: **622 tests passing** (127 files) · `tsc` 0 errors · `astro check` 0 errors · `astro build` succeeds
407
+- OpenAPI coverage **100% (275/275)** with a fully idempotent generator · wiring audit 0 new (203 baselined)
408
+
409
+### Remaining for full GA
410
+- WS7 observability (OTel traces, SLO dashboards), i18n, org repo-transfer UI page, package-registry npm publish completion, PR checks UI parity polish, OAuth app management is done — next: refresh token flow
411
+
412
+### Remaining for full GA
413
+- WS7 scale/observability (OTel traces, multi-instance chaos suite), PR-level check runs from native runs (checks UI parity), org repo transfer UI, i18n, a11y expansion to authenticated pages, package-registry completion (npm publish flow)
414
+
415
+### Remaining for full GA
416
+- WS5-01 OpenAPI ≥80% coverage, WS4-04 SAML config wiring, WS7 scale/observability (OTel, chaos suite), WS1-15 queue gating from DB runs, team management UI (SCIM-only today), OAuth app management UI page
417
+
418
+### Remaining for full GA
419
+- WS3-02 member invites/teams UI, WS5-02 GraphQL expansion, WS5-01 OpenAPI ≥80%, WS4-02 OAuth-provider mode, WS1-09 speculative-build CI execution, WS4-04 SAML wiring, WS7 scale/observability work
420
+
421
+### Remaining for full GA (not done in sprint)
422
+- WS3 org/team management, WS5-02 GraphQL expansion, WS5-01 OpenAPI ≥80%, WS4-02 OAuth-provider mode, WS1-05 matrix expansion, WS1-08 cron scheduler, WS1-09 speculative-build CI execution, WS4-04 SAML wiring, WS7 scale/observability work
423
+
424
+---
425
+
426
+*This plan supersedes `notimplemented.md` (which remains as historical context). `doc/feature_audit.md` statuses should be re-reconciled after each phase.*
0
427
▼src/pages/api/change-sets/index.ts
+1-1
59
59
for (const input of parsed.data.items) {
60
60
const repo = await resolveRepo(input.owner, input.repo);
61
61
if (!repo) return notFound(`Repository not found: ${input.owner}/${input.repo}`);
62
- if (!(await canWriteRepo(user.userId, repo, { isAdmin: user.isAdmin }))) {
62
+ if (!(await canWriteRepo(user.userId, repo, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
63
63
return forbidden(`No write access to ${input.owner}/${input.repo}`);
64
64
}
65
65
66
66
▼src/pages/api/projects/[id]/cards/[cardId].ts
+2-2
23
23
});
24
24
if (!project) return notFound("Project not found");
25
25
26
- if (!(await canWriteRepo(user.userId, project.repository, { isAdmin: user.isAdmin }))) {
26
+ if (!(await canWriteRepo(user.userId, project.repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
27
27
return unauthorized("Write access required");
28
28
}
29
29
58
58
});
59
59
if (!project) return notFound("Project not found");
60
60
61
- if (!(await canWriteRepo(user.userId, project.repository, { isAdmin: user.isAdmin }))) {
61
+ if (!(await canWriteRepo(user.userId, project.repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
62
62
return unauthorized("Write access required");
63
63
}
64
64
65
65
▼src/pages/api/repos/[owner]/[repo]/branches/[branch].ts
+1-1
46
46
return notFound("Repository not found");
47
47
}
48
48
49
- if (!(await canWriteRepo(user.id, repo))) {
49
+ if (!(await canWriteRepo(user.id, repo, { tokenScopes: user.scopes }))) {
50
50
return forbidden();
51
51
}
52
52
53
53
▼src/pages/api/repos/[owner]/[repo]/cloud/deploy.ts
+1-1
45
45
46
46
const repository = await resolveRepository(owner, repoName);
47
47
if (!repository) return notFound("Repository not found");
48
- if (!(await canWriteRepo(user.id, repository, { isAdmin: user.isAdmin }))) {
48
+ if (!(await canWriteRepo(user.id, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
49
49
return forbidden();
50
50
}
51
51
52
52
▼src/pages/api/repos/[owner]/[repo]/iac/hooks.ts
+1-1
45
45
46
46
const repository = await resolveRepository(owner, repoName);
47
47
if (!repository) return notFound("Repository not found");
48
- if (!(await canWriteRepo(user.id, repository, { isAdmin: user.isAdmin }))) {
48
+ if (!(await canWriteRepo(user.id, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
49
49
return forbidden();
50
50
}
51
51
52
52
▼src/pages/api/repos/[owner]/[repo]/integrations/cloud.ts
+1-1
110
110
const repository = await resolveRepository(owner, repoName);
111
111
if (!repository) return notFound("Repository not found");
112
112
113
- if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
113
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
114
114
return forbidden();
115
115
}
116
116
117
117
▼src/pages/api/repos/[owner]/[repo]/integrations/cloud/[id].ts
+2-2
54
54
const repository = await resolveRepository(owner, repoName);
55
55
if (!repository) return notFound("Repository not found");
56
56
57
- if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
57
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
58
58
return forbidden();
59
59
}
60
60
103
103
const repository = await resolveRepository(owner, repoName);
104
104
if (!repository) return notFound("Repository not found");
105
105
106
- if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
106
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
107
107
return forbidden();
108
108
}
109
109
110
110
▼src/pages/api/repos/[owner]/[repo]/integrations/code-quality.ts
+1-1
108
108
const repository = await resolveRepository(owner, repoName);
109
109
if (!repository) return notFound("Repository not found");
110
110
111
- if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
111
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
112
112
return forbidden();
113
113
}
114
114
115
115
▼src/pages/api/repos/[owner]/[repo]/integrations/code-quality/[id].ts
+2-2
64
64
const repository = await resolveRepository(owner, repoName);
65
65
if (!repository) return notFound("Repository not found");
66
66
67
- if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
67
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
68
68
return forbidden();
69
69
}
70
70
125
125
const repository = await resolveRepository(owner, repoName);
126
126
if (!repository) return notFound("Repository not found");
127
127
128
- if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
128
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
129
129
return forbidden();
130
130
}
131
131
132
132
▼src/pages/api/repos/[owner]/[repo]/integrations/external-ci.ts
+1-1
131
131
const repository = await resolveRepository(owner, repoName);
132
132
if (!repository) return notFound("Repository not found");
133
133
134
- if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
134
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
135
135
return forbidden();
136
136
}
137
137
138
138
▼src/pages/api/repos/[owner]/[repo]/integrations/external-ci/[id].ts
+2-2
63
63
const repository = await resolveRepository(owner, repoName);
64
64
if (!repository) return notFound("Repository not found");
65
65
66
- if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
66
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
67
67
return forbidden();
68
68
}
69
69
123
123
const repository = await resolveRepository(owner, repoName);
124
124
if (!repository) return notFound("Repository not found");
125
125
126
- if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
126
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
127
127
return forbidden();
128
128
}
129
129
130
130
▼src/pages/api/repos/[owner]/[repo]/integrations/issue-trackers.ts
+1-1
116
116
const repository = await resolveRepository(owner, repoName);
117
117
if (!repository) return notFound("Repository not found");
118
118
119
- if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
119
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
120
120
return forbidden();
121
121
}
122
122
123
123
▼src/pages/api/repos/[owner]/[repo]/integrations/issue-trackers/[id].ts
+2-2
64
64
const repository = await resolveRepository(owner, repoName);
65
65
if (!repository) return notFound("Repository not found");
66
66
67
- if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
67
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
68
68
return forbidden();
69
69
}
70
70
125
125
const repository = await resolveRepository(owner, repoName);
126
126
if (!repository) return notFound("Repository not found");
127
127
128
- if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
128
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
129
129
return forbidden();
130
130
}
131
131
132
132
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/checks.ts
+3-11
19
19
}
20
20
21
21
const db = getDatabase() as NodePgDatabase<typeof schema>;
22
- const ownerUser = await db.query.users.findFirst({
23
- where: eq(schema.users.username, owner),
24
- });
25
- if (!ownerUser) return notFound("Repository not found");
26
-
27
- const repository = await db.query.repositories.findFirst({
28
- where: and(
29
- eq(schema.repositories.ownerId, ownerUser.id),
30
- eq(schema.repositories.name, repoName)
31
- ),
32
- });
22
+ const { resolveOwnerRepo } = await import("@/lib/repo-owner");
23
+ const resolved = await resolveOwnerRepo(owner!, repoName);
24
+ const repository = resolved?.repository;
33
25
if (!repository) return notFound("Repository not found");
34
26
35
27
if (!(await canReadRepo(user.id, repository, { isAdmin: user.isAdmin }))) {
36
28
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/comments.ts
+33-10
26
26
suggestionContent: z.string().optional(),
27
27
});
28
28
29
-const updateCommentSchema = z.object({
30
- body: z.string().min(1),
31
-});
29
+const updateCommentSchema = z
30
+ .object({
31
+ body: z.string().min(1).optional(),
32
+ // WS2-09: review threads can now be resolved/unresolved
33
+ resolved: z.boolean().optional(),
34
+ })
35
+ .refine((d) => d.body !== undefined || d.resolved !== undefined, {
36
+ message: "body or resolved must be provided",
37
+ });
32
38
33
39
import { withErrorHandler } from "@/lib/errors";
34
40
import { logger } from "@/lib/logger";
212
218
213
219
logger.info({ userId: tokenPayload.userId, repoId: pr.repository.id, prId: pr.id, commentId }, "PR comment created");
214
220
221
+ // Notify @mentioned users (WS2-10)
222
+ import("@/lib/mentions").then(({ notifyMentionedUsers }) => {
223
+ notifyMentionedUsers({
224
+ text: body,
225
+ actorId: tokenPayload.userId,
226
+ repositoryId: pr.repository.id,
227
+ subjectType: "comment",
228
+ subjectId: commentId,
229
+ url: `/${owner}/${repo}/pulls/${number}#comment-${commentId}`,
230
+ titlePrefix: "mentioned you in a comment",
231
+ }).catch((err) => logger.error({ err }, "Mention notification failed"));
232
+ });
233
+
215
234
return success({ comment, message: "Comment created" });
216
235
});
217
236
250
269
return notFound("Comment not found");
251
270
}
252
271
253
- // Check ownership
254
- if (comment.authorId !== tokenPayload.userId) {
272
+ // Check ownership (only required for content edits; resolving a thread
273
+ // is allowed for anyone with write access, matching GitHub semantics)
274
+ if (parsed.data.body !== undefined && comment.authorId !== tokenPayload.userId) {
255
275
return unauthorized("Not your comment");
256
276
}
257
277
268
288
}
269
289
270
290
// Update
291
+ const commentUpdate: any = { isEdited: true, editedAt: new Date() };
292
+ if (parsed.data.body !== undefined) commentUpdate.body = parsed.data.body;
293
+ if (parsed.data.resolved !== undefined) {
294
+ commentUpdate.isResolved = parsed.data.resolved;
295
+ commentUpdate.resolvedById = parsed.data.resolved ? tokenPayload.userId : null;
296
+ commentUpdate.resolvedAt = parsed.data.resolved ? new Date() : null;
297
+ }
271
298
await db
272
299
.update(schema.pullRequestComments)
273
- .set({
274
- body: parsed.data.body,
275
- isEdited: true,
276
- editedAt: new Date(),
277
- })
300
+ .set(commentUpdate)
278
301
.where(eq(schema.pullRequestComments.id, commentId as string));
279
302
280
303
logger.info({ userId: tokenPayload.userId, commentId }, "PR comment updated");
281
304
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/file-approvals.ts
+1-1
107
107
108
108
if (!repository) return notFound("Repository not found");
109
109
110
- if (!(await canWriteRepo(user.id, repository))) {
110
+ if (!(await canWriteRepo(user.id, repository, { tokenScopes: user.scopes }))) {
111
111
return forbidden();
112
112
}
113
113
114
114
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/index.ts
+3-2
81
81
82
82
if (!repo) return notFound("Repository not found");
83
83
84
- if (!(await canWriteRepo(user.id, repo))) {
84
+ if (!(await canWriteRepo(user.id, repo, { tokenScopes: user.scopes }))) {
85
85
return forbidden();
86
86
}
87
87
98
98
if (!body || typeof body !== "object") {
99
99
return badRequest("Invalid request body");
100
100
}
101
- const { title, body: description, state } = body;
101
+ const { title, body: description, state, draft } = body;
102
102
103
103
const updateData: any = { updatedAt: new Date() };
104
104
if (title !== undefined) updateData.title = title;
105
105
if (description !== undefined) updateData.body = description;
106
+ if (draft !== undefined) updateData.isDraft = draft === true;
106
107
107
108
// Handle state change
108
109
let stateChanged = false;
109
110
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/issue-links.ts
+1-1
73
73
74
74
if (!repository) return notFound("Repository not found");
75
75
76
- if (!(await canWriteRepo(user.id, repository))) {
76
+ if (!(await canWriteRepo(user.id, repository, { tokenScopes: user.scopes }))) {
77
77
return forbidden();
78
78
}
79
79
80
80
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/issue-links/[id].ts
+1-1
35
35
36
36
if (!repository) return notFound("Repository not found");
37
37
38
- if (!(await canWriteRepo(user.id, repository))) {
38
+ if (!(await canWriteRepo(user.id, repository, { tokenScopes: user.scopes }))) {
39
39
return forbidden();
40
40
}
41
41
42
42
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/merge.ts
+23-3
15
15
16
16
// ... existing imports ...
17
17
18
-export const POST: APIRoute = withErrorHandler(async ({ params, locals }) => {
18
+export const POST: APIRoute = withErrorHandler(async ({ params, locals, request }) => {
19
19
const { owner: ownerName, repo: repoName, number } = params;
20
20
const user = locals.user;
21
21
28
28
return badRequest("Missing parameters");
29
29
}
30
30
31
+ // Parse merge method (WS2-08: manual merge previously always used a merge commit)
32
+ let mergeMethod: "merge" | "squash" | "rebase" = "merge";
33
+ try {
34
+ const rawBody = await request.text();
35
+ if (rawBody) {
36
+ const body = JSON.parse(rawBody);
37
+ if (body.mergeMethod) {
38
+ if (!["merge", "squash", "rebase"].includes(body.mergeMethod)) {
39
+ return badRequest("mergeMethod must be one of: merge, squash, rebase");
40
+ }
41
+ mergeMethod = body.mergeMethod;
42
+ }
43
+ }
44
+ } catch {
45
+ // non-JSON or empty body — default to merge
46
+ }
47
+
31
48
// Check repo existence and permissions
32
49
const db = getDatabase() as NodePgDatabase<typeof schema>;
33
50
const repoOwner = await db.query.users.findFirst({
49
66
return notFound("Repository not found");
50
67
}
51
68
52
- if (!(await canWriteRepo(user.id, repo, { isAdmin: user.isAdmin }))) {
69
+ if (!(await canWriteRepo(user.id, repo, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
53
70
return forbidden();
54
71
}
55
72
111
128
return conflict(`Merge blocked: ${failedGates}`);
112
129
}
113
130
// Merge branch
114
- const result = await mergeBranch(repoPath, pr.baseBranch, pr.headBranch);
131
+ const result = await mergeBranch(repoPath, pr.baseBranch, pr.headBranch, undefined, mergeMethod);
115
132
116
133
if (result.success) {
117
134
// Update PR state with all merge fields
122
139
isMerged: true,
123
140
mergedAt: now,
124
141
mergedById: user.id,
142
+ mergeCommitSha: result.sha || null,
143
+ mergeSha: result.sha || null,
144
+ mergeMethod,
125
145
updatedAt: now,
126
146
})
127
147
.where(eq(schema.pullRequests.id, pr.id));
128
148
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/reviews.ts
+1-1
153
153
return notFound("Repository not found");
154
154
}
155
155
156
- if (state !== "COMMENTED" && !(await canWriteRepo(user.id, repo))) {
156
+ if (state !== "COMMENTED" && !(await canWriteRepo(user.id, repo, { tokenScopes: user.scopes }))) {
157
157
return forbidden("Write access required to approve or request changes");
158
158
}
159
159
160
160
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/rewrite.ts
+1-1
46
46
if (!pr) return new Response("PR not found", { status: 404 });
47
47
48
48
// 2. Check Permissions
49
- if (!(await canWriteRepo(user.id, repository))) {
49
+ if (!(await canWriteRepo(user.id, repository, { tokenScopes: (user as any).scopes }))) {
50
50
return new Response("Forbidden", { status: 403 });
51
51
}
52
52
53
53
▼src/pages/api/repos/[owner]/[repo]/pulls/bulk-merge.ts
+1-1
36
36
),
37
37
});
38
38
if (!repo) return notFound("Repository not found");
39
- if (!(await canWriteRepo(user.id, repo))) return forbidden();
39
+ if (!(await canWriteRepo(user.id, repo, { tokenScopes: user.scopes }))) return forbidden();
40
40
41
41
const body = await request.json().catch(() => null);
42
42
const parsed = bulkMergeSchema.safeParse(body || {});
43
43
▼src/pages/api/repos/[owner]/[repo]/pulls/bulk-queue-manage.ts
+1-1
56
56
),
57
57
});
58
58
if (!repo) return notFound("Repository not found");
59
- if (!(await canWriteRepo(user.id, repo, { isAdmin: user.isAdmin }))) {
59
+ if (!(await canWriteRepo(user.id, repo, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
60
60
return forbidden();
61
61
}
62
62
63
63
▼src/pages/api/repos/[owner]/[repo]/pulls/bulk-queue.ts
+1-1
52
52
),
53
53
});
54
54
if (!repo) return notFound("Repository not found");
55
- if (!(await canWriteRepo(user.id, repo, { isAdmin: user.isAdmin }))) {
55
+ if (!(await canWriteRepo(user.id, repo, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
56
56
return forbidden();
57
57
}
58
58
59
59
▼src/pages/api/repos/[owner]/[repo]/pulls/index.ts
+16-2
52
52
return notFound("Repository not found");
53
53
}
54
54
55
- if (!(await canWriteRepo(user.id, repo, { isAdmin: user.isAdmin }))) {
55
+ if (!(await canWriteRepo(user.id, repo, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
56
56
return forbidden();
57
57
}
58
58
59
59
// Parse body
60
60
const body = await request.json();
61
- const { title, body: description, base, head } = body;
61
+ const { title, body: description, base, head, draft } = body;
62
62
63
63
if (!title || !base || !head) {
64
64
return badRequest("Missing required fields");
145
145
title: cleanTitle,
146
146
body: cleanBody,
147
147
state: "open",
148
+ isDraft: draft === true,
148
149
authorId: user.id,
149
150
headBranch: head,
150
151
headSha: headCommit.sha,
220
221
221
222
logger.info({ userId: user.id, repoId: repo.id, prNumber: number }, "Pull request created");
222
223
224
+ // Notify @mentioned users (WS2-10)
225
+ import("@/lib/mentions").then(({ notifyMentionedUsers }) => {
226
+ notifyMentionedUsers({
227
+ text: `${cleanTitle} ${cleanBody}`,
228
+ actorId: user.id,
229
+ repositoryId: repo.id,
230
+ subjectType: "pull_request",
231
+ subjectId: prId,
232
+ url: `/${ownerName}/${repoName}/pulls/${number}`,
233
+ titlePrefix: "mentioned you in a pull request",
234
+ }).catch((err) => logger.error({ err }, "Mention notification failed"));
235
+ });
236
+
223
237
// Send email notification to repo owner
224
238
if (repoOwner.email && repoOwner.id !== user.id) {
225
239
// Fire and forget
226
240
▼src/pages/api/repos/[owner]/[repo]/pulls/review-health.ts
+1-1
50
50
),
51
51
});
52
52
if (!repo) return notFound("Repository not found");
53
- if (!(await canWriteRepo(user.id, repo, { isAdmin: user.isAdmin }))) {
53
+ if (!(await canWriteRepo(user.id, repo, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
54
54
return forbidden();
55
55
}
56
56
57
57
▼src/pages/api/repos/[owner]/[repo]/queue/[id]/conflicts.ts
+2-2
33
33
if (!repository) return new Response("Repo not found", { status: 404 });
34
34
35
35
// 2. Check Permissions
36
- if (!(await canWriteRepo(user.id, repository))) {
36
+ if (!(await canWriteRepo(user.id, repository, { tokenScopes: (user as any).scopes }))) {
37
37
return new Response("Forbidden", { status: 403 });
38
38
}
39
39
74
74
75
75
if (!repository) return new Response("Repo not found", { status: 404 });
76
76
77
- if (!(await canWriteRepo(user.id, repository))) {
77
+ if (!(await canWriteRepo(user.id, repository, { tokenScopes: (user as any).scopes }))) {
78
78
return new Response("Forbidden", { status: 403 });
79
79
}
80
80
81
81
▼src/pages/api/repos/[owner]/[repo]/security/policies.ts
+1-1
68
68
69
69
const resolved = await resolveRepo(ownerName, repoName);
70
70
if (!resolved?.repo) return notFound("Repository not found");
71
- if (!(await canWriteRepo(user.id, resolved.repo))) return forbidden();
71
+ if (!(await canWriteRepo(user.id, resolved.repo, { tokenScopes: user.scopes }))) return forbidden();
72
72
73
73
const body = await request.json();
74
74
const parsed = PolicySchema.safeParse(body);
75
75
▼src/pages/api/repos/[owner]/[repo]/security/policy-actions.ts
+1-1
58
58
59
59
const resolved = await resolveRepo(ownerName, repoName);
60
60
if (!resolved?.repo) return notFound("Repository not found");
61
- if (!(await canWriteRepo(user.id, resolved.repo))) return forbidden();
61
+ if (!(await canWriteRepo(user.id, resolved.repo, { tokenScopes: user.scopes }))) return forbidden();
62
62
63
63
const policy = await getRepositorySecurityPolicy(resolved.repo.id);
64
64
const value = parsed.data.value.trim();
65
65
▼src/pages/api/repos/[owner]/[repo]/security/scan.ts
+1-1
37
37
38
38
if (!repo) return notFound("Not Found");
39
39
40
- if (!(await canWriteRepo(user.id, repo))) {
40
+ if (!(await canWriteRepo(user.id, repo, { tokenScopes: user.scopes }))) {
41
41
return forbidden();
42
42
}
43
43
44
44
▼src/pages/api/repos/[owner]/[repo]/security/secret-scan.ts
+1-1
29
29
});
30
30
31
31
if (!repo) return notFound("Not Found");
32
- if (!(await canWriteRepo(user.id, repo))) return forbidden();
32
+ if (!(await canWriteRepo(user.id, repo, { tokenScopes: user.scopes }))) return forbidden();
33
33
34
34
const repoPath = await resolveRepoPath(repo.diskPath);
35
35
const result = await scanRepositoryForSecrets({
36
36
▼src/pages/api/repos/[owner]/[repo]/security/secret-scan/[id].ts
+1-1
44
44
45
45
const resolved = await resolveRepo(ownerName, repoName);
46
46
if (!resolved?.repo) return notFound("Repository not found");
47
- if (!(await canWriteRepo(user.id, resolved.repo))) return forbidden();
47
+ if (!(await canWriteRepo(user.id, resolved.repo, { tokenScopes: user.scopes }))) return forbidden();
48
48
49
49
const finding = await resolved.db.query.secretScanResults.findFirst({
50
50
where: and(
51
51
▼src/pages/api/repos/[owner]/[repo]/settings/fields.ts
+1-1
60
60
const repository = await resolveRepository(owner, repoName);
61
61
if (!repository) return notFound("Repository not found");
62
62
63
- if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
63
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
64
64
return forbidden();
65
65
}
66
66
67
67
▼src/pages/api/repos/[owner]/[repo]/settings/fields/[id].ts
+1-1
36
36
const repository = await resolveRepository(owner, repoName);
37
37
if (!repository) return notFound("Repository not found");
38
38
39
- if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
39
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
40
40
return forbidden();
41
41
}
42
42
43
43
▼src/pages/api/repos/[owner]/[repo]/settings/mirror/sync.ts
+1-1
33
33
const repository = await resolveRepository(owner, repoName);
34
34
if (!repository) return notFound("Repository not found");
35
35
36
- if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
36
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
37
37
return forbidden();
38
38
}
39
39
40
40
▼src/pages/api/repos/[owner]/[repo]/subscription.ts
+119-0
1
+import type { APIRoute } from "astro";
2
+import { getDatabase, schema } from "@/db";
3
+import { and, eq } from "drizzle-orm";
4
+import { getUserFromRequest, getRepoAndUser } from "@/lib/auth";
5
+import { unauthorized, badRequest, success, notFound, serverError } from "@/lib/api";
6
+import { repositoryWatchers } from "@/db/schema";
7
+import type { NodePgDatabase } from "drizzle-orm/node-postgres";
8
+import { generateId } from "@/lib/utils";
9
+import { logger } from "@/lib/logger";
10
+
11
+const WATCH_LEVELS = ["watching", "releases_only", "ignoring"] as const;
12
+
13
+// GET: current watch state (null when not watching)
14
+export const GET: APIRoute = async ({ request, params }) => {
15
+ try {
16
+ const { owner, repo } = params;
17
+ if (!owner || !repo) return badRequest("Missing parameters");
18
+
19
+ const user = await getUserFromRequest(request);
20
+ if (!user) return unauthorized();
21
+
22
+ const repoData = await getRepoAndUser(request, owner, repo);
23
+ if (!repoData) return notFound("Repository not found");
24
+
25
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
26
+ const watcher = await db.query.repositoryWatchers.findFirst({
27
+ where: and(
28
+ eq(repositoryWatchers.repositoryId, repoData.repository.id),
29
+ eq(repositoryWatchers.userId, user.userId),
30
+ ),
31
+ });
32
+
33
+ return success({ watching: !!watcher, watchLevel: watcher?.watchLevel || null });
34
+ } catch (error) {
35
+ logger.error({ err: error }, "Failed to get watch state");
36
+ return serverError("Failed to get watch state");
37
+ }
38
+};
39
+
40
+// PUT: watch a repository { watchLevel?: "watching" | "releases_only" | "ignoring" }
41
+export const PUT: APIRoute = async ({ request, params }) => {
42
+ try {
43
+ const { owner, repo } = params;
44
+ if (!owner || !repo) return badRequest("Missing parameters");
45
+
46
+ const user = await getUserFromRequest(request);
47
+ if (!user) return unauthorized();
48
+
49
+ const repoData = await getRepoAndUser(request, owner, repo);
50
+ if (!repoData) return notFound("Repository not found");
51
+
52
+ let watchLevel: string = "watching";
53
+ try {
54
+ const body = await request.json();
55
+ if (body.watchLevel !== undefined) watchLevel = body.watchLevel;
56
+ } catch {
57
+ // empty body → default watching
58
+ }
59
+ if (!WATCH_LEVELS.includes(watchLevel as any)) {
60
+ return badRequest("watchLevel must be one of: watching, releases_only, ignoring");
61
+ }
62
+
63
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
64
+ const existing = await db.query.repositoryWatchers.findFirst({
65
+ where: and(
66
+ eq(repositoryWatchers.repositoryId, repoData.repository.id),
67
+ eq(repositoryWatchers.userId, user.userId),
68
+ ),
69
+ });
70
+
71
+ if (existing) {
72
+ await db
73
+ .update(repositoryWatchers)
74
+ .set({ watchLevel })
75
+ .where(eq(repositoryWatchers.id, existing.id));
76
+ } else {
77
+ await db.insert(repositoryWatchers).values({
78
+ id: generateId(),
79
+ repositoryId: repoData.repository.id,
80
+ userId: user.userId,
81
+ watchLevel,
82
+ });
83
+ }
84
+
85
+ return success({ watching: true, watchLevel });
86
+ } catch (error) {
87
+ logger.error({ err: error }, "Failed to watch repository");
88
+ return serverError("Failed to watch repository");
89
+ }
90
+};
91
+
92
+// DELETE: unwatch
93
+export const DELETE: APIRoute = async ({ request, params }) => {
94
+ try {
95
+ const { owner, repo } = params;
96
+ if (!owner || !repo) return badRequest("Missing parameters");
97
+
98
+ const user = await getUserFromRequest(request);
99
+ if (!user) return unauthorized();
100
+
101
+ const repoData = await getRepoAndUser(request, owner, repo);
102
+ if (!repoData) return notFound("Repository not found");
103
+
104
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
105
+ await db
106
+ .delete(repositoryWatchers)
107
+ .where(
108
+ and(
109
+ eq(repositoryWatchers.repositoryId, repoData.repository.id),
110
+ eq(repositoryWatchers.userId, user.userId),
111
+ ),
112
+ );
113
+
114
+ return success({ watching: false });
115
+ } catch (error) {
116
+ logger.error({ err: error }, "Failed to unwatch repository");
117
+ return serverError("Failed to unwatch repository");
118
+ }
119
+};
0
120
▼src/pages/api/repos/[owner]/[repo]/wiki/[slug].ts
+39-0
102
102
return serverError("Failed to update wiki page");
103
103
}
104
104
};
105
+
106
+// DELETE: Delete a wiki page (WS2-14)
107
+export const DELETE: APIRoute = async ({ request, params }) => {
108
+ try {
109
+ const { owner, repo, slug } = params;
110
+ if (!owner || !repo || !slug) return badRequest("Missing parameters");
111
+
112
+ const user = await getUserFromRequest(request);
113
+ if (!user) return unauthorized();
114
+
115
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
116
+ const repoData = await getRepoAndUser(request, owner, repo);
117
+
118
+ if (!repoData) return notFound("Repository not found");
119
+ if (repoData.permission !== "admin" && repoData.permission !== "write") {
120
+ return unauthorized("Write access required");
121
+ }
122
+
123
+ const page = await db.query.wikiPages.findFirst({
124
+ where: and(
125
+ eq(wikiPages.repositoryId, repoData.repository.id),
126
+ eq(wikiPages.slug, slug)
127
+ )
128
+ });
129
+
130
+ if (!page) return notFound("Wiki page not found");
131
+
132
+ // Revisions are cascade-deleted with the page
133
+ await db.delete(wikiPages).where(eq(wikiPages.id, page.id));
134
+
135
+ logger.info({ userId: user.userId, pageId: page.id }, "Wiki page deleted");
136
+
137
+ return success({ message: "Page deleted successfully" });
138
+
139
+ } catch (error) {
140
+ logger.error({ err: error }, "Failed to delete wiki page");
141
+ return serverError("Failed to delete wiki page");
142
+ }
143
+};
105
144
▼src/pages/api/repos/[owner]/[repo]/workflow/templates.ts
+1-1
69
69
const repository = await resolveRepository(owner, repoName);
70
70
if (!repository) return notFound("Repository not found");
71
71
72
- if (!(await canWriteRepo(user.id, repository, { isAdmin: user.isAdmin }))) {
72
+ if (!(await canWriteRepo(user.id, repository, { isAdmin: user.isAdmin, tokenScopes: user.scopes }))) {
73
73
return forbidden();
74
74
}
75
75
76
76
▼src/pages/api/repos/[repoId]/webhooks/[id].ts
+1-1
24
24
});
25
25
26
26
if (!repo) return notFound("Not Found");
27
- if (!await canWriteRepo(user.id, repo)) return forbidden();
27
+ if (!await canWriteRepo(user.id, repo, { tokenScopes: user.scopes })) return forbidden();
28
28
29
29
const webhook = await db.query.webhooks.findFirst({
30
30
where: and(
31
31
▼src/pages/api/repos/[repoId]/webhooks/index.ts
+1-1
24
24
});
25
25
26
26
if (!repo) return notFound("Not Found");
27
- if (!await canWriteRepo(user.id, repo)) return forbidden();
27
+ if (!await canWriteRepo(user.id, repo, { tokenScopes: user.scopes })) return forbidden();
28
28
29
29
const body = await request.json();
30
30
const {
31
31
▼src/pages/login.astro
+31-0
128
128
>
129
129
Sign in
130
130
</button>
131
+
132
+ <div class="flex items-center gap-3 my-2">
133
+ <div class="flex-1 h-px bg-border"></div>
134
+ <span class="text-xs text-muted-foreground">or continue with</span>
135
+ <div class="flex-1 h-px bg-border"></div>
136
+ </div>
137
+
138
+ <div class="grid grid-cols-2 gap-3">
139
+ {
140
+ import.meta.env.GITHUB_CLIENT_ID && (
141
+ <a
142
+ href="/api/auth/github"
143
+ class="flex items-center justify-center gap-2 rounded-lg border border-border bg-secondary px-4 py-2.5 text-sm font-medium text-foreground hover:border-cyan-500/40 hover:text-cyan-400 transition-all"
144
+ >
145
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56 0-.28-.01-1.02-.02-2-3.2.7-3.87-1.54-3.87-1.54-.52-1.33-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.19 1.76 1.19 1.03 1.75 2.69 1.25 3.35.95.1-.74.4-1.25.72-1.54-2.55-.29-5.24-1.28-5.24-5.68 0-1.26.45-2.28 1.19-3.09-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.18 1.18a11.1 11.1 0 0 1 5.8 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.76.11 3.05.74.81 1.19 1.83 1.19 3.09 0 4.41-2.7 5.38-5.26 5.66.41.35.77 1.05.77 2.12 0 1.53-.01 2.76-.01 3.14 0 .31.21.68.8.56A11.53 11.53 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5z"/></svg>
146
+ GitHub
147
+ </a>
148
+ )
149
+ }
150
+ {
151
+ import.meta.env.GOOGLE_CLIENT_ID && (
152
+ <a
153
+ href="/api/auth/google"
154
+ class="flex items-center justify-center gap-2 rounded-lg border border-border bg-secondary px-4 py-2.5 text-sm font-medium text-foreground hover:border-cyan-500/40 hover:text-cyan-400 transition-all"
155
+ >
156
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
157
+ Google
158
+ </a>
159
+ )
160
+ }
161
+ </div>
131
162
</form>
132
163
133
164
134
165