@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.
- package/README.md +162 -20
- package/client/github-mirror.tsx +10 -4
- package/package.json +2 -2
- package/src/cli.ts +3 -2
- package/src/github-a11y-snapshot.uitest.ts +117 -0
- package/src/github-budget.ts +198 -0
- package/src/github-capabilities.ts +502 -23
- package/src/github-connector.ts +832 -61
- package/src/github-events.ts +11 -1
- package/src/github-git-http.ts +248 -0
- package/src/github-git-plane.ts +511 -0
- package/src/github-graphql.ts +212 -1
- package/src/github-journey.uitest.ts +193 -0
- package/src/github-mirror-state.ts +369 -0
- package/src/github-mirror-ui.ts +53 -372
- package/src/github-server.ts +125 -21
- package/src/github-shared.ts +26 -0
- package/src/github-twin.ts +821 -53
- package/src/github-ui-conformance.ts +2 -2
- package/src/index.ts +71 -5
- package/test-fixtures/github-openapi-operations.json +467 -34
package/src/github-twin.ts
CHANGED
|
@@ -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') {
|
|
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') {
|
|
1088
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
//
|
|
3216
|
-
//
|
|
3217
|
-
//
|
|
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]) —
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 = {
|
|
4578
|
-
|
|
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
|
-
|
|
4682
|
-
|
|
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
|
-
|
|
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
|
|
4744
|
-
|
|
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
|
|
5400
|
-
// PUT /repos/:o/:r/branches/:branch/protection →
|
|
5401
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
5507
|
-
|
|
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
|
-
|
|
5517
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)!;
|