@volter/twin-github 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,12 +10,56 @@
10
10
  // declared scope rather than fabricated. The shapes match GitHub's REST objects (R1).
11
11
  import { createHash } from 'node:crypto';
12
12
  import { applyTwinWrite, isEgressEventType, listEvents, pendingActions } from '@volter/twin';
13
+ // The REAL git plane (bare repos + spawned git — see github-git-plane.ts). Git Data writes
14
+ // materialize REAL objects there (real shas); reads fall back to it for objects that only
15
+ // exist in the plane (a `git push` via smart HTTP); ref writes materialize whenever the
16
+ // target object exists. One object store, not two.
17
+ import {
18
+ deleteBareRepo, deleteGitRefInPlane, diffFilesInPlane, ensureBareRepo, forkBareRepo,
19
+ isAncestorInPlane, isValidBranchName, listGitPlaneRefs, mergeIntoBranchInPlane, objectExistsInPlane, objectTypeInPlane,
20
+ readGitBlobFromPlane, readGitCommitFromPlane, readGitTagFromPlane, readGitTreeFromPlane,
21
+ setBareRepoHead, updateGitRefInPlane, writeGitBlobToPlane, writeGitCommitToPlane, writeGitTagToPlane, writeGitTreeToPlane,
22
+ } from './github-git-plane.ts';
13
23
 
14
24
  const SERVICE = 'github';
15
25
 
16
26
  // Deterministic 40-hex merge-commit sha derived from repo+number — NEVER random
17
27
  // (repo forbids Math.random/Date.now in non-runtime code). Stable per repo+number,
18
28
  // and a PR can only merge once (a second attempt 405s), so this is unique per merge.
29
+ // ── Landing a pull request: the gates, then the merge ───────────────────────────────────────
30
+ // The base branch's protection names required status checks; each must be a successful check run or
31
+ // commit status on the pull request's head sha (real GitHub: 405 "Required status check … is expected.").
32
+ function requiredChecksMissing(st: GithubState, repo: string, baseRef: string, headSha: string): string[] {
33
+ const branch = st.branches.find((b) => b.repository === repo && b.name === baseRef);
34
+ const contexts = branch?.protection_config?.required_status_checks?.contexts ?? [];
35
+ return contexts.filter((ctx) =>
36
+ !st.checkRuns.some((c) => c.repository === repo && c.head_sha === headSha && c.name === ctx && c.conclusion === 'success')
37
+ && !st.statuses.some((c) => c.repository === repo && c.sha === headSha && c.context === ctx && c.state === 'success'));
38
+ }
39
+ // Merge = the gates, then a REAL merge commit on the git plane when the repository has one (main moves
40
+ // on the git wire, as it does on GitHub — a REST merge that left the clone behind was a fake success),
41
+ // then the pull request folded closed+merged. Without a plane the merge sha is the deterministic one.
42
+ async function mergePullRequest(repo: string, pr: GithubPr, method: 'merge' | 'squash' | 'rebase', occurredAt: string, root?: string): Promise<{ ok: true; sha: string } | { ok: false; status: number; message: string }> {
43
+ const missing = requiredChecksMissing(githubState(root), repo, pr.base_ref ?? 'main', pr.head_sha ?? '');
44
+ if (missing.length) return { ok: false, status: 405, message: `Required status check "${missing[0]}" is expected.` };
45
+ let sha = mergeCommitSha(repo, pr.number);
46
+ if (pr.head_sha) {
47
+ const landed = mergeIntoBranchInPlane(repo, pr.base_ref ?? 'main', pr.head_sha, `Merge pull request #${pr.number} from ${pr.head_ref ?? 'head'}\n\n${pr.title ?? ''}`, method, occurredAt, root);
48
+ if (landed && 'conflict' in landed) return { ok: false, status: 405, message: 'Pull Request is not mergeable' };
49
+ if (landed) sha = landed.sha;
50
+ }
51
+ const fields = { number: pr.number, repository: repo, state: 'closed', merged: true, merged_at: occurredAt, merge_commit_sha: sha, merge_method: method };
52
+ await applyTwinWrite(SERVICE, { operation: 'pull_request.merge', subjectType: 'pull_request', subjectId: pr.id, fields, occurredAt, actor: { kind: 'agent' } }, root);
53
+ return { ok: true, sha };
54
+ }
55
+ // Auto-merge: a pull request armed with a merge method lands the moment its required checks are all
56
+ // green — evaluated when a check run or commit status on its head sha succeeds. A merge that still fails
57
+ // a gate simply waits for the next signal, as on GitHub.
58
+ async function landArmedPullRequests(repo: string, headSha: string, occurredAt: string, root?: string): Promise<void> {
59
+ const armed = githubState(root).prs.filter((p) => p.repository === repo && p.head_sha === headSha && p.state === 'open' && !p.merged && p.auto_merge_method);
60
+ for (const pr of armed) await mergePullRequest(repo, pr, (pr.auto_merge_method as 'merge' | 'squash' | 'rebase') ?? 'merge', occurredAt, root);
61
+ }
62
+
19
63
  function mergeCommitSha(repo: string, number: number): string {
20
64
  return createHash('sha256').update(`${repo}#${number}`).digest('hex').slice(0, 40);
21
65
  }
@@ -30,6 +74,15 @@ export type GithubReview = {
30
74
  state?: string;
31
75
  body?: string;
32
76
  submitted_at?: string;
77
+ // The PR head sha the review was submitted AGAINST (real GitHub's `commit_id`) — the
78
+ // exact-head binding merge gates re-earn per push (a review's commit_id never moves).
79
+ commit_id?: string;
80
+ // WHO reviewed. A twin-authored review carries the twin's own writer login (`twin`); a
81
+ // PULLED one carries the login real GitHub named, with `user_type: 'Bot'` when GitHub
82
+ // said a machine wrote it. Absent = genuinely unknown (a legacy row, a deleted account),
83
+ // and the serve then answers `user: null` rather than inventing an account.
84
+ user_login?: string;
85
+ user_type?: 'User' | 'Bot';
33
86
  };
34
87
  // A review overlay (LOCAL) — a dismiss or a body-edit applied to an already-submitted review,
35
88
  // keyed by the review id. Overlaid onto the folded review array (last write per field wins).
@@ -54,6 +107,17 @@ export type GithubComment = {
54
107
  diff_hunk?: string;
55
108
  // in_reply_to: the root review-comment id this comment replies to (threaded review reply).
56
109
  in_reply_to?: number;
110
+ // THE REVIEW THAT WRAPS THIS COMMENT (real GitHub's `pull_request_review_id`). Every
111
+ // inline comment has one: the review it was submitted under, or the PR's implicit review
112
+ // when it was posted on its own (see `implicitReviewId`). It is the join a consumer does
113
+ // to read a review's findings, so it is served, never nulled.
114
+ review_id?: number;
115
+ // WHO wrote it — same rule as a review's (see GithubReview.user_login).
116
+ user_login?: string;
117
+ user_type?: 'User' | 'Bot';
118
+ // The REAL vendor id of this comment when the row came from real GitHub (pulled), so a
119
+ // push can address the vendor's own comment instead of forwarding a twin-minted id.
120
+ external_id?: string;
57
121
  };
58
122
  // A per-file diff carried on a LOCAL PR write (real GitHub's pull-request file object).
59
123
  // Observed PR evidence carries a changed-files COUNT only, never per-file diffs, so this
@@ -276,6 +340,16 @@ export type GithubBranch = {
276
340
  name: string;
277
341
  commit_sha: string;
278
342
  protected?: boolean;
343
+ // The PERSISTED classic branch-protection config (PUT .../protection body, normalized):
344
+ // required_status_checks {strict, contexts, checks}, enforce_admins, required_pull_request_reviews.
345
+ // This is what merge gates read back (required-check contexts, enforce_admins) — a
346
+ // protection PUT that forgets its own body is a fake success.
347
+ protection_config?: {
348
+ required_status_checks?: { strict: boolean; contexts: string[]; checks: Array<{ context: string; app_id: number | null }> } | null;
349
+ enforce_admins?: boolean;
350
+ required_pull_request_reviews?: Record<string, unknown> | null;
351
+ restrictions?: Record<string, unknown> | null;
352
+ };
279
353
  };
280
354
  // A repo collaborator (LOCAL). permission is GitHub's role enum (pull|triage|push|maintain|admin).
281
355
  export type GithubCollaborator = {
@@ -658,6 +732,9 @@ export function githubState(root?: string): GithubState {
658
732
  const deletedRepos = new Set<string>();
659
733
  const branchesById = new Map<string, GithubBranch>();
660
734
  const deletedBranches = new Set<string>();
735
+ // Protection survives a branch tombstone (GitHub's pattern-rule semantics) — keyed by the
736
+ // branch subject id, consumed (and cleared) by the next branch.create for that id.
737
+ const branchProtectionMemory = new Map<string, { protected?: boolean; protection_config?: GithubBranch['protection_config'] }>();
661
738
  const collaboratorsById = new Map<string, GithubCollaborator>();
662
739
  const deletedCollaborators = new Set<string>();
663
740
  const webhooksById = new Map<string, GithubWebhook>();
@@ -779,6 +856,35 @@ export function githubState(root?: string): GithubState {
779
856
  if (isEgressEventType(ev.type)) continue; // egress write intent/result are not GitHub objects
780
857
  const data = (ev.data ?? {}) as Record<string, any>;
781
858
  const t = ev.subject.type;
859
+ // A pulled field rides flat on first observation and under `changed.<field>.after` on a
860
+ // delta; read either.
861
+ const observed = (field: string): unknown => data[field] !== undefined ? data[field] : (data.changed as Record<string, { after?: unknown }> | undefined)?.[field]?.after;
862
+ if (t === 'repository') {
863
+ // The client's repository, as reality shows it (observeRepositoryAndBranches).
864
+ const prev = reposById.get(ev.subject.id);
865
+ const owner = String(observed('owner') ?? prev?.owner ?? ev.subject.id.replace(/^repo:/, '').split('/')[0] ?? '');
866
+ const name = String(observed('name') ?? prev?.name ?? ev.subject.id.replace(/^repo:/, '').split('/')[1] ?? '');
867
+ if (owner !== '' && name !== '') {
868
+ reposById.set(ev.subject.id, {
869
+ ...(prev ?? {}), id: prev?.id ?? reposById.size + 1, owner, name, full_name: `${owner}/${name}`,
870
+ description: observed('description') !== undefined ? (observed('description') as string | null) : prev?.description,
871
+ private: observed('private') !== undefined ? Boolean(observed('private')) : prev?.private,
872
+ default_branch: String(observed('default_branch') ?? prev?.default_branch ?? 'main'),
873
+ } as GithubRepo);
874
+ deletedRepos.delete(ev.subject.id);
875
+ }
876
+ continue;
877
+ }
878
+ if (t === 'branch') {
879
+ const prev = branchesById.get(ev.subject.id);
880
+ const repository = String(observed('repository') ?? prev?.repository ?? ev.subject.id.replace(/^branch:/, '').split('#')[0] ?? '');
881
+ const name = String(observed('name') ?? prev?.name ?? ev.subject.id.split('#').pop() ?? '');
882
+ if (repository !== '' && name !== '') {
883
+ branchesById.set(ev.subject.id, { ...(prev ?? {}), repository, name, commit_sha: String(observed('commit_sha') ?? prev?.commit_sha ?? '') } as GithubBranch);
884
+ deletedBranches.delete(ev.subject.id);
885
+ }
886
+ continue;
887
+ }
782
888
  if (t === 'pull_request') {
783
889
  const id = ev.subject.id;
784
890
  const number = Number(data.number ?? id.split('#').pop());
@@ -831,11 +937,27 @@ export function githubState(root?: string): GithubState {
831
937
  if (data.state !== undefined) review.state = String(data.state);
832
938
  if (data.body !== undefined && data.body !== null) review.body = String(data.body);
833
939
  if (data.submitted_at !== undefined) review.submitted_at = String(data.submitted_at);
940
+ // WHO reviewed, as the pull observed it — the login and the bot/human split real
941
+ // GitHub gave. Absent when the vendor named nobody (a deleted account).
942
+ if (data.user_login !== undefined && data.user_login !== null) review.user_login = String(data.user_login);
943
+ if (data.user_type === 'Bot' || data.user_type === 'User') review.user_type = data.user_type;
834
944
  reviews.push(review);
835
945
  } else {
836
946
  const comment: GithubComment = { id, repository, number, kind: t === 'pull_request_review_comment' ? 'review' : 'issue' };
837
947
  if (data.body !== undefined && data.body !== null) comment.body = String(data.body);
838
948
  if (data.created_at !== undefined) comment.created_at = String(data.created_at);
949
+ if (data.user_login !== undefined && data.user_login !== null) comment.user_login = String(data.user_login);
950
+ if (data.user_type === 'Bot' || data.user_type === 'User') comment.user_type = data.user_type;
951
+ // A PULLED inline comment carries its diff anchor and its thread position — the pull
952
+ // fetched all of it, so the twin serves all of it rather than dropping it on the floor.
953
+ if (data.path !== undefined && data.path !== null) comment.path = String(data.path);
954
+ if (data.line !== undefined && data.line !== null) comment.line = Number(data.line);
955
+ if (data.in_reply_to !== undefined && data.in_reply_to !== null) comment.in_reply_to = observedId(String(data.in_reply_to));
956
+ // THE JOIN, preserved across the fold: the pull names the review's own SUBJECT id,
957
+ // which hashes to the same twin-side review id the review row got.
958
+ if (data.review_key !== undefined && data.review_key !== null) comment.review_id = observedId(String(data.review_key));
959
+ // The vendor's real id, so a push can address the vendor's comment (never a minted id).
960
+ if (data.external_id !== undefined && data.external_id !== null) comment.external_id = String(data.external_id);
839
961
  comments.push(comment);
840
962
  }
841
963
  }
@@ -1061,7 +1183,17 @@ export function githubState(root?: string): GithubState {
1061
1183
  resolved: f.resolved !== undefined ? Boolean(f.resolved) : prev?.resolved,
1062
1184
  });
1063
1185
  } else if (a.subject.type === 'repository') {
1064
- if (a.operation === 'repository.delete') { deletedRepos.add(a.subject.id); continue; }
1186
+ if (a.operation === 'repository.delete') {
1187
+ deletedRepos.add(a.subject.id);
1188
+ // Cascade: the repo's refs/branches die WITH it (the bare repo is removed too), so a
1189
+ // re-created repo starts truly empty on BOTH planes — REST must not serve dead refs.
1190
+ const dyingRepo = reposById.get(a.subject.id);
1191
+ const dyingFull = dyingRepo ? `${dyingRepo.owner}/${dyingRepo.name}` : `${String(f.owner ?? '')}/${String(f.name ?? '')}`;
1192
+ for (const [k, v] of [...gitRefsById]) if (v.repository === dyingFull) { gitRefsById.delete(k); deletedGitRefs.delete(k); }
1193
+ for (const [k, v] of [...branchesById]) if (v.repository === dyingFull) { branchesById.delete(k); deletedBranches.delete(k); }
1194
+ for (const k of [...branchProtectionMemory.keys()]) if (k.startsWith(`branch:${dyingFull}#`)) branchProtectionMemory.delete(k);
1195
+ continue;
1196
+ }
1065
1197
  const m = /^repo:(\d+)$/.exec(a.subject.id);
1066
1198
  const id = m ? Number(m[1]) : observedId(a.subject.id);
1067
1199
  const prev = reposById.get(a.subject.id);
@@ -1084,12 +1216,26 @@ export function githubState(root?: string): GithubState {
1084
1216
  created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
1085
1217
  });
1086
1218
  } else if (a.subject.type === 'branch') {
1087
- if (a.operation === 'branch.delete') { deletedBranches.add(a.subject.id); continue; }
1088
- const prev = branchesById.get(a.subject.id);
1219
+ if (a.operation === 'branch.delete') {
1220
+ // The row dies, but PROTECTION does not: real GitHub's classic protection is a
1221
+ // repo-level pattern rule, so deleting `main` and re-pushing it comes back
1222
+ // protected. Remember the protection across the tombstone.
1223
+ const dying = branchesById.get(a.subject.id);
1224
+ if (dying && (dying.protected || dying.protection_config)) {
1225
+ branchProtectionMemory.set(a.subject.id, { protected: dying.protected, protection_config: dying.protection_config });
1226
+ }
1227
+ deletedBranches.add(a.subject.id); branchesById.delete(a.subject.id); continue;
1228
+ }
1229
+ deletedBranches.delete(a.subject.id); // delete→re-create resurrects (see git_ref fold below)
1230
+ const prev = branchesById.get(a.subject.id) ?? branchProtectionMemory.get(a.subject.id) as GithubBranch | undefined;
1231
+ if (branchesById.get(a.subject.id) === undefined && branchProtectionMemory.has(a.subject.id)) branchProtectionMemory.delete(a.subject.id);
1089
1232
  branchesById.set(a.subject.id, {
1090
1233
  repository: String(f.repository ?? prev?.repository ?? ''), name: String(f.name ?? prev?.name ?? ''),
1091
1234
  commit_sha: String(f.commit_sha ?? prev?.commit_sha ?? ''),
1092
1235
  protected: f.protected !== undefined ? Boolean(f.protected) : prev?.protected,
1236
+ ...(f.protection_config !== undefined
1237
+ ? (f.protection_config === null ? {} : { protection_config: f.protection_config as GithubBranch['protection_config'] })
1238
+ : (prev?.protection_config !== undefined ? { protection_config: prev.protection_config } : {})),
1093
1239
  });
1094
1240
  } else if (a.subject.type === 'collaborator') {
1095
1241
  if (a.operation === 'collaborator.remove') { deletedCollaborators.add(a.subject.id); continue; }
@@ -1141,7 +1287,8 @@ export function githubState(root?: string): GithubState {
1141
1287
  updated_at: f.updated_at ?? prev?.updated_at,
1142
1288
  });
1143
1289
  } else if (a.subject.type === 'git_ref') {
1144
- if (a.operation === 'git_ref.delete') { deletedGitRefs.add(a.subject.id); continue; }
1290
+ if (a.operation === 'git_ref.delete') { deletedGitRefs.add(a.subject.id); gitRefsById.delete(a.subject.id); continue; }
1291
+ deletedGitRefs.delete(a.subject.id); // a later create RESURRECTS a deleted ref (delete→re-push must not be tombstoned forever)
1145
1292
  const prev = gitRefsById.get(a.subject.id);
1146
1293
  gitRefsById.set(a.subject.id, {
1147
1294
  repository: String(f.repository ?? prev?.repository ?? ''), ref: String(f.ref ?? prev?.ref ?? ''),
@@ -1602,7 +1749,7 @@ export function githubState(root?: string): GithubState {
1602
1749
  if (!repository || !number) continue;
1603
1750
  ensure(`${repository}#${number}`, number, repository);
1604
1751
  const m = /^review:(\d+)$/.exec(a.subject.id);
1605
- reviews.push({ id: m ? Number(m[1]) : observedId(a.subject.id), repository, number, state: f.state, body: f.body, submitted_at: f.submitted_at });
1752
+ reviews.push({ id: m ? Number(m[1]) : observedId(a.subject.id), repository, number, state: f.state, body: f.body, submitted_at: f.submitted_at, ...(f.commit_id !== undefined ? { commit_id: String(f.commit_id) } : {}), ...(f.user_login !== undefined ? { user_login: String(f.user_login) } : {}) });
1606
1753
  } else if (a.subject.type === 'pull_request_review_overlay') {
1607
1754
  // A dismiss / body-edit on a submitted review (keyed by review id). Overlaid below.
1608
1755
  const rid = Number(f.review_id ?? 0);
@@ -1627,6 +1774,10 @@ export function githubState(root?: string): GithubState {
1627
1774
  if (f.start_line !== undefined) comment.start_line = Number(f.start_line);
1628
1775
  if (f.diff_hunk !== undefined) comment.diff_hunk = String(f.diff_hunk);
1629
1776
  if (f.in_reply_to !== undefined) comment.in_reply_to = Number(f.in_reply_to);
1777
+ // The review this comment was submitted under (an explicit one when the write named it,
1778
+ // otherwise the PR's implicit review) and WHO wrote it — both recorded at write time.
1779
+ if (f.review_id !== undefined) comment.review_id = Number(f.review_id);
1780
+ if (f.user_login !== undefined) comment.user_login = String(f.user_login);
1630
1781
  comments.push(comment);
1631
1782
  }
1632
1783
  }
@@ -1639,6 +1790,20 @@ export function githubState(root?: string): GithubState {
1639
1790
  }
1640
1791
  // Derive the evidence counts the _twin block still reports from the folded objects.
1641
1792
  for (const r of reviews) ensure(`${r.repository}#${r.number}`, r.number, r.repository).review_count += 1;
1793
+ // THE IMPLICIT REVIEW IS ONE OF THEM. `GET /pulls/:n/reviews` synthesizes the wrapper from
1794
+ // the comments that name it and SERVES it, exactly as real GitHub does — and real GitHub
1795
+ // counts it. The count read only the STORED rows, so a PR whose reviews page answers one
1796
+ // review reported `review_count: 0` and the mirror rendered "Review count 0" over it. The
1797
+ // condition here is the handler's, verbatim, so the count and the list can never describe
1798
+ // different populations: one wrapper per PR that has at least one comment naming it.
1799
+ const wrappedPrs = new Set<string>();
1800
+ for (const c of comments) {
1801
+ if (c.kind !== 'review' || c.review_id !== implicitReviewId(c.repository, c.number)) continue;
1802
+ const key = `${c.repository}#${c.number}`;
1803
+ if (wrappedPrs.has(key)) continue;
1804
+ wrappedPrs.add(key);
1805
+ ensure(key, c.number, c.repository).review_count += 1;
1806
+ }
1642
1807
  for (const c of comments) ensure(`${c.repository}#${c.number}`, c.number, c.repository).comment_count += 1;
1643
1808
  const byRepoNum = <T extends { repository: string; number: number }>(a: T, b: T): number =>
1644
1809
  a.repository === b.repository ? a.number - b.number : a.repository < b.repository ? -1 : 1;
@@ -1962,17 +2127,74 @@ export function toGithubRest(pr: GithubPr, milestones?: GithubMilestone[]): Reco
1962
2127
  return out;
1963
2128
  }
1964
2129
 
2130
+ // ── WHO the twin says wrote a review or a review comment ─────────────────────────────────
2131
+ // A review the twin serves used to answer `user: null`, so a twin-written review folded with
2132
+ // no author at all — the one fact a review feed is FOR. The twin ledgers exactly one caller
2133
+ // identity for a local write (`actor: { kind: 'agent' }`, which carries no login), so the
2134
+ // login it serves for its own writes is the stable `twin` — the same name the jira twin gives
2135
+ // a local writer. A rehearsal reviewer's REAL login rides in the review/comment BODY by
2136
+ // convention; the twin invents no header for it and never guesses a human's account from an
2137
+ // unauthenticated write. A PULLED review carries the login real GitHub named instead.
2138
+ export const GITHUB_TWIN_LOGIN = 'twin';
2139
+ // Deterministic account id per login (never random, never Date.now), in its own id band so it
2140
+ // cannot collide with an observed subject id (0x40000000…) or a check-suite id (0x70000000…).
2141
+ function twinUserId(login: string): number {
2142
+ return 0x10000000 + (parseInt(createHash('sha256').update(`user:${login}`).digest('hex').slice(0, 8), 16) % 0x0fffffff);
2143
+ }
2144
+ // GitHub's simple-user object for a login the twin actually knows. Only `login` and `type`
2145
+ // are FACTS; the URLs are derived shape and the unmodeled remainder is declared scope
2146
+ // (`user.*` in github-known-deviations.json), exactly like `toAssignees`.
2147
+ function toSimpleUser(login: string, type: 'User' | 'Bot' = 'User'): Record<string, unknown> {
2148
+ // `node_id` is OMITTED, not nulled: GitHub declares it a non-nullable string, so a null
2149
+ // there is a type violation rather than a declared gap — the twin omits what it does not
2150
+ // know instead of faking it.
2151
+ return {
2152
+ login,
2153
+ id: twinUserId(login),
2154
+ type,
2155
+ site_admin: false,
2156
+ url: `https://api.github.com/users/${login}`,
2157
+ html_url: `https://github.com/${login}`,
2158
+ };
2159
+ }
2160
+ // THE IMPLICIT REVIEW. Real GitHub wraps EVERY inline comment in a review: a comment posted
2161
+ // through `POST .../pulls/:n/comments` with no review of its own still comes back naming a
2162
+ // `pull_request_review_id`, and that wrapper review appears on `GET .../pulls/:n/reviews`.
2163
+ // The twin used to serve `pull_request_review_id: null` on every review comment, which made
2164
+ // the join a consumer does (comment → its review) DEAD against the twin's own data. So the
2165
+ // wrapper is minted DETERMINISTICALLY per (repo, PR): one implicit review per PR, the same id
2166
+ // every time. It is synthesized on the reviews READ from the comments that name it — never
2167
+ // stored as a review of its own, because it is a wrapper, not a verdict — and `githubState`
2168
+ // counts it into `_twin.review_count` under exactly the condition the read uses, so the count
2169
+ // and the list describe one population.
2170
+ //
2171
+ // WHY IT CANNOT COLLIDE, precisely — the band is NOT the reason. Its offset (0x80000000…)
2172
+ // separates it from the twin's own two id spaces, the local sequence (small integers) and
2173
+ // `observedId`'s hash band (0x40000000…), and that is all it does: real GitHub's own review
2174
+ // ids have been in the 0x80000000 range since ~2024, so a band argument against VENDOR ids
2175
+ // would be false. The actual invariant is that a vendor id is never a twin id: every pulled
2176
+ // review and comment is REHASHED through `observedId` before it is served (nothing arrives
2177
+ // carrying its raw GitHub id), and no review id is ever sent back OUT — a push addresses a
2178
+ // vendor comment by the `external_id` the pull recorded, never by a minted one.
2179
+ export function implicitReviewId(repository: string, number: number): number {
2180
+ return 0x80000000 + (parseInt(createHash('sha256').update(`implicit-review:${repository}#${number}`).digest('hex').slice(0, 8), 16) % 0x3fffffff);
2181
+ }
2182
+
1965
2183
  // Map a submitted review to GitHub's pull-request-review shape. `id` is an INTEGER
1966
2184
  // (real GitHub ids are integers; the twin's internal subject id stays `review:N`).
1967
2185
  // node_id/_links/author_association are declared evidence-twin scope (not fabricated).
1968
- export function toGithubReview(a: { id: number; repository: string; number: number; state?: string; body?: string; submitted_at?: string }): Record<string, unknown> {
2186
+ export function toGithubReview(a: { id: number; repository: string; number: number; state?: string; body?: string; submitted_at?: string; commit_id?: string; user_login?: string; user_type?: 'User' | 'Bot' }): Record<string, unknown> {
1969
2187
  const api = `https://api.github.com/repos/${a.repository}`;
1970
2188
  const out: Record<string, unknown> = {
1971
2189
  id: a.id,
1972
- user: null,
2190
+ // WHO reviewed — the twin's own login on a local write, the vendor's on a pulled one,
2191
+ // and `null` only when the author is genuinely unknown.
2192
+ user: a.user_login === undefined ? null : toSimpleUser(a.user_login, a.user_type ?? 'User'),
1973
2193
  body: a.body ?? '',
1974
2194
  state: a.state ?? 'COMMENTED',
1975
- commit_id: null,
2195
+ // The head sha the review bound to at submission (exact-head re-earn); null only for
2196
+ // legacy/observed reviews whose submission head was never recorded.
2197
+ commit_id: a.commit_id ?? null,
1976
2198
  html_url: `https://github.com/${a.repository}/pull/${a.number}#pullrequestreview-${a.id}`,
1977
2199
  pull_request_url: `${api}/pulls/${a.number}`,
1978
2200
  };
@@ -2003,19 +2225,25 @@ export function toGithubComment(a: { id: number; repository: string; number: num
2003
2225
  // diff-position fields (path/line/side/start_line/diff_hunk) are emitted for LOCAL writes
2004
2226
  // that anchored the comment to the diff; for OBSERVED review comments (no diff evidence)
2005
2227
  // they are omitted and stay declared scope (reviewComment.*). `id` is an INTEGER.
2006
- export function toGithubReviewComment(a: { id: number; repository: string; number: number; body?: string; at?: string; path?: string; line?: number; side?: string; start_line?: number; diff_hunk?: string }): Record<string, unknown> {
2228
+ export function toGithubReviewComment(a: { id: number; repository: string; number: number; body?: string; at?: string; path?: string; line?: number; side?: string; start_line?: number; diff_hunk?: string; in_reply_to?: number; review_id?: number; user_login?: string; user_type?: 'User' | 'Bot' }): Record<string, unknown> {
2007
2229
  const api = `https://api.github.com/repos/${a.repository}`;
2008
2230
  const out: Record<string, unknown> = {
2009
2231
  id: a.id,
2010
- user: null,
2232
+ user: a.user_login === undefined ? null : toSimpleUser(a.user_login, a.user_type ?? 'User'),
2011
2233
  body: a.body ?? '',
2012
2234
  url: `${api}/pulls/comments/${a.id}`,
2013
2235
  html_url: `https://github.com/${a.repository}/pull/${a.number}#discussion_r${a.id}`,
2014
2236
  pull_request_url: `${api}/pulls/${a.number}`,
2015
- pull_request_review_id: null,
2237
+ // THE JOIN. Every inline comment names the review that wraps it — the review it was
2238
+ // submitted under, or the PR's implicit review when it was posted on its own. `null`
2239
+ // only for a row whose review is genuinely unknown; it is no longer the constant answer.
2240
+ pull_request_review_id: a.review_id ?? null,
2016
2241
  };
2017
2242
  // Observed review comments carry no timestamp — omit rather than fake an empty date.
2018
2243
  if (a.at !== undefined) { out.created_at = a.at; out.updated_at = a.at; }
2244
+ // A threaded reply names the comment it answers — the field a client (and this pack's own
2245
+ // pull) threads a review conversation by. Absent on a root comment, exactly as GitHub.
2246
+ if (a.in_reply_to !== undefined) out.in_reply_to_id = a.in_reply_to;
2019
2247
  // Diff anchoring — present only when a LOCAL write supplied it. diff_hunk defaults to ''
2020
2248
  // (the real API always returns the string) once a path is anchored; line/side per write.
2021
2249
  if (a.path !== undefined) {
@@ -2281,18 +2509,68 @@ export function toGithubRepo(r: GithubRepo): Record<string, unknown> {
2281
2509
  };
2282
2510
  }
2283
2511
 
2284
- // Map a branch to GitHub's branch shape (GET .../branches[/:name]).
2512
+ // Map a branch to GitHub's branch shape (GET .../branches[/:name]). The protection summary
2513
+ // carries the PERSISTED required-check contexts + the real enforcement_level semantics
2514
+ // (everyone when enforce_admins, non_admins otherwise, off when unprotected) — this summary
2515
+ // is what merge-gate scripts read their required contexts from.
2285
2516
  export function toGithubBranch(b: GithubBranch): Record<string, unknown> {
2286
2517
  const api = `https://api.github.com/repos/${b.repository}`;
2518
+ const rsc = b.protection_config?.required_status_checks;
2519
+ // enforcement_level is about REQUIRED STATUS CHECKS: 'off' when the branch has none
2520
+ // configured (even while otherwise protected), 'everyone' under enforce_admins,
2521
+ // 'non_admins' otherwise — real GitHub's branch-summary semantics.
2522
+ const enforcement = !(b.protected ?? false) || !rsc ? 'off' : (b.protection_config?.enforce_admins ? 'everyone' : 'non_admins');
2287
2523
  return {
2288
2524
  name: b.name,
2289
2525
  commit: { sha: b.commit_sha, url: `${api}/commits/${b.commit_sha}` },
2290
2526
  protected: b.protected ?? false,
2291
- protection: { enabled: b.protected ?? false, required_status_checks: { enforcement_level: b.protected ? 'everyone' : 'off', contexts: [] } },
2527
+ protection: {
2528
+ enabled: b.protected ?? false,
2529
+ required_status_checks: { enforcement_level: enforcement, contexts: rsc?.contexts ?? [], checks: rsc?.checks ?? [] },
2530
+ },
2292
2531
  protection_url: `${api}/branches/${encodeURIComponent(b.name)}/protection`,
2293
2532
  };
2294
2533
  }
2295
2534
 
2535
+ // Render the FULL classic protection object (GET/PUT .../branches/:b/protection).
2536
+ export function toGithubBranchProtection(b: GithubBranch): Record<string, unknown> {
2537
+ const base = `https://api.github.com/repos/${b.repository}/branches/${encodeURIComponent(b.name)}/protection`;
2538
+ const cfg = b.protection_config ?? {};
2539
+ const out: Record<string, unknown> = { url: base, enabled: true };
2540
+ if (cfg.required_status_checks) {
2541
+ out.required_status_checks = {
2542
+ url: `${base}/required_status_checks`,
2543
+ strict: cfg.required_status_checks.strict,
2544
+ contexts: cfg.required_status_checks.contexts,
2545
+ contexts_url: `${base}/required_status_checks/contexts`,
2546
+ checks: cfg.required_status_checks.checks,
2547
+ };
2548
+ }
2549
+ out.enforce_admins = { url: `${base}/enforce_admins`, enabled: cfg.enforce_admins ?? false };
2550
+ if (cfg.required_pull_request_reviews) {
2551
+ out.required_pull_request_reviews = { url: `${base}/required_pull_request_reviews`, ...cfg.required_pull_request_reviews };
2552
+ }
2553
+ if (cfg.restrictions) {
2554
+ out.restrictions = { url: `${base}/restrictions`, ...cfg.restrictions };
2555
+ }
2556
+ return out;
2557
+ }
2558
+
2559
+ // Normalize a PUT .../protection body's required_status_checks into the persisted shape.
2560
+ // Real GitHub accepts `contexts` (deprecated) or `checks` [{context, app_id}]; both are kept
2561
+ // in sync so either read-back form answers.
2562
+ export function normalizeRequiredStatusChecks(v: unknown): { strict: boolean; contexts: string[]; checks: Array<{ context: string; app_id: number | null }> } | null {
2563
+ if (v === null || v === undefined || typeof v !== 'object') return null;
2564
+ const o = v as Record<string, unknown>;
2565
+ const checksIn = Array.isArray(o.checks) ? (o.checks as Array<Record<string, unknown>>) : undefined;
2566
+ const contextsIn = Array.isArray(o.contexts) ? (o.contexts as unknown[]).map(String) : undefined;
2567
+ const checks = checksIn
2568
+ ? checksIn.map((c) => ({ context: String(c.context ?? ''), app_id: c.app_id === undefined || c.app_id === null || Number(c.app_id) === -1 ? null : Number(c.app_id) }))
2569
+ : (contextsIn ?? []).map((context) => ({ context, app_id: null }));
2570
+ const contexts = contextsIn ?? checks.map((c) => c.context);
2571
+ return { strict: o.strict === true, contexts, checks };
2572
+ }
2573
+
2296
2574
  // Map a collaborator to GitHub's collaborator simple-user shape (with a permissions block).
2297
2575
  export function toGithubCollaborator(c: GithubCollaborator): Record<string, unknown> {
2298
2576
  const perm = c.permission;
@@ -2335,6 +2613,75 @@ export function toGithubWebhook(h: GithubWebhook): Record<string, unknown> {
2335
2613
  // Map a contents file to GitHub's contents shape (GET/PUT .../contents/:path). The twin
2336
2614
  // honors small content the caller PUT (base64), and serves it back; raw bytes for large
2337
2615
  // blobs remain the declared Non-goal.
2616
+
2617
+ // ── Git-plane-backed READS for the content surfaces real GitHub serves from the same objects a
2618
+ // `git push` wrote: GET contents/*, GET readme, GET commits/:ref. A connector-pulled (REST-stored)
2619
+ // file wins when present; otherwise the bare repo is the truth — one object store, not two.
2620
+ function planeQueryRef(query: string | undefined): string | undefined {
2621
+ if (!query) return undefined;
2622
+ const v = new URLSearchParams(query.startsWith('?') ? query.slice(1) : query).get('ref');
2623
+ return v && v.length ? v : undefined;
2624
+ }
2625
+ function resolvePlaneRef(repo: string, ref: string | undefined, defaultBranch: string, root?: string): string | null {
2626
+ const want = ref && ref.length ? ref : defaultBranch;
2627
+ if (/^[0-9a-f]{40}$/.test(want) && objectExistsInPlane(repo, want, root)) return want;
2628
+ const refs = listGitPlaneRefs(repo, root);
2629
+ const hit = refs.find((r) => r.ref === want || r.ref === `refs/heads/${want}` || r.ref === `refs/tags/${want}`);
2630
+ if (!hit) return null;
2631
+ if (hit.object_type === 'tag') return readGitTagFromPlane(repo, hit.object_sha, root)?.object_sha ?? null;
2632
+ return hit.object_sha;
2633
+ }
2634
+ type PlaneLookup = { kind: 'file'; entry: { path: string; mode: string; type: string; sha: string; size?: number } } | { kind: 'dir'; entries: Array<{ path: string; mode: string; type: string; sha: string; size?: number }> };
2635
+ function planeEntryAt(repo: string, commitSha: string, path: string, root?: string): PlaneLookup | null {
2636
+ const commit = readGitCommitFromPlane(repo, commitSha, root);
2637
+ if (!commit) return null;
2638
+ let treeSha = commit.tree_sha;
2639
+ const segs = path ? path.split('/').filter(Boolean) : [];
2640
+ for (let i = 0; i < segs.length; i += 1) {
2641
+ const entries = readGitTreeFromPlane(repo, treeSha, root);
2642
+ const e = entries?.find((x) => x.path === segs[i]);
2643
+ if (!e) return null;
2644
+ if (e.type === 'blob') return i === segs.length - 1 ? { kind: 'file', entry: e } : null;
2645
+ if (e.type !== 'tree') return null;
2646
+ treeSha = e.sha;
2647
+ }
2648
+ const entries = readGitTreeFromPlane(repo, treeSha, root);
2649
+ return entries ? { kind: 'dir', entries } : null;
2650
+ }
2651
+ function planeFileContent(repo: string, ref: string, path: string, entry: { sha: string; size?: number }, root?: string): Record<string, unknown> | null {
2652
+ const blob = readGitBlobFromPlane(repo, entry.sha, root);
2653
+ if (!blob) return null;
2654
+ return toGithubContent({ repository: repo, path, sha: entry.sha, size: blob.size, content_b64: blob.content_b64, branch: ref } as unknown as GithubContentFile);
2655
+ }
2656
+ function planeContents(repo: string, ref: string | undefined, path: string, defaultBranch: string, root?: string): { status: number; body: unknown } | null {
2657
+ const sha = resolvePlaneRef(repo, ref, defaultBranch, root);
2658
+ if (!sha) return null;
2659
+ const hit = planeEntryAt(repo, sha, path, root);
2660
+ if (!hit) return null;
2661
+ const shownRef = ref ?? defaultBranch;
2662
+ if (hit.kind === 'file') {
2663
+ const body = planeFileContent(repo, shownRef, path, hit.entry, root);
2664
+ return body ? { status: 200, body } : null;
2665
+ }
2666
+ const prefix = path ? `${path}/` : '';
2667
+ return { status: 200, body: hit.entries.map((e) => ({ type: e.type === 'tree' ? 'dir' : e.type === 'commit' ? 'submodule' : 'file', name: e.path, path: `${prefix}${e.path}`, sha: e.sha, size: e.size ?? 0 })) };
2668
+ }
2669
+ function planeCommitBody(repo: string, sha: string, root?: string): Record<string, unknown> | null {
2670
+ const c = readGitCommitFromPlane(repo, sha, root);
2671
+ if (!c) return null;
2672
+ const who = { name: c.author_name ?? 'github-twin', email: c.author_email ?? 'github-twin@localhost', date: c.created_at ?? new Date(0).toISOString() };
2673
+ return {
2674
+ sha: c.sha,
2675
+ node_id: `C_${c.sha.slice(0, 12)}`,
2676
+ commit: { author: who, committer: who, message: c.message, tree: { sha: c.tree_sha, url: `https://api.github.com/repos/${repo}/git/trees/${c.tree_sha}` }, comment_count: 0 },
2677
+ url: `https://api.github.com/repos/${repo}/commits/${c.sha}`,
2678
+ html_url: `https://github.com/${repo}/commit/${c.sha}`,
2679
+ author: null,
2680
+ committer: null,
2681
+ parents: c.parents.map((sha) => ({ sha, url: `https://api.github.com/repos/${repo}/commits/${sha}`, html_url: `https://github.com/${repo}/commit/${sha}` })),
2682
+ };
2683
+ }
2684
+
2338
2685
  export function toGithubContent(f: GithubContentFile): Record<string, unknown> {
2339
2686
  const api = `https://api.github.com/repos/${f.repository}`;
2340
2687
  const ref = f.branch ?? 'main';
@@ -2686,6 +3033,10 @@ export function toGithubGitCommit(c: GithubGitCommit): Record<string, unknown> {
2686
3033
  parents: c.parents.map((p) => ({ sha: p, url: `${api}/git/commits/${p}`, html_url: `https://github.com/${c.repository}/commit/${p}` })),
2687
3034
  author: { name: c.author_name ?? null, email: c.author_email ?? null, date: c.created_at ?? null },
2688
3035
  committer: { name: c.author_name ?? null, email: c.author_email ?? null, date: c.created_at ?? null },
3036
+ // Real responses always carry a verification block. The twin's objects are unsigned —
3037
+ // reported honestly (never a fabricated "verified": the signing GitHub's API does for
3038
+ // bot-created commits needs GitHub's key, which a local twin cannot have).
3039
+ verification: { verified: false, reason: 'unsigned', signature: null, payload: null },
2689
3040
  html_url: `https://github.com/${c.repository}/commit/${c.sha}`,
2690
3041
  };
2691
3042
  }
@@ -2715,6 +3066,8 @@ export function toGithubGitTagObject(t: GithubGitTagObject): Record<string, unkn
2715
3066
  url: `${api}/git/tags/${t.sha}`,
2716
3067
  tagger: { name: t.tagger_name ?? null, email: null, date: t.created_at ?? null },
2717
3068
  object: { type: t.object_type, sha: t.object_sha, url: `${api}/git/${t.object_type === 'tag' ? 'tags' : 'commits'}/${t.object_sha}` },
3069
+ // Honest unsigned verification block (see toGithubGitCommit).
3070
+ verification: { verified: false, reason: 'unsigned', signature: null, payload: null },
2718
3071
  };
2719
3072
  }
2720
3073
 
@@ -3195,6 +3548,26 @@ export function handleGithubRequest(req: { method: string; path: string; root?:
3195
3548
  const n = Number(decodeURIComponent(seg[4]));
3196
3549
  if (seg[5] === 'reviews') {
3197
3550
  const items = state.reviews.filter((r) => r.repository === repo && r.number === n);
3551
+ // THE IMPLICIT REVIEW rides the list, exactly as real GitHub's does: a review comment
3552
+ // posted on its own is still wrapped in a review, and a consumer that joins
3553
+ // `pull_request_review_id` → this list must find it. It is synthesized FROM the
3554
+ // comments that name it (never stored as a review of its own): submitted at the
3555
+ // earliest of them, authored by the first of them, state COMMENTED like GitHub's.
3556
+ // `githubState` counts it into `_twin.review_count` under this same condition — the
3557
+ // count and this list must describe the same population, or the mirror says "Review
3558
+ // count 0" over a PR whose reviews page answers one.
3559
+ const wrapperId = implicitReviewId(repo, n);
3560
+ const wrapped = state.comments.filter((c) => c.repository === repo && c.number === n && c.kind === 'review' && c.review_id === wrapperId);
3561
+ if (wrapped.length > 0) {
3562
+ const dated = wrapped.map((c) => c.created_at).filter((d): d is string => d !== undefined).sort();
3563
+ const author = wrapped.find((c) => c.user_login !== undefined);
3564
+ items.push({
3565
+ id: wrapperId, repository: repo, number: n, state: 'COMMENTED', body: '',
3566
+ ...(dated[0] !== undefined ? { submitted_at: dated[0] } : {}),
3567
+ ...(author?.user_login !== undefined ? { user_login: author.user_login } : {}),
3568
+ ...(author?.user_type !== undefined ? { user_type: author.user_type } : {}),
3569
+ });
3570
+ }
3198
3571
  return { status: 200, body: paginate(items, query).map(toGithubReview) };
3199
3572
  }
3200
3573
  if (seg[5] === 'comments') {
@@ -3209,15 +3582,30 @@ export function handleGithubRequest(req: { method: string; path: string; root?:
3209
3582
  ...(c.side !== undefined ? { side: c.side } : {}),
3210
3583
  ...(c.start_line !== undefined ? { start_line: c.start_line } : {}),
3211
3584
  ...(c.diff_hunk !== undefined ? { diff_hunk: c.diff_hunk } : {}),
3585
+ // a reply reads back naming the comment it answers, so a thread survives the round-trip
3586
+ ...(c.in_reply_to !== undefined ? { in_reply_to: c.in_reply_to } : {}),
3587
+ // and the review that wraps it, so the join a consumer does is live on twin data
3588
+ ...(c.review_id !== undefined ? { review_id: c.review_id } : {}),
3589
+ ...(c.user_login !== undefined ? { user_login: c.user_login } : {}),
3590
+ ...(c.user_type !== undefined ? { user_type: c.user_type } : {}),
3212
3591
  })) };
3213
3592
  }
3214
3593
  if (seg[5] === 'files') {
3215
- // LOCAL PR writes can carry per-file diffs (filename/status/additions/deletions/patch),
3216
- // returned here paginated. OBSERVED PRs carry a changed-files COUNT only (no per-file
3217
- // diff evidence), so they return [] never fabricated. Declared scope: pullFile.*.
3594
+ // Three sources, in order of evidence strength: (1) per-file diffs a LOCAL PR write
3595
+ // carried; (2) the REAL diff computed from the git plane when the PR's base+head
3596
+ // commits both exist there (a pushed PR's true changed-file list what merge-gate
3597
+ // scope classification reads); (3) [] for observed PRs with no diff evidence — never
3598
+ // fabricated. Declared scope: pullFile.*.
3218
3599
  const pr = inRepo.find((p) => p.number === n);
3219
- const items = pr?.files ?? [];
3220
3600
  const headSha = pr?.head_sha ?? '';
3601
+ let items = pr?.files ?? [];
3602
+ // Guard on changed_files === undefined (like the GraphQL path): an OBSERVED PR
3603
+ // carries a pulled count but no per-file evidence — serving a plane list that could
3604
+ // contradict its own count would be two answers for one object.
3605
+ if (items.length === 0 && pr && pr.changed_files === undefined) {
3606
+ const planeFiles = prPlaneDiffFiles(state, repo, pr, req.root);
3607
+ if (planeFiles) items = planeFiles;
3608
+ }
3221
3609
  return { status: 200, body: paginate(items, query).map((f) => toGithubPullFile(repo, headSha, f)) };
3222
3610
  }
3223
3611
  if (seg[5] === 'commits') {
@@ -3265,6 +3653,14 @@ export function handleGithubRequest(req: { method: string; path: string; root?:
3265
3653
  return { status: 200, body: paginate(filtered, query).map((p) => toGithubRest(p, state.milestones)) };
3266
3654
  }
3267
3655
  // Commit statuses + check runs (keyed by sha/ref).
3656
+ // GET .../commits/:ref — a single commit by sha, branch, or tag, read from the REAL git plane.
3657
+ if (req.method === 'GET' && seg[0] === 'repos' && seg[3] === 'commits' && seg[4] && !seg[5]) {
3658
+ const repo = `${seg[1]}/${seg[2]}`;
3659
+ const defaultBranch = state.repos.find((r) => r.full_name === repo)?.default_branch ?? 'main';
3660
+ const sha = resolvePlaneRef(repo, decodeURIComponent(seg[4]), defaultBranch, req.root);
3661
+ const body = sha ? planeCommitBody(repo, sha, req.root) : null;
3662
+ return body ? { status: 200, body } : githubNotFound();
3663
+ }
3268
3664
  if (req.method === 'GET' && seg[0] === 'repos' && seg[3] === 'commits' && seg[4] && seg[5]) {
3269
3665
  const repo = `${seg[1]}/${seg[2]}`;
3270
3666
  const ref = decodeURIComponent(seg[4]);
@@ -3312,6 +3708,16 @@ export function handleGithubRequest(req: { method: string; path: string; root?:
3312
3708
  if (req.method === 'GET' && seg[0] === 'repos' && seg[3] === 'readme' && !seg[4]) {
3313
3709
  const repo = `${seg[1]}/${seg[2]}`;
3314
3710
  const readme = state.contents.filter((c) => c.repository === repo && /^readme(\.[a-z]+)?$/i.test(c.path)).sort((a, b) => a.path.length - b.path.length)[0];
3711
+ if (!readme) {
3712
+ const defaultBranch = state.repos.find((r) => r.full_name === repo)?.default_branch ?? 'main';
3713
+ const sha = resolvePlaneRef(repo, planeQueryRef(query), defaultBranch, req.root);
3714
+ const top = sha ? planeEntryAt(repo, sha, '', req.root) : null;
3715
+ const entry = top?.kind === 'dir' ? top.entries.find((e) => e.type === 'blob' && /^readme(\.[a-z]+)?$/i.test(e.path)) : undefined;
3716
+ if (entry) {
3717
+ const body = planeFileContent(repo, planeQueryRef(query) ?? defaultBranch, entry.path, entry, req.root);
3718
+ if (body) return { status: 200, body };
3719
+ }
3720
+ }
3315
3721
  if (!readme) return githubNotFound();
3316
3722
  return { status: 200, body: {
3317
3723
  type: 'file', name: readme.path.split('/').pop(), path: readme.path, sha: readme.sha, size: readme.size ?? 0,
@@ -3670,16 +4076,24 @@ export function handleGithubRequest(req: { method: string; path: string; root?:
3670
4076
  const inRepo = state.tags.filter((t) => t.repository === repo);
3671
4077
  return { status: 200, body: paginate(inRepo, query).map(toGithubTag) };
3672
4078
  }
3673
- // Git refs for tags (GET .../git/refs/tags[/:tag]) — lightweight commit refs.
4079
+ // Git refs for tags (GET .../git/refs/tags[/:tag]) — the UNION of release-derived tag refs
4080
+ // and first-class git refs (REST-created or pushed refs/tags/*). The two used to be served
4081
+ // by disjoint handlers, so a pushed tag was visible at .../git/ref/tags/:t but invisible
4082
+ // here — two answers for one object. First-class refs win a name collision.
3674
4083
  if (req.method === 'GET' && seg[0] === 'repos' && seg[3] === 'git' && seg[4] === 'refs' && seg[5] === 'tags') {
3675
4084
  const repo = `${seg[1]}/${seg[2]}`;
3676
- const inRepo = state.tags.filter((t) => t.repository === repo);
4085
+ const gitTagRefs = state.gitRefs.filter((x) => x.repository === repo && x.ref.startsWith('refs/tags/'));
4086
+ const gitTagNames = new Set(gitTagRefs.map((x) => x.ref.slice('refs/tags/'.length)));
4087
+ const releaseTags = state.tags.filter((t) => t.repository === repo && !gitTagNames.has(t.name));
3677
4088
  if (seg[6]) {
3678
4089
  const name = decodeURIComponent(seg.slice(6).join('/'));
3679
- const tag = inRepo.find((t) => t.name === name);
4090
+ const gitRef = gitTagRefs.find((x) => x.ref === `refs/tags/${name}`);
4091
+ if (gitRef) return { status: 200, body: toGithubGitRef(gitRef) };
4092
+ const tag = releaseTags.find((t) => t.name === name);
3680
4093
  return tag ? { status: 200, body: toGithubGitTagRef(tag) } : githubNotFound();
3681
4094
  }
3682
- return { status: 200, body: paginate(inRepo, query).map(toGithubGitTagRef) };
4095
+ const union = [...releaseTags.map(toGithubGitTagRef), ...gitTagRefs.map(toGithubGitRef)];
4096
+ return { status: 200, body: paginate(union, query) };
3683
4097
  }
3684
4098
  // ── Git Data API reads (refs/commits/trees/blobs/annotated tags). These are LOCAL Git
3685
4099
  // objects authored via the Git Data write endpoints (distinct from the derived tag refs
@@ -3702,20 +4116,31 @@ export function handleGithubRequest(req: { method: string; path: string; root?:
3702
4116
  if (exact && matches.length === 1) return { status: 200, body: toGithubGitRef(exact) };
3703
4117
  return { status: 200, body: paginate(matches, query).map(toGithubGitRef) };
3704
4118
  }
4119
+ // Object reads consult the kernel row first (REST-authored objects land in BOTH with the
4120
+ // same real sha), then fall back to the REAL git plane — so an object that arrived by
4121
+ // `git push` (receive-pack) is visible through the Git Data REST surface too.
3705
4122
  if (seg[4] === 'commits' && seg[5]) {
3706
- const c = state.gitCommits.find((x) => x.repository === repo && x.sha === decodeURIComponent(seg[5]!));
4123
+ const sha = decodeURIComponent(seg[5]!);
4124
+ const c = state.gitCommits.find((x) => x.repository === repo && x.sha === sha)
4125
+ ?? (() => { const p = readGitCommitFromPlane(repo, sha, req.root); return p ? { repository: repo, ...p } : undefined; })();
3707
4126
  return c ? { status: 200, body: toGithubGitCommit(c) } : githubNotFound();
3708
4127
  }
3709
4128
  if (seg[4] === 'trees' && seg[5]) {
3710
- const t = state.gitTrees.find((x) => x.repository === repo && x.sha === decodeURIComponent(seg[5]!));
4129
+ const sha = decodeURIComponent(seg[5]!);
4130
+ const t = state.gitTrees.find((x) => x.repository === repo && x.sha === sha)
4131
+ ?? (() => { const p = readGitTreeFromPlane(repo, sha, req.root); return p ? { repository: repo, sha, tree: p, truncated: false } : undefined; })();
3711
4132
  return t ? { status: 200, body: toGithubGitTree(t) } : githubNotFound();
3712
4133
  }
3713
4134
  if (seg[4] === 'blobs' && seg[5]) {
3714
- const b = state.gitBlobs.find((x) => x.repository === repo && x.sha === decodeURIComponent(seg[5]!));
4135
+ const sha = decodeURIComponent(seg[5]!);
4136
+ const b = state.gitBlobs.find((x) => x.repository === repo && x.sha === sha)
4137
+ ?? (() => { const p = readGitBlobFromPlane(repo, sha, req.root); return p ? { repository: repo, sha, content_b64: p.content_b64, size: p.size, encoding: 'base64' } : undefined; })();
3715
4138
  return b ? { status: 200, body: toGithubGitBlob(b) } : githubNotFound();
3716
4139
  }
3717
4140
  if (seg[4] === 'tags' && seg[5]) {
3718
- const t = state.gitTagObjects.find((x) => x.repository === repo && x.sha === decodeURIComponent(seg[5]!));
4141
+ const sha = decodeURIComponent(seg[5]!);
4142
+ const t = state.gitTagObjects.find((x) => x.repository === repo && x.sha === sha)
4143
+ ?? (() => { const p = readGitTagFromPlane(repo, sha, req.root); return p ? { repository: repo, ...p } : undefined; })();
3719
4144
  return t ? { status: 200, body: toGithubGitTagObject(t) } : githubNotFound();
3720
4145
  }
3721
4146
  return githubNotFound();
@@ -3806,6 +4231,24 @@ export function handleGithubRequest(req: { method: string; path: string; root?:
3806
4231
  if (req.method === 'GET' && seg[0] === 'repos' && seg[3] === 'branches') {
3807
4232
  const repo = `${seg[1]}/${seg[2]}`;
3808
4233
  const inRepo = state.branches.filter((b) => b.repository === repo);
4234
+ // Protection reads (GET .../branches/:b/protection[/required_status_checks|enforce_admins])
4235
+ // — the persisted config back out, 404 "Branch not protected" when protection is off.
4236
+ if (seg[4] && seg[5] === 'protection') {
4237
+ const name = decodeURIComponent(seg[4]!);
4238
+ const b = inRepo.find((x) => x.name === name);
4239
+ if (!b?.protected) return { status: 404, body: { message: 'Branch not protected', documentation_url: 'https://docs.github.com/rest/branches/branch-protection' } };
4240
+ const base = `https://api.github.com/repos/${repo}/branches/${encodeURIComponent(name)}/protection`;
4241
+ if (!seg[6]) return { status: 200, body: toGithubBranchProtection(b) };
4242
+ if (seg[6] === 'required_status_checks' && !seg[7]) {
4243
+ const rsc = b.protection_config?.required_status_checks;
4244
+ if (!rsc) return { status: 404, body: { message: 'Required status checks not enabled', documentation_url: 'https://docs.github.com/rest/branches/branch-protection' } };
4245
+ return { status: 200, body: { url: `${base}/required_status_checks`, strict: rsc.strict, contexts: rsc.contexts, contexts_url: `${base}/required_status_checks/contexts`, checks: rsc.checks } };
4246
+ }
4247
+ if (seg[6] === 'enforce_admins' && !seg[7]) {
4248
+ return { status: 200, body: { url: `${base}/enforce_admins`, enabled: b.protection_config?.enforce_admins ?? false } };
4249
+ }
4250
+ return githubNotFound();
4251
+ }
3809
4252
  if (seg[4]) {
3810
4253
  const name = decodeURIComponent(seg.slice(4).join('/'));
3811
4254
  const b = inRepo.find((x) => x.name === name);
@@ -3847,6 +4290,9 @@ export function handleGithubRequest(req: { method: string; path: string; root?:
3847
4290
  const prefix = p ? `${p}/` : '';
3848
4291
  const children = inRepo.filter((c) => c.path.startsWith(prefix) && c.path !== p);
3849
4292
  if (children.length) return { status: 200, body: children.map((c) => ({ type: 'file', name: c.path.slice(prefix.length).split('/')[0], path: c.path, sha: c.sha, size: c.size })) };
4293
+ // Nothing REST-stored at this path: the bare repo (a real `git push`) is the truth.
4294
+ const planeHit = planeContents(repo, planeQueryRef(query), p, state.repos.find((r) => r.full_name === repo)?.default_branch ?? 'main', req.root);
4295
+ if (planeHit) return planeHit;
3850
4296
  return githubNotFound();
3851
4297
  }
3852
4298
  // ── Teams: GET /orgs/:org/teams[/:slug][/...] (CRUD + membership + repo access + discussions)
@@ -4256,8 +4702,21 @@ export function handleGithubRequest(req: { method: string; path: string; root?:
4256
4702
  const [base, head] = basehead.split('...');
4257
4703
  const pr = state.prs.find((p) => p.repository === repo && (p.head_sha === head || p.base_ref === base));
4258
4704
  const commitsList = pr?.commits_list ?? [];
4259
- const files = pr?.files ?? [];
4705
+ let files = pr?.files ?? [];
4260
4706
  const headSha = pr?.head_sha ?? head ?? '';
4707
+ // Same evidence order as GET /pulls/:n/files: carried diffs first, then the REAL diff
4708
+ // from the git plane when both sides resolve there (compare is three-dot by definition —
4709
+ // diffFilesInPlane already diffs from the merge base). Never two answers for one object.
4710
+ if (files.length === 0 && base && head) {
4711
+ const resolveRef = (name: string): string | undefined => /^[0-9a-f]{40}$/i.test(name) ? name
4712
+ : (state.gitRefs.find((x) => x.repository === repo && x.ref === `refs/heads/${name}`)?.object_sha
4713
+ ?? state.branches.find((b) => b.repository === repo && b.name === name)?.commit_sha);
4714
+ const baseSha = resolveRef(base); const tipSha = resolveRef(head);
4715
+ if (baseSha && tipSha) {
4716
+ const diff = diffFilesInPlane(repo, baseSha, tipSha, req.root);
4717
+ if (diff) files = diff.map((f) => ({ filename: f.filename, status: f.status, additions: f.additions, deletions: f.deletions, changes: f.additions + f.deletions, ...(f.sha !== undefined ? { sha: f.sha } : {}) }));
4718
+ }
4719
+ }
4261
4720
  return { status: 200, body: {
4262
4721
  status: commitsList.length ? 'ahead' : 'identical',
4263
4722
  ahead_by: commitsList.length,
@@ -4574,8 +5033,18 @@ function nextRunNumber(repo: string, workflowId: number, root?: string): number
4574
5033
  return max + 1;
4575
5034
  }
4576
5035
 
4577
- export type GithubWriteEvent = { event: string; action: string; repository: string; number: number; pr?: Record<string, unknown>; body?: string };
4578
- export type GithubWriteOutcome = { response: GithubResponse; webhook?: GithubWriteEvent };
5036
+ export type GithubWriteEvent = {
5037
+ event: string; action: string; repository: string; number: number; pr?: Record<string, unknown>; body?: string;
5038
+ // push events (a git-receive-pack push or a contents write): GitHub's ref/before/after triple.
5039
+ ref?: string; before?: string; after?: string;
5040
+ };
5041
+ export type GithubWriteOutcome = {
5042
+ response: GithubResponse;
5043
+ webhook?: GithubWriteEvent;
5044
+ // Some writes fire SEVERAL events (a ref write is a push AND may synchronize/close PRs) —
5045
+ // REST and receive-pack converge on the same event stream.
5046
+ webhooks?: GithubWriteEvent[];
5047
+ };
4579
5048
 
4580
5049
  /**
4581
5050
  * Handle a GitHub REST WRITE as a local action (R5/R18). Supports:
@@ -4588,6 +5057,60 @@ export type GithubWriteOutcome = { response: GithubResponse; webhook?: GithubWri
4588
5057
  * POST /repos/:o/:r/issues/:n/comments → comment (issue_comment)
4589
5058
  * Mirror mode rejects writes (R4). No real GitHub I/O (R16).
4590
5059
  */
5060
+ /**
5061
+ * The PR's REAL changed-file list from the git plane: resolve the base branch tip + the PR
5062
+ * head sha; when both commits exist in the bare repo, `git diff-tree` computes the truth.
5063
+ * Null when either side is not in the plane (observed/simulated PRs keep their carried or
5064
+ * empty file lists — never fabricated). This is what merge-gate scope classification reads
5065
+ * (GET /pulls/:n/files + GraphQL changedFiles), so a pushed PR must answer with its actual
5066
+ * paths, not 0 files.
5067
+ */
5068
+ export function prPlaneDiffFiles(state: GithubState, repo: string, pr: GithubPr, root?: string): GithubPullFile[] | null {
5069
+ const headSha = pr.head_sha;
5070
+ if (!headSha || !/^[0-9a-f]{40}$/i.test(headSha)) return null;
5071
+ const baseBranch = pr.base_ref;
5072
+ if (!baseBranch) return null;
5073
+ const baseSha = state.gitRefs.find((x) => x.repository === repo && x.ref === `refs/heads/${baseBranch}`)?.object_sha
5074
+ ?? state.branches.find((b) => b.repository === repo && b.name === baseBranch)?.commit_sha;
5075
+ if (!baseSha || !/^[0-9a-f]{40}$/i.test(baseSha)) return null;
5076
+ const diff = diffFilesInPlane(repo, baseSha, headSha, root);
5077
+ if (!diff) return null;
5078
+ // Real post-image blob shas ride along (from --raw); `patch` text stays absent — a filed
5079
+ // todo (github.pulls.files_patch_from_plane), never a fabricated diff body.
5080
+ return diff.map((f) => ({ filename: f.filename, status: f.status, additions: f.additions, deletions: f.deletions, changes: f.additions + f.deletions, ...(f.sha !== undefined ? { sha: f.sha } : {}) }));
5081
+ }
5082
+
5083
+ /**
5084
+ * GitHub closes open PRs whose HEAD BRANCH is deleted. Shared by the receive-pack reconcile
5085
+ * and the REST ref delete so both paths converge. Returns the closed PR numbers.
5086
+ */
5087
+ export async function closePrsForDeletedBranch(repo: string, branch: string, root: string | undefined, occurredAt: string): Promise<number[]> {
5088
+ const closed: number[] = [];
5089
+ for (const pr of githubState(root).prs) {
5090
+ if (pr.repository !== repo || pr.head_ref !== branch || pr.merged || (pr.state ?? 'open') !== 'open') continue;
5091
+ await applyTwinWrite(SERVICE, { operation: 'pull_request.update', subjectType: 'pull_request', subjectId: pr.id, fields: { number: pr.number, repository: repo, state: 'closed' }, occurredAt, actor: { kind: 'agent' } }, root);
5092
+ closed.push(pr.number);
5093
+ }
5094
+ return closed;
5095
+ }
5096
+
5097
+ /**
5098
+ * Re-point every OPEN PR whose head branch is `branch` at the branch's new tip — GitHub's
5099
+ * `synchronize` semantic: a push (receive-pack or an API ref update) moves the PR head, so
5100
+ * statuses/checks/reviews earned on the old head no longer match. Returns the PR numbers
5101
+ * moved (the receive-pack path emits `pull_request synchronize` webhooks for them).
5102
+ */
5103
+ export async function syncPrHeadsToBranchTip(repo: string, branch: string, sha: string, root: string | undefined, occurredAt: string): Promise<number[]> {
5104
+ const moved: number[] = [];
5105
+ for (const pr of githubState(root).prs) {
5106
+ if (pr.repository !== repo || pr.head_ref !== branch || pr.merged) continue;
5107
+ if ((pr.state ?? 'open') !== 'open' || pr.head_sha === sha) continue;
5108
+ await applyTwinWrite(SERVICE, { operation: 'pull_request.update', subjectType: 'pull_request', subjectId: pr.id, fields: { number: pr.number, repository: repo, head_sha: sha }, occurredAt, actor: { kind: 'agent' } }, root);
5109
+ moved.push(pr.number);
5110
+ }
5111
+ return moved;
5112
+ }
5113
+
4591
5114
  export async function applyGithubWrite(req: {
4592
5115
  method: string; path: string; body?: string; root?: string; readOnly?: boolean; occurredAt?: string;
4593
5116
  }): Promise<GithubWriteOutcome> {
@@ -4674,12 +5197,35 @@ export async function applyGithubWrite(req: {
4674
5197
  const pr = st.prs.find((p) => p.id === id);
4675
5198
  return { response: { status: 200, body: pr ? toGithubRest(pr, st.milestones) : { number } }, webhook: { event: 'pull_request', action: body.state === 'closed' ? 'closed' : 'edited', repository: repo, number } };
4676
5199
  }
4677
- // POST /repos/:o/:r/pulls/:n/reviews → submit review
5200
+ // POST /repos/:o/:r/pulls/:n/reviews → submit review. The review BINDS to the PR's head
5201
+ // sha at submission (real GitHub's `commit_id`) — a caller may pin it explicitly with
5202
+ // `commit_id` (the real API accepts one); otherwise the CURRENT head is recorded. This is
5203
+ // the exact-head re-earn semantic merge gates depend on: a later push moves the PR head,
5204
+ // the review's commit_id stays where it was.
4678
5205
  if (req.method === 'POST' && seg[3] === 'pulls' && seg[4] && seg[5] === 'reviews') {
4679
5206
  const number = Number(decodeURIComponent(seg[4]!));
4680
5207
  const seq = nextSeq('review', req.root);
4681
- await applyTwinWrite(SERVICE, { operation: 'pull_request_review.submit', subjectType: 'pull_request_review', subjectId: `review:${seq}`, fields: { repository: repo, number, state: body.event ?? 'COMMENTED', body: body.body, submitted_at: occurredAt }, occurredAt, actor: { kind: 'agent' } }, req.root);
4682
- return { response: { status: 200, body: toGithubReview({ id: seq, repository: repo, number, state: body.event, body: body.body, submitted_at: occurredAt }) }, webhook: { event: 'pull_request_review', action: 'submitted', repository: repo, number, body: body.body } };
5208
+ const prHead = githubState(req.root).prs.find((p) => p.repository === repo && p.number === number)?.head_sha;
5209
+ const commitId = body.commit_id !== undefined ? String(body.commit_id) : prHead;
5210
+ const fields: Record<string, unknown> = { repository: repo, number, state: body.event ?? 'COMMENTED', body: body.body, submitted_at: occurredAt, user_login: GITHUB_TWIN_LOGIN };
5211
+ if (commitId !== undefined) fields.commit_id = commitId;
5212
+ await applyTwinWrite(SERVICE, { operation: 'pull_request_review.submit', subjectType: 'pull_request_review', subjectId: `review:${seq}`, fields, occurredAt, actor: { kind: 'agent' } }, req.root);
5213
+ // Real GitHub's review create takes an inline `comments` array — the diff findings the
5214
+ // review is submitting. Each becomes a review comment WRAPPED BY THIS REVIEW (its
5215
+ // `pull_request_review_id` is this review's id, not the PR's implicit wrapper), which is
5216
+ // the one path where the create route knows the review a comment was posted under.
5217
+ if (Array.isArray(body.comments)) {
5218
+ for (const raw of body.comments as Array<Record<string, unknown>>) {
5219
+ const cSeq = nextSeq('comment', req.root);
5220
+ const cFields: Record<string, unknown> = { repository: repo, number, body: raw.body, created_at: occurredAt, updated_at: occurredAt, review_id: seq, user_login: GITHUB_TWIN_LOGIN };
5221
+ if (raw.path !== undefined) cFields.path = String(raw.path);
5222
+ if (raw.line !== undefined) cFields.line = Number(raw.line);
5223
+ if (raw.side !== undefined) cFields.side = String(raw.side);
5224
+ if (raw.start_line !== undefined) cFields.start_line = Number(raw.start_line);
5225
+ await applyTwinWrite(SERVICE, { operation: 'pull_request_review_comment.create', subjectType: 'pull_request_review_comment', subjectId: `comment:${cSeq}`, fields: cFields, occurredAt, actor: { kind: 'agent' } }, req.root);
5226
+ }
5227
+ }
5228
+ return { response: { status: 200, body: toGithubReview({ id: seq, repository: repo, number, state: body.event, body: body.body, submitted_at: occurredAt, user_login: GITHUB_TWIN_LOGIN, ...(commitId !== undefined ? { commit_id: commitId } : {}) }) }, webhook: { event: 'pull_request_review', action: 'submitted', repository: repo, number, body: body.body } };
4683
5229
  }
4684
5230
  // PUT /repos/:o/:r/pulls/:n/reviews/:rid/dismissals → dismiss a review (state → DISMISSED).
4685
5231
  // Requires a `message` (real GitHub 422s without it). PUT .../reviews/:rid → update body.
@@ -4702,13 +5248,46 @@ export async function applyGithubWrite(req: {
4702
5248
  return { response: { status: 200, body: toGithubReview(r) } };
4703
5249
  }
4704
5250
  }
5251
+ // POST /repos/:o/:r/pulls/:n/comments/:cid/replies → reply IN a review thread.
5252
+ // GitHub's dedicated reply route, and the one a seat answering a bot's finding can
5253
+ // actually use: it read the finding from `GET .../pulls/:n/comments` and knows the
5254
+ // comment id, not the diff anchor — so the reply inherits the root comment's anchor and
5255
+ // `in_reply_to_id` is what threads it. Matched BEFORE the create route below, whose
5256
+ // `seg[5] === 'comments'` test would otherwise swallow this path and lose the thread.
5257
+ if (req.method === 'POST' && seg[3] === 'pulls' && seg[4] && seg[5] === 'comments' && seg[6] && seg[7] === 'replies') {
5258
+ const number = Number(decodeURIComponent(seg[4]!));
5259
+ const rootId = Number(decodeURIComponent(seg[6]!));
5260
+ const parent = githubState(req.root).comments.find((c) => c.repository === repo && c.number === number && c.kind === 'review' && c.id === rootId);
5261
+ // Real GitHub 404s a reply to a comment that isn't there rather than starting a thread.
5262
+ if (!parent) return { response: githubNotFound() };
5263
+ // Real GitHub refuses an EMPTY body the same way it refuses a missing one — a blank reply
5264
+ // is not a reply. (`missing_field` is the code its validator returns for both.)
5265
+ if (body.body === undefined || String(body.body) === '') return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'PullRequestReviewComment', field: 'body', code: 'missing_field' }] } } };
5266
+ const seq = nextSeq('comment', req.root);
5267
+ const anchor: Record<string, unknown> = {};
5268
+ if (parent.path !== undefined) anchor.path = parent.path;
5269
+ if (parent.line !== undefined) anchor.line = parent.line;
5270
+ if (parent.side !== undefined) anchor.side = parent.side;
5271
+ if (parent.diff_hunk !== undefined) anchor.diff_hunk = parent.diff_hunk;
5272
+ // The reply is the TWIN's own comment, so it rides the PR's implicit review — not the
5273
+ // parent's, which would claim the answer was part of the reviewer's verdict. Real GitHub
5274
+ // does the same: a reply is wrapped in a fresh review belonging to the replier.
5275
+ const wrapperId = implicitReviewId(repo, number);
5276
+ const fields: Record<string, unknown> = { repository: repo, number, body: body.body, created_at: occurredAt, updated_at: occurredAt, in_reply_to: rootId, review_id: wrapperId, user_login: GITHUB_TWIN_LOGIN, ...anchor };
5277
+ await applyTwinWrite(SERVICE, { operation: 'pull_request_review_comment.create', subjectType: 'pull_request_review_comment', subjectId: `comment:${seq}`, fields, occurredAt, actor: { kind: 'agent' } }, req.root);
5278
+ return { response: { status: 201, body: toGithubReviewComment({ id: seq, repository: repo, number, body: body.body, at: occurredAt, in_reply_to: rootId, review_id: wrapperId, user_login: GITHUB_TWIN_LOGIN, ...anchor }) }, webhook: { event: 'pull_request_review_comment', action: 'created', repository: repo, number, body: body.body } };
5279
+ }
4705
5280
  // POST /repos/:o/:r/pulls/:n/comments → create a review (diff-thread) comment.
4706
5281
  // LOCAL writes may anchor the comment to the diff via path + line + side (+ start_line),
4707
5282
  // like real GitHub — those fields ride on the stored comment and the read-back.
4708
5283
  if (req.method === 'POST' && seg[3] === 'pulls' && seg[4] && seg[5] === 'comments') {
4709
5284
  const number = Number(decodeURIComponent(seg[4]!));
4710
5285
  const seq = nextSeq('comment', req.root);
4711
- const fields: Record<string, unknown> = { repository: repo, number, body: body.body, created_at: occurredAt, updated_at: occurredAt };
5286
+ // A comment posted with no review of its own is still WRAPPED in one on real GitHub —
5287
+ // the PR's implicit review (deterministic per repo+PR), so the id this route serves back
5288
+ // as `pull_request_review_id` resolves on `GET .../pulls/:n/reviews`.
5289
+ const wrapperId = implicitReviewId(repo, number);
5290
+ const fields: Record<string, unknown> = { repository: repo, number, body: body.body, created_at: occurredAt, updated_at: occurredAt, review_id: wrapperId, user_login: GITHUB_TWIN_LOGIN };
4712
5291
  if (body.path !== undefined) fields.path = String(body.path);
4713
5292
  if (body.line !== undefined) fields.line = Number(body.line);
4714
5293
  if (body.side !== undefined) fields.side = String(body.side);
@@ -4719,11 +5298,15 @@ export async function applyGithubWrite(req: {
4719
5298
  await applyTwinWrite(SERVICE, { operation: 'pull_request_review_comment.create', subjectType: 'pull_request_review_comment', subjectId: `comment:${seq}`, fields, occurredAt, actor: { kind: 'agent' } }, req.root);
4720
5299
  return { response: { status: 201, body: toGithubReviewComment({
4721
5300
  id: seq, repository: repo, number, body: body.body, at: occurredAt,
5301
+ review_id: wrapperId, user_login: GITHUB_TWIN_LOGIN,
4722
5302
  ...(body.path !== undefined ? { path: String(body.path) } : {}),
4723
5303
  ...(body.line !== undefined ? { line: Number(body.line) } : {}),
4724
5304
  ...(body.side !== undefined ? { side: String(body.side) } : {}),
4725
5305
  ...(body.start_line !== undefined ? { start_line: Number(body.start_line) } : {}),
4726
5306
  ...(body.diff_hunk !== undefined ? { diff_hunk: String(body.diff_hunk) } : {}),
5307
+ // The create accepted `in_reply_to` but answered without it, so a client that threads
5308
+ // by the response alone lost the thread it had just joined. It rides the 201 now.
5309
+ ...(body.in_reply_to !== undefined ? { in_reply_to: Number(body.in_reply_to) } : {}),
4727
5310
  }) }, webhook: { event: 'pull_request_review_comment', action: 'created', repository: repo, number, body: body.body } };
4728
5311
  }
4729
5312
  // PUT /repos/:o/:r/pulls/:n/merge → merge a pull request
@@ -4736,15 +5319,20 @@ export async function applyGithubWrite(req: {
4736
5319
  if (pr.merged || pr.state === 'closed') return { response: { status: 405, body: { message: 'Pull Request is not mergeable' } } };
4737
5320
  // A draft PR cannot be merged (real GitHub 405s).
4738
5321
  if (pr.draft) return { response: { status: 405, body: { message: 'Draft pull requests cannot be merged.' } } };
5322
+ // GitHub's optional `sha` is an optimistic-concurrency precondition: callers that reviewed
5323
+ // one exact head can refuse to merge if the branch advanced between their read and this
5324
+ // write. Ignoring it turns every external merge gate into a check-then-act race.
5325
+ if (body.sha !== undefined && String(body.sha) !== pr.head_sha) {
5326
+ return { response: { status: 409, body: { message: 'Head branch was modified. Review and try the merge again.' } } };
5327
+ }
4739
5328
  // merge_method (merge|squash|rebase) selects how the merge commit is produced; default 'merge'.
4740
5329
  const allowedMethods = ['merge', 'squash', 'rebase'];
4741
5330
  const method = body.merge_method ? String(body.merge_method) : 'merge';
4742
5331
  if (!allowedMethods.includes(method)) return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'PullRequest', field: 'merge_method', code: 'invalid' }] } } };
4743
- const sha = mergeCommitSha(repo, number);
4744
- const fields = { number, repository: repo, state: 'closed', merged: true, merged_at: occurredAt, merge_commit_sha: sha, merge_method: method };
4745
- await applyTwinWrite(SERVICE, { operation: 'pull_request.merge', subjectType: 'pull_request', subjectId: id, fields, occurredAt, actor: { kind: 'agent' } }, req.root);
5332
+ const landed = await mergePullRequest(repo, pr, method as 'merge' | 'squash' | 'rebase', occurredAt, req.root);
5333
+ if (!landed.ok) return { response: { status: landed.status, body: { message: landed.message } } };
4746
5334
  return {
4747
- response: { status: 200, body: { sha, merged: true, message: 'Pull Request successfully merged' } },
5335
+ response: { status: 200, body: { sha: landed.sha, merged: true, message: 'Pull Request successfully merged' } },
4748
5336
  webhook: { event: 'pull_request', action: 'closed', repository: repo, number },
4749
5337
  };
4750
5338
  }
@@ -4814,6 +5402,7 @@ export async function applyGithubWrite(req: {
4814
5402
  const seq = nextSeq('status', req.root);
4815
5403
  const fields = { repository: repo, sha, state: body.state, context: body.context ?? 'default', description: body.description, target_url: body.target_url, created_at: occurredAt, updated_at: occurredAt };
4816
5404
  await applyTwinWrite(SERVICE, { operation: 'commit_status.create', subjectType: 'commit_status', subjectId: `status:${seq}`, fields, occurredAt, actor: { kind: 'agent' } }, req.root);
5405
+ if (body.state === 'success') await landArmedPullRequests(repo, sha, occurredAt, req.root);
4817
5406
  return { response: { status: 201, body: toGithubStatus({ id: seq, repository: repo, sha, state: body.state, context: body.context ?? 'default', description: body.description, target_url: body.target_url, created_at: occurredAt, updated_at: occurredAt }) } };
4818
5407
  }
4819
5408
  // POST /repos/:o/:r/check-runs → create a check run (LOCAL CI construct)
@@ -4826,6 +5415,7 @@ export async function applyGithubWrite(req: {
4826
5415
  const fields: Record<string, unknown> = { repository: repo, head_sha: body.head_sha, name: body.name, status, conclusion: body.conclusion, details_url: body.details_url, started_at: body.started_at ?? occurredAt };
4827
5416
  if (completed_at !== undefined) fields.completed_at = completed_at;
4828
5417
  await applyTwinWrite(SERVICE, { operation: 'check_run.create', subjectType: 'check_run', subjectId: `check:${seq}`, fields, occurredAt, actor: { kind: 'agent' } }, req.root);
5418
+ if (body.conclusion === 'success') await landArmedPullRequests(repo, String(body.head_sha), occurredAt, req.root);
4829
5419
  return { response: { status: 201, body: toGithubCheckRun({ id: seq, repository: repo, head_sha: body.head_sha, name: body.name, status, conclusion: body.conclusion, details_url: body.details_url, started_at: body.started_at ?? occurredAt, ...(completed_at !== undefined ? { completed_at } : {}) }) } };
4830
5420
  }
4831
5421
  // POST /repos/:o/:r/pulls/:n/requested_reviewers → request reviewers (body {reviewers:[login]})
@@ -5334,6 +5924,7 @@ export async function applyGithubWrite(req: {
5334
5924
  }));
5335
5925
  } else if (existing.annotations) { fields.annotations = existing.annotations; }
5336
5926
  await applyTwinWrite(SERVICE, { operation: 'check_run.update', subjectType: 'check_run', subjectId: `check:${id}`, fields, occurredAt, actor: { kind: 'agent' } }, req.root);
5927
+ if (fields.conclusion === 'success') await landArmedPullRequests(repo, existing.head_sha, occurredAt, req.root);
5337
5928
  const updated = githubState(req.root).checkRuns.find((c) => c.id === id)!;
5338
5929
  return { response: { status: 200, body: toGithubCheckRun(updated) } };
5339
5930
  }
@@ -5366,7 +5957,14 @@ export async function applyGithubWrite(req: {
5366
5957
  if (body[k] !== undefined) fields[k] = body[k];
5367
5958
  }
5368
5959
  if (body.name !== undefined) fields.name = String(body.name);
5960
+ // Validate BEFORE the kernel write (never half-apply): a branch name symbolic-ref would
5961
+ // refuse is GitHub's 422, not a thrown 500.
5962
+ if (body.default_branch !== undefined && !isValidBranchName(String(body.default_branch))) {
5963
+ return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'Repository', field: 'default_branch', code: 'invalid' }] } } };
5964
+ }
5369
5965
  await applyTwinWrite(SERVICE, { operation: 'repository.update', subjectType: 'repository', subjectId: `repo:${r0.id}`, fields, occurredAt, actor: { kind: 'agent' } }, req.root);
5966
+ // A default_branch change re-points the bare repo's HEAD too (clone checks out the new one).
5967
+ if (body.default_branch !== undefined) setBareRepoHead(repo, String(body.default_branch), req.root);
5370
5968
  const r = githubState(req.root).repos.find((x) => x.id === r0.id)!;
5371
5969
  return { response: { status: 200, body: toGithubRepo(r) }, webhook: { event: 'repository', action: 'edited', repository: repo, number: 0 } };
5372
5970
  }
@@ -5374,6 +5972,8 @@ export async function applyGithubWrite(req: {
5374
5972
  const r0 = githubState(req.root).repos.find((x) => x.full_name === repo);
5375
5973
  if (!r0) return { response: githubNotFound() };
5376
5974
  await applyTwinWrite(SERVICE, { operation: 'repository.delete', subjectType: 'repository', subjectId: `repo:${r0.id}`, fields: { owner: r0.owner, name: r0.name }, occurredAt, actor: { kind: 'agent' } }, req.root);
5975
+ // The bare repo dies with the repo — a later re-create must NOT inherit its history.
5976
+ deleteBareRepo(repo, req.root);
5377
5977
  return { response: { status: 204, body: undefined }, webhook: { event: 'repository', action: 'deleted', repository: repo, number: 0 } };
5378
5978
  }
5379
5979
  // POST /repos/:o/:r/forks → create a fork (a new repo with fork:true + parent).
@@ -5381,10 +5981,25 @@ export async function applyGithubWrite(req: {
5381
5981
  const parent = githubState(req.root).repos.find((x) => x.full_name === repo);
5382
5982
  const owner = String(body.organization ?? 'octocat');
5383
5983
  const name = String(body.name ?? seg[2]);
5984
+ if (!/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(name) || ['.', '..'].includes(owner) || ['.', '..'].includes(name)) {
5985
+ return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'Repository', field: 'name', code: 'invalid' }] } } };
5986
+ }
5384
5987
  const full = `${owner}/${name}`;
5385
5988
  const seq = nextSeq('repo', req.root);
5386
5989
  const fields = { owner, name, fork: true, parent_full_name: repo, description: parent?.description ?? null, default_branch: parent?.default_branch ?? 'main', created_at: occurredAt, updated_at: occurredAt };
5387
5990
  await applyTwinWrite(SERVICE, { operation: 'repository.fork', subjectType: 'repository', subjectId: `repo:${seq}`, fields, occurredAt, actor: { kind: 'agent' } }, req.root);
5991
+ // A fork copies the parent's OBJECT STORE + refs (what GitHub does server-side) — cloning
5992
+ // the fork must serve the parent's history, not an empty repo — AND the copied refs get
5993
+ // control-plane rows (else REST refs/branches would answer empty while clone serves
5994
+ // history: two answers for one object).
5995
+ forkBareRepo(repo, full, req.root, String(fields.default_branch));
5996
+ for (const planeRef of listGitPlaneRefs(full, req.root)) {
5997
+ await applyTwinWrite(SERVICE, { operation: 'git_ref.create', subjectType: 'git_ref', subjectId: `gitref:${full}#${planeRef.ref}`, fields: { repository: full, ref: planeRef.ref, object_sha: planeRef.object_sha, object_type: planeRef.object_type === 'tag' ? 'tag' : 'commit' }, occurredAt, actor: { kind: 'agent' } }, req.root);
5998
+ if (planeRef.ref.startsWith('refs/heads/')) {
5999
+ const bn = planeRef.ref.slice('refs/heads/'.length);
6000
+ await applyTwinWrite(SERVICE, { operation: 'branch.create', subjectType: 'branch', subjectId: `branch:${full}#${bn}`, fields: { repository: full, name: bn, commit_sha: planeRef.object_sha }, occurredAt, actor: { kind: 'agent' } }, req.root);
6001
+ }
6002
+ }
5388
6003
  const r = githubState(req.root).repos.find((x) => x.full_name === full)!;
5389
6004
  return { response: { status: 202, body: toGithubRepo(r) }, webhook: { event: 'fork', action: 'created', repository: repo, number: 0 } };
5390
6005
  }
@@ -5396,9 +6011,11 @@ export async function applyGithubWrite(req: {
5396
6011
  await applyTwinWrite(SERVICE, { operation: 'repository.set_topics', subjectType: 'repository', subjectId: `repo:${r0.id}`, fields: { owner: r0.owner, name: r0.name, topics: names, updated_at: occurredAt }, occurredAt, actor: { kind: 'agent' } }, req.root);
5397
6012
  return { response: { status: 200, body: { names } } };
5398
6013
  }
5399
- // ── Branches: create/delete via git refs is below; protection toggles here ───────────────
5400
- // PUT /repos/:o/:r/branches/:branch/protection → mark protected. DELETE unmark.
5401
- if (seg[3] === 'branches' && seg[4] && seg[5] === 'protection' && (req.method === 'PUT' || req.method === 'DELETE')) {
6014
+ // ── Branches: create/delete via git refs is below; protection lives here ─────────────────
6015
+ // PUT /repos/:o/:r/branches/:branch/protection → protect + PERSIST the config (required
6016
+ // status-check contexts, enforce_admins, required PR reviews what merge gates read back).
6017
+ // DELETE → unprotect (204). Sub-resources (required_status_checks / enforce_admins) below.
6018
+ if (seg[3] === 'branches' && seg[4] && seg[5] === 'protection' && !seg[6] && (req.method === 'PUT' || req.method === 'DELETE')) {
5402
6019
  const name = decodeURIComponent(seg[4]!);
5403
6020
  const st0 = githubState(req.root);
5404
6021
  let b = st0.branches.find((x) => x.repository === repo && x.name === name);
@@ -5409,9 +6026,53 @@ export async function applyGithubWrite(req: {
5409
6026
  b = githubState(req.root).branches.find((x) => x.repository === repo && x.name === name)!;
5410
6027
  }
5411
6028
  const protect = req.method === 'PUT';
5412
- await applyTwinWrite(SERVICE, { operation: protect ? 'branch.protect' : 'branch.unprotect', subjectType: 'branch', subjectId: `branch:${repo}#${name}`, fields: { repository: repo, name, commit_sha: b.commit_sha, protected: protect }, occurredAt, actor: { kind: 'agent' } }, req.root);
6029
+ // Fidelity: real GitHub 422s a protection PUT unless ALL FOUR keys are present
6030
+ // (required_status_checks / enforce_admins / required_pull_request_reviews /
6031
+ // restrictions — each nullable, but never omittable).
6032
+ if (protect) {
6033
+ const missingKeys = (['required_status_checks', 'enforce_admins', 'required_pull_request_reviews', 'restrictions'] as const)
6034
+ .filter((k) => !(k in body));
6035
+ if (missingKeys.length) {
6036
+ return { response: { status: 422, body: { message: 'Validation Failed', errors: missingKeys.map((field) => ({ resource: 'BranchProtection', field, code: 'missing_field' })) } } };
6037
+ }
6038
+ }
6039
+ const protection_config = protect
6040
+ ? {
6041
+ required_status_checks: normalizeRequiredStatusChecks(body.required_status_checks),
6042
+ enforce_admins: body.enforce_admins === true,
6043
+ required_pull_request_reviews: body.required_pull_request_reviews && typeof body.required_pull_request_reviews === 'object' ? (body.required_pull_request_reviews as Record<string, unknown>) : null,
6044
+ restrictions: body.restrictions && typeof body.restrictions === 'object' ? (body.restrictions as Record<string, unknown>) : null,
6045
+ }
6046
+ : null;
6047
+ await applyTwinWrite(SERVICE, { operation: protect ? 'branch.protect' : 'branch.unprotect', subjectType: 'branch', subjectId: `branch:${repo}#${name}`, fields: { repository: repo, name, commit_sha: b.commit_sha, protected: protect, protection_config }, occurredAt, actor: { kind: 'agent' } }, req.root);
5413
6048
  if (!protect) return { response: { status: 204, body: undefined } };
5414
- return { response: { status: 200, body: { url: `https://api.github.com/repos/${repo}/branches/${encodeURIComponent(name)}/protection`, enabled: true, required_status_checks: { enforcement_level: 'everyone', contexts: [] } } } };
6049
+ const after = githubState(req.root).branches.find((x) => x.repository === repo && x.name === name)!;
6050
+ return { response: { status: 200, body: toGithubBranchProtection(after) } };
6051
+ }
6052
+ // PATCH /repos/:o/:r/branches/:branch/protection/required_status_checks → update the
6053
+ // required-check config on an already-protected branch (404 when not protected, like GitHub).
6054
+ if (req.method === 'PATCH' && seg[3] === 'branches' && seg[4] && seg[5] === 'protection' && seg[6] === 'required_status_checks' && !seg[7]) {
6055
+ const name = decodeURIComponent(seg[4]!);
6056
+ const b = githubState(req.root).branches.find((x) => x.repository === repo && x.name === name);
6057
+ if (!b?.protected) return { response: githubNotFound() };
6058
+ const prev = b.protection_config?.required_status_checks ?? { strict: false, contexts: [], checks: [] };
6059
+ const next = normalizeRequiredStatusChecks({ strict: body.strict ?? prev.strict, contexts: body.contexts ?? (body.checks === undefined ? prev.contexts : undefined), checks: body.checks })!;
6060
+ const protection_config = { ...(b.protection_config ?? {}), required_status_checks: next };
6061
+ await applyTwinWrite(SERVICE, { operation: 'branch.protect', subjectType: 'branch', subjectId: `branch:${repo}#${name}`, fields: { repository: repo, name, commit_sha: b.commit_sha, protected: true, protection_config }, occurredAt, actor: { kind: 'agent' } }, req.root);
6062
+ const base = `https://api.github.com/repos/${repo}/branches/${encodeURIComponent(name)}/protection`;
6063
+ return { response: { status: 200, body: { url: `${base}/required_status_checks`, strict: next.strict, contexts: next.contexts, contexts_url: `${base}/required_status_checks/contexts`, checks: next.checks } } };
6064
+ }
6065
+ // POST/DELETE /repos/:o/:r/branches/:branch/protection/enforce_admins → toggle admin
6066
+ // enforcement on a protected branch (the enforce_admins:true the twin-sdlc gate provisions).
6067
+ if (seg[3] === 'branches' && seg[4] && seg[5] === 'protection' && seg[6] === 'enforce_admins' && !seg[7] && (req.method === 'POST' || req.method === 'DELETE')) {
6068
+ const name = decodeURIComponent(seg[4]!);
6069
+ const b = githubState(req.root).branches.find((x) => x.repository === repo && x.name === name);
6070
+ if (!b?.protected) return { response: githubNotFound() };
6071
+ const enabled = req.method === 'POST';
6072
+ const protection_config = { ...(b.protection_config ?? {}), enforce_admins: enabled };
6073
+ await applyTwinWrite(SERVICE, { operation: 'branch.protect', subjectType: 'branch', subjectId: `branch:${repo}#${name}`, fields: { repository: repo, name, commit_sha: b.commit_sha, protected: true, protection_config }, occurredAt, actor: { kind: 'agent' } }, req.root);
6074
+ if (!enabled) return { response: { status: 204, body: undefined } };
6075
+ return { response: { status: 200, body: { url: `https://api.github.com/repos/${repo}/branches/${encodeURIComponent(name)}/protection/enforce_admins`, enabled } } };
5415
6076
  }
5416
6077
  // ── Collaborators: PUT/DELETE /repos/:o/:r/collaborators/:login ──────────────────────────
5417
6078
  if (seg[3] === 'collaborators' && seg[4] && (req.method === 'PUT' || req.method === 'DELETE')) {
@@ -5486,25 +6147,48 @@ export async function applyGithubWrite(req: {
5486
6147
  }
5487
6148
 
5488
6149
  // ── Git Data API writes (refs/commits/trees/blobs/annotated tags) ──────────────────────────
6150
+ // Re-backed by the REAL git plane (github-git-plane.ts): each create writes a REAL git
6151
+ // object into the repo's bare repo and returns the REAL git sha, so a REST-authored chain
6152
+ // (blob → tree → commit → ref) is byte-for-byte fetchable by an unmodified `git` client.
6153
+ // The kernel event log still records every write (the control-plane ledger the projection,
6154
+ // mirror and webhooks fold), but the OBJECT store is the bare repo — one store, not two.
6155
+ // An owner/name the plane's path validation rejects can never name a twin repo: answer the
6156
+ // vendor-shaped 404 up front rather than letting bareRepoDir throw into a 500.
6157
+ if (seg[3] === 'git' && (!/^[A-Za-z0-9_.-]+$/.test(seg[1]!) || !/^[A-Za-z0-9_.-]+$/.test(seg[2]!) || ['.', '..'].includes(seg[1]!) || ['.', '..'].includes(seg[2]!))) {
6158
+ return { response: githubNotFound() };
6159
+ }
5489
6160
  // POST .../git/blobs → create a blob object (content base64). Returns {sha,url}.
5490
6161
  if (req.method === 'POST' && seg[3] === 'git' && seg[4] === 'blobs' && !seg[5]) {
5491
6162
  if (body.content === undefined) return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'Blob', field: 'content', code: 'missing_field' }] } } };
5492
6163
  const encoding = String(body.encoding ?? 'utf-8');
5493
6164
  const content_b64 = encoding === 'base64' ? String(body.content) : Buffer.from(String(body.content), 'utf-8').toString('base64');
5494
6165
  let size = 0; try { size = Buffer.from(content_b64, 'base64').length; } catch { size = content_b64.length; }
5495
- const sha = createHash('sha256').update(`${repo}:blob:${content_b64}`).digest('hex').slice(0, 40);
6166
+ const sha = writeGitBlobToPlane(repo, content_b64, req.root); // REAL git sha (hash-object -w)
5496
6167
  await applyTwinWrite(SERVICE, { operation: 'git_blob.create', subjectType: 'git_blob', subjectId: `gitblob:${repo}#${sha}`, fields: { repository: repo, sha, content_b64, size, encoding: 'base64' }, occurredAt, actor: { kind: 'agent' } }, req.root);
5497
6168
  return { response: { status: 201, body: { sha, url: `https://api.github.com/repos/${repo}/git/blobs/${sha}` } } };
5498
6169
  }
5499
- // POST .../git/trees → create a tree object from {tree:[{path,mode,type,sha}]}.
6170
+ // POST .../git/trees → create a tree object from {tree:[{path,mode,type,sha}]}, with
6171
+ // base_tree overlay semantics (an entry with sha:null deletes its path) like real GitHub.
5500
6172
  if (req.method === 'POST' && seg[3] === 'git' && seg[4] === 'trees' && !seg[5]) {
5501
6173
  if (!Array.isArray(body.tree)) return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'Tree', field: 'tree', code: 'missing_field' }] } } };
5502
6174
  const entries: GithubGitTreeEntry[] = (body.tree as Array<Record<string, unknown>>).map((e) => ({
5503
- path: String(e.path ?? ''), mode: String(e.mode ?? '100644'), type: String(e.type ?? 'blob'), sha: String(e.sha ?? ''),
6175
+ path: String(e.path ?? ''), mode: String(e.mode ?? '100644'), type: String(e.type ?? 'blob'), sha: e.sha === null ? '' : String(e.sha ?? ''),
5504
6176
  ...(e.size !== undefined ? { size: Number(e.size) } : {}),
5505
6177
  }));
5506
- const sha = createHash('sha256').update(`${repo}:tree:${JSON.stringify(entries)}:${body.base_tree ?? ''}`).digest('hex').slice(0, 40);
5507
- await applyTwinWrite(SERVICE, { operation: 'git_tree.create', subjectType: 'git_tree', subjectId: `gittree:${repo}#${sha}`, fields: { repository: repo, sha, tree: entries, truncated: false }, occurredAt, actor: { kind: 'agent' } }, req.root);
6178
+ // Fidelity: entry shas must be 40-hex (sha:null deletes a base_tree path; both real).
6179
+ if (entries.some((e) => e.sha !== '' && !/^[0-9a-f]{40}$/i.test(e.sha))) {
6180
+ return { response: { status: 422, body: { message: 'The sha parameter must be exactly 40 characters and contain only [0-9a-f]' } } };
6181
+ }
6182
+ // Fidelity: a base_tree naming a tree the store does not hold is a 422 (real GitHub) —
6183
+ // silently dropping the base would LOSE the base's entries in the produced tree.
6184
+ if (body.base_tree !== undefined
6185
+ && (!/^[0-9a-f]{40}$/i.test(String(body.base_tree)) || objectTypeInPlane(repo, String(body.base_tree), req.root) !== 'tree')) {
6186
+ return { response: { status: 422, body: { message: `The base tree parameter (${String(body.base_tree)}) is not a valid tree` } } };
6187
+ }
6188
+ const sha = writeGitTreeToPlane(repo, entries, body.base_tree !== undefined ? String(body.base_tree) : undefined, req.root); // REAL git sha (mktree)
6189
+ // The kernel row records the REAL tree listing the plane now holds (post-overlay).
6190
+ const planeTree = readGitTreeFromPlane(repo, sha, req.root) ?? entries.filter((e) => e.sha);
6191
+ await applyTwinWrite(SERVICE, { operation: 'git_tree.create', subjectType: 'git_tree', subjectId: `gittree:${repo}#${sha}`, fields: { repository: repo, sha, tree: planeTree, truncated: false }, occurredAt, actor: { kind: 'agent' } }, req.root);
5508
6192
  const t = githubState(req.root).gitTrees.find((x) => x.repository === repo && x.sha === sha)!;
5509
6193
  return { response: { status: 201, body: toGithubGitTree(t) } };
5510
6194
  }
@@ -5513,8 +6197,27 @@ export async function applyGithubWrite(req: {
5513
6197
  const missing = (['message', 'tree'] as const).filter((k) => body[k] === undefined);
5514
6198
  if (missing.length) return { response: { status: 422, body: { message: 'Validation Failed', errors: missing.map((field) => ({ resource: 'Commit', field, code: 'missing_field' })) } } };
5515
6199
  const parents = Array.isArray(body.parents) ? (body.parents as unknown[]).map(String) : [];
5516
- const sha = createHash('sha256').update(`${repo}:commit:${body.message}:${body.tree}:${parents.join(',')}`).digest('hex').slice(0, 40);
5517
- const fields = { repository: repo, sha, message: String(body.message), tree_sha: String(body.tree), parents, author_name: body.author?.name, author_email: body.author?.email, created_at: occurredAt };
6200
+ // Fidelity: a git sha is 40 hex chars — real GitHub 422s a malformed tree/parent sha
6201
+ // (and real git refuses to frame a commit around one).
6202
+ if (!/^[0-9a-f]{40}$/i.test(String(body.tree)) || parents.some((p) => !/^[0-9a-f]{40}$/i.test(p))) {
6203
+ return { response: { status: 422, body: { message: 'The tree and parent parameters must be exactly 40 characters and contain only [0-9a-f]' } } };
6204
+ }
6205
+ const author = body.author?.name !== undefined || body.author?.email !== undefined
6206
+ ? { name: String(body.author?.name ?? 'github-twin'), email: String(body.author?.email ?? 'github-twin@localhost') }
6207
+ : undefined;
6208
+ const committer = body.committer?.name !== undefined || body.committer?.email !== undefined
6209
+ ? { name: String(body.committer?.name ?? 'github-twin'), email: String(body.committer?.email ?? 'github-twin@localhost') }
6210
+ : undefined;
6211
+ // REAL git sha: the commit object is framed and stored by git itself. author.date (when
6212
+ // given) pins the sha exactly as real GitHub honors it; otherwise the write time does.
6213
+ const sha = writeGitCommitToPlane(repo, {
6214
+ message: String(body.message), treeSha: String(body.tree), parents,
6215
+ ...(author !== undefined ? { author } : {}), ...(committer !== undefined ? { committer } : {}),
6216
+ occurredAt: String(body.author?.date ?? body.committer?.date ?? occurredAt),
6217
+ }, req.root);
6218
+ // The kernel row records the SAME identity the object bytes carry (the twin ident when
6219
+ // the caller supplied none) — REST rendering and `git cat-file` must agree.
6220
+ const fields = { repository: repo, sha, message: String(body.message), tree_sha: String(body.tree), parents, author_name: body.author?.name ?? 'github-twin', author_email: body.author?.email ?? 'github-twin@localhost', created_at: occurredAt };
5518
6221
  await applyTwinWrite(SERVICE, { operation: 'git_commit.create', subjectType: 'git_commit', subjectId: `gitcommit:${repo}#${sha}`, fields, occurredAt, actor: { kind: 'agent' } }, req.root);
5519
6222
  const c = githubState(req.root).gitCommits.find((x) => x.repository === repo && x.sha === sha)!;
5520
6223
  return { response: { status: 201, body: toGithubGitCommit(c) } };
@@ -5523,7 +6226,14 @@ export async function applyGithubWrite(req: {
5523
6226
  if (req.method === 'POST' && seg[3] === 'git' && seg[4] === 'tags' && !seg[5]) {
5524
6227
  const missing = (['tag', 'message', 'object'] as const).filter((k) => body[k] === undefined);
5525
6228
  if (missing.length) return { response: { status: 422, body: { message: 'Validation Failed', errors: missing.map((field) => ({ resource: 'Tag', field, code: 'missing_field' })) } } };
5526
- const sha = createHash('sha256').update(`${repo}:tagobj:${body.tag}:${body.object}`).digest('hex').slice(0, 40);
6229
+ if (!/^[0-9a-f]{40}$/i.test(String(body.object))) return { response: { status: 422, body: { message: 'The object parameter must be exactly 40 characters and contain only [0-9a-f]' } } };
6230
+ const tagger = body.tagger?.name !== undefined || body.tagger?.email !== undefined
6231
+ ? { name: String(body.tagger?.name ?? 'github-twin'), email: String(body.tagger?.email ?? 'github-twin@localhost') }
6232
+ : undefined;
6233
+ const sha = writeGitTagToPlane(repo, {
6234
+ tag: String(body.tag), message: String(body.message), objectSha: String(body.object), objectType: String(body.type ?? 'commit'),
6235
+ ...(tagger !== undefined ? { tagger } : {}), occurredAt: String(body.tagger?.date ?? occurredAt),
6236
+ }, req.root); // REAL git sha
5527
6237
  const fields = { repository: repo, sha, tag: String(body.tag), message: String(body.message), object_sha: String(body.object), object_type: String(body.type ?? 'commit'), tagger_name: body.tagger?.name, created_at: occurredAt };
5528
6238
  await applyTwinWrite(SERVICE, { operation: 'git_tag_object.create', subjectType: 'git_tag_object', subjectId: `gittag:${repo}#${sha}`, fields, occurredAt, actor: { kind: 'agent' } }, req.root);
5529
6239
  const t = githubState(req.root).gitTagObjects.find((x) => x.repository === repo && x.sha === sha)!;
@@ -5537,10 +6247,26 @@ export async function applyGithubWrite(req: {
5537
6247
  const refName = String(body.ref);
5538
6248
  if (!refName.startsWith('refs/')) return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'Reference', field: 'ref', code: 'invalid' }] } } };
5539
6249
  if (githubState(req.root).gitRefs.some((x) => x.repository === repo && x.ref === refName)) return { response: { status: 422, body: { message: 'Reference already exists' } } };
5540
- const object_type = refName.startsWith('refs/tags/') && githubState(req.root).gitTagObjects.some((t) => t.repository === repo && t.sha === String(body.sha)) ? 'tag' : 'commit';
6250
+ const planeType = objectTypeInPlane(repo, String(body.sha), req.root);
6251
+ const object_type = planeType === 'tag' || (refName.startsWith('refs/tags/') && githubState(req.root).gitTagObjects.some((t) => t.repository === repo && t.sha === String(body.sha))) ? 'tag' : 'commit';
5541
6252
  await applyTwinWrite(SERVICE, { operation: 'git_ref.create', subjectType: 'git_ref', subjectId: `gitref:${repo}#${refName}`, fields: { repository: repo, ref: refName, object_sha: String(body.sha), object_type }, occurredAt, actor: { kind: 'agent' } }, req.root);
6253
+ // Materialize into the git plane when the object exists there — the REST-created ref is
6254
+ // then FETCHABLE by a real git client (git plane ↔ control plane agreement). A ref minted
6255
+ // against a sha the plane has never seen stays control-plane-only (lenient legacy surface).
6256
+ updateGitRefInPlane(repo, refName, String(body.sha), req.root);
6257
+ // REST and receive-pack CONVERGE: a branch ref create also lands the branch row (so
6258
+ // GET /branches sees it), re-points any open PR riding the branch, and fires the SAME
6259
+ // event stream a push does (an API ref write IS a push on real GitHub).
6260
+ const createEvents: GithubWriteEvent[] = [{ event: 'push', action: '', repository: repo, number: 0, ref: refName, before: '0'.repeat(40), after: String(body.sha) }];
6261
+ if (refName.startsWith('refs/heads/')) {
6262
+ const branchNm = refName.slice('refs/heads/'.length);
6263
+ await applyTwinWrite(SERVICE, { operation: 'branch.create', subjectType: 'branch', subjectId: `branch:${repo}#${branchNm}`, fields: { repository: repo, name: branchNm, commit_sha: String(body.sha) }, occurredAt, actor: { kind: 'agent' } }, req.root);
6264
+ for (const n of await syncPrHeadsToBranchTip(repo, branchNm, String(body.sha), req.root, occurredAt)) {
6265
+ createEvents.push({ event: 'pull_request', action: 'synchronize', repository: repo, number: n });
6266
+ }
6267
+ }
5542
6268
  const r = githubState(req.root).gitRefs.find((x) => x.repository === repo && x.ref === refName)!;
5543
- return { response: { status: 201, body: toGithubGitRef(r) } };
6269
+ return { response: { status: 201, body: toGithubGitRef(r) }, webhooks: createEvents };
5544
6270
  }
5545
6271
  // PATCH .../git/refs/:ref → fast-forward/force-update a ref's sha. 404 if absent.
5546
6272
  if (req.method === 'PATCH' && seg[3] === 'git' && seg[4] === 'refs' && seg[5]) {
@@ -5548,9 +6274,30 @@ export async function applyGithubWrite(req: {
5548
6274
  const existing = githubState(req.root).gitRefs.find((x) => x.repository === repo && x.ref === refName);
5549
6275
  if (!existing) return { response: githubNotFound() };
5550
6276
  if (body.sha === undefined) return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'Reference', field: 'sha', code: 'missing_field' }] } } };
6277
+ // Fidelity: a non-fast-forward update needs force:true (GitHub 422s it). Decidable only
6278
+ // when BOTH commits exist in the plane; control-plane-only shas keep the lenient surface.
6279
+ // gh -f sends the STRING "true"; -F sends boolean true. GitHub accepts both.
6280
+ const forceRequested = body.force === true || body.force === 'true';
6281
+ if (!forceRequested
6282
+ && objectExistsInPlane(repo, existing.object_sha, req.root) && objectExistsInPlane(repo, String(body.sha), req.root)
6283
+ && !isAncestorInPlane(repo, existing.object_sha, String(body.sha), req.root)) {
6284
+ return { response: { status: 422, body: { message: 'Update is not a fast forward' } } };
6285
+ }
5551
6286
  await applyTwinWrite(SERVICE, { operation: 'git_ref.update', subjectType: 'git_ref', subjectId: `gitref:${repo}#${refName}`, fields: { repository: repo, ref: refName, object_sha: String(body.sha), object_type: existing.object_type }, occurredAt, actor: { kind: 'agent' } }, req.root);
6287
+ updateGitRefInPlane(repo, refName, String(body.sha), req.root); // fetchable when the object exists in the plane
6288
+ // A head-branch move re-points every open PR riding it (real GitHub: an API ref update IS
6289
+ // a push — the PR head follows and the old head's reviews/approvals stop matching), and
6290
+ // fires the same push/synchronize event stream a receive-pack push does.
6291
+ const updateEvents: GithubWriteEvent[] = [{ event: 'push', action: '', repository: repo, number: 0, ref: refName, before: existing.object_sha, after: String(body.sha) }];
6292
+ if (refName.startsWith('refs/heads/')) {
6293
+ const branchNm = refName.slice('refs/heads/'.length);
6294
+ await applyTwinWrite(SERVICE, { operation: 'branch.create', subjectType: 'branch', subjectId: `branch:${repo}#${branchNm}`, fields: { repository: repo, name: branchNm, commit_sha: String(body.sha) }, occurredAt, actor: { kind: 'agent' } }, req.root);
6295
+ for (const n of await syncPrHeadsToBranchTip(repo, branchNm, String(body.sha), req.root, occurredAt)) {
6296
+ updateEvents.push({ event: 'pull_request', action: 'synchronize', repository: repo, number: n });
6297
+ }
6298
+ }
5552
6299
  const r = githubState(req.root).gitRefs.find((x) => x.repository === repo && x.ref === refName)!;
5553
- return { response: { status: 200, body: toGithubGitRef(r) } };
6300
+ return { response: { status: 200, body: toGithubGitRef(r) }, webhooks: updateEvents };
5554
6301
  }
5555
6302
  // DELETE .../git/refs/:ref → delete a branch/tag ref (204). 404 if absent.
5556
6303
  if (req.method === 'DELETE' && seg[3] === 'git' && seg[4] === 'refs' && seg[5]) {
@@ -5558,7 +6305,20 @@ export async function applyGithubWrite(req: {
5558
6305
  const existing = githubState(req.root).gitRefs.find((x) => x.repository === repo && x.ref === refName);
5559
6306
  if (!existing) return { response: githubNotFound() };
5560
6307
  await applyTwinWrite(SERVICE, { operation: 'git_ref.delete', subjectType: 'git_ref', subjectId: `gitref:${repo}#${refName}`, fields: { repository: repo, ref: refName }, occurredAt, actor: { kind: 'agent' } }, req.root);
5561
- return { response: { status: 204, body: undefined } };
6308
+ deleteGitRefInPlane(repo, refName, req.root); // keep the plane in agreement
6309
+ // Deleting a branch ref removes the branch row, CLOSES open PRs riding it, and fires the
6310
+ // push(deleted)/closed events — full REST ↔ receive-pack convergence.
6311
+ const deleteEvents: GithubWriteEvent[] = [{ event: 'push', action: '', repository: repo, number: 0, ref: refName, before: existing.object_sha, after: '0'.repeat(40) }];
6312
+ if (refName.startsWith('refs/heads/')) {
6313
+ const branchNm = refName.slice('refs/heads/'.length);
6314
+ if (githubState(req.root).branches.some((b) => b.repository === repo && b.name === branchNm)) {
6315
+ await applyTwinWrite(SERVICE, { operation: 'branch.delete', subjectType: 'branch', subjectId: `branch:${repo}#${branchNm}`, fields: { repository: repo, name: branchNm }, occurredAt, actor: { kind: 'agent' } }, req.root);
6316
+ }
6317
+ for (const n of await closePrsForDeletedBranch(repo, branchNm, req.root, occurredAt)) {
6318
+ deleteEvents.push({ event: 'pull_request', action: 'closed', repository: repo, number: n });
6319
+ }
6320
+ }
6321
+ return { response: { status: 204, body: undefined }, webhooks: deleteEvents };
5562
6322
  }
5563
6323
 
5564
6324
  // ── Deployments writes ─────────────────────────────────────────────────────────────────────
@@ -6355,11 +7115,19 @@ async function applyGithubNonRepoWrite(req: {
6355
7115
  if (!body.name) return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'Repository', field: 'name', code: 'missing_field' }] } } };
6356
7116
  const owner = seg[0] === 'orgs' ? String(seg[1]) : 'octocat';
6357
7117
  const name = String(body.name);
7118
+ // Fidelity: GitHub 422s a repo name outside [A-Za-z0-9_.-] (it would also be unservable
7119
+ // as a bare-repo path).
7120
+ if (!/^[A-Za-z0-9_.-]+$/.test(name) || name === '.' || name === '..' || !/^[A-Za-z0-9_.-]+$/.test(owner)) {
7121
+ return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'Repository', field: 'name', code: 'invalid' }] } } };
7122
+ }
6358
7123
  const full = `${owner}/${name}`;
6359
7124
  if (githubState(req.root).repos.some((r) => r.full_name === full)) return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'Repository', code: 'already_exists', field: 'name' }] } } };
6360
7125
  const seq = nextSeq('repo', req.root);
6361
7126
  const fields = { owner, name, description: body.description ?? null, private: body.private === true, default_branch: body.default_branch ?? 'main', has_issues: body.has_issues ?? true, has_projects: body.has_projects ?? true, has_wiki: body.has_wiki ?? true, has_discussions: body.has_discussions ?? false, homepage: body.homepage ?? null, created_at: occurredAt, updated_at: occurredAt };
6362
7127
  await applyTwinWrite(SERVICE, { operation: 'repository.create', subjectType: 'repository', subjectId: `repo:${seq}`, fields, occurredAt, actor: { kind: 'agent' } }, req.root);
7128
+ // Creating a repo via REST initializes its REAL bare repo (the git plane): the new repo
7129
+ // is immediately clonable (empty) and pushable by an unmodified `git` client.
7130
+ ensureBareRepo(full, req.root, String(fields.default_branch));
6363
7131
  const sha = createHash('sha256').update(`${full}:${fields.default_branch}`).digest('hex').slice(0, 40);
6364
7132
  await applyTwinWrite(SERVICE, { operation: 'branch.create', subjectType: 'branch', subjectId: `branch:${full}#${fields.default_branch}`, fields: { repository: full, name: fields.default_branch, commit_sha: sha }, occurredAt, actor: { kind: 'agent' } }, req.root);
6365
7133
  const r = githubState(req.root).repos.find((x) => x.full_name === full)!;