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,5 +1,5 @@ | ||
1 | 1 | { |
2 | 2 | "_variables": { |
3 | - "lastUpdateCheck": 1783961044818 | |
3 | + "lastUpdateCheck": 1785773647919 | |
4 | 4 | } |
5 | 5 | } |
6 | 6 | |
▼doc/production-parity-plan.md
+426-0
| @@ -0,0 +1,426 @@ | ||
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,7 +59,7 @@ export const POST: APIRoute = withErrorHandler(async ({ request }) => { | ||
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,7 +23,7 @@ export const DELETE: APIRoute = withErrorHandler(async ({ request, params }) => | ||
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,7 +58,7 @@ export const PATCH: APIRoute = withErrorHandler(async ({ request, params }) => { | ||
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,7 +46,7 @@ export const DELETE: APIRoute = withErrorHandler(async ({ params, locals }) => { | ||
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,7 +45,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, request, locals | ||
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,7 +45,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, request, locals | ||
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,7 +110,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, request }) => { | ||
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,7 +54,7 @@ export const PATCH: APIRoute = withErrorHandler(async ({ params, request }) => { | ||
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,7 +103,7 @@ export const DELETE: APIRoute = withErrorHandler(async ({ params, request }) => | ||
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,7 +108,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, request }) => { | ||
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,7 +64,7 @@ export const PATCH: APIRoute = withErrorHandler(async ({ params, request }) => { | ||
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,7 +125,7 @@ export const DELETE: APIRoute = withErrorHandler(async ({ params, request }) => | ||
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,7 +131,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, request }) => { | ||
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,7 +63,7 @@ export const PATCH: APIRoute = withErrorHandler(async ({ params, request }) => { | ||
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,7 +123,7 @@ export const DELETE: APIRoute = withErrorHandler(async ({ params, request }) => | ||
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,7 +116,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, request }) => { | ||
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,7 +64,7 @@ export const PATCH: APIRoute = withErrorHandler(async ({ params, request }) => { | ||
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,7 +125,7 @@ export const DELETE: APIRoute = withErrorHandler(async ({ params, request }) => | ||
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,17 +19,9 @@ export const GET: APIRoute = withErrorHandler(async ({ params, locals }) => { | ||
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,9 +26,15 @@ const createCommentSchema = z.object({ | ||
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,6 +218,19 @@ export const POST: APIRoute = withErrorHandler(async ({ params, request }) => { | ||
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,8 +269,9 @@ export const PATCH: APIRoute = withErrorHandler(async ({ params, request }) => { | ||
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,13 +288,16 @@ export const PATCH: APIRoute = withErrorHandler(async ({ params, request }) => { | ||
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,7 +107,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, locals, request | ||
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,7 +81,7 @@ export const PATCH: APIRoute = withErrorHandler(async ({ params, request, locals | ||
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,11 +98,12 @@ export const PATCH: APIRoute = withErrorHandler(async ({ params, request, locals | ||
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,7 +73,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, locals, request | ||
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,7 +35,7 @@ export const DELETE: APIRoute = withErrorHandler(async ({ params, locals }) => { | ||
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,7 +15,7 @@ import { checkPathPermissions } from "@/lib/path-scoping"; | ||
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,6 +28,23 @@ export const POST: APIRoute = withErrorHandler(async ({ params, locals }) => { | ||
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,7 +66,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, locals }) => { | ||
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,7 +128,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, locals }) => { | ||
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,6 +139,9 @@ export const POST: APIRoute = withErrorHandler(async ({ params, locals }) => { | ||
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,7 +153,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, request, locals | ||
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,7 +46,7 @@ export const POST: APIRoute = async ({ params, request, locals }) => { | ||
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,7 +36,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, locals, request | ||
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,7 +56,7 @@ export const POST: APIRoute = withErrorHandler( | ||
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,7 +52,7 @@ export const POST: APIRoute = withErrorHandler( | ||
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,13 +52,13 @@ export const POST: APIRoute = withErrorHandler(async ({ params, request, locals | ||
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,6 +145,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, request, locals | ||
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,6 +221,19 @@ export const POST: APIRoute = withErrorHandler(async ({ params, request, locals | ||
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,7 +50,7 @@ export const POST: APIRoute = withErrorHandler( | ||
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,7 +33,7 @@ export const GET: APIRoute = async ({ params, locals }) => { | ||
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,7 +74,7 @@ export const POST: APIRoute = async ({ params, request, locals }) => { | ||
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,7 +68,7 @@ export const PUT: APIRoute = withErrorHandler(async ({ locals, params, request } | ||
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,7 +58,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, locals, request | ||
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,7 +37,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, locals }) => { | ||
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,7 +29,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, locals }) => { | ||
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,7 +44,7 @@ export const PATCH: APIRoute = withErrorHandler(async ({ params, locals, request | ||
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,7 +60,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, request }) => { | ||
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,7 +36,7 @@ export const DELETE: APIRoute = withErrorHandler(async ({ params, request }) => | ||
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,7 +33,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, request }) => { | ||
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
| @@ -0,0 +1,119 @@ | ||
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,3 +102,42 @@ export const PATCH: APIRoute = async ({ request, params }) => { | ||
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,7 +69,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, request, locals | ||
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,7 +24,7 @@ export const DELETE: APIRoute = withErrorHandler(async ({ params, locals }) => { | ||
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,7 +24,7 @@ export const POST: APIRoute = withErrorHandler(async ({ params, request, locals | ||
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,6 +128,37 @@ Astro.response.headers.append("Set-Cookie", cookie); | ||
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 | |