@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
|
@@ -16,9 +16,34 @@
|
|
|
16
16
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
17
17
|
import { tmpdir } from 'node:os';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
|
-
import { checkCapabilities, uiDataCoupled, type CapabilityReport, type CapabilitySpec } from '@volter/twin-tooling';
|
|
20
|
-
import { githubMirrorState } from './github-mirror-
|
|
19
|
+
import { checkCapabilities, uiDataCoupled, type CapabilityReport, type CapabilitySpec, verifyBoundary } from '@volter/twin-tooling';
|
|
20
|
+
import { githubMirrorState } from './github-mirror-state.ts';
|
|
21
21
|
import { applyGithubWrite, handleGithubRequest } from './github-twin.ts';
|
|
22
|
+
import { handleGithubGitSmartHttp } from './github-git-http.ts';
|
|
23
|
+
import { createGithubTwinServer } from './github-server.ts';
|
|
24
|
+
import { clearGithubWebhooks, registerGithubWebhook } from './github-events.ts';
|
|
25
|
+
|
|
26
|
+
// ── git-plane helpers: spawn the REAL git CLI (async — verifies that also run the twin's
|
|
27
|
+
// HTTP server must keep the event loop free; a spawnSync would deadlock the clone against
|
|
28
|
+
// the in-process server). Local subprocess + 127.0.0.1 only: offline, deterministic, no
|
|
29
|
+
// rate budget. Proxy env is stripped so an active world proxy can never intercept loopback.
|
|
30
|
+
function gitCliEnv(): Record<string, string> {
|
|
31
|
+
const env: Record<string, string> = {};
|
|
32
|
+
for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v;
|
|
33
|
+
for (const k of ['http_proxy', 'https_proxy', 'all_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY']) delete env[k];
|
|
34
|
+
Object.assign(env, {
|
|
35
|
+
NO_PROXY: '*', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', GIT_CONFIG_NOSYSTEM: '1',
|
|
36
|
+
GIT_TERMINAL_PROMPT: '0', GIT_AUTHOR_NAME: 'Cap Verify', GIT_AUTHOR_EMAIL: 'cap@verify.test',
|
|
37
|
+
GIT_COMMITTER_NAME: 'Cap Verify', GIT_COMMITTER_EMAIL: 'cap@verify.test',
|
|
38
|
+
GIT_AUTHOR_DATE: '2026-01-02T03:04:05Z', GIT_COMMITTER_DATE: '2026-01-02T03:04:05Z',
|
|
39
|
+
});
|
|
40
|
+
return env;
|
|
41
|
+
}
|
|
42
|
+
async function gitc(cwd: string, ...args: string[]): Promise<{ code: number; stdout: string }> {
|
|
43
|
+
const proc = Bun.spawn({ cmd: ['git', ...args], cwd, stdout: 'pipe', stderr: 'pipe', env: gitCliEnv() });
|
|
44
|
+
const [code, stdout] = await Promise.all([proc.exited, new Response(proc.stdout).text()]);
|
|
45
|
+
return { code, stdout: stdout.trim() };
|
|
46
|
+
}
|
|
22
47
|
|
|
23
48
|
// ── API predicate: run a real verify() against a fresh temp root (mkdtemp), exercising the
|
|
24
49
|
// twin's handleGithubRequest (GET) + applyGithubWrite (POST/PATCH/PUT). Each verify creates
|
|
@@ -30,9 +55,7 @@ import { applyGithubWrite, handleGithubRequest } from './github-twin.ts';
|
|
|
30
55
|
export async function withRoot(fn: (root: string) => Promise<boolean>): Promise<boolean> {
|
|
31
56
|
const root = mkdtempSync(join(tmpdir(), 'gh-cap-'));
|
|
32
57
|
try {
|
|
33
|
-
return await fn(root);
|
|
34
|
-
} catch {
|
|
35
|
-
return false;
|
|
58
|
+
return await verifyBoundary('github.withRoot', () => fn(root));
|
|
36
59
|
} finally {
|
|
37
60
|
rmSync(root, { recursive: true, force: true });
|
|
38
61
|
}
|
|
@@ -112,12 +135,15 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
112
135
|
const res = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/pulls/1`, body: JSON.stringify({ title: 'new' }), root });
|
|
113
136
|
return ok(res.response.status) && (res.response.body as { title?: string }).title === 'new';
|
|
114
137
|
})),
|
|
115
|
-
done('github.pulls.merge', 'pulls', 'PRs: merge (PUT .../pulls/:n/merge → 200
|
|
138
|
+
done('github.pulls.merge', 'pulls', 'PRs: merge (PUT .../pulls/:n/merge → 200; optional exact-head sha precondition → 409 after a push)', 'api', 'core', () =>
|
|
116
139
|
withRoot(async (root) => {
|
|
117
140
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'm', head: 'a', base: 'main' }), root });
|
|
118
|
-
const
|
|
141
|
+
const pr = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1`, root });
|
|
142
|
+
const head = (pr.body as { head?: { sha?: string } }).head?.sha;
|
|
143
|
+
const stale = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/1/merge`, body: JSON.stringify({ sha: `${head}-stale` }), root });
|
|
144
|
+
const res = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/1/merge`, body: JSON.stringify({ sha: head }), root });
|
|
119
145
|
const merged = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/merge`, root });
|
|
120
|
-
return ok(res.response.status) && (res.response.body as { merged?: boolean }).merged === true && merged.status === 204;
|
|
146
|
+
return stale.response.status === 409 && ok(res.response.status) && (res.response.body as { merged?: boolean }).merged === true && merged.status === 204;
|
|
121
147
|
})),
|
|
122
148
|
done('github.pulls.reviews', 'pulls', 'PRs: reviews submit + list (APPROVE/COMMENT/REQUEST_CHANGES)', 'api', 'core', () =>
|
|
123
149
|
withRoot(async (root) => {
|
|
@@ -126,12 +152,81 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
126
152
|
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/reviews`, root });
|
|
127
153
|
return ok(sub.response.status) && ok(list.status) && isArr(list.body) && list.body.length === 1;
|
|
128
154
|
})),
|
|
129
|
-
done('github.pulls.
|
|
155
|
+
done('github.pulls.files_from_git_plane', 'pulls', 'PRs: files/changedFiles derive from the REAL git diff (base tip…head tip in the plane) when the PR carries no diff evidence — merge-gate scope classification reads actual paths, never 0', 'api', 'common', () =>
|
|
156
|
+
withRoot(async (root) => {
|
|
157
|
+
// Author base + head commits purely through Git Data REST (real objects, real diff).
|
|
158
|
+
const mk = async (path: string, content: string, parent?: string, baseTree?: string) => {
|
|
159
|
+
const b = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/git/blobs`, body: JSON.stringify({ content: Buffer.from(content).toString('base64'), encoding: 'base64' }), root });
|
|
160
|
+
const t = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/git/trees`, body: JSON.stringify({ ...(baseTree ? { base_tree: baseTree } : {}), tree: [{ path, mode: '100644', type: 'blob', sha: (b.response.body as { sha?: string }).sha }] }), root });
|
|
161
|
+
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/git/commits`, body: JSON.stringify({ message: `add ${path}`, tree: (t.response.body as { sha?: string }).sha, parents: parent ? [parent] : [] }), root });
|
|
162
|
+
return { commit: (c.response.body as { sha?: string }).sha!, tree: (t.response.body as { sha?: string }).sha! };
|
|
163
|
+
};
|
|
164
|
+
const base = await mk('README.md', 'base\n');
|
|
165
|
+
const head = await mk('scripts/gate.ts', 'gate\n', base.commit, base.tree);
|
|
166
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/git/refs`, body: JSON.stringify({ ref: 'refs/heads/main', sha: base.commit }), root });
|
|
167
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/git/refs`, body: JSON.stringify({ ref: 'refs/heads/feat', sha: head.commit }), root });
|
|
168
|
+
const pr = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'F', head: 'feat', base: 'main' }), root });
|
|
169
|
+
if (pr.response.status !== 201) return false;
|
|
170
|
+
// GET /pulls/:n/files answers the REAL one-file diff with status + counts.
|
|
171
|
+
const files = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/files`, root });
|
|
172
|
+
if (!(ok(files.status) && isArr(files.body) && files.body.length === 1)) return false;
|
|
173
|
+
const f = files.body[0] as { filename?: string; status?: string; additions?: number };
|
|
174
|
+
if (!(f.filename === 'scripts/gate.ts' && f.status === 'added' && f.additions === 1)) return false;
|
|
175
|
+
// GraphQL changedFiles agrees (the field human-approval-gate cross-checks completeness against).
|
|
176
|
+
const gq = await applyGithubWrite({ method: 'POST', path: '/graphql', body: JSON.stringify({ query: `query { repository(owner: "octo", name: "demo") { pullRequest(number: 1) { changedFiles } } }` }), root });
|
|
177
|
+
const cf = ((gq.response.body as { data?: { repository?: { pullRequest?: { changedFiles?: number } } } }).data)?.repository?.pullRequest?.changedFiles;
|
|
178
|
+
if (cf !== 1) return false;
|
|
179
|
+
// THREE-DOT semantics (the merge-gate load-bearing half): advance the BASE branch past
|
|
180
|
+
// the PR's branch point — the PR's file list must stay ITS OWN one file, never inherit
|
|
181
|
+
// the base's new paths as phantom "removed" entries (a two-dot diff would).
|
|
182
|
+
const advance = await mk('docs/onmain.md', 'later\n', base.commit, base.tree);
|
|
183
|
+
const ff = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/git/refs/heads/main`, body: JSON.stringify({ sha: advance.commit }), root });
|
|
184
|
+
if (ff.response.status !== 200) return false;
|
|
185
|
+
const after = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/files`, root });
|
|
186
|
+
if (!(ok(after.status) && isArr(after.body) && after.body.length === 1)) return false;
|
|
187
|
+
return (after.body[0] as { filename?: string }).filename === 'scripts/gate.ts';
|
|
188
|
+
})),
|
|
189
|
+
done('github.pulls.review_head_sha_binding', 'pulls', 'PRs: a review binds commit_id to the EXACT head sha at submission; a later push moves the PR head, the review stays where it was', 'api', 'core', () =>
|
|
190
|
+
withRoot(async (root) => {
|
|
191
|
+
// Register a head-branch tip, open a PR on it, review it: commit_id == that tip.
|
|
192
|
+
const shaA = 'a'.repeat(40);
|
|
193
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/git/refs`, body: JSON.stringify({ ref: 'refs/heads/feat', sha: shaA }), root });
|
|
194
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'B', head: 'feat', base: 'main' }), root });
|
|
195
|
+
const r1 = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/1/reviews`, body: JSON.stringify({ event: 'APPROVE', body: 'lgtm' }), root });
|
|
196
|
+
if (!(ok(r1.response.status) && (r1.response.body as { commit_id?: string }).commit_id === shaA)) return false;
|
|
197
|
+
// "Push" (an API ref move IS a push): the PR head follows, the OLD review's commit_id
|
|
198
|
+
// does NOT — the exact-head re-earn semantics merge gates depend on.
|
|
199
|
+
const shaB = 'b'.repeat(40);
|
|
200
|
+
const upd = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/git/refs/heads/feat`, body: JSON.stringify({ sha: shaB }), root });
|
|
201
|
+
if (upd.response.status !== 200) return false;
|
|
202
|
+
const pr = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1`, root });
|
|
203
|
+
if ((pr.body as { head?: { sha?: string } }).head?.sha !== shaB) return false;
|
|
204
|
+
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/reviews`, root });
|
|
205
|
+
const first = (list.body as Array<{ commit_id?: string }>)[0]!;
|
|
206
|
+
if (first.commit_id !== shaA) return false; // pinned to the head it judged
|
|
207
|
+
// A fresh review binds the NEW head; a caller-pinned commit_id is honored verbatim.
|
|
208
|
+
const r2 = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/1/reviews`, body: JSON.stringify({ event: 'APPROVE' }), root });
|
|
209
|
+
if ((r2.response.body as { commit_id?: string }).commit_id !== shaB) return false;
|
|
210
|
+
const r3 = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/1/reviews`, body: JSON.stringify({ event: 'COMMENT', commit_id: shaA }), root });
|
|
211
|
+
return (r3.response.body as { commit_id?: string }).commit_id === shaA;
|
|
212
|
+
})),
|
|
213
|
+
done('github.pulls.review_comments', 'pulls', 'PRs: diff-anchored review comments (write, list, reply)', 'api', 'core', () =>
|
|
130
214
|
withRoot(async (root) => {
|
|
131
215
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'c', head: 'a', base: 'main' }), root });
|
|
132
216
|
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/1/comments`, body: JSON.stringify({ body: 'nit', path: 'a.ts', line: 10, side: 'RIGHT' }), root });
|
|
133
217
|
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/comments`, root });
|
|
134
|
-
|
|
218
|
+
if (!(c.response.status === 201 && ok(list.status) && isArr(list.body) && (list.body[0] as { path?: string }).path === 'a.ts')) return false;
|
|
219
|
+
// The READ is how a seat finds a finding, and the REPLY is how it answers one: GitHub's
|
|
220
|
+
// own reply route takes the comment id alone and inherits the root's diff anchor.
|
|
221
|
+
const cid = (c.response.body as { id?: number }).id!;
|
|
222
|
+
const reply = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/1/comments/${cid}/replies`, body: JSON.stringify({ body: 'fixed in b3f1' }), root });
|
|
223
|
+
const replied = reply.response.body as { in_reply_to_id?: number; path?: string };
|
|
224
|
+
if (!(reply.response.status === 201 && replied.in_reply_to_id === cid && replied.path === 'a.ts')) return false;
|
|
225
|
+
// a reply to a comment that isn't there is a 404, not a new thread
|
|
226
|
+
const orphan = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/1/comments/99999/replies`, body: JSON.stringify({ body: 'nobody home' }), root });
|
|
227
|
+
if (orphan.response.status !== 404) return false;
|
|
228
|
+
const after = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/comments`, root });
|
|
229
|
+
return isArr(after.body) && after.body.length === 2 && (after.body[1] as { in_reply_to_id?: number }).in_reply_to_id === cid;
|
|
135
230
|
})),
|
|
136
231
|
done('github.pulls.requested_reviewers', 'pulls', 'PRs: request/remove reviewers', 'api', 'core', () =>
|
|
137
232
|
withRoot(async (root) => {
|
|
@@ -558,7 +653,10 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
558
653
|
done('github.repos.branch_protection', 'repos', 'Repos: branch protection rules + rulesets', 'api', 'niche', () =>
|
|
559
654
|
withRoot(async (root) => {
|
|
560
655
|
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'demo' }), root });
|
|
561
|
-
|
|
656
|
+
// Real GitHub 422s a protection PUT missing any of the four required (nullable) keys.
|
|
657
|
+
const partial = await applyGithubWrite({ method: 'PUT', path: `/repos/octo/demo/branches/main/protection`, body: JSON.stringify({ required_status_checks: null }), root });
|
|
658
|
+
if (partial.response.status !== 422) return false;
|
|
659
|
+
const p = await applyGithubWrite({ method: 'PUT', path: `/repos/octo/demo/branches/main/protection`, body: JSON.stringify({ required_status_checks: null, enforce_admins: false, required_pull_request_reviews: null, restrictions: null }), root });
|
|
562
660
|
if (!(ok(p.response.status) && (p.response.body as { enabled?: boolean }).enabled === true)) return false;
|
|
563
661
|
const b = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root });
|
|
564
662
|
if ((b.body as { protected?: boolean }).protected !== true) return false;
|
|
@@ -566,6 +664,50 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
566
664
|
const after = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root });
|
|
567
665
|
return d.response.status === 204 && (after.body as { protected?: boolean }).protected === false;
|
|
568
666
|
})),
|
|
667
|
+
done('github.repos.branch_protection_required_checks', 'repos', 'Repos: branch protection PERSISTS its config — required-check contexts, strict, enforce_admins — and serves it back (the merge-gate read path)', 'api', 'common', () =>
|
|
668
|
+
withRoot(async (root) => {
|
|
669
|
+
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'demo' }), root });
|
|
670
|
+
// GET protection on an unprotected branch → GitHub's 404 "Branch not protected".
|
|
671
|
+
const bare = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main/protection`, root });
|
|
672
|
+
if (!(bare.status === 404 && (bare.body as { message?: string }).message === 'Branch not protected')) return false;
|
|
673
|
+
// PUT the twin-sdlc-shaped config: four required contexts + enforce_admins.
|
|
674
|
+
const put = await applyGithubWrite({ method: 'PUT', path: `/repos/octo/demo/branches/main/protection`, body: JSON.stringify({
|
|
675
|
+
required_status_checks: { strict: false, contexts: ['test', 'agent-review', 'security', 'human-approval'] },
|
|
676
|
+
enforce_admins: true, required_pull_request_reviews: null, restrictions: null,
|
|
677
|
+
}), root });
|
|
678
|
+
const putBody = put.response.body as { required_status_checks?: { contexts?: string[] }; enforce_admins?: { enabled?: boolean } };
|
|
679
|
+
if (!(ok(put.response.status) && putBody.required_status_checks?.contexts?.length === 4 && putBody.enforce_admins?.enabled === true)) return false;
|
|
680
|
+
// The BRANCH summary carries the persisted contexts + the real enforcement_level
|
|
681
|
+
// semantics (everyone == enforce_admins) — exactly what review-prerequisites.ts reads.
|
|
682
|
+
const branch = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root });
|
|
683
|
+
const prot = (branch.body as { protection?: { required_status_checks?: { contexts?: string[]; enforcement_level?: string } } }).protection;
|
|
684
|
+
if (!(prot?.required_status_checks?.contexts?.includes('agent-review') && prot.required_status_checks.enforcement_level === 'everyone')) return false;
|
|
685
|
+
// Sub-resource reads + PATCH round-trip; enforce_admins DELETE flips the level.
|
|
686
|
+
const rsc = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main/protection/required_status_checks`, root });
|
|
687
|
+
if (!(ok(rsc.status) && (rsc.body as { contexts?: string[] }).contexts?.length === 4 && (rsc.body as { strict?: boolean }).strict === false)) return false;
|
|
688
|
+
const patched = await applyGithubWrite({ method: 'PATCH', path: `/repos/octo/demo/branches/main/protection/required_status_checks`, body: JSON.stringify({ strict: true, contexts: ['test'] }), root });
|
|
689
|
+
if (!(ok(patched.response.status) && (patched.response.body as { strict?: boolean }).strict === true && (patched.response.body as { contexts?: string[] }).contexts?.length === 1)) return false;
|
|
690
|
+
const dropAdmins = await applyGithubWrite({ method: 'DELETE', path: `/repos/octo/demo/branches/main/protection/enforce_admins`, root });
|
|
691
|
+
if (dropAdmins.response.status !== 204) return false;
|
|
692
|
+
const after = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root });
|
|
693
|
+
const lvl = (after.body as { protection?: { required_status_checks?: { enforcement_level?: string } } }).protection?.required_status_checks?.enforcement_level;
|
|
694
|
+
if (lvl !== 'non_admins') return false;
|
|
695
|
+
// Protection SURVIVES a branch delete→re-create (real GitHub: classic protection is a
|
|
696
|
+
// repo-level pattern rule — deleting `main` and re-pushing it comes back protected).
|
|
697
|
+
const mainSha = (handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root }).body as { commit?: { sha?: string } }).commit?.sha ?? 'a'.repeat(40);
|
|
698
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/octo/demo/git/refs`, body: JSON.stringify({ ref: 'refs/heads/main', sha: mainSha }), root });
|
|
699
|
+
const delRef = await applyGithubWrite({ method: 'DELETE', path: `/repos/octo/demo/git/refs/heads/main`, root });
|
|
700
|
+
if (delRef.response.status !== 204) return false;
|
|
701
|
+
const recreate = await applyGithubWrite({ method: 'POST', path: `/repos/octo/demo/git/refs`, body: JSON.stringify({ ref: 'refs/heads/main', sha: 'b'.repeat(40) }), root });
|
|
702
|
+
if (recreate.response.status !== 201) return false;
|
|
703
|
+
const survived = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main/protection`, root });
|
|
704
|
+
if (!(ok(survived.status) && ((survived.body as { required_status_checks?: { contexts?: string[] } }).required_status_checks?.contexts?.length === 1))) return false;
|
|
705
|
+
// DELETE protection → the summary reads off again (and protection GET 404s).
|
|
706
|
+
await applyGithubWrite({ method: 'DELETE', path: `/repos/octo/demo/branches/main/protection`, root });
|
|
707
|
+
const off = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main/protection`, root });
|
|
708
|
+
return off.status === 404;
|
|
709
|
+
})),
|
|
710
|
+
todo('github.repos.protection_required_reviews_enforced', 'repos', 'Repos: required_pull_request_reviews ENFORCED on merge (config persists; merge does not yet refuse on it)', 'api', 'niche'),
|
|
569
711
|
done('github.repos.collaborators', 'repos', 'Repos: collaborators + permissions', 'api', 'common', () =>
|
|
570
712
|
withRoot(async (root) => {
|
|
571
713
|
const add = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/collaborators/alice`, body: JSON.stringify({ permission: 'maintain' }), root });
|
|
@@ -650,17 +792,31 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
650
792
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1)) return false;
|
|
651
793
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/git/refs/heads/feat`, root });
|
|
652
794
|
const gone = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/ref/heads/feat`, root });
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
795
|
+
if (!(del.response.status === 204 && gone.status === 404)) return false;
|
|
796
|
+
// Dirty-state pin (§9): delete → RE-CREATE must resurrect, not stay tombstoned forever
|
|
797
|
+
// (the delete→re-push flow; a permanent tombstone once made recreated refs invisible
|
|
798
|
+
// to REST while the git plane served them). Re-created at a DIFFERENT sha than the ref
|
|
799
|
+
// ever held, so a stale row surviving in the fold cannot satisfy this by accident.
|
|
800
|
+
const sha3 = 'c'.repeat(40);
|
|
801
|
+
const again = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/git/refs`, body: JSON.stringify({ ref: 'refs/heads/feat', sha: sha3 }), root });
|
|
802
|
+
if (again.response.status !== 201) return false;
|
|
803
|
+
const back = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/ref/heads/feat`, root });
|
|
804
|
+
return ok(back.status) && (back.body as { object?: { sha?: string } }).object?.sha === sha3;
|
|
805
|
+
})),
|
|
806
|
+
done('github.git.commits', 'git', 'Git Data: commit objects (get/create; REAL git shas)', 'api', 'niche', () =>
|
|
807
|
+
withRoot(async (root) => {
|
|
808
|
+
const tree = 'f'.repeat(40);
|
|
809
|
+
// missing required fields → 422; a MALFORMED (non-hex) tree sha → 422 like real GitHub
|
|
810
|
+
// (and like real git, which now frames+stores the object — see github-git-plane.ts).
|
|
659
811
|
const bad = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/git/commits`, body: JSON.stringify({ message: 'x' }), root });
|
|
660
812
|
if (bad.response.status !== 422) return false;
|
|
661
|
-
const
|
|
813
|
+
const malformed = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/git/commits`, body: JSON.stringify({ message: 'x', tree: 't'.repeat(40) }), root });
|
|
814
|
+
if (malformed.response.status !== 422) return false;
|
|
815
|
+
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/git/commits`, body: JSON.stringify({ message: 'init', tree, parents: ['e'.repeat(40)], author: { name: 'Ann', email: 'a@x' } }), root });
|
|
662
816
|
if (!(c.response.status === 201 && (c.response.body as { message?: string }).message === 'init')) return false;
|
|
663
817
|
const sha = (c.response.body as { sha?: string }).sha!;
|
|
818
|
+
// The sha is a REAL git object id (the plane stored a real commit object under it).
|
|
819
|
+
if (!/^[0-9a-f]{40}$/.test(sha)) return false;
|
|
664
820
|
const get = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/commits/${sha}`, root });
|
|
665
821
|
const b = get.body as { tree?: { sha?: string }; parents?: unknown[]; author?: { name?: string } };
|
|
666
822
|
return ok(get.status) && b.tree?.sha === tree && b.parents?.length === 1 && b.author?.name === 'Ann';
|
|
@@ -705,6 +861,160 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
705
861
|
return ref.response.status === 201 && (ref.response.body as { object?: { type?: string } }).object?.type === 'tag';
|
|
706
862
|
})),
|
|
707
863
|
|
|
864
|
+
// ── Git smart-HTTP (the REAL git plane: bare repos + spawned git, github-git-plane.ts) ───
|
|
865
|
+
done('github.git.smart_http_discovery', 'git', 'Git smart-HTTP: info/refs service advertisement (pkt-line framing per gitprotocol-http)', 'api', 'core', () =>
|
|
866
|
+
withRoot(async (root) => {
|
|
867
|
+
// Unknown repo → the vendor-shaped 404 miss.
|
|
868
|
+
const missing = await handleGithubGitSmartHttp({ method: 'GET', pathname: '/octo/absent.git/info/refs', query: new URLSearchParams('service=git-upload-pack'), root });
|
|
869
|
+
if (missing?.status !== 404) return false;
|
|
870
|
+
// No service param → 403 (dumb HTTP refused — git http-backend's own status for it).
|
|
871
|
+
await applyGithubWrite({ method: 'POST', path: '/orgs/octo/repos', body: JSON.stringify({ name: 'demo' }), root });
|
|
872
|
+
const dumb = await handleGithubGitSmartHttp({ method: 'GET', pathname: '/octo/demo/info/refs', root });
|
|
873
|
+
if (dumb?.status !== 403) return false;
|
|
874
|
+
// Author a real chain so the advertisement carries a REF (git's own output, not just
|
|
875
|
+
// the twin-written prefix — a saboteur echoing only the prefix must fail this).
|
|
876
|
+
const blob = await applyGithubWrite({ method: 'POST', path: '/repos/octo/demo/git/blobs', body: JSON.stringify({ content: 'YWR2', encoding: 'base64' }), root });
|
|
877
|
+
const tree = await applyGithubWrite({ method: 'POST', path: '/repos/octo/demo/git/trees', body: JSON.stringify({ tree: [{ path: 'a', mode: '100644', type: 'blob', sha: (blob.response.body as { sha?: string }).sha }] }), root });
|
|
878
|
+
const commit = await applyGithubWrite({ method: 'POST', path: '/repos/octo/demo/git/commits', body: JSON.stringify({ message: 'adv', tree: (tree.response.body as { sha?: string }).sha }), root });
|
|
879
|
+
const commitSha = (commit.response.body as { sha?: string }).sha!;
|
|
880
|
+
if ((await applyGithubWrite({ method: 'POST', path: '/repos/octo/demo/git/refs', body: JSON.stringify({ ref: 'refs/heads/main', sha: commitSha }), root })).response.status !== 201) return false;
|
|
881
|
+
// The advertisement: correct content-type + the DOCUMENTED prefix — one pkt-line
|
|
882
|
+
// `# service=git-upload-pack\n` (length 001e) then a flush-pkt (0000) — and then GIT'S
|
|
883
|
+
// OWN payload advertising the materialized ref at its real sha.
|
|
884
|
+
const adv = await handleGithubGitSmartHttp({ method: 'GET', pathname: '/octo/demo.git/info/refs', query: new URLSearchParams('service=git-upload-pack'), root });
|
|
885
|
+
if (adv?.status !== 200 || adv.contentType !== 'application/x-git-upload-pack-advertisement') return false;
|
|
886
|
+
const head = Buffer.from(adv.body.slice(0, 34)).toString('utf8');
|
|
887
|
+
if (!head.startsWith('001e# service=git-upload-pack\n0000')) return false;
|
|
888
|
+
const advText = Buffer.from(adv.body).toString('utf8');
|
|
889
|
+
if (!advText.includes('refs/heads/main') || !advText.includes(commitSha)) return false;
|
|
890
|
+
// receive-pack advertisement answers too — and a READ-ONLY twin refuses it (403).
|
|
891
|
+
const rp = await handleGithubGitSmartHttp({ method: 'GET', pathname: '/octo/demo.git/info/refs', query: new URLSearchParams('service=git-receive-pack'), root });
|
|
892
|
+
if (rp?.status !== 200 || rp.contentType !== 'application/x-git-receive-pack-advertisement') return false;
|
|
893
|
+
const ro = await handleGithubGitSmartHttp({ method: 'GET', pathname: '/octo/demo.git/info/refs', query: new URLSearchParams('service=git-receive-pack'), root, readOnly: true });
|
|
894
|
+
return ro?.status === 403;
|
|
895
|
+
})),
|
|
896
|
+
done('github.git.smart_http_clone_push', 'git', 'Git smart-HTTP: an UNMODIFIED git CLI clones and pushes; REST refs/commits/branches agree; push fires the push webhook', 'api', 'core', () =>
|
|
897
|
+
withRoot(async (root) => {
|
|
898
|
+
const { mkdtempSync: mkTmp, rmSync: rmTmp, writeFileSync: writeTmp } = await import('node:fs');
|
|
899
|
+
const { tmpdir: osTmp } = await import('node:os');
|
|
900
|
+
const { join: j } = await import('node:path');
|
|
901
|
+
const work = mkTmp(j(osTmp(), 'gh-cap-git-'));
|
|
902
|
+
const server = createGithubTwinServer({ root });
|
|
903
|
+
const deliveries: Array<Record<string, unknown>> = [];
|
|
904
|
+
const hook = Bun.serve({ port: 0, async fetch(req) { deliveries.push({ event: req.headers.get('x-github-event'), ...(await req.json() as Record<string, unknown>) }); return new Response('ok'); } });
|
|
905
|
+
clearGithubWebhooks();
|
|
906
|
+
registerGithubWebhook(`http://127.0.0.1:${hook.port}/hook`);
|
|
907
|
+
try {
|
|
908
|
+
const base = `http://127.0.0.1:${server.port}`;
|
|
909
|
+
// Creating the repo over REST initializes its REAL bare repo; the clone sees it empty.
|
|
910
|
+
const created = await fetch(`${base}/orgs/octo/repos`, { method: 'POST', body: JSON.stringify({ name: 'demo' }) });
|
|
911
|
+
if (created.status !== 201) return false;
|
|
912
|
+
if ((await gitc(work, 'clone', `${base}/octo/demo.git`, 'c1')).code !== 0) return false;
|
|
913
|
+
const c1 = j(work, 'c1');
|
|
914
|
+
writeTmp(j(c1, 'a.txt'), 'clone-push\n');
|
|
915
|
+
if ((await gitc(c1, 'add', 'a.txt')).code !== 0) return false;
|
|
916
|
+
if ((await gitc(c1, 'commit', '-m', 'first')).code !== 0) return false;
|
|
917
|
+
if ((await gitc(c1, 'push', 'origin', 'HEAD:refs/heads/main')).code !== 0) return false;
|
|
918
|
+
const tip = (await gitc(c1, 'rev-parse', 'HEAD')).stdout;
|
|
919
|
+
// REST agreement: ref, commit object (plane fallback), branch tip — all the PUSHED sha.
|
|
920
|
+
const ref = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/git/ref/heads/main`, root });
|
|
921
|
+
if (!(ok(ref.status) && (ref.body as { object?: { sha?: string } }).object?.sha === tip)) return false;
|
|
922
|
+
const commit = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/git/commits/${tip}`, root });
|
|
923
|
+
if (!(ok(commit.status) && (commit.body as { message?: string }).message === 'first')) return false;
|
|
924
|
+
const branch = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root });
|
|
925
|
+
if (!(ok(branch.status) && (branch.body as { commit?: { sha?: string } }).commit?.sha === tip)) return false;
|
|
926
|
+
// The push rode the EXISTING webhook pipeline: a `push` delivery with ref/before/after.
|
|
927
|
+
const push = deliveries.find((d) => d.event === 'push' && d.ref === 'refs/heads/main');
|
|
928
|
+
return push !== undefined && push.before === '0'.repeat(40) && push.after === tip;
|
|
929
|
+
} finally {
|
|
930
|
+
server.stop(); hook.stop(true); clearGithubWebhooks(); rmTmp(work, { recursive: true, force: true });
|
|
931
|
+
}
|
|
932
|
+
})),
|
|
933
|
+
done('github.git.rest_authored_chain_fetchable', 'git', 'Git Data REST ↔ protocol agreement: a REST blob→tree→commit→ref chain is fetchable by real git; a dangling ref is NOT advertised', 'api', 'core', () =>
|
|
934
|
+
withRoot(async (root) => {
|
|
935
|
+
const { mkdtempSync: mkTmp, rmSync: rmTmp, readFileSync: readTmp } = await import('node:fs');
|
|
936
|
+
const { tmpdir: osTmp } = await import('node:os');
|
|
937
|
+
const { join: j } = await import('node:path');
|
|
938
|
+
const work = mkTmp(j(osTmp(), 'gh-cap-chain-'));
|
|
939
|
+
const server = createGithubTwinServer({ root });
|
|
940
|
+
try {
|
|
941
|
+
const base = `http://127.0.0.1:${server.port}`;
|
|
942
|
+
await fetch(`${base}/orgs/octo/repos`, { method: 'POST', body: JSON.stringify({ name: 'demo' }) });
|
|
943
|
+
// Author a commit purely through Git Data REST — REAL git objects, REAL shas.
|
|
944
|
+
const blob = await applyGithubWrite({ method: 'POST', path: `/repos/octo/demo/git/blobs`, body: JSON.stringify({ content: Buffer.from('rest chain\n').toString('base64'), encoding: 'base64' }), root });
|
|
945
|
+
if (blob.response.status !== 201) return false;
|
|
946
|
+
const blobSha = (blob.response.body as { sha?: string }).sha!;
|
|
947
|
+
const tree = await applyGithubWrite({ method: 'POST', path: `/repos/octo/demo/git/trees`, body: JSON.stringify({ tree: [{ path: 'chain.txt', mode: '100644', type: 'blob', sha: blobSha }] }), root });
|
|
948
|
+
if (tree.response.status !== 201) return false;
|
|
949
|
+
const commit = await applyGithubWrite({ method: 'POST', path: `/repos/octo/demo/git/commits`, body: JSON.stringify({ message: 'rest chain', tree: (tree.response.body as { sha?: string }).sha }), root });
|
|
950
|
+
if (commit.response.status !== 201) return false;
|
|
951
|
+
const commitSha = (commit.response.body as { sha?: string }).sha!;
|
|
952
|
+
const mkRef = await applyGithubWrite({ method: 'POST', path: `/repos/octo/demo/git/refs`, body: JSON.stringify({ ref: 'refs/heads/feat', sha: commitSha }), root });
|
|
953
|
+
if (mkRef.response.status !== 201) return false;
|
|
954
|
+
// A ref minted on a sha NO object backs (lenient legacy surface) stays
|
|
955
|
+
// control-plane-only: REST serves it, the protocol must NOT advertise it (an
|
|
956
|
+
// advertised ref must always be clonable).
|
|
957
|
+
const dangling = await applyGithubWrite({ method: 'POST', path: `/repos/octo/demo/git/refs`, body: JSON.stringify({ ref: 'refs/heads/ghost', sha: 'a'.repeat(40) }), root });
|
|
958
|
+
if (dangling.response.status !== 201) return false;
|
|
959
|
+
// Sharper still: a commit OBJECT that exists but DANGLES (its tree was never
|
|
960
|
+
// written — the lenient surface permits it) must also stay un-advertised; existence
|
|
961
|
+
// alone is not clonability, CONNECTIVITY is (updateGitRefInPlane's rev-list guard).
|
|
962
|
+
const dCommit = await applyGithubWrite({ method: 'POST', path: `/repos/octo/demo/git/commits`, body: JSON.stringify({ message: 'dangling', tree: 'f'.repeat(40) }), root });
|
|
963
|
+
if (dCommit.response.status !== 201) return false;
|
|
964
|
+
const ghost2 = await applyGithubWrite({ method: 'POST', path: `/repos/octo/demo/git/refs`, body: JSON.stringify({ ref: 'refs/heads/ghost2', sha: (dCommit.response.body as { sha?: string }).sha }), root });
|
|
965
|
+
if (ghost2.response.status !== 201) return false;
|
|
966
|
+
const adv = await handleGithubGitSmartHttp({ method: 'GET', pathname: '/octo/demo/info/refs', query: new URLSearchParams('service=git-upload-pack'), root });
|
|
967
|
+
const advText = Buffer.from(adv!.body).toString('utf8');
|
|
968
|
+
if (!advText.includes('refs/heads/feat') || advText.includes('refs/heads/ghost')) return false;
|
|
969
|
+
// Real git clones the REST-authored branch and reads the REST-authored bytes.
|
|
970
|
+
if ((await gitc(work, 'clone', '--branch', 'feat', `${base}/octo/demo.git`, 'c1')).code !== 0) return false;
|
|
971
|
+
if ((await gitc(j(work, 'c1'), 'rev-parse', 'HEAD')).stdout !== commitSha) return false;
|
|
972
|
+
return readTmp(j(work, 'c1', 'chain.txt'), 'utf8') === 'rest chain\n';
|
|
973
|
+
} finally {
|
|
974
|
+
server.stop(); rmTmp(work, { recursive: true, force: true });
|
|
975
|
+
}
|
|
976
|
+
})),
|
|
977
|
+
done('github.git.push_synchronizes_pr', 'git', 'Git smart-HTTP: a push to a PR head branch re-points the PR (synchronize) — old-head statuses/reviews stop matching', 'api', 'core', () =>
|
|
978
|
+
withRoot(async (root) => {
|
|
979
|
+
const { mkdtempSync: mkTmp, rmSync: rmTmp, writeFileSync: writeTmp } = await import('node:fs');
|
|
980
|
+
const { tmpdir: osTmp } = await import('node:os');
|
|
981
|
+
const { join: j } = await import('node:path');
|
|
982
|
+
const work = mkTmp(j(osTmp(), 'gh-cap-sync-'));
|
|
983
|
+
const server = createGithubTwinServer({ root });
|
|
984
|
+
try {
|
|
985
|
+
const base = `http://127.0.0.1:${server.port}`;
|
|
986
|
+
await fetch(`${base}/orgs/octo/repos`, { method: 'POST', body: JSON.stringify({ name: 'demo' }) });
|
|
987
|
+
if ((await gitc(work, 'clone', `${base}/octo/demo.git`, 'c1')).code !== 0) return false;
|
|
988
|
+
const c1 = j(work, 'c1');
|
|
989
|
+
writeTmp(j(c1, 'f.txt'), 'one\n');
|
|
990
|
+
await gitc(c1, 'add', 'f.txt');
|
|
991
|
+
await gitc(c1, 'commit', '-m', 'one');
|
|
992
|
+
if ((await gitc(c1, 'push', 'origin', 'HEAD:refs/heads/topic')).code !== 0) return false;
|
|
993
|
+
const tip1 = (await gitc(c1, 'rev-parse', 'HEAD')).stdout;
|
|
994
|
+
// Open a PR on the pushed branch — its head is the PUSHED tip.
|
|
995
|
+
const pr = await applyGithubWrite({ method: 'POST', path: `/repos/octo/demo/pulls`, body: JSON.stringify({ title: 'T', head: 'topic', base: 'main' }), root });
|
|
996
|
+
if (!(pr.response.status === 201 && (pr.response.body as { head?: { sha?: string } }).head?.sha === tip1)) return false;
|
|
997
|
+
const n = (pr.response.body as { number?: number }).number!;
|
|
998
|
+
// Push again: the PR head must FOLLOW (GitHub's synchronize), not stay stale.
|
|
999
|
+
writeTmp(j(c1, 'f.txt'), 'two\n');
|
|
1000
|
+
await gitc(c1, 'add', 'f.txt');
|
|
1001
|
+
await gitc(c1, 'commit', '-m', 'two');
|
|
1002
|
+
if ((await gitc(c1, 'push', 'origin', 'HEAD:refs/heads/topic')).code !== 0) return false;
|
|
1003
|
+
const tip2 = (await gitc(c1, 'rev-parse', 'HEAD')).stdout;
|
|
1004
|
+
if (tip1 === tip2) return false;
|
|
1005
|
+
const after = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/pulls/${n}`, root });
|
|
1006
|
+
return ok(after.status) && (after.body as { head?: { sha?: string } }).head?.sha === tip2;
|
|
1007
|
+
} finally {
|
|
1008
|
+
server.stop(); rmTmp(work, { recursive: true, force: true });
|
|
1009
|
+
}
|
|
1010
|
+
})),
|
|
1011
|
+
todo('github.git.protected_branch_push_rules', 'git', 'Git smart-HTTP: receive-pack enforces branch protection (force-push/deletion refusal on protected branches)', 'api', 'niche'),
|
|
1012
|
+
todo('github.repos.contents_backed_by_git_plane', 'repos', 'Repos: the contents API (PUT/DELETE .../contents/:path) writes REAL git objects + moves the branch ref — today it is still a disconnected side-store the git plane never sees', 'api', 'common'),
|
|
1013
|
+
todo('github.git.trees_recursive_param', 'git', 'Git Data: GET .../git/trees/:sha?recursive=1 (full recursive listing)', 'api', 'niche'),
|
|
1014
|
+
todo('github.git.shallow_partial_clone', 'git', 'Git smart-HTTP: shallow (--depth) + partial (--filter) clones asserted end-to-end (upload-pack supports them natively; unasserted)', 'api', 'niche'),
|
|
1015
|
+
todo('github.git.lfs', 'git', 'Git LFS: the batch API + object storage', 'api', 'niche'),
|
|
1016
|
+
todo('github.git.ssh_transport', 'git', 'Git over SSH (port 22 transport + key auth) — smart HTTP is the modeled transport', 'api', 'niche'),
|
|
1017
|
+
|
|
708
1018
|
// ── Releases / Tags ──────────────────────────────────────────────────────────────────────
|
|
709
1019
|
done('github.releases.crud', 'releases', 'Releases: create/edit/list/get + latest + by-tag', 'api', 'common', () =>
|
|
710
1020
|
withRoot(async (root) => {
|
|
@@ -1752,6 +2062,76 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1752
2062
|
// ── Next honest gaps (top-down GitHub surface NOT yet modeled — keeps the denominator
|
|
1753
2063
|
// honest; a high % against a thin list would be misleading). Each is a real product
|
|
1754
2064
|
// area still to close in a future cycle. ─────────────────────────────────────────────────
|
|
2065
|
+
done('github.graphql.pr_view_merge_gate_fields', 'api', 'GraphQL repository.pullRequest(number): the gh pr view --json fields the OA merge-gate scripts read (headRefOid, state, baseRefName, statusCheckRollup, closingIssuesReferences, changedFiles, labels, body)', 'api', 'common', () =>
|
|
2066
|
+
withRoot(async (root) => {
|
|
2067
|
+
// Seed: an issue, a PR whose body closes it, a check run + two commit statuses (the
|
|
2068
|
+
// second supersedes the first — the rollup shows each context ONCE, latest wins).
|
|
2069
|
+
const shaA = 'a'.repeat(40);
|
|
2070
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'bug' }), root });
|
|
2071
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/git/refs`, body: JSON.stringify({ ref: 'refs/heads/feat', sha: shaA }), root });
|
|
2072
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'Fix', head: 'feat', base: 'main', body: 'Closes #1' }), root });
|
|
2073
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/check-runs`, body: JSON.stringify({ name: 'test', head_sha: shaA, status: 'completed', conclusion: 'success' }), root });
|
|
2074
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/statuses/${shaA}`, body: JSON.stringify({ state: 'pending', context: 'agent-review' }), root });
|
|
2075
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/statuses/${shaA}`, body: JSON.stringify({ state: 'success', context: 'agent-review' }), root });
|
|
2076
|
+
await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/pulls/2`, body: JSON.stringify({ labels: ['human-required'] }), root });
|
|
2077
|
+
// The gh-CLI-shaped query (PullRequestByNumber with the --json field selection).
|
|
2078
|
+
const query = `query PullRequestByNumber($owner: String!, $repo: String!, $number: Int!) {
|
|
2079
|
+
repository(owner: $owner, name: $repo) {
|
|
2080
|
+
pullRequest(number: $number) {
|
|
2081
|
+
number state headRefOid baseRefName changedFiles body
|
|
2082
|
+
labels(first: 100) { nodes { name } }
|
|
2083
|
+
closingIssuesReferences(first: 100) { nodes { number } }
|
|
2084
|
+
statusCheckRollup: commits(last: 1) { nodes { commit { statusCheckRollup { state contexts(first: 100) { nodes { __typename ...on StatusContext { context state } ...on CheckRun { name status conclusion } } } } } } }
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
}`;
|
|
2088
|
+
const res = await applyGithubWrite({ method: 'POST', path: '/graphql', body: JSON.stringify({ query, variables: { owner: 'octo', repo: 'demo', number: 2 } }), root });
|
|
2089
|
+
const pr = ((res.response.body as { data?: { repository?: { pullRequest?: Record<string, any> } } }).data)?.repository?.pullRequest;
|
|
2090
|
+
if (!pr) return false;
|
|
2091
|
+
if (!(pr.number === 2 && pr.state === 'OPEN' && pr.headRefOid === shaA && pr.baseRefName === 'main' && pr.body === 'Closes #1')) return false;
|
|
2092
|
+
if (pr.labels?.nodes?.[0]?.name !== 'human-required') return false;
|
|
2093
|
+
if (pr.closingIssuesReferences?.nodes?.[0]?.number !== 1) return false;
|
|
2094
|
+
const rollup = pr.statusCheckRollup?.nodes?.[0]?.commit?.statusCheckRollup;
|
|
2095
|
+
if (rollup?.state !== 'SUCCESS') return false; // latest agent-review status won; check run green
|
|
2096
|
+
const ctx = rollup?.contexts?.nodes as Array<Record<string, unknown>>;
|
|
2097
|
+
const run = ctx?.find((c) => c.__typename === 'CheckRun');
|
|
2098
|
+
const st = ctx?.filter((c) => c.__typename === 'StatusContext');
|
|
2099
|
+
if (!(run?.name === 'test' && run.status === 'COMPLETED' && run.conclusion === 'SUCCESS')) return false;
|
|
2100
|
+
if (!(st?.length === 1 && st[0]!.context === 'agent-review' && st[0]!.state === 'SUCCESS')) return false;
|
|
2101
|
+
// An unmodeled PR field stays an HONEST undefinedField error, never a fabricated value.
|
|
2102
|
+
const badQ = `query { repository(owner: "octo", name: "demo") { pullRequest(number: 2) { projectCards { totalCount } } } }`;
|
|
2103
|
+
const bad = await applyGithubWrite({ method: 'POST', path: '/graphql', body: JSON.stringify({ query: badQ }), root });
|
|
2104
|
+
const errs = (bad.response.body as { errors?: Array<{ type?: string }> }).errors;
|
|
2105
|
+
return errs?.[0]?.type === 'undefinedField';
|
|
2106
|
+
})),
|
|
2107
|
+
done('github.graphql.issue_view', 'api', 'GraphQL repository.issue(number): the gh issue view --json fields the gate scripts read (labels for human-approval scope, comments for finalize routing) — and single-node token matching never hijacks a repo NAMED "issue-*"', 'api', 'common', () =>
|
|
2108
|
+
withRoot(async (root) => {
|
|
2109
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'bug', body: 'details' }), root });
|
|
2110
|
+
await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/issues/1`, body: JSON.stringify({ labels: ['agent-develop-only'] }), root });
|
|
2111
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues/1/comments`, body: JSON.stringify({ body: 'triaged' }), root });
|
|
2112
|
+
const query = `query IssueByNumber($owner: String!, $repo: String!, $number: Int!) {
|
|
2113
|
+
repository(owner: $owner, name: $repo) {
|
|
2114
|
+
issue(number: $number) { number title state body labels(first: 100) { nodes { name } } comments(first: 100) { nodes { body } } }
|
|
2115
|
+
}
|
|
2116
|
+
}`;
|
|
2117
|
+
const res = await applyGithubWrite({ method: 'POST', path: '/graphql', body: JSON.stringify({ query, variables: { owner: 'octo', repo: 'demo', number: 1 } }), root });
|
|
2118
|
+
const issue = ((res.response.body as { data?: { repository?: { issue?: Record<string, any> } } }).data)?.repository?.issue;
|
|
2119
|
+
if (!issue) return false;
|
|
2120
|
+
if (!(issue.number === 1 && issue.title === 'bug' && issue.state === 'OPEN' && issue.body === 'details')) return false;
|
|
2121
|
+
if (issue.labels?.nodes?.[0]?.name !== 'agent-develop-only') return false;
|
|
2122
|
+
if (issue.comments?.nodes?.[0]?.body !== 'triaged') return false;
|
|
2123
|
+
// NOT_FOUND negative — an absent issue is an honest error, never a fabricated node.
|
|
2124
|
+
const missing = await applyGithubWrite({ method: 'POST', path: '/graphql', body: JSON.stringify({ query, variables: { owner: 'octo', repo: 'demo', number: 99 } }), root });
|
|
2125
|
+
if ((missing.response.body as { errors?: Array<{ type?: string }> }).errors?.[0]?.type !== 'NOT_FOUND') return false;
|
|
2126
|
+
// Token-hijack regression pin (§9 round two): a repository whose NAME contains "issue"
|
|
2127
|
+
// resolves ordinary repository fields — the single-issue branch must not capture it.
|
|
2128
|
+
await applyGithubWrite({ method: 'POST', path: '/orgs/octo/repos', body: JSON.stringify({ name: 'issue-tracker' }), root });
|
|
2129
|
+
const repoQ = await applyGithubWrite({ method: 'POST', path: '/graphql', body: JSON.stringify({ query: `query { repository(owner: "octo", name: "issue-tracker") { name nameWithOwner isPrivate } }` }), root });
|
|
2130
|
+
const repoData = (repoQ.response.body as { data?: { repository?: { nameWithOwner?: string } }; errors?: unknown[] });
|
|
2131
|
+
return repoData.errors === undefined && repoData.data?.repository?.nameWithOwner === 'octo/issue-tracker';
|
|
2132
|
+
})),
|
|
2133
|
+
todo('github.graphql.pr_view_full_field_set', 'api', 'GraphQL pullRequest: the rest of gh pr view --json (reviewDecision, mergeable, mergeStateStatus, files, commits detail, reviews connection)', 'api', 'niche'),
|
|
2134
|
+
todo('github.graphql.gh_edit_mutations', 'api', 'GraphQL mutations gh pr/issue edit send (updatePullRequest, addLabelsToLabelable/removeLabels, replaceActorsForAssignable, requestReviews) — finalize-agent-review\'s label routing needs these', 'api', 'common'),
|
|
1755
2135
|
todo('github.graphql.connections', 'api', 'GraphQL: cursor pagination (pageInfo/edges + after) on connections', 'api', 'niche'),
|
|
1756
2136
|
todo('github.graphql.search', 'api', 'GraphQL: the search() connection (typed SearchResultItem nodes)', 'api', 'niche'),
|
|
1757
2137
|
todo('github.security.advisories', 'security', 'Repository security advisories (GHSA drafts + CVE request + credits)', 'api', 'niche'),
|
|
@@ -1830,6 +2210,94 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1830
2210
|
return ok(pr.status) && (pr.body as { title?: string }).title === 'Real title'
|
|
1831
2211
|
&& ok(iss.status) && (iss.body as { title?: string }).title === 'Bug';
|
|
1832
2212
|
})),
|
|
2213
|
+
// THE CONVERSATION, cell by cell. `github.connector.pull` above proves a PR and an issue
|
|
2214
|
+
// fold with their content; it says NOTHING about the review/comment subjects, and its fake
|
|
2215
|
+
// answers [] to every comments route, so the whole conversation feature could be DELETED and
|
|
2216
|
+
// it would stay green (2026-09-03 review finding). This cell is the one that names the
|
|
2217
|
+
// feature: distinct reviews / issue-comment / inline routes, each row shaped the way GitHub
|
|
2218
|
+
// shapes it (a `user`, a `pull_request_review_id`, a `submitted_at`), and it asserts the
|
|
2219
|
+
// exact subject ids, WHO said it, WHEN they said it, the inline finding under its review —
|
|
2220
|
+
// and that a PR whose `updated_at` has not moved costs zero detail calls on the next pull.
|
|
2221
|
+
done('github.connector.pull_conversation', 'connector', "Connector: a PR's conversation pulls as SUBJECTS — every review (with the inline comments it wrapped, joined by pull_request_review_id) and every issue comment, each naming ITS OWN author and dated by the provider, with the unchanged-PR budget skip", 'connector', 'core', () =>
|
|
2222
|
+
withRoot(async (root) => {
|
|
2223
|
+
const { observedPrsToResources, pullGithubPrs, syncGithubFromReal } = await import('./github-connector.ts');
|
|
2224
|
+
const detailRoutes = /\/(reviews|comments)$/;
|
|
2225
|
+
const calls: string[] = [];
|
|
2226
|
+
const pr = { number: 7, title: 'Conversation', state: 'open', updated_at: '2026-03-01T12:00:00Z', user: { login: 'anne', type: 'User' } };
|
|
2227
|
+
const execute = {
|
|
2228
|
+
async request(route: string, params: Record<string, unknown> = {}) {
|
|
2229
|
+
calls.push(route);
|
|
2230
|
+
if (route === 'GET /repos/{owner}/{repo}/pulls') return { status: 200, data: params.state === 'closed' ? [] : [pr] };
|
|
2231
|
+
if (route === 'GET /repos/{owner}/{repo}/issues') return { status: 200, data: [] };
|
|
2232
|
+
// Three DISTINCT routes with three distinct shapes — the reviews page, the issue
|
|
2233
|
+
// (conversation-tab) comments, and the inline diff comments.
|
|
2234
|
+
if (route === 'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews') {
|
|
2235
|
+
// Chronological, as GitHub returns them — so the LAST row is not the newest
|
|
2236
|
+
// verdict: review 13 is a reviewer's unsubmitted PENDING draft, the most recently
|
|
2237
|
+
// created row and no verdict at all. `latestReview` must still be review 12.
|
|
2238
|
+
return { status: 200, data: [
|
|
2239
|
+
{ id: 11, state: 'CHANGES_REQUESTED', body: 'needs a test', submitted_at: '2026-03-01T10:00:00Z', commit_id: 'aaa', user: { login: 'anne', type: 'User' } },
|
|
2240
|
+
{ id: 12, state: 'COMMENTED', body: 'scanned', submitted_at: '2026-03-01T11:00:00Z', commit_id: 'aaa', user: { login: 'volter-scanner[bot]', type: 'Bot' } },
|
|
2241
|
+
{ id: 13, state: 'PENDING', body: 'draft', user: { login: 'dan', type: 'User' } },
|
|
2242
|
+
] };
|
|
2243
|
+
}
|
|
2244
|
+
if (route === 'GET /repos/{owner}/{repo}/pulls/{pull_number}/comments') {
|
|
2245
|
+
return { status: 200, data: [
|
|
2246
|
+
{ id: 900, pull_request_review_id: 12, path: 'src/a.ts', line: 12, body: 'unchecked index', created_at: '2026-03-01T11:00:00Z', user: { login: 'volter-scanner[bot]', type: 'Bot' } },
|
|
2247
|
+
// A BOT finding inside the HUMAN's review: the join is by review id, and it is
|
|
2248
|
+
// not an authorship claim — this comment is the scanner's, not anne's.
|
|
2249
|
+
{ id: 901, pull_request_review_id: 11, path: 'src/b.ts', line: 3, body: 'null deref', created_at: '2026-03-01T10:00:00Z', user: { login: 'volter-scanner[bot]', type: 'Bot' } },
|
|
2250
|
+
] };
|
|
2251
|
+
}
|
|
2252
|
+
if (route === 'GET /repos/{owner}/{repo}/issues/{issue_number}/comments') {
|
|
2253
|
+
return { status: 200, data: [{ id: 41, body: 'ship it', created_at: '2026-03-01T09:00:00Z', updated_at: '2026-03-01T09:30:00Z', user: { login: 'bob', type: 'User' } }] };
|
|
2254
|
+
}
|
|
2255
|
+
return { status: 404, data: { message: 'not found' } };
|
|
2256
|
+
},
|
|
2257
|
+
};
|
|
2258
|
+
const resources = observedPrsToResources(await pullGithubPrs(execute, { owner: 'octo', repo: 'demo', state: 'all' }));
|
|
2259
|
+
// ONE subject per thing that was said, at the exact ids a consumer threads a feed by.
|
|
2260
|
+
const ids = resources.map((r) => `${r.type} ${r.id}`);
|
|
2261
|
+
if (ids.join('|') !== ['pull_request octo/demo#7', 'review octo/demo#7:review:11', 'review octo/demo#7:review:12', 'review octo/demo#7:review:13', 'issue_comment octo/demo#7:ic:41'].join('|')) return false;
|
|
2262
|
+
const human = resources.find((r) => r.id === 'octo/demo#7:review:11')!;
|
|
2263
|
+
const bot = resources.find((r) => r.id === 'octo/demo#7:review:12')!;
|
|
2264
|
+
const draft = resources.find((r) => r.id === 'octo/demo#7:review:13')!;
|
|
2265
|
+
const issueComment = resources.find((r) => r.id === 'octo/demo#7:ic:41')!;
|
|
2266
|
+
// WHO said it — the bot verdict is separable from the human's changes-requested.
|
|
2267
|
+
if (human.fields.authorLogin !== 'anne' || human.fields.authorType !== 'user') return false;
|
|
2268
|
+
if (bot.fields.authorLogin !== 'volter-scanner[bot]' || bot.fields.authorType !== 'bot') return false;
|
|
2269
|
+
if (issueComment.fields.authorLogin !== 'bob' || issueComment.fields.authorType !== 'user') return false;
|
|
2270
|
+
// WHEN it was said — the provider's own instant, never the poll clock. The unsubmitted
|
|
2271
|
+
// draft has no instant to be dated by, and is given none.
|
|
2272
|
+
if (human.occurredAt !== '2026-03-01T10:00:00Z' || bot.occurredAt !== '2026-03-01T11:00:00Z') return false;
|
|
2273
|
+
if (issueComment.occurredAt !== '2026-03-01T09:30:00Z') return false;
|
|
2274
|
+
if (resources[0]!.occurredAt !== '2026-03-01T12:00:00Z') return false;
|
|
2275
|
+
if (draft.occurredAt !== undefined) return false;
|
|
2276
|
+
// THE JOIN — each inline finding rides the review that wrapped it, and only that one.
|
|
2277
|
+
const botComments = bot.fields.comments as Array<Record<string, unknown>>;
|
|
2278
|
+
const humanComments = human.fields.comments as Array<Record<string, unknown>>;
|
|
2279
|
+
if (!Array.isArray(botComments) || botComments.length !== 1) return false;
|
|
2280
|
+
if (botComments[0]!.id !== '900' || botComments[0]!.path !== 'src/a.ts' || botComments[0]!.line !== 12 || botComments[0]!.body !== 'unchecked index') return false;
|
|
2281
|
+
if (!Array.isArray(humanComments) || humanComments.length !== 1) return false;
|
|
2282
|
+
if (humanComments[0]!.id !== '901' || humanComments[0]!.body !== 'null deref') return false;
|
|
2283
|
+
// ...AUTHORED BY WHOEVER WROTE IT. The scanner's finding inside anne's review is the
|
|
2284
|
+
// scanner's; reading the wrapper's login served a bot finding as a human's.
|
|
2285
|
+
if (humanComments[0]!.authorLogin !== 'volter-scanner[bot]' || humanComments[0]!.authorType !== 'bot') return false;
|
|
2286
|
+
if (botComments[0]!.authorLogin !== 'volter-scanner[bot]' || botComments[0]!.authorType !== 'bot') return false;
|
|
2287
|
+
// The summary a consumer reading only the PR sees agrees with the subjects above: the
|
|
2288
|
+
// count excludes the PENDING draft, and the newest verdict is the newest SUBMITTED one
|
|
2289
|
+
// rather than the last row on the page.
|
|
2290
|
+
if (resources[0]!.fields.reviewCount !== 2) return false;
|
|
2291
|
+
const latest = resources[0]!.fields.latestReview as Record<string, unknown> | null;
|
|
2292
|
+
if (latest === null || latest.state !== 'COMMENTED' || latest.submittedAt !== '2026-03-01T11:00:00Z') return false;
|
|
2293
|
+
// THE BUDGET — a second pull whose `updated_at` has not moved buys the list and nothing
|
|
2294
|
+
// else. Folded through syncGithubFromReal, because the memory is the SHADOW.
|
|
2295
|
+
await syncGithubFromReal(execute, { owner: 'octo', repo: 'demo', root, occurredAt: '2026-03-01T13:00:00Z', state: 'all' });
|
|
2296
|
+
calls.length = 0;
|
|
2297
|
+
await syncGithubFromReal(execute, { owner: 'octo', repo: 'demo', root, occurredAt: '2026-03-01T14:00:00Z', state: 'all' });
|
|
2298
|
+
if (calls.some((route) => detailRoutes.test(route))) return false;
|
|
2299
|
+
return calls.includes('GET /repos/{owner}/{repo}/pulls');
|
|
2300
|
+
})),
|
|
1833
2301
|
done('github.connector.push', 'connector', 'Connector: push reconcile (apply desired-state writes)', 'connector', 'common', () =>
|
|
1834
2302
|
withRoot(async (root) => {
|
|
1835
2303
|
const { pushPendingGithubActions } = await import('./github-connector.ts');
|
|
@@ -1874,10 +2342,19 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1874
2342
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/1/requested_reviewers`, body: JSON.stringify({ reviewers: ['carol'] }), root });
|
|
1875
2343
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/1/comments`, body: JSON.stringify({ body: 'nit', path: 'a.ts', line: 1, side: 'RIGHT' }), root });
|
|
1876
2344
|
},
|
|
1877
|
-
|
|
2345
|
+
// The screen's "Review count" and the reviews the API serves for the same PR are read
|
|
2346
|
+
// TOGETHER: the seed leaves two reviews on the PR — the APPROVE, and the implicit review
|
|
2347
|
+
// wrapping the standalone inline comment, which `GET /pulls/:n/reviews` serves exactly as
|
|
2348
|
+
// real GitHub does. The count used to read only the stored rows, so the mirror rendered
|
|
2349
|
+
// "Review count 1" over a reviews page answering two.
|
|
2350
|
+
fetch: async (root) => ({
|
|
2351
|
+
...githubMirrorState(root),
|
|
2352
|
+
served: (handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/reviews`, root }).body as unknown[]).length,
|
|
2353
|
+
}),
|
|
1878
2354
|
assert: (m) => {
|
|
1879
2355
|
const pr = (m.github as any).pullRequests.find((p: any) => p.title === 'Conversation Probe');
|
|
1880
|
-
|
|
2356
|
+
if (!pr || pr.reviewCommentsCount !== 1 || !(pr.requestedReviewers as string[]).includes('carol')) return false;
|
|
2357
|
+
return pr.reviewCount === 2 && pr.reviewCount === m.served;
|
|
1881
2358
|
},
|
|
1882
2359
|
})),
|
|
1883
2360
|
// Issues list: seed an issue with a distinct label + assignee — both must appear on
|
|
@@ -2097,9 +2574,9 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2097
2574
|
return !!d && d.state === 'success' && d.environmentUrl === 'https://probe.x' && !!e && e.protected === true;
|
|
2098
2575
|
},
|
|
2099
2576
|
})),
|
|
2100
|
-
// ── Pull-surface coverage audit gaps (TWIN-46 / G2) — filed as manifest todos
|
|
2101
|
-
//
|
|
2102
|
-
//
|
|
2577
|
+
// ── Pull-surface coverage audit gaps (TWIN-46 / G2) — filed as manifest todos, which is
|
|
2578
|
+
// what the demand-ordered build list is drawn from. See pull-audit.json (repo root) for the
|
|
2579
|
+
// per-pack pull-vs-read-surface evidence.
|
|
2103
2580
|
todo('github.connector.pull_pr_files', 'connector', 'Connector: pull PR files (per-file diff list) from the real vendor', 'connector', 'core'),
|
|
2104
2581
|
todo('github.connector.pull_repo_metadata', 'connector', 'Connector: pull repo metadata/settings (GET /repos/:owner/:repo) from the real vendor', 'connector', 'common'),
|
|
2105
2582
|
|
|
@@ -2116,6 +2593,8 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2116
2593
|
todo('github.billing.packages_storage', 'billing', 'Billing: shared storage/packages billing usage', 'api', 'niche'),
|
|
2117
2594
|
todo('github.migrations.org_export', 'migrations', 'Migrations: start + poll an org migration (data export archive)', 'api', 'niche'),
|
|
2118
2595
|
todo('github.migrations.repo_import', 'migrations', 'Migrations: source import status (GitHub Importer API)', 'api', 'niche'),
|
|
2596
|
+
todo('github.pulls.merge_gated_by_protection', 'pulls', 'PRs: merge refused (405) while a protected base branch has failing/missing required checks (the config persists; PUT .../merge does not yet consult it)', 'api', 'common'),
|
|
2597
|
+
todo('github.pulls.review_actor_identity', 'pulls', 'PRs: review.user/comment.user reflect the authenticated actor. LOAD-BEARING gap: human-approval-gate.ts reads review.user.login (then checks collaborators/:login/permission), so with user:null a native Approve or /agent-approve comment can NEVER authorize against the twin — hermetic human-approval needs this modeled', 'api', 'common'),
|
|
2119
2598
|
todo('github.actions.runners_crud', 'actions', 'Actions: self-hosted runners CRUD (repo/org) + registration/removal tokens', 'api', 'niche'),
|
|
2120
2599
|
todo('github.actions.runner_groups', 'actions', 'Actions: runner groups (org) CRUD + repo/runner assignment', 'api', 'niche'),
|
|
2121
2600
|
todo('github.interactions.limits', 'interactions', 'Interactions: repo/org temporary interaction limits (collaborators-only restriction)', 'api', 'niche'),
|