@volter/twin-github 0.1.1 → 2.0.0
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 +88 -36
- package/client/github-mirror.css +277 -319
- package/client/github-mirror.d.ts +418 -0
- package/client/github-mirror.js +485 -0
- package/client/github-mirror.tsx +153 -357
- package/client/pulls-rest.ts +159 -0
- package/client/pulls-workspace.tsx +841 -0
- package/dist/client/github-mirror.bundle.js +239 -0
- package/dist/client/github-mirror.css +916 -0
- package/dist/client/github-mirror.d.ts +418 -0
- package/dist/client/github-mirror.js +485 -0
- package/dist/client/github-mirror.tsx +1315 -0
- package/dist/client/pulls-rest.d.ts +42 -0
- package/dist/client/pulls-rest.js +140 -0
- package/dist/client/pulls-rest.ts +159 -0
- package/dist/client/pulls-workspace.bundle.js +22 -0
- package/dist/client/pulls-workspace.d.ts +114 -0
- package/dist/client/pulls-workspace.js +418 -0
- package/dist/client/pulls-workspace.tsx +841 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +40 -0
- package/dist/src/generated/graphql-sdl.gen.json +1 -0
- package/dist/src/generated/graphql.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/github-budget.d.ts +69 -0
- package/dist/src/github-budget.js +172 -0
- package/dist/src/github-capabilities.d.ts +5 -0
- package/dist/src/github-capabilities.js +4468 -0
- package/dist/src/github-conformance.d.ts +43 -0
- package/dist/src/github-conformance.js +76 -0
- package/dist/src/github-connector.d.ts +307 -0
- package/dist/src/github-connector.js +1398 -0
- package/dist/src/github-events.d.ts +41 -0
- package/dist/src/github-events.js +230 -0
- package/dist/src/github-git-http.d.ts +49 -0
- package/dist/src/github-git-http.js +185 -0
- package/dist/src/github-git-plane.d.ts +114 -0
- package/dist/src/github-git-plane.js +407 -0
- package/dist/src/github-mirror-state.d.ts +2 -0
- package/dist/src/github-mirror-state.js +335 -0
- package/dist/src/github-mirror-ui.d.ts +20 -0
- package/dist/src/github-mirror-ui.js +101 -0
- package/dist/src/github-server.d.ts +14 -0
- package/dist/src/github-server.js +240 -0
- package/dist/src/github-shared.d.ts +12 -0
- package/dist/src/github-shared.js +21 -0
- package/dist/src/github-twin.d.ts +1411 -0
- package/dist/src/github-twin.js +4084 -0
- package/dist/src/github-ui-conformance.d.ts +4 -0
- package/dist/src/github-ui-conformance.js +105 -0
- package/dist/src/github-ui-structure.d.ts +18 -0
- package/dist/src/github-ui-structure.js +251 -0
- package/dist/src/graphql-wire.d.ts +22 -0
- package/dist/src/graphql-wire.js +89 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/index.js +101 -0
- package/dist/src/manifest.d.ts +8 -0
- package/dist/src/manifest.js +597 -0
- package/dist/src/npm-registry.d.ts +7 -0
- package/dist/src/npm-registry.js +47 -0
- package/dist/src/screens/app-installation.d.ts +3 -0
- package/dist/src/screens/app-installation.js +166 -0
- package/dist/src/screens/app-manifest.d.ts +3 -0
- package/dist/src/screens/app-manifest.js +81 -0
- package/dist/src/screens/oauth.d.ts +15 -0
- package/dist/src/screens/oauth.js +257 -0
- package/dist/src/screens/session.d.ts +8 -0
- package/dist/src/screens/session.js +159 -0
- package/dist/src/semantics/actions.d.ts +2 -0
- package/dist/src/semantics/actions.js +413 -0
- package/dist/src/semantics/activity.d.ts +4 -0
- package/dist/src/semantics/activity.js +161 -0
- package/dist/src/semantics/apps.d.ts +2 -0
- package/dist/src/semantics/apps.js +144 -0
- package/dist/src/semantics/branches.d.ts +2 -0
- package/dist/src/semantics/branches.js +136 -0
- package/dist/src/semantics/checks.d.ts +2 -0
- package/dist/src/semantics/checks.js +176 -0
- package/dist/src/semantics/code-scanning-upload.d.ts +2 -0
- package/dist/src/semantics/code-scanning-upload.js +97 -0
- package/dist/src/semantics/codespaces.d.ts +2 -0
- package/dist/src/semantics/codespaces.js +58 -0
- package/dist/src/semantics/commits.d.ts +2 -0
- package/dist/src/semantics/commits.js +109 -0
- package/dist/src/semantics/contents.d.ts +2 -0
- package/dist/src/semantics/contents.js +131 -0
- package/dist/src/semantics/deployments.d.ts +2 -0
- package/dist/src/semantics/deployments.js +127 -0
- package/dist/src/semantics/gists.d.ts +2 -0
- package/dist/src/semantics/gists.js +86 -0
- package/dist/src/semantics/git.d.ts +2 -0
- package/dist/src/semantics/git.js +235 -0
- package/dist/src/semantics/graphql.d.ts +7 -0
- package/dist/src/semantics/graphql.js +512 -0
- package/dist/src/semantics/index.d.ts +5 -0
- package/dist/src/semantics/index.js +62 -0
- package/dist/src/semantics/issues.d.ts +2 -0
- package/dist/src/semantics/issues.js +456 -0
- package/dist/src/semantics/keys.d.ts +2 -0
- package/dist/src/semantics/keys.js +66 -0
- package/dist/src/semantics/labels.d.ts +2 -0
- package/dist/src/semantics/labels.js +100 -0
- package/dist/src/semantics/meta.d.ts +12 -0
- package/dist/src/semantics/meta.js +144 -0
- package/dist/src/semantics/notifications.d.ts +4 -0
- package/dist/src/semantics/notifications.js +62 -0
- package/dist/src/semantics/orgs.d.ts +2 -0
- package/dist/src/semantics/orgs.js +420 -0
- package/dist/src/semantics/packages.d.ts +2 -0
- package/dist/src/semantics/packages.js +55 -0
- package/dist/src/semantics/pages.d.ts +2 -0
- package/dist/src/semantics/pages.js +176 -0
- package/dist/src/semantics/projects.d.ts +2 -0
- package/dist/src/semantics/projects.js +239 -0
- package/dist/src/semantics/pulls.d.ts +2 -0
- package/dist/src/semantics/pulls.js +462 -0
- package/dist/src/semantics/push-reactions.d.ts +101 -0
- package/dist/src/semantics/push-reactions.js +513 -0
- package/dist/src/semantics/releases.d.ts +19 -0
- package/dist/src/semantics/releases.js +231 -0
- package/dist/src/semantics/repo-invitations.d.ts +2 -0
- package/dist/src/semantics/repo-invitations.js +98 -0
- package/dist/src/semantics/repos.d.ts +17 -0
- package/dist/src/semantics/repos.js +390 -0
- package/dist/src/semantics/rulesets.d.ts +2 -0
- package/dist/src/semantics/rulesets.js +111 -0
- package/dist/src/semantics/search.d.ts +2 -0
- package/dist/src/semantics/search.js +84 -0
- package/dist/src/semantics/security.d.ts +2 -0
- package/dist/src/semantics/security.js +177 -0
- package/dist/src/semantics/shared.d.ts +61 -0
- package/dist/src/semantics/shared.js +151 -0
- package/dist/src/semantics/users.d.ts +2 -0
- package/dist/src/semantics/users.js +255 -0
- package/dist/src/semantics/webhooks.d.ts +2 -0
- package/dist/src/semantics/webhooks.js +107 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.SOURCE.md +56 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.json +1447 -0
- package/dist/test-fixtures/github-comment-schema.SOURCE.md +11 -0
- package/dist/test-fixtures/github-comment-schema.json +702 -0
- package/dist/test-fixtures/github-known-deviations.json +94 -0
- package/dist/test-fixtures/github-openapi-operations.SOURCE.md +76 -0
- package/dist/test-fixtures/github-openapi-operations.json +362 -0
- package/dist/test-fixtures/github-pull-schema.SOURCE.md +37 -0
- package/dist/test-fixtures/github-pull-schema.json +3601 -0
- package/dist/test-fixtures/github-review-schema.SOURCE.md +12 -0
- package/dist/test-fixtures/github-review-schema.json +236 -0
- package/package.json +20 -11
- package/src/cli.ts +7 -6
- package/src/generated/graphql-sdl.gen.json +1 -0
- package/src/generated/graphql.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/github-a11y-snapshot.uitest.ts +5 -5
- package/src/github-budget.ts +4 -4
- package/src/github-capabilities.ts +1993 -556
- package/src/github-conformance.ts +12 -7
- package/src/github-connector.ts +108 -96
- package/src/github-events.ts +223 -95
- package/src/github-git-http.ts +47 -83
- package/src/github-git-plane.ts +247 -385
- package/src/github-journey.uitest.ts +28 -44
- package/src/github-mirror-state.ts +23 -61
- package/src/github-mirror-ui.ts +16 -10
- package/src/github-server.ts +158 -60
- package/src/github-shared.ts +1 -1
- package/src/github-twin.ts +1525 -4494
- package/src/github-ui-conformance.ts +7 -8
- package/src/github-ui-structure.ts +19 -18
- package/src/graphql-wire.ts +115 -0
- package/src/index.ts +47 -7
- package/src/manifest.ts +605 -0
- package/src/npm-registry.ts +43 -0
- package/src/screens/app-installation.tsx +217 -0
- package/src/screens/app-manifest.tsx +97 -0
- package/src/screens/oauth.tsx +258 -0
- package/src/screens/session.tsx +186 -0
- package/src/semantics/actions.ts +395 -0
- package/src/semantics/activity.ts +171 -0
- package/src/semantics/apps.ts +133 -0
- package/src/semantics/branches.ts +133 -0
- package/src/semantics/checks.ts +163 -0
- package/src/semantics/code-scanning-upload.ts +92 -0
- package/src/semantics/codespaces.ts +58 -0
- package/src/semantics/commits.ts +109 -0
- package/src/semantics/contents.ts +112 -0
- package/src/semantics/deployments.ts +116 -0
- package/src/semantics/gists.ts +85 -0
- package/src/semantics/git.ts +226 -0
- package/src/semantics/graphql.ts +505 -0
- package/src/semantics/index.ts +68 -0
- package/src/semantics/issues.ts +434 -0
- package/src/semantics/keys.ts +66 -0
- package/src/semantics/labels.ts +91 -0
- package/src/semantics/meta.ts +139 -0
- package/src/semantics/notifications.ts +58 -0
- package/src/semantics/orgs.ts +383 -0
- package/src/semantics/packages.ts +59 -0
- package/src/semantics/pages.ts +154 -0
- package/src/semantics/projects.ts +246 -0
- package/src/semantics/pulls.ts +421 -0
- package/src/semantics/push-reactions.ts +471 -0
- package/src/semantics/releases.ts +213 -0
- package/src/semantics/repo-invitations.ts +112 -0
- package/src/semantics/repos.ts +373 -0
- package/src/semantics/rulesets.ts +107 -0
- package/src/semantics/search.ts +83 -0
- package/src/semantics/security.ts +153 -0
- package/src/semantics/shared.ts +181 -0
- package/src/semantics/users.ts +252 -0
- package/src/semantics/webhooks.ts +101 -0
- package/test-fixtures/github-known-deviations.json +7 -8
- package/test-fixtures/github-openapi-operations.json +46 -227
- package/src/github-graphql.ts +0 -398
|
@@ -0,0 +1,4084 @@
|
|
|
1
|
+
// GitHub twin — the fifth twin. CONTENT note: the `github` world is now a CONTENT
|
|
2
|
+
// mirror for the text GitHub's REST API actually returns — OBSERVED PR/issue events
|
|
3
|
+
// carry title, body, state and the BODIES of reviews/comments (folded by the
|
|
4
|
+
// connector on pull), alongside the metadata (number, repo, base ref, head sha,
|
|
5
|
+
// file/commit counts). A mirror read of observed data therefore reads back the real
|
|
6
|
+
// title/body, not null. LOCAL simulator/fork writes also carry content and overlay
|
|
7
|
+
// the observed fold (R5/R18). The remaining honesty boundary is the Non-goal: actual
|
|
8
|
+
// repository file CONTENTS are not pulled by this connector. PR authors retain observed
|
|
9
|
+
// identity or the modeled local caller; PR node IDs address the twin's GraphQL nodes.
|
|
10
|
+
// Other unmodeled fields remain declared scope. The shapes match GitHub's REST objects (R1).
|
|
11
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
12
|
+
import { applyTwinWrite, applyTwinWriteAtomic, getActiveWorldStore, ownFields, resolveSubjectId, subjectHistory, transitionFor, treeStamp, twinResources } from '@volter/world-core';
|
|
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 { diffFilesInPlane, hasBareRepo, listGitPlaneRefs, mergeIntoBranchInPlane, objectExistsInPlane, readGitBlobFromPlane, readGitCommitFromPlane, readGitTagFromPlane, readGitTreeFromPlane, } from "./github-git-plane.js";
|
|
18
|
+
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
19
|
+
/** What the World keeps of a person's password: its hash, never the password (src/screens/session.tsx checks it). */
|
|
20
|
+
export const passwordHash = (login, password) => createHash('sha256').update(`password:${login.toLowerCase()}:${password}`).digest('hex');
|
|
21
|
+
/** A person's authenticator app: the RFC 6238 code of a base32 secret at an instant, as GitHub's TOTP setup encodes it
|
|
22
|
+
* (docs.github.com/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication:
|
|
23
|
+
* SHA1, 6 digits, a 30-second period). `step` counts 30-second periods since the epoch. */
|
|
24
|
+
export const totpStep = (at) => Math.floor(Date.parse(at) / 1000 / 30);
|
|
25
|
+
export function totpCode(secret, step) {
|
|
26
|
+
const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
27
|
+
let bits = '';
|
|
28
|
+
for (const ch of secret.replace(/=+$/, '').toUpperCase())
|
|
29
|
+
bits += A.indexOf(ch).toString(2).padStart(5, '0');
|
|
30
|
+
const key = Buffer.from((bits.match(/.{8}/g) ?? []).map((b) => parseInt(b, 2)));
|
|
31
|
+
const counter = Buffer.alloc(8);
|
|
32
|
+
counter.writeBigUInt64BE(BigInt(step));
|
|
33
|
+
const mac = createHmac('sha1', key).update(counter).digest();
|
|
34
|
+
const at = mac[mac.length - 1] & 0x0f;
|
|
35
|
+
return String((mac.readUInt32BE(at) & 0x7fffffff) % 1_000_000).padStart(6, '0');
|
|
36
|
+
}
|
|
37
|
+
const base32 = (bytes) => {
|
|
38
|
+
const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
39
|
+
const bits = [...bytes].map((b) => b.toString(2).padStart(8, '0')).join('');
|
|
40
|
+
return (bits.match(/.{1,5}/g) ?? []).map((b) => A[parseInt(b.padEnd(5, '0'), 2)]).join('');
|
|
41
|
+
};
|
|
42
|
+
const SERVICE = 'github';
|
|
43
|
+
/** The engine slot's plane, as handlers reach it (semantics/ imports shared helpers from here). */
|
|
44
|
+
export { deleteBareRepo, deleteGitRefInPlane, diffFilesInPlane, mergeBaseInPlane, mergeIntoBranchInPlane, ensureBareRepo, forkBareRepo, setBareRepoHead, hasBareRepo, isAncestorInPlane, isValidBranchName, listGitPlaneRefs, objectExistsInPlane, objectTypeInPlane, readGitBlobFromPlane, readGitCommitFromPlane, readGitTagFromPlane, readGitTreeFromPlane, updateGitRefInPlane, writeGitBlobToPlane, writeGitCommitToPlane, writeGitTagToPlane, writeGitTreeToPlane, } from "./github-git-plane.js";
|
|
45
|
+
// Deterministic 40-hex merge-commit sha derived from repo+number — NEVER random
|
|
46
|
+
// (repo forbids Math.random/Date.now in non-runtime code). Stable per repo+number,
|
|
47
|
+
// and a PR can only merge once (a second attempt 405s), so this is unique per merge.
|
|
48
|
+
// ── Landing a pull request: the gates, then the merge ───────────────────────────────────────
|
|
49
|
+
// The base branch's protection names required status checks; each must be a successful check run or
|
|
50
|
+
// commit status on the pull request's head sha (real GitHub: 405 "Required status check … is expected.").
|
|
51
|
+
function requiredChecksMissing(st, repo, baseRef, headSha) {
|
|
52
|
+
const branch = st.branches.find((b) => b.repository === repo && b.name === baseRef);
|
|
53
|
+
const contexts = branch?.protection_config?.required_status_checks?.contexts ?? [];
|
|
54
|
+
return contexts.filter((ctx) => {
|
|
55
|
+
const check = st.checkRuns.filter((c) => c.repository === repo && c.head_sha === headSha && c.name === ctx).sort((a, b) => b.id - a.id)[0];
|
|
56
|
+
const status = st.statuses.filter((c) => c.repository === repo && c.sha === headSha && c.context === ctx).sort((a, b) => b.id - a.id)[0];
|
|
57
|
+
return (!check && !status) || (check !== undefined && check.conclusion !== 'success') || (status !== undefined && status.state !== 'success');
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
// A protected branch's required approving reviews: the latest review of each reviewer other than the author who has
|
|
61
|
+
// write access, APPROVED, at least the count the protection asks for. People with admin access to the repository are
|
|
62
|
+
// not held to it unless the protection includes administrators (enforce_admins)
|
|
63
|
+
// (docs.github.com/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-pull-request-reviews-before-merging).
|
|
64
|
+
function requiredReviewsMissing(st, repo, pr, merger) {
|
|
65
|
+
const protection = st.branches.find((b) => b.repository === repo && b.name === (pr.base_ref ?? 'main'))?.protection_config;
|
|
66
|
+
const reviews = protection?.required_pull_request_reviews;
|
|
67
|
+
if (!reviews || typeof reviews !== 'object')
|
|
68
|
+
return undefined;
|
|
69
|
+
const need = Number(reviews.required_approving_review_count ?? 1);
|
|
70
|
+
const owner = repo.split('/')[0];
|
|
71
|
+
const admin = (login) => !!login && (login === owner || st.orgMembers.some((m) => m.org === owner && m.login === login && m.role === 'admin' && m.state === 'active') || st.collaborators.some((c) => c.repository === repo && c.login === login && c.permission === 'admin'));
|
|
72
|
+
if (admin(merger ?? GITHUB_WORLD_USER) && protection.enforce_admins !== true)
|
|
73
|
+
return undefined;
|
|
74
|
+
const writer = (login) => admin(login) || st.collaborators.some((c) => c.repository === repo && c.login === login && ['push', 'write', 'maintain'].includes(c.permission));
|
|
75
|
+
const latest = new Map();
|
|
76
|
+
for (const r of st.reviews.filter((x) => x.repository === repo && x.number === pr.number && x.state !== 'COMMENTED' && x.state !== 'PENDING').sort((a, b) => (a.submitted_at ?? '').localeCompare(b.submitted_at ?? '') || a.id - b.id))
|
|
77
|
+
latest.set(r.user_login ?? '', r);
|
|
78
|
+
const have = [...latest.values()].filter((r) => r.state === 'APPROVED' && r.user_login && r.user_login !== pr.user_login && writer(r.user_login)).length;
|
|
79
|
+
return have >= need ? undefined : `At least ${need} approving review${need === 1 ? ' is' : 's are'} required by reviewers with write access.`;
|
|
80
|
+
}
|
|
81
|
+
// Merge = the gates, then a REAL merge commit on the git plane when the repository has one (main moves
|
|
82
|
+
// on the git wire, as it does on GitHub — a REST merge that left the clone behind was a fake success),
|
|
83
|
+
// then the pull request folded closed+merged. Without a plane the merge sha is the deterministic one.
|
|
84
|
+
export async function mergePullRequest(repo, pr, method, occurredAt, root, merger, write) {
|
|
85
|
+
if (pr.draft)
|
|
86
|
+
return { ok: false, status: 405, message: 'Draft pull requests cannot be merged' };
|
|
87
|
+
const missing = requiredChecksMissing(githubState(root), repo, pr.base_ref ?? 'main', pr.head_sha ?? '');
|
|
88
|
+
if (missing.length)
|
|
89
|
+
return { ok: false, status: 405, message: `Required status check "${missing[0]}" is expected.` };
|
|
90
|
+
const reviewsMissing = requiredReviewsMissing(githubState(root), repo, pr, merger);
|
|
91
|
+
if (reviewsMissing)
|
|
92
|
+
return { ok: false, status: 405, message: reviewsMissing };
|
|
93
|
+
const rules = await evaluateMergeRules(repo, pr, merger, occurredAt, root, write);
|
|
94
|
+
if (rules && rules.result === 'fail')
|
|
95
|
+
return { ok: false, status: 405, message: `Repository rule violations found\n\n${rules.message}` };
|
|
96
|
+
let sha = mergeCommitSha(repo, pr.number);
|
|
97
|
+
if (pr.head_sha) {
|
|
98
|
+
const landed = await mergeIntoBranchInPlane(repo, pr.base_ref ?? 'main', pr.head_sha, `Merge pull request #${pr.number} from ${pr.head_ref ? `${(pr.head_repo ?? repo).split('/')[0]}/${pr.head_ref}` : 'head'}\n\n${pr.title ?? ''}`, method, occurredAt, root, merger ? { name: merger, email: `${twinUserId(merger)}+${merger}@users.noreply.github.com` } : undefined);
|
|
99
|
+
if (landed && 'conflict' in landed)
|
|
100
|
+
return { ok: false, status: 405, message: 'Pull Request is not mergeable' };
|
|
101
|
+
if (landed) {
|
|
102
|
+
sha = landed.sha;
|
|
103
|
+
// the base branch moved on the plane: its ref and branch records follow, as a push's do (github-git-http.ts)
|
|
104
|
+
const base = pr.base_ref ?? 'main';
|
|
105
|
+
await write({ operation: 'git_ref.update', subjectType: 'git_ref', subjectId: `gitref:${repo}#refs/heads/${base}`, fields: { repository: repo, ref: `refs/heads/${base}`, object_sha: sha, object_type: 'commit' } });
|
|
106
|
+
await write({ operation: 'branch.create', subjectType: 'branch', subjectId: `branch:${repo}#${base}`, fields: { repository: repo, name: base, commit_sha: sha } });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const fields = { number: pr.number, repository: repo, state: 'closed', merged: true, merged_at: occurredAt, merge_commit_sha: sha, merge_method: method, ...(merger ? { merged_by_login: merger } : {}) };
|
|
110
|
+
await write({ operation: 'pull_request.merge', subjectType: 'pull_request', subjectId: pr.id, fields });
|
|
111
|
+
if (rules)
|
|
112
|
+
await recordRuleSuite(repo, { ...rules.suite, after_sha: sha }, occurredAt, root, write);
|
|
113
|
+
return { ok: true, sha };
|
|
114
|
+
}
|
|
115
|
+
// The active branch rulesets whose ref_name condition covers `branch` (~ALL, ~DEFAULT_BRANCH or refs/heads/<branch>,
|
|
116
|
+
// minus its excludes). A ruleset with no ref_name condition targets no branch.
|
|
117
|
+
export function rulesetsFor(st, repo, branch, enforcement = 'active') {
|
|
118
|
+
const isDefault = (st.repos.find((r) => r.full_name === repo)?.default_branch ?? 'main') === branch;
|
|
119
|
+
const hit = (pats) => (Array.isArray(pats) ? pats.map(String) : []).some((p) => p === '~ALL' || (p === '~DEFAULT_BRANCH' && isDefault) || p === `refs/heads/${branch}`);
|
|
120
|
+
return st.rulesets.filter((r) => rulesetTargets(r, repo, enforcement, hit));
|
|
121
|
+
}
|
|
122
|
+
/** Whether a ruleset of this repository and enforcement names the branch in its ref_name condition. */
|
|
123
|
+
function rulesetTargets(r, repo, enforcement, hit) {
|
|
124
|
+
const cond = (r.conditions?.ref_name ?? null);
|
|
125
|
+
return r.repository === repo && r.target === 'branch' && r.enforcement === enforcement && !!cond && hit(cond.include) && !hit(cond.exclude);
|
|
126
|
+
}
|
|
127
|
+
// A merge into a ruled branch is evaluated as GitHub evaluates it: each pull_request rule needs its count of approving
|
|
128
|
+
// reviews from someone other than the author (on the head commit when stale reviews are dismissed on push). A violation
|
|
129
|
+
// fails unless the caller is one of the ruleset's bypass actors (an organization administrator when OrganizationAdmin
|
|
130
|
+
// or the admin repository role is listed), in which case the merge goes through as a bypass. Either way the evaluation
|
|
131
|
+
// is a rule suite.
|
|
132
|
+
async function evaluateMergeRules(repo, pr, merger, occurredAt, root, write) {
|
|
133
|
+
const st = githubState(root);
|
|
134
|
+
const branch = pr.base_ref ?? 'main';
|
|
135
|
+
const sets = rulesetsFor(st, repo, branch);
|
|
136
|
+
return sets.length ? evaluateRulesets(st, sets, repo, pr, merger, occurredAt, root, write) : null;
|
|
137
|
+
}
|
|
138
|
+
/** The pull_request rules of the rulesets on a merge's base branch, evaluated into a rule suite. */
|
|
139
|
+
async function evaluateRulesets(st, sets, repo, pr, merger, occurredAt, root, write) {
|
|
140
|
+
const branch = pr.base_ref ?? 'main';
|
|
141
|
+
const latest = new Map();
|
|
142
|
+
for (const r of st.reviews.filter((x) => x.repository === repo && x.number === pr.number && x.state !== 'COMMENTED' && x.state !== 'PENDING').sort((a, b) => (a.submitted_at ?? '').localeCompare(b.submitted_at ?? '') || a.id - b.id))
|
|
143
|
+
latest.set(r.user_login ?? '', r);
|
|
144
|
+
const isAdmin = !!merger && st.orgMembers.some((m) => m.org === repo.split('/')[0] && m.login === merger && m.role === 'admin' && m.state !== 'pending');
|
|
145
|
+
const evaluations = [];
|
|
146
|
+
let failed = false, bypassed = false, message = '';
|
|
147
|
+
for (const set of sets) {
|
|
148
|
+
const canBypass = isAdmin && (set.bypass_actors ?? []).some((b) => b.actor_type === 'OrganizationAdmin' || (b.actor_type === 'RepositoryRole' && Number(b.actor_id) === 5));
|
|
149
|
+
for (const rule of set.rules.filter((x) => x.type === 'pull_request')) {
|
|
150
|
+
const params = (rule.parameters ?? {});
|
|
151
|
+
const need = Number(params.required_approving_review_count ?? 0);
|
|
152
|
+
const have = [...latest.values()].filter((r) => r.state === 'APPROVED' && r.user_login && r.user_login !== pr.user_login && (!params.dismiss_stale_reviews_on_push || !pr.head_sha || r.commit_id === pr.head_sha)).length;
|
|
153
|
+
const ok = have >= need;
|
|
154
|
+
const details = ok ? null : `At least ${need} approving review${need === 1 ? ' is' : 's are'} required by reviewers with write access.`;
|
|
155
|
+
evaluations.push({ ruleset_id: set.id, ruleset_name: set.name, rule_type: 'pull_request', enforcement: 'active', result: ok ? 'pass' : 'fail', details });
|
|
156
|
+
if (!ok) {
|
|
157
|
+
if (canBypass)
|
|
158
|
+
bypassed = true;
|
|
159
|
+
else {
|
|
160
|
+
failed = true;
|
|
161
|
+
message = details;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const result = failed ? 'fail' : bypassed ? 'bypass' : 'pass';
|
|
167
|
+
const before = (await resolvePlaneRef(repo, branch, branch, root)) ?? st.branches.find((b) => b.repository === repo && b.name === branch)?.commit_sha ?? '';
|
|
168
|
+
const suite = { repository: repo, ref: `refs/heads/${branch}`, actor_login: merger ?? '', before_sha: before, after_sha: '', pushed_at: occurredAt, result, rules: evaluations };
|
|
169
|
+
if (failed)
|
|
170
|
+
await recordRuleSuite(repo, suite, occurredAt, root, write);
|
|
171
|
+
return { result, message, suite };
|
|
172
|
+
}
|
|
173
|
+
export function kernelAtomic(occurredAt, root) {
|
|
174
|
+
return async (decide) => (await applyTwinWriteAtomic(SERVICE, () => {
|
|
175
|
+
const d = decide();
|
|
176
|
+
return d.write ? { kind: 'write', value: d.value, write: { ...d.write, occurredAt, actor: { kind: 'agent' } } } : { kind: 'skip', value: d.value };
|
|
177
|
+
}, root)).value;
|
|
178
|
+
}
|
|
179
|
+
// A ruleset write adds a version holding the ruleset as it now stands and who wrote it.
|
|
180
|
+
export async function recordRulesetVersion(repo, id, login, occurredAt, root, write) {
|
|
181
|
+
const r = githubState(root).rulesets.find((x) => x.repository === repo && x.id === id);
|
|
182
|
+
if (!r)
|
|
183
|
+
return;
|
|
184
|
+
const { versions, ...state } = r;
|
|
185
|
+
const next = [...(versions ?? []), { version_id: (versions?.length ?? 0) + 1, actor_login: login, updated_at: occurredAt, state: toGithubRuleset(state) }];
|
|
186
|
+
await write({ operation: 'ruleset.version', subjectType: 'ruleset', subjectId: `ruleset:${id}`, fields: { repository: repo, versions: next } });
|
|
187
|
+
}
|
|
188
|
+
export async function recordRuleSuite(repo, suite, occurredAt, root, write) {
|
|
189
|
+
const id = nextSeq('rule_suite', root);
|
|
190
|
+
await write({ operation: 'rule_suite.evaluate', subjectType: 'rule_suite', subjectId: `rulesuite:${id}`, fields: { ...suite, repository: repo } });
|
|
191
|
+
}
|
|
192
|
+
function mergeCommitSha(repo, number) {
|
|
193
|
+
return createHash('sha256').update(`${repo}#${number}`).digest('hex').slice(0, 40);
|
|
194
|
+
}
|
|
195
|
+
/** The registered GitHub Apps at `root`. */
|
|
196
|
+
export function githubApps(root) {
|
|
197
|
+
return twinResources(SERVICE, root).filter((r) => r.type === 'app_registration' && r.deleted !== true).map((r) => ownFields(r)).sort((a, b) => a.id - b.id);
|
|
198
|
+
}
|
|
199
|
+
/** GitHub's App object (GET /apps/{app_slug}). */
|
|
200
|
+
export function toGithubApp(a, installations, st) {
|
|
201
|
+
return {
|
|
202
|
+
// an app an organization registered is the organization's
|
|
203
|
+
id: a.id, slug: a.slug, node_id: nodeId('App', a.id), client_id: a.client_id, owner: { login: a.owner, type: st?.orgs.some((o) => o.login === a.owner) ? 'Organization' : 'User' }, name: a.name,
|
|
204
|
+
description: a.description ?? null, external_url: a.url ?? a.setup_url ?? `https://github.com/apps/${a.slug}`, html_url: `https://github.com/apps/${a.slug}`,
|
|
205
|
+
created_at: a.created_at ?? null, updated_at: a.created_at ?? null, permissions: a.permissions, events: a.events, installations_count: installations,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
const countBy = (keys) => keys.reduce((m, k) => m.set(k, (m.get(k) ?? 0) + 1), new Map());
|
|
209
|
+
// Linguist's programming languages by extension; prose (Markdown) and data (JSON, YAML) are not a repository's language
|
|
210
|
+
// (docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-repository-languages)
|
|
211
|
+
const LANGUAGES = {
|
|
212
|
+
js: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript', jsx: 'JavaScript', ts: 'TypeScript', tsx: 'TypeScript', py: 'Python', rb: 'Ruby', go: 'Go', rs: 'Rust',
|
|
213
|
+
java: 'Java', kt: 'Kotlin', swift: 'Swift', c: 'C', h: 'C', cpp: 'C++', cc: 'C++', cs: 'C#', php: 'PHP', sh: 'Shell', html: 'HTML', css: 'CSS', scss: 'SCSS', vue: 'Vue', dart: 'Dart', ex: 'Elixir', scala: 'Scala',
|
|
214
|
+
};
|
|
215
|
+
/** The language most of a repository's code bytes are in, or null when it holds no code. */
|
|
216
|
+
function languageOf(files) {
|
|
217
|
+
const byLang = new Map();
|
|
218
|
+
for (const f of files) {
|
|
219
|
+
const lang = LANGUAGES[(f.path.split('.').pop() ?? '').toLowerCase()];
|
|
220
|
+
if (lang)
|
|
221
|
+
byLang.set(lang, (byLang.get(lang) ?? 0) + f.bytes);
|
|
222
|
+
}
|
|
223
|
+
return [...byLang].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? null;
|
|
224
|
+
}
|
|
225
|
+
// GitHub's licence records (GET /licenses/{key}): key, name, SPDX id and node id
|
|
226
|
+
const LICENSES = [
|
|
227
|
+
[/MIT License/i, 'mit', 'MIT License', 'MIT', 'MDc6TGljZW5zZTEz'], [/Apache License[,\s]+Version 2\.0/i, 'apache-2.0', 'Apache License 2.0', 'Apache-2.0', 'MDc6TGljZW5zZTI='],
|
|
228
|
+
[/GNU GENERAL PUBLIC LICENSE[\s\S]*Version 3/i, 'gpl-3.0', 'GNU General Public License v3.0', 'GPL-3.0', 'MDc6TGljZW5zZTk='], [/BSD 3-Clause/i, 'bsd-3-clause', 'BSD 3-Clause "New" or "Revised" License', 'BSD-3-Clause', 'MDc6TGljZW5zZTU='],
|
|
229
|
+
];
|
|
230
|
+
/** The licence GitHub detects in a repository's licence file, `other` when it names none it knows, or null when there is no file. */
|
|
231
|
+
function licenseOf(file) {
|
|
232
|
+
if (!file)
|
|
233
|
+
return null;
|
|
234
|
+
const text = Buffer.from(file.content_b64 ?? '', 'base64').toString('utf8');
|
|
235
|
+
const [, key, name, spdx, nodeId] = LICENSES.find(([re]) => re.test(text)) ?? [null, 'other', 'Other', 'NOASSERTION', 'MDc6TGljZW5zZTA='];
|
|
236
|
+
return { key, name, spdx_id: spdx, url: key === 'other' ? null : `https://api.github.com/licenses/${key}`, node_id: nodeId };
|
|
237
|
+
}
|
|
238
|
+
const edgesOf = (m) => [...m.entries()].filter(([, on]) => on).map(([k]) => { const i = k.indexOf('|'); return { from: k.slice(0, i), to: k.slice(i + 1) }; });
|
|
239
|
+
/** The World's user's side of the edges, as the set its own endpoints read. */
|
|
240
|
+
const pairsOf = (m, login) => new Set(edgesOf(m).filter((e) => e.from === login).map((e) => e.to));
|
|
241
|
+
// The default Discussion categories every repo starts with (mirrors GitHub's defaults).
|
|
242
|
+
// Only Q&A is answerable. Ids are deterministic (hash of repo+slug) — never random.
|
|
243
|
+
const DEFAULT_DISCUSSION_CATEGORIES = [
|
|
244
|
+
{ name: 'Announcements', slug: 'announcements', emoji: '📣', description: 'Updates from maintainers', is_answerable: false },
|
|
245
|
+
{ name: 'General', slug: 'general', emoji: '💬', description: 'Chat about anything and everything here', is_answerable: false },
|
|
246
|
+
{ name: 'Ideas', slug: 'ideas', emoji: '💡', description: 'Share ideas for new features', is_answerable: false },
|
|
247
|
+
{ name: 'Q&A', slug: 'q-a', emoji: '🙏', description: 'Ask the community for help', is_answerable: true },
|
|
248
|
+
{ name: 'Show and tell', slug: 'show-and-tell', emoji: '🙌', description: 'Show off something you have made', is_answerable: false },
|
|
249
|
+
];
|
|
250
|
+
function discussionCategoryId(repository, slug) {
|
|
251
|
+
return 0x60000000 + (parseInt(createHash('sha256').update(`${repository}:catg:${slug}`).digest('hex').slice(0, 8), 16) % 0x0fffffff);
|
|
252
|
+
}
|
|
253
|
+
/** The category list for a repo: GitHub's defaults (a custom category is made on github.com's discussion settings,
|
|
254
|
+
* which the twin does not have). */
|
|
255
|
+
export function discussionCategoriesFor(repository) {
|
|
256
|
+
return DEFAULT_DISCUSSION_CATEGORIES.map((c) => ({ id: discussionCategoryId(repository, c.slug), repository, name: c.name, slug: c.slug, emoji: c.emoji, description: c.description, is_answerable: c.is_answerable }))
|
|
257
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
258
|
+
}
|
|
259
|
+
// Deterministic positive integer id for an OBSERVED review/comment whose subject id is
|
|
260
|
+
// not a numeric local sequence (e.g. "rev1", "c1"). Derived from a stable hash — never
|
|
261
|
+
// random — and offset above the local sequence space to avoid colliding with it.
|
|
262
|
+
export function observedId(subjectId) {
|
|
263
|
+
return 0x40000000 + (parseInt(createHash('sha256').update(subjectId).digest('hex').slice(0, 8), 16) % 0x3fffffff);
|
|
264
|
+
}
|
|
265
|
+
// Deterministic check_suite id for a (repo, head_sha) — all check runs for a sha belong to
|
|
266
|
+
// one suite (real GitHub groups runs into a suite per commit). Never random.
|
|
267
|
+
function checkSuiteId(repository, headSha, app) {
|
|
268
|
+
return 0x70000000 + (parseInt(createHash('sha256').update(`${repository}:suite:${headSha}${app ? `:${app}` : ''}`).digest('hex').slice(0, 8), 16) % 0x0fffffff);
|
|
269
|
+
}
|
|
270
|
+
// GitHub's projection is folded once per World state, not once per read: a request reads it many times
|
|
271
|
+
// (its caller's login, every handler's lookups), and a long-lived World made each fold cost more. The
|
|
272
|
+
// projection is shared between readers, so its rows are frozen: a reader that wants a changed row copies it.
|
|
273
|
+
const stateMemos = new WeakMap();
|
|
274
|
+
/** GitHub's projection of the tree at `root`. */
|
|
275
|
+
export function githubState(root) {
|
|
276
|
+
const store = getActiveWorldStore();
|
|
277
|
+
const memos = stateMemos.get(store) ?? stateMemos.set(store, new Map()).get(store);
|
|
278
|
+
const stamp = treeStamp(SERVICE, root);
|
|
279
|
+
const held = memos.get(root ?? '');
|
|
280
|
+
if (held && held.stamp === stamp)
|
|
281
|
+
return held.state;
|
|
282
|
+
const state = foldGithubState(root);
|
|
283
|
+
for (const value of Object.values(state))
|
|
284
|
+
if (Array.isArray(value))
|
|
285
|
+
for (const row of value)
|
|
286
|
+
Object.freeze(row);
|
|
287
|
+
Object.freeze(state.userProfile);
|
|
288
|
+
memos.set(root ?? '', { stamp, state });
|
|
289
|
+
return state;
|
|
290
|
+
}
|
|
291
|
+
/** Where each subject a repository's transfer moved lives now (semantics/repos.ts, moveRepository): a subject whose
|
|
292
|
+
* id names the repository is written anew under its new name, and the old one keeps a pointer (`moved_to`), so
|
|
293
|
+
* what is read by subject (an issue's timeline, from its writes) follows it. */
|
|
294
|
+
function movedSubjects(root) {
|
|
295
|
+
const to = new Map();
|
|
296
|
+
for (const r of twinResources(SERVICE, root))
|
|
297
|
+
if (typeof r.moved_to === 'string')
|
|
298
|
+
to.set(`${r.type}:${r.id}`, r.moved_to);
|
|
299
|
+
return (type, id) => { let at = id; for (let next = to.get(`${type}:${at}`); next !== undefined; next = to.get(`${type}:${at}`))
|
|
300
|
+
at = next; return at; };
|
|
301
|
+
}
|
|
302
|
+
function foldGithubState(root) {
|
|
303
|
+
const prs = new Map();
|
|
304
|
+
const issues = new Map();
|
|
305
|
+
const reviews = [];
|
|
306
|
+
const comments = [];
|
|
307
|
+
// LOCAL comment writes are keyed by their action-log subject id so an EDIT overlays the
|
|
308
|
+
// comment it names instead of appending a second copy of it (the list and the per-comment
|
|
309
|
+
// door have to be the same object), and a DELETE tombstones it.
|
|
310
|
+
const localCommentBySubject = new Map();
|
|
311
|
+
const deletedCommentSubjects = new Set();
|
|
312
|
+
const statuses = [];
|
|
313
|
+
// Check runs are last-write-wins per subject id (a PATCH updates the run's status/output),
|
|
314
|
+
// unlike commit statuses which keep history. Keyed by `check:<id>`.
|
|
315
|
+
const checkRunsById = new Map();
|
|
316
|
+
const milestones = new Map();
|
|
317
|
+
// Actions: workflows keyed by their action-log subject id; runs + jobs accumulate, and a
|
|
318
|
+
// later write to the SAME run/job subject id overlays the earlier state (rerun/cancel
|
|
319
|
+
// transition an existing run rather than appending a new one). Last write per id wins.
|
|
320
|
+
const workflows = new Map();
|
|
321
|
+
const runsById = new Map();
|
|
322
|
+
const jobsById = new Map();
|
|
323
|
+
// Releases + assets keyed by their action-log subject id (last write per id wins, so a
|
|
324
|
+
// PATCH overlays the created release / a DELETE tombstones it). LOCAL constructs only.
|
|
325
|
+
const releasesById = new Map();
|
|
326
|
+
const assetsById = new Map();
|
|
327
|
+
const deletedReleases = new Set();
|
|
328
|
+
const deletedAssets = new Set();
|
|
329
|
+
// Discussions + their comments + custom categories — all LOCAL constructs keyed by their
|
|
330
|
+
// action-log subject id (last write per id wins; a DELETE tombstones).
|
|
331
|
+
const discussionsById = new Map();
|
|
332
|
+
const discussionCommentsById = new Map();
|
|
333
|
+
const deletedDiscussions = new Set();
|
|
334
|
+
const deletedDiscussionComments = new Set();
|
|
335
|
+
// Reactions, labels, review threads, repos, branches, collaborators, webhooks, contents,
|
|
336
|
+
// gists, notifications — all LOCAL constructs keyed by their action-log subject id (last
|
|
337
|
+
// write per id wins; a DELETE/remove tombstones). Vendor-faithful CRUD over the kernel.
|
|
338
|
+
const reactionsById = new Map();
|
|
339
|
+
const deletedReactions = new Set();
|
|
340
|
+
const labelsById = new Map();
|
|
341
|
+
const deletedLabels = new Set();
|
|
342
|
+
const reposById = new Map();
|
|
343
|
+
const deletedRepos = new Set();
|
|
344
|
+
const pushedAt = new Map();
|
|
345
|
+
const branchesById = new Map();
|
|
346
|
+
const deletedBranches = new Set();
|
|
347
|
+
// Protection survives a branch tombstone (GitHub's pattern-rule semantics) — keyed by the
|
|
348
|
+
// branch subject id, consumed (and cleared) by the next branch.create for that id.
|
|
349
|
+
const branchProtectionMemory = new Map();
|
|
350
|
+
const collaboratorsById = new Map();
|
|
351
|
+
const deletedCollaborators = new Set();
|
|
352
|
+
const webhooksById = new Map();
|
|
353
|
+
const deletedWebhooks = new Set();
|
|
354
|
+
const contentsById = new Map();
|
|
355
|
+
const deletedContents = new Set();
|
|
356
|
+
const gistsById = new Map();
|
|
357
|
+
const deletedGists = new Set();
|
|
358
|
+
const notificationsById = new Map();
|
|
359
|
+
// Git Data API objects — refs/commits/trees/blobs/annotated tags. LOCAL constructs keyed
|
|
360
|
+
// by their action-log subject id (last write per id wins; a DELETE tombstones refs).
|
|
361
|
+
const gitRefsById = new Map();
|
|
362
|
+
const deletedGitRefs = new Set();
|
|
363
|
+
const gitCommitsById = new Map();
|
|
364
|
+
const gitTreesById = new Map();
|
|
365
|
+
const gitBlobsById = new Map();
|
|
366
|
+
const gitTagObjectsById = new Map();
|
|
367
|
+
// Deployments + their statuses + environments. LOCAL constructs keyed by subject id.
|
|
368
|
+
const deploymentsById = new Map();
|
|
369
|
+
const deploymentStatusesById = new Map();
|
|
370
|
+
const environmentsById = new Map();
|
|
371
|
+
const deletedEnvironments = new Set();
|
|
372
|
+
// Actions secrets/variables/caches/artifacts. LOCAL constructs keyed by subject id.
|
|
373
|
+
const actionsSecretsById = new Map();
|
|
374
|
+
const deletedActionsSecrets = new Set();
|
|
375
|
+
const actionsVariablesById = new Map();
|
|
376
|
+
const deletedActionsVariables = new Set();
|
|
377
|
+
const actionsCachesById = new Map();
|
|
378
|
+
const deletedActionsCaches = new Set();
|
|
379
|
+
const actionsArtifactsById = new Map();
|
|
380
|
+
const deletedActionsArtifacts = new Set();
|
|
381
|
+
// Activity: the authenticated user's star + watch (repo subscription) on each repo. A PUT
|
|
382
|
+
// sets the flag, a DELETE clears it (last write per subject id wins) — booleans, not objects.
|
|
383
|
+
// who stars and watches what, and who follows whom: `<login>|<repo>` and `<follower>|<followee>`
|
|
384
|
+
const starsByRepo = new Map();
|
|
385
|
+
const watchingByRepo = new Map();
|
|
386
|
+
// Following: the authenticated user's follow on each login (PUT sets, DELETE clears).
|
|
387
|
+
const followingByLogin = new Map();
|
|
388
|
+
// Orgs / Teams / commit-comments / issue-types — LOCAL constructs keyed by subject id
|
|
389
|
+
// (last write per id wins; a DELETE/remove tombstones). Vendor-faithful CRUD over the kernel.
|
|
390
|
+
const orgsById = new Map();
|
|
391
|
+
const orgMembersById = new Map();
|
|
392
|
+
const deletedOrgMembers = new Set();
|
|
393
|
+
const outsideCollabsById = new Map();
|
|
394
|
+
const deletedOutsideCollabs = new Set();
|
|
395
|
+
const orgWebhooksById = new Map();
|
|
396
|
+
const deletedOrgWebhooks = new Set();
|
|
397
|
+
const teamsById = new Map();
|
|
398
|
+
const deletedTeams = new Set();
|
|
399
|
+
const teamMembersById = new Map();
|
|
400
|
+
const deletedTeamMembers = new Set();
|
|
401
|
+
const teamReposById = new Map();
|
|
402
|
+
const deletedTeamRepos = new Set();
|
|
403
|
+
const commitCommentsById = new Map();
|
|
404
|
+
const deletedCommitComments = new Set();
|
|
405
|
+
const issueTypesById = new Map();
|
|
406
|
+
const deletedIssueTypes = new Set();
|
|
407
|
+
// Repos niche: deploy keys / autolinks / rulesets — LOCAL constructs keyed by subject id.
|
|
408
|
+
const deployKeysById = new Map();
|
|
409
|
+
const deletedDeployKeys = new Set();
|
|
410
|
+
const autolinksById = new Map();
|
|
411
|
+
const deletedAutolinks = new Set();
|
|
412
|
+
const rulesetsById = new Map();
|
|
413
|
+
const deletedRulesets = new Set();
|
|
414
|
+
const ruleSuitesById = new Map();
|
|
415
|
+
// User niche: emails (email-keyed) / SSH keys / GPG keys / blocked logins / profile overlay.
|
|
416
|
+
const userEmailsByAddr = new Map();
|
|
417
|
+
const deletedUserEmails = new Set();
|
|
418
|
+
const userSshKeysById = new Map();
|
|
419
|
+
const deletedUserSshKeys = new Set();
|
|
420
|
+
const userGpgKeysById = new Map();
|
|
421
|
+
const deletedUserGpgKeys = new Set();
|
|
422
|
+
const blockedByLogin = new Map();
|
|
423
|
+
const userProfile = {};
|
|
424
|
+
// Projects v2: projects / fields / items / views — LOCAL constructs keyed by subject id.
|
|
425
|
+
const projectsById = new Map();
|
|
426
|
+
const deletedProjects = new Set();
|
|
427
|
+
const projectParts = { project_field: new Map(), project_item: new Map(), project_view: new Map() };
|
|
428
|
+
// Security alerts (code scanning / dependabot / secret scanning) + scanning analyses —
|
|
429
|
+
// LOCAL constructs keyed by subject id (last write per id wins; the alert's state machine
|
|
430
|
+
// is just a field overlay: open→dismissed/fixed/resolved). A DELETE tombstones analyses.
|
|
431
|
+
const codeScanningAlertsById = new Map();
|
|
432
|
+
const codeScanningAnalysesById = new Map();
|
|
433
|
+
const deletedCodeScanningAnalyses = new Set();
|
|
434
|
+
const dependabotAlertsById = new Map();
|
|
435
|
+
const secretScanningAlertsById = new Map();
|
|
436
|
+
// Apps / OAuth — LOCAL constructs keyed by subject id. Installations seed by simulator;
|
|
437
|
+
// OAuth grants are minted by the authorize flow (token→scopes binding).
|
|
438
|
+
const appInstallationsById = new Map();
|
|
439
|
+
const deletedAppInstallations = new Set();
|
|
440
|
+
const oauthGrantsById = new Map();
|
|
441
|
+
const deletedOAuthGrants = new Set();
|
|
442
|
+
// Packages + versions / Pages site+builds / Codespaces — LOCAL constructs keyed by subject id.
|
|
443
|
+
const packagesById = new Map();
|
|
444
|
+
const deletedPackages = new Set();
|
|
445
|
+
const packageVersionsById = new Map();
|
|
446
|
+
const deletedPackageVersions = new Set();
|
|
447
|
+
const pagesSitesById = new Map();
|
|
448
|
+
const deletedPagesSites = new Set();
|
|
449
|
+
const pagesBuildsById = new Map();
|
|
450
|
+
const codespacesById = new Map();
|
|
451
|
+
const deletedCodespaces = new Set();
|
|
452
|
+
// Review overlays — a dismiss/body-edit on an already-submitted review, keyed by review id.
|
|
453
|
+
const reviewOverlaysById = new Map();
|
|
454
|
+
const ensure = (id, number, repository) => {
|
|
455
|
+
let pr = prs.get(id);
|
|
456
|
+
if (!pr) {
|
|
457
|
+
pr = { id, number, repository, review_count: 0, comment_count: 0 };
|
|
458
|
+
prs.set(id, pr);
|
|
459
|
+
}
|
|
460
|
+
return pr;
|
|
461
|
+
};
|
|
462
|
+
// a transferred repository's subjects were written anew under its new name: their history follows them
|
|
463
|
+
const moved = movedSubjects(root);
|
|
464
|
+
const transitionsOf = (type) => {
|
|
465
|
+
const current = new Map();
|
|
466
|
+
const out = new Map();
|
|
467
|
+
for (const e of subjectHistory(SERVICE, { type }, root)) {
|
|
468
|
+
const sid = moved(type, e.subject.id);
|
|
469
|
+
const fields = (e.fields ?? {});
|
|
470
|
+
const changed = e.fields === undefined ? undefined : fields.changed;
|
|
471
|
+
const state = fields.state ?? changed?.state?.after;
|
|
472
|
+
if (state === undefined || state === null)
|
|
473
|
+
continue;
|
|
474
|
+
const next = String(state);
|
|
475
|
+
const prev = current.get(sid);
|
|
476
|
+
current.set(sid, next);
|
|
477
|
+
if (prev === undefined || prev === next)
|
|
478
|
+
continue;
|
|
479
|
+
// the instant: the row's own updated_at when it carries one; a pull-request write's is its occurredAt
|
|
480
|
+
const at = fields.updated_at ?? (type === 'pull_request' ? e.occurredAt : undefined);
|
|
481
|
+
(out.get(sid) ?? out.set(sid, []).get(sid)).push({ state: next, state_reason: fields.state_reason === undefined ? null : fields.state_reason, ...(typeof at === 'string' ? { at } : {}) });
|
|
482
|
+
}
|
|
483
|
+
return out;
|
|
484
|
+
};
|
|
485
|
+
// What was done to each issue or pull request, from the order of its writes: each write compared with the
|
|
486
|
+
// fields before it (labels, assignees, milestone, lock, state, merge), stamped with its instant and the login
|
|
487
|
+
// its actor carries (the core records the caller's identity on every write it makes).
|
|
488
|
+
const activityOf = (type) => {
|
|
489
|
+
const seen = new Map();
|
|
490
|
+
const out = new Map();
|
|
491
|
+
const names = (v) => (Array.isArray(v) ? v.map((x) => (typeof x === 'string' ? x : String(x?.name ?? x?.login ?? ''))).filter(Boolean) : []);
|
|
492
|
+
for (const e of subjectHistory(SERVICE, { type }, root)) {
|
|
493
|
+
const sid = moved(type, e.subject.id);
|
|
494
|
+
if (e.fields === undefined)
|
|
495
|
+
continue;
|
|
496
|
+
const f = e.fields;
|
|
497
|
+
const before = seen.get(sid) ?? {};
|
|
498
|
+
const at = typeof e.occurredAt === 'string' ? e.occurredAt : undefined;
|
|
499
|
+
const by = typeof e.actor?.id === 'string' ? e.actor.id : undefined;
|
|
500
|
+
const push = (a) => (out.get(sid) ?? out.set(sid, []).get(sid)).push({ ...a, ...(at ? { at } : {}), ...(by ? { by } : {}) });
|
|
501
|
+
const first = !seen.has(sid);
|
|
502
|
+
if ('labels' in f) {
|
|
503
|
+
const was = new Set(names(before.labels));
|
|
504
|
+
const now = new Set(names(f.labels));
|
|
505
|
+
for (const l of now)
|
|
506
|
+
if (!was.has(l))
|
|
507
|
+
push({ event: 'labeled', label: l });
|
|
508
|
+
if (!first)
|
|
509
|
+
for (const l of was)
|
|
510
|
+
if (!now.has(l))
|
|
511
|
+
push({ event: 'unlabeled', label: l });
|
|
512
|
+
}
|
|
513
|
+
// who was assigned or unassigned
|
|
514
|
+
const assigneeActivity = () => {
|
|
515
|
+
const was = new Set(names(before.assignees));
|
|
516
|
+
const now = new Set(names(f.assignees));
|
|
517
|
+
for (const a of now)
|
|
518
|
+
if (!was.has(a))
|
|
519
|
+
push({ event: 'assigned', assignee: a });
|
|
520
|
+
if (!first)
|
|
521
|
+
for (const a of was)
|
|
522
|
+
if (!now.has(a))
|
|
523
|
+
push({ event: 'unassigned', assignee: a });
|
|
524
|
+
};
|
|
525
|
+
if ('assignees' in f)
|
|
526
|
+
assigneeActivity();
|
|
527
|
+
if ('milestone' in f && f.milestone !== before.milestone)
|
|
528
|
+
push(f.milestone === null || f.milestone === undefined ? { event: 'demilestoned', milestone: before.milestone } : { event: 'milestoned', milestone: f.milestone });
|
|
529
|
+
if ('locked' in f && Boolean(f.locked) !== Boolean(before.locked) && !(first && !f.locked))
|
|
530
|
+
push(f.locked ? { event: 'locked', lock_reason: f.active_lock_reason ?? null } : { event: 'unlocked' });
|
|
531
|
+
// an issue or pull request is open until a write says otherwise
|
|
532
|
+
if ('state' in f && !first && f.state !== (before.state ?? 'open'))
|
|
533
|
+
push(f.state === 'closed' ? { event: 'closed', state_reason: f.state_reason ?? null } : { event: 'reopened' });
|
|
534
|
+
if ('merged' in f && f.merged === true && before.merged !== true)
|
|
535
|
+
push({ event: 'merged', commit_id: f.merge_commit_sha ?? null });
|
|
536
|
+
seen.set(sid, { ...before, ...f });
|
|
537
|
+
}
|
|
538
|
+
return out;
|
|
539
|
+
};
|
|
540
|
+
// PROTOCOL 2: the serve path reads only the TREE — observed, landed and this branch's own
|
|
541
|
+
// entries are one projection (checkpoint plus suffix): one resource per subject, last write per
|
|
542
|
+
// field. A delete is the tombstone the write carried (`deleted: true`); open↔closed transitions
|
|
543
|
+
// are HISTORY, replayed from the subject's entries after the fold, never accumulated in it.
|
|
544
|
+
// Older local creates predate the author field. Recover only the modeled caller
|
|
545
|
+
// from an explicit local create, never from a repository owner or a missing observation.
|
|
546
|
+
const prHistory = subjectHistory(SERVICE, { type: 'pull_request' }, root);
|
|
547
|
+
const localPrWrites = new Map(prHistory.filter(e => e.actor?.kind === 'agent' && !e.event && !e.batch && e.operation !== 'repository.transfer').map(e => [moved('pull_request', e.subject.id), e.occurredAt]));
|
|
548
|
+
const localPrCreates = new Set(prHistory
|
|
549
|
+
.filter(e => e.operation === 'pull_request.create' && e.actor?.kind === 'agent' && !e.event && !e.batch)
|
|
550
|
+
.map(e => moved('pull_request', e.subject.id)));
|
|
551
|
+
for (const r of twinResources(SERVICE, root)) {
|
|
552
|
+
// what a transfer moved lives on under its new id; the old one is only the pointer there
|
|
553
|
+
if (typeof r.moved_to === 'string')
|
|
554
|
+
continue;
|
|
555
|
+
const a = { subject: { type: r.type, id: r.id }, fields: ownFields(r), occurredAt: r.updatedAt };
|
|
556
|
+
const f = a.fields;
|
|
557
|
+
// a repository was pushed to when a branch, a ref or a file in it last changed
|
|
558
|
+
if ((r.type === 'branch' || r.type === 'git_ref' || r.type === 'content_file') && typeof f.repository === 'string' && r.updatedAt && !(pushedAt.get(f.repository) >= r.updatedAt))
|
|
559
|
+
pushedAt.set(f.repository, r.updatedAt);
|
|
560
|
+
// Both observation and write paths carry these vendor fields; legacy observation aliases
|
|
561
|
+
// are normalized in the read projection, without consulting the old action/event logs.
|
|
562
|
+
for (const [from, to] of [['pr', 'number'], ['authorLogin', 'user_login'], ['createdAt', 'created_at'], ['updatedAt', 'updated_at'], ['submittedAt', 'submitted_at']]) {
|
|
563
|
+
if (f[to] === undefined && f[from] !== undefined)
|
|
564
|
+
f[to] = f[from];
|
|
565
|
+
}
|
|
566
|
+
if (f.user_type === undefined && f.authorType !== undefined)
|
|
567
|
+
f.user_type = f.authorType === 'bot' ? 'Bot' : 'User';
|
|
568
|
+
if (f.review_key !== undefined)
|
|
569
|
+
f.review_id = observedId(String(f.review_key));
|
|
570
|
+
if (typeof f.in_reply_to === 'string' && !/^\d+$/.test(f.in_reply_to))
|
|
571
|
+
f.in_reply_to = observedId(f.in_reply_to);
|
|
572
|
+
// The pulled pull-request shape names a few fields the connector's way; read both.
|
|
573
|
+
if (a.subject.type === 'pull_request') {
|
|
574
|
+
for (const [from, to] of [['changedFiles', 'changed_files'], ['commitsCount', 'commits'], ['baseRef', 'base_ref'], ['headSha', 'head_sha'], ['mergeCommit', 'merge_commit_sha']])
|
|
575
|
+
if (f[to] === undefined && f[from] !== undefined)
|
|
576
|
+
f[to] = f[from];
|
|
577
|
+
}
|
|
578
|
+
if (a.subject.type === 'pull_request') {
|
|
579
|
+
const number = Number(f.number ?? a.subject.id.split('#').pop());
|
|
580
|
+
const repository = String(f.repository ?? a.subject.id.split('#')[0]);
|
|
581
|
+
const pr = ensure(a.subject.id, number, repository);
|
|
582
|
+
pr.is_pull_request = true; // a pull_request write: this row IS a pull request
|
|
583
|
+
// A local mutation has a source instant; observation time is never vendor updated_at.
|
|
584
|
+
const localUpdatedAt = localPrWrites.get(a.subject.id);
|
|
585
|
+
const observedUpdatedAt = f.updated_at ?? f.updatedAt;
|
|
586
|
+
pr.updated_at = localUpdatedAt && (!observedUpdatedAt || Date.parse(localUpdatedAt) >= Date.parse(observedUpdatedAt)) ? localUpdatedAt : observedUpdatedAt;
|
|
587
|
+
pr.user_login = f.user_login !== undefined ? f.user_login : localPrCreates.has(a.subject.id) ? 'octocat' : undefined;
|
|
588
|
+
if (f.user_type !== undefined)
|
|
589
|
+
pr.user_type = String(f.user_type);
|
|
590
|
+
else if (f.user_login === undefined && localPrCreates.has(a.subject.id))
|
|
591
|
+
pr.user_type = 'User';
|
|
592
|
+
for (const k of ['base_ref', 'head_sha', 'head_ref', 'changed_files', 'commits', 'additions', 'deletions', 'title', 'body', 'state', 'merged', 'merged_at', 'merge_commit_sha', 'labels', 'assignees', 'requested_reviewers', 'milestone', 'draft', 'merge_method', 'mergeable_state', 'merged_by_login', 'created_at', 'locked', 'active_lock_reason', 'author_association', 'maintainer_can_modify', 'head_repo', 'born_in']) {
|
|
593
|
+
if (f[k] !== undefined)
|
|
594
|
+
pr[k] = f[k];
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
else if (a.subject.type === 'issue') {
|
|
598
|
+
const number = Number(f.number ?? a.subject.id.split('#issue:').pop());
|
|
599
|
+
const repository = String(f.repository ?? a.subject.id.split('#issue:')[0]);
|
|
600
|
+
if (!repository || !Number.isFinite(number))
|
|
601
|
+
continue;
|
|
602
|
+
let iss = issues.get(a.subject.id);
|
|
603
|
+
if (!iss) {
|
|
604
|
+
iss = { id: a.subject.id, number, repository };
|
|
605
|
+
issues.set(a.subject.id, iss);
|
|
606
|
+
}
|
|
607
|
+
for (const k of ['title', 'body', 'state', 'created_at', 'updated_at', 'labels', 'assignees', 'milestone', 'state_reason', 'locked', 'active_lock_reason', 'sub_issue_numbers', 'parent_issue_number', 'user_login', 'author_association', 'born_in']) {
|
|
608
|
+
if (f[k] !== undefined)
|
|
609
|
+
iss[k] = f[k];
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
else if (a.subject.type === 'commit_status') {
|
|
613
|
+
// Commit status: a LOCAL CI construct keyed by sha. Each write is a distinct status
|
|
614
|
+
// (GitHub keeps history per context); the local sequence id makes them distinct.
|
|
615
|
+
const m = /^status:(\d+)$/.exec(a.subject.id);
|
|
616
|
+
statuses.push({
|
|
617
|
+
id: m ? Number(m[1]) : observedId(a.subject.id),
|
|
618
|
+
repository: String(f.repository ?? ''), sha: String(f.sha ?? ''), state: String(f.state ?? 'pending'),
|
|
619
|
+
context: f.context, description: f.description, target_url: f.target_url, created_at: f.created_at, updated_at: f.updated_at,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
else if (a.subject.type === 'check_run') {
|
|
623
|
+
const m = /^check:(\d+)$/.exec(a.subject.id);
|
|
624
|
+
const prev = checkRunsById.get(a.subject.id);
|
|
625
|
+
const cr = {
|
|
626
|
+
id: m ? Number(m[1]) : observedId(a.subject.id),
|
|
627
|
+
repository: String(f.repository ?? prev?.repository ?? ''), head_sha: String(f.head_sha ?? prev?.head_sha ?? ''), name: String(f.name ?? prev?.name ?? ''),
|
|
628
|
+
status: f.status ?? prev?.status, conclusion: f.conclusion ?? prev?.conclusion, details_url: f.details_url ?? prev?.details_url, external_id: f.external_id ?? prev?.external_id,
|
|
629
|
+
started_at: f.started_at ?? prev?.started_at, completed_at: f.completed_at ?? prev?.completed_at,
|
|
630
|
+
};
|
|
631
|
+
// each app's runs on a commit share that app's suite (docs.github.com/rest/guides/using-the-rest-api-to-interact-with-checks)
|
|
632
|
+
const app = f.app_slug ?? prev?.app_slug;
|
|
633
|
+
if (typeof app === 'string')
|
|
634
|
+
cr.app_slug = app;
|
|
635
|
+
cr.check_suite_id = checkSuiteId(cr.repository, cr.head_sha, cr.app_slug);
|
|
636
|
+
cr.output_title = f.output_title !== undefined ? String(f.output_title) : prev?.output_title;
|
|
637
|
+
cr.output_summary = f.output_summary !== undefined ? String(f.output_summary) : prev?.output_summary;
|
|
638
|
+
cr.output_text = f.output_text !== undefined ? String(f.output_text) : prev?.output_text;
|
|
639
|
+
if (Array.isArray(f.annotations))
|
|
640
|
+
cr.annotations = f.annotations.map((an) => ({
|
|
641
|
+
path: String(an.path), start_line: Number(an.start_line), end_line: Number(an.end_line),
|
|
642
|
+
annotation_level: String(an.annotation_level), message: String(an.message),
|
|
643
|
+
title: an.title !== undefined ? String(an.title) : undefined,
|
|
644
|
+
raw_details: an.raw_details !== undefined ? String(an.raw_details) : undefined,
|
|
645
|
+
start_column: an.start_column !== undefined ? Number(an.start_column) : undefined,
|
|
646
|
+
end_column: an.end_column !== undefined ? Number(an.end_column) : undefined,
|
|
647
|
+
}));
|
|
648
|
+
else
|
|
649
|
+
cr.annotations = prev?.annotations;
|
|
650
|
+
checkRunsById.set(a.subject.id, cr);
|
|
651
|
+
}
|
|
652
|
+
else if (a.subject.type === 'milestone') {
|
|
653
|
+
const number = Number(f.number ?? a.subject.id.split('#milestone:').pop());
|
|
654
|
+
const repository = String(f.repository ?? a.subject.id.split('#milestone:')[0]);
|
|
655
|
+
let ms = milestones.get(a.subject.id);
|
|
656
|
+
if (!ms) {
|
|
657
|
+
ms = { id: observedId(a.subject.id), number, repository };
|
|
658
|
+
milestones.set(a.subject.id, ms);
|
|
659
|
+
}
|
|
660
|
+
for (const k of ['title', 'description', 'state', 'due_on', 'created_at', 'updated_at', 'creator_login']) {
|
|
661
|
+
if (f[k] !== undefined)
|
|
662
|
+
ms[k] = f[k];
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
else if (a.subject.type === 'workflow') {
|
|
666
|
+
// A registered workflow file (LOCAL — Actions has no observed-CI evidence). The
|
|
667
|
+
// action-log subject id is `workflow:<id>`; later writes to the same id overlay.
|
|
668
|
+
const m = /^workflow:(\d+)$/.exec(a.subject.id);
|
|
669
|
+
const id = m ? Number(m[1]) : observedId(a.subject.id);
|
|
670
|
+
const wf = {
|
|
671
|
+
id, repository: String(f.repository ?? ''), name: String(f.name ?? ''), path: String(f.path ?? ''),
|
|
672
|
+
state: f.state, created_at: f.created_at, updated_at: f.updated_at,
|
|
673
|
+
};
|
|
674
|
+
workflows.set(a.subject.id, wf);
|
|
675
|
+
}
|
|
676
|
+
else if (a.subject.type === 'workflow_run') {
|
|
677
|
+
// A workflow run (created by dispatch, transitioned by rerun/cancel). Subject id is
|
|
678
|
+
// `run:<id>`; a rerun/cancel write to the SAME id overlays status/conclusion.
|
|
679
|
+
const m = /^run:(\d+)$/.exec(a.subject.id);
|
|
680
|
+
const id = m ? Number(m[1]) : observedId(a.subject.id);
|
|
681
|
+
const run = {
|
|
682
|
+
id, repository: String(f.repository ?? ''), workflow_id: Number(f.workflow_id ?? 0),
|
|
683
|
+
name: f.name ?? undefined, run_number: Number(f.run_number ?? 0), event: String(f.event ?? 'workflow_dispatch'),
|
|
684
|
+
status: String(f.status ?? 'queued'), conclusion: f.conclusion ?? undefined, head_sha: String(f.head_sha ?? ''),
|
|
685
|
+
head_branch: f.head_branch ?? undefined, created_at: f.created_at ?? undefined, updated_at: f.updated_at ?? undefined,
|
|
686
|
+
...(typeof (f.actor_login ?? runsById.get(a.subject.id)?.actor_login) === 'string' ? { actor_login: String(f.actor_login ?? runsById.get(a.subject.id)?.actor_login) } : {}),
|
|
687
|
+
approvals: Array.isArray(f.approvals) ? f.approvals : runsById.get(a.subject.id)?.approvals,
|
|
688
|
+
...(typeof (f.head_repository ?? runsById.get(a.subject.id)?.head_repository) === 'string' ? { head_repository: String(f.head_repository ?? runsById.get(a.subject.id)?.head_repository) } : {}),
|
|
689
|
+
};
|
|
690
|
+
runsById.set(a.subject.id, run);
|
|
691
|
+
}
|
|
692
|
+
else if (a.subject.type === 'workflow_job') {
|
|
693
|
+
const m = /^job:(\d+)$/.exec(a.subject.id);
|
|
694
|
+
const id = m ? Number(m[1]) : observedId(a.subject.id);
|
|
695
|
+
const job = {
|
|
696
|
+
id, run_id: Number(f.run_id ?? 0), repository: String(f.repository ?? ''), name: String(f.name ?? ''),
|
|
697
|
+
status: String(f.status ?? 'queued'), conclusion: f.conclusion ?? undefined, head_sha: String(f.head_sha ?? ''),
|
|
698
|
+
started_at: f.started_at ?? undefined, completed_at: f.completed_at ?? undefined,
|
|
699
|
+
labels: Array.isArray(f.labels) ? f.labels.map(String) : [], workflow_name: f.workflow_name ?? undefined,
|
|
700
|
+
head_branch: f.head_branch ?? undefined, created_at: f.created_at ?? undefined,
|
|
701
|
+
steps: Array.isArray(f.steps) ? f.steps.map((s) => ({ name: String(s.name), status: String(s.status), conclusion: s.conclusion ?? undefined, number: Number(s.number) })) : [],
|
|
702
|
+
};
|
|
703
|
+
jobsById.set(a.subject.id, job);
|
|
704
|
+
}
|
|
705
|
+
else if (a.subject.type === 'release') {
|
|
706
|
+
// A release (LOCAL construct). Subject id `release:<id>`; a PATCH overlays the same
|
|
707
|
+
// id, a DELETE marks it tombstoned so it drops out of the read.
|
|
708
|
+
const m = /^release:(\d+)$/.exec(a.subject.id);
|
|
709
|
+
const id = m ? Number(m[1]) : observedId(a.subject.id);
|
|
710
|
+
if (f.deleted === true) {
|
|
711
|
+
deletedReleases.add(a.subject.id);
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
const prev = releasesById.get(a.subject.id);
|
|
715
|
+
const rel = {
|
|
716
|
+
id,
|
|
717
|
+
repository: String(f.repository ?? prev?.repository ?? ''),
|
|
718
|
+
tag_name: String(f.tag_name ?? prev?.tag_name ?? ''),
|
|
719
|
+
target_commitish: f.target_commitish ?? prev?.target_commitish,
|
|
720
|
+
name: f.name ?? prev?.name,
|
|
721
|
+
body: f.body ?? prev?.body,
|
|
722
|
+
draft: f.draft !== undefined ? Boolean(f.draft) : prev?.draft,
|
|
723
|
+
prerelease: f.prerelease !== undefined ? Boolean(f.prerelease) : prev?.prerelease,
|
|
724
|
+
created_at: f.created_at ?? prev?.created_at,
|
|
725
|
+
published_at: f.published_at ?? prev?.published_at,
|
|
726
|
+
...(typeof (f.author_login ?? prev?.author_login) === 'string' ? { author_login: String(f.author_login ?? prev?.author_login) } : {}),
|
|
727
|
+
};
|
|
728
|
+
releasesById.set(a.subject.id, rel);
|
|
729
|
+
}
|
|
730
|
+
else if (a.subject.type === 'release_asset') {
|
|
731
|
+
const m = /^asset:(\d+)$/.exec(a.subject.id);
|
|
732
|
+
const id = m ? Number(m[1]) : observedId(a.subject.id);
|
|
733
|
+
if (f.deleted === true) {
|
|
734
|
+
deletedAssets.add(a.subject.id);
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
const prev = assetsById.get(a.subject.id);
|
|
738
|
+
const asset = {
|
|
739
|
+
id,
|
|
740
|
+
repository: String(f.repository ?? prev?.repository ?? ''),
|
|
741
|
+
release_id: Number(f.release_id ?? prev?.release_id ?? 0),
|
|
742
|
+
name: String(f.name ?? prev?.name ?? ''),
|
|
743
|
+
label: f.label ?? prev?.label,
|
|
744
|
+
content_type: f.content_type ?? prev?.content_type,
|
|
745
|
+
size: f.size !== undefined ? Number(f.size) : prev?.size,
|
|
746
|
+
...(typeof (f.uploader_login ?? prev?.uploader_login) === 'string' ? { uploader_login: String(f.uploader_login ?? prev?.uploader_login) } : {}),
|
|
747
|
+
state: f.state ?? prev?.state,
|
|
748
|
+
download_count: f.download_count !== undefined ? Number(f.download_count) : prev?.download_count,
|
|
749
|
+
created_at: f.created_at ?? prev?.created_at,
|
|
750
|
+
updated_at: f.updated_at ?? prev?.updated_at,
|
|
751
|
+
};
|
|
752
|
+
assetsById.set(a.subject.id, asset);
|
|
753
|
+
}
|
|
754
|
+
else if (a.subject.type === 'discussion') {
|
|
755
|
+
// A discussion (LOCAL construct). Subject id `discussion:<repo>#<number>`; a PATCH
|
|
756
|
+
// overlays the same id, a DELETE tombstones it.
|
|
757
|
+
if (f.deleted === true) {
|
|
758
|
+
deletedDiscussions.add(a.subject.id);
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
const prev = discussionsById.get(a.subject.id);
|
|
762
|
+
const disc = {
|
|
763
|
+
number: Number(f.number ?? prev?.number ?? 0),
|
|
764
|
+
repository: String(f.repository ?? prev?.repository ?? ''),
|
|
765
|
+
title: f.title ?? prev?.title,
|
|
766
|
+
body: f.body ?? prev?.body,
|
|
767
|
+
category_slug: f.category_slug ?? prev?.category_slug,
|
|
768
|
+
state: f.state ?? prev?.state,
|
|
769
|
+
state_reason: f.state_reason ?? prev?.state_reason,
|
|
770
|
+
// answer_comment_id is DERIVED from the comments' is_answer flags after the fold —
|
|
771
|
+
// never read from a write (avoids a dedup-prone discussion write on each answer mark).
|
|
772
|
+
answer_comment_id: prev?.answer_comment_id ?? null,
|
|
773
|
+
locked: f.locked !== undefined ? Boolean(f.locked) : prev?.locked,
|
|
774
|
+
created_at: f.created_at ?? prev?.created_at,
|
|
775
|
+
updated_at: f.updated_at ?? prev?.updated_at,
|
|
776
|
+
};
|
|
777
|
+
discussionsById.set(a.subject.id, disc);
|
|
778
|
+
}
|
|
779
|
+
else if (a.subject.type === 'discussion_comment') {
|
|
780
|
+
// A discussion comment/reply (LOCAL). Subject id `discomment:<id>`; an answer-mark
|
|
781
|
+
// write overlays is_answer on the same id, a DELETE tombstones it.
|
|
782
|
+
const m = /^discomment:(\d+)$/.exec(a.subject.id);
|
|
783
|
+
const id = m ? Number(m[1]) : observedId(a.subject.id);
|
|
784
|
+
if (f.deleted === true) {
|
|
785
|
+
deletedDiscussionComments.add(a.subject.id);
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
const prev = discussionCommentsById.get(a.subject.id);
|
|
789
|
+
const comment = {
|
|
790
|
+
id,
|
|
791
|
+
repository: String(f.repository ?? prev?.repository ?? ''),
|
|
792
|
+
discussion_number: Number(f.discussion_number ?? prev?.discussion_number ?? 0),
|
|
793
|
+
parent_id: f.parent_id !== undefined ? (f.parent_id === null ? null : Number(f.parent_id)) : prev?.parent_id,
|
|
794
|
+
body: f.body ?? prev?.body,
|
|
795
|
+
is_answer: f.is_answer !== undefined ? Boolean(f.is_answer) : prev?.is_answer,
|
|
796
|
+
created_at: f.created_at ?? prev?.created_at,
|
|
797
|
+
updated_at: f.updated_at ?? prev?.updated_at,
|
|
798
|
+
};
|
|
799
|
+
discussionCommentsById.set(a.subject.id, comment);
|
|
800
|
+
}
|
|
801
|
+
else if (a.subject.type === 'reaction') {
|
|
802
|
+
const m = /^reaction:(\d+)$/.exec(a.subject.id);
|
|
803
|
+
const id = m ? Number(m[1]) : observedId(a.subject.id);
|
|
804
|
+
if (f.deleted === true) {
|
|
805
|
+
deletedReactions.add(a.subject.id);
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
reactionsById.set(a.subject.id, {
|
|
809
|
+
id, repository: String(f.repository ?? ''), subject_type: (f.subject_type ?? 'issue'),
|
|
810
|
+
subject_key: String(f.subject_key ?? ''), content: String(f.content ?? '+1'), created_at: f.created_at,
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
else if (a.subject.type === 'label') {
|
|
814
|
+
const m = /^label:(\d+)$/.exec(a.subject.id);
|
|
815
|
+
const id = m ? Number(m[1]) : observedId(a.subject.id);
|
|
816
|
+
if (f.deleted === true) {
|
|
817
|
+
deletedLabels.add(a.subject.id);
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
const prev = labelsById.get(a.subject.id);
|
|
821
|
+
labelsById.set(a.subject.id, {
|
|
822
|
+
id, repository: String(f.repository ?? prev?.repository ?? ''), name: String(f.name ?? prev?.name ?? ''),
|
|
823
|
+
color: f.color ?? prev?.color, description: f.description !== undefined ? f.description : prev?.description,
|
|
824
|
+
default: f.default !== undefined ? Boolean(f.default) : prev?.default,
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
else if (a.subject.type === 'repository') {
|
|
828
|
+
if (f.deleted === true) {
|
|
829
|
+
deletedRepos.add(a.subject.id);
|
|
830
|
+
// Cascade: the repo's refs/branches die WITH it (the bare repo is removed too), so a
|
|
831
|
+
// re-created repo starts truly empty on BOTH planes — REST must not serve dead refs.
|
|
832
|
+
const dyingRepo = reposById.get(a.subject.id);
|
|
833
|
+
const dyingFull = dyingRepo ? `${dyingRepo.owner}/${dyingRepo.name}` : `${String(f.owner ?? '')}/${String(f.name ?? '')}`;
|
|
834
|
+
for (const [k, v] of [...gitRefsById])
|
|
835
|
+
if (v.repository === dyingFull) {
|
|
836
|
+
gitRefsById.delete(k);
|
|
837
|
+
deletedGitRefs.delete(k);
|
|
838
|
+
}
|
|
839
|
+
for (const [k, v] of [...branchesById])
|
|
840
|
+
if (v.repository === dyingFull) {
|
|
841
|
+
branchesById.delete(k);
|
|
842
|
+
deletedBranches.delete(k);
|
|
843
|
+
}
|
|
844
|
+
for (const k of [...branchProtectionMemory.keys()])
|
|
845
|
+
if (k.startsWith(`branch:${dyingFull}#`))
|
|
846
|
+
branchProtectionMemory.delete(k);
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
const m = /^repo:(\d+)$/.exec(a.subject.id);
|
|
850
|
+
const id = m ? Number(m[1]) : observedId(a.subject.id);
|
|
851
|
+
const prev = reposById.get(a.subject.id);
|
|
852
|
+
const owner = String(f.owner ?? prev?.owner ?? '');
|
|
853
|
+
const name = String(f.name ?? prev?.name ?? '');
|
|
854
|
+
reposById.set(a.subject.id, {
|
|
855
|
+
id, owner, name, full_name: `${owner}/${name}`,
|
|
856
|
+
description: f.description !== undefined ? f.description : prev?.description,
|
|
857
|
+
private: f.private !== undefined ? Boolean(f.private) : prev?.private,
|
|
858
|
+
default_branch: f.default_branch ?? prev?.default_branch ?? 'main',
|
|
859
|
+
topics: f.topics !== undefined ? f.topics : prev?.topics,
|
|
860
|
+
homepage: f.homepage !== undefined ? f.homepage : prev?.homepage,
|
|
861
|
+
has_issues: f.has_issues !== undefined ? Boolean(f.has_issues) : prev?.has_issues,
|
|
862
|
+
has_projects: f.has_projects !== undefined ? Boolean(f.has_projects) : prev?.has_projects,
|
|
863
|
+
has_wiki: f.has_wiki !== undefined ? Boolean(f.has_wiki) : prev?.has_wiki,
|
|
864
|
+
has_discussions: f.has_discussions !== undefined ? Boolean(f.has_discussions) : prev?.has_discussions,
|
|
865
|
+
archived: f.archived !== undefined ? Boolean(f.archived) : prev?.archived,
|
|
866
|
+
fork: f.fork !== undefined ? Boolean(f.fork) : prev?.fork,
|
|
867
|
+
parent_full_name: f.parent_full_name ?? prev?.parent_full_name,
|
|
868
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
869
|
+
// an observed repository carries GitHub's own counts; a local one gets the fold's
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
else if (a.subject.type === 'branch') {
|
|
873
|
+
if (f.deleted === true) {
|
|
874
|
+
// The row dies, but PROTECTION does not: real GitHub's classic protection is a
|
|
875
|
+
// repo-level pattern rule, so deleting `main` and re-pushing it comes back
|
|
876
|
+
// protected. Remember the protection across the tombstone.
|
|
877
|
+
const dying = branchesById.get(a.subject.id);
|
|
878
|
+
if (dying && (dying.protected || dying.protection_config)) {
|
|
879
|
+
branchProtectionMemory.set(a.subject.id, { protected: dying.protected, protection_config: dying.protection_config });
|
|
880
|
+
}
|
|
881
|
+
deletedBranches.add(a.subject.id);
|
|
882
|
+
branchesById.delete(a.subject.id);
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
deletedBranches.delete(a.subject.id); // delete→re-create resurrects (see git_ref fold below)
|
|
886
|
+
const prev = branchesById.get(a.subject.id) ?? branchProtectionMemory.get(a.subject.id);
|
|
887
|
+
if (branchesById.get(a.subject.id) === undefined && branchProtectionMemory.has(a.subject.id))
|
|
888
|
+
branchProtectionMemory.delete(a.subject.id);
|
|
889
|
+
branchesById.set(a.subject.id, {
|
|
890
|
+
repository: String(f.repository ?? prev?.repository ?? ''), name: String(f.name ?? prev?.name ?? ''),
|
|
891
|
+
commit_sha: String(f.commit_sha ?? prev?.commit_sha ?? ''),
|
|
892
|
+
protected: f.protected !== undefined ? Boolean(f.protected) : prev?.protected,
|
|
893
|
+
...(f.protection_config !== undefined
|
|
894
|
+
? (f.protection_config === null ? {} : { protection_config: f.protection_config })
|
|
895
|
+
: (prev?.protection_config !== undefined ? { protection_config: prev.protection_config } : {})),
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
else if (a.subject.type === 'collaborator') {
|
|
899
|
+
if (f.deleted === true) {
|
|
900
|
+
deletedCollaborators.add(a.subject.id);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
const prev = collaboratorsById.get(a.subject.id);
|
|
904
|
+
collaboratorsById.set(a.subject.id, {
|
|
905
|
+
repository: String(f.repository ?? prev?.repository ?? ''), login: String(f.login ?? prev?.login ?? ''),
|
|
906
|
+
permission: String(f.permission ?? prev?.permission ?? 'push'),
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
else if (a.subject.type === 'webhook') {
|
|
910
|
+
if (f.deleted === true) {
|
|
911
|
+
deletedWebhooks.add(a.subject.id);
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
const m = /^webhook:(\d+)$/.exec(a.subject.id);
|
|
915
|
+
const id = m ? Number(m[1]) : observedId(a.subject.id);
|
|
916
|
+
const prev = webhooksById.get(a.subject.id);
|
|
917
|
+
webhooksById.set(a.subject.id, {
|
|
918
|
+
id, repository: String(f.repository ?? prev?.repository ?? ''), name: 'web',
|
|
919
|
+
active: f.active !== undefined ? Boolean(f.active) : (prev?.active ?? true),
|
|
920
|
+
events: f.events !== undefined ? f.events : (prev?.events ?? ['push']),
|
|
921
|
+
url: f.url ?? prev?.url, content_type: f.content_type ?? prev?.content_type,
|
|
922
|
+
secret: f.secret !== undefined ? String(f.secret) : prev?.secret,
|
|
923
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
else if (a.subject.type === 'content_file') {
|
|
927
|
+
if (f.deleted === true) {
|
|
928
|
+
deletedContents.add(a.subject.id);
|
|
929
|
+
contentsById.delete(a.subject.id);
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
deletedContents.delete(a.subject.id);
|
|
933
|
+
const prev = contentsById.get(a.subject.id);
|
|
934
|
+
contentsById.set(a.subject.id, {
|
|
935
|
+
repository: String(f.repository ?? prev?.repository ?? ''), path: String(f.path ?? prev?.path ?? ''),
|
|
936
|
+
content_b64: f.content_b64 ?? prev?.content_b64, sha: String(f.sha ?? prev?.sha ?? ''),
|
|
937
|
+
size: f.size !== undefined ? Number(f.size) : (prev?.size ?? 0), branch: f.branch ?? prev?.branch,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
else if (a.subject.type === 'gist') {
|
|
941
|
+
if (f.deleted === true) {
|
|
942
|
+
deletedGists.add(a.subject.id);
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
const prev = gistsById.get(a.subject.id);
|
|
946
|
+
gistsById.set(a.subject.id, {
|
|
947
|
+
id: String(f.id ?? prev?.id ?? a.subject.id.replace(/^gist:/, '')),
|
|
948
|
+
description: f.description !== undefined ? f.description : prev?.description,
|
|
949
|
+
public: f.public !== undefined ? Boolean(f.public) : prev?.public,
|
|
950
|
+
files: Array.isArray(f.files) ? f.files : (prev?.files ?? []),
|
|
951
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
952
|
+
history: Array.isArray(f.history) ? f.history : prev?.history,
|
|
953
|
+
owner: f.owner ?? prev?.owner,
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
else if (a.subject.type === 'notification') {
|
|
957
|
+
const prev = notificationsById.get(a.subject.id);
|
|
958
|
+
notificationsById.set(a.subject.id, {
|
|
959
|
+
id: String(f.id ?? prev?.id ?? a.subject.id.replace(/^notification:/, '')),
|
|
960
|
+
repository: String(f.repository ?? prev?.repository ?? ''),
|
|
961
|
+
subject_title: String(f.subject_title ?? prev?.subject_title ?? ''),
|
|
962
|
+
subject_type: String(f.subject_type ?? prev?.subject_type ?? 'Issue'),
|
|
963
|
+
subject_url: f.subject_url ?? prev?.subject_url,
|
|
964
|
+
reason: String(f.reason ?? prev?.reason ?? 'subscribed'),
|
|
965
|
+
unread: f.unread !== undefined ? Boolean(f.unread) : (prev?.unread ?? true),
|
|
966
|
+
updated_at: f.updated_at ?? prev?.updated_at,
|
|
967
|
+
...(f.subscription !== undefined ? { subscription: f.subscription } : prev && 'subscription' in prev ? { subscription: prev.subscription } : {}),
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
else if (a.subject.type === 'git_ref') {
|
|
971
|
+
if (f.deleted === true) {
|
|
972
|
+
deletedGitRefs.add(a.subject.id);
|
|
973
|
+
gitRefsById.delete(a.subject.id);
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
deletedGitRefs.delete(a.subject.id); // a later create RESURRECTS a deleted ref (delete→re-push must not be tombstoned forever)
|
|
977
|
+
const prev = gitRefsById.get(a.subject.id);
|
|
978
|
+
gitRefsById.set(a.subject.id, {
|
|
979
|
+
repository: String(f.repository ?? prev?.repository ?? ''), ref: String(f.ref ?? prev?.ref ?? ''),
|
|
980
|
+
object_sha: String(f.object_sha ?? prev?.object_sha ?? ''), object_type: String(f.object_type ?? prev?.object_type ?? 'commit'),
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
else if (a.subject.type === 'git_commit') {
|
|
984
|
+
const prev = gitCommitsById.get(a.subject.id);
|
|
985
|
+
gitCommitsById.set(a.subject.id, {
|
|
986
|
+
repository: String(f.repository ?? prev?.repository ?? ''), sha: String(f.sha ?? prev?.sha ?? ''),
|
|
987
|
+
message: String(f.message ?? prev?.message ?? ''), tree_sha: String(f.tree_sha ?? prev?.tree_sha ?? ''),
|
|
988
|
+
parents: Array.isArray(f.parents) ? f.parents : (prev?.parents ?? []),
|
|
989
|
+
author_name: f.author_name ?? prev?.author_name, author_email: f.author_email ?? prev?.author_email, created_at: f.created_at ?? prev?.created_at,
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
else if (a.subject.type === 'git_tree') {
|
|
993
|
+
const prev = gitTreesById.get(a.subject.id);
|
|
994
|
+
gitTreesById.set(a.subject.id, {
|
|
995
|
+
repository: String(f.repository ?? prev?.repository ?? ''), sha: String(f.sha ?? prev?.sha ?? ''),
|
|
996
|
+
tree: Array.isArray(f.tree) ? f.tree : (prev?.tree ?? []), truncated: f.truncated ?? prev?.truncated ?? false,
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
else if (a.subject.type === 'git_blob') {
|
|
1000
|
+
const prev = gitBlobsById.get(a.subject.id);
|
|
1001
|
+
gitBlobsById.set(a.subject.id, {
|
|
1002
|
+
repository: String(f.repository ?? prev?.repository ?? ''), sha: String(f.sha ?? prev?.sha ?? ''),
|
|
1003
|
+
content_b64: String(f.content_b64 ?? prev?.content_b64 ?? ''), size: f.size !== undefined ? Number(f.size) : (prev?.size ?? 0),
|
|
1004
|
+
encoding: String(f.encoding ?? prev?.encoding ?? 'base64'),
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
else if (a.subject.type === 'git_tag_object') {
|
|
1008
|
+
const prev = gitTagObjectsById.get(a.subject.id);
|
|
1009
|
+
gitTagObjectsById.set(a.subject.id, {
|
|
1010
|
+
repository: String(f.repository ?? prev?.repository ?? ''), sha: String(f.sha ?? prev?.sha ?? ''),
|
|
1011
|
+
tag: String(f.tag ?? prev?.tag ?? ''), message: String(f.message ?? prev?.message ?? ''),
|
|
1012
|
+
object_sha: String(f.object_sha ?? prev?.object_sha ?? ''), object_type: String(f.object_type ?? prev?.object_type ?? 'commit'),
|
|
1013
|
+
tagger_name: f.tagger_name ?? prev?.tagger_name, created_at: f.created_at ?? prev?.created_at,
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
else if (a.subject.type === 'deployment') {
|
|
1017
|
+
const prev = deploymentsById.get(a.subject.id);
|
|
1018
|
+
const m = /^deployment:(\d+)$/.exec(a.subject.id);
|
|
1019
|
+
deploymentsById.set(a.subject.id, {
|
|
1020
|
+
id: m ? Number(m[1]) : (prev?.id ?? observedId(a.subject.id)), repository: String(f.repository ?? prev?.repository ?? ''),
|
|
1021
|
+
ref: String(f.ref ?? prev?.ref ?? ''), sha: String(f.sha ?? prev?.sha ?? ''), task: String(f.task ?? prev?.task ?? 'deploy'),
|
|
1022
|
+
environment: String(f.environment ?? prev?.environment ?? 'production'), description: f.description !== undefined ? f.description : prev?.description,
|
|
1023
|
+
payload: f.payload !== undefined ? f.payload : prev?.payload,
|
|
1024
|
+
transient_environment: f.transient_environment ?? prev?.transient_environment, production_environment: f.production_environment ?? prev?.production_environment,
|
|
1025
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
1026
|
+
...(typeof (f.creator_login ?? prev?.creator_login) === 'string' ? { creator_login: String(f.creator_login ?? prev?.creator_login) } : {}),
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
else if (a.subject.type === 'deployment_status') {
|
|
1030
|
+
const prev = deploymentStatusesById.get(a.subject.id);
|
|
1031
|
+
const m = /^depstatus:(\d+)$/.exec(a.subject.id);
|
|
1032
|
+
deploymentStatusesById.set(a.subject.id, {
|
|
1033
|
+
id: m ? Number(m[1]) : (prev?.id ?? observedId(a.subject.id)), repository: String(f.repository ?? prev?.repository ?? ''),
|
|
1034
|
+
deployment_id: f.deployment_id !== undefined ? Number(f.deployment_id) : (prev?.deployment_id ?? 0), state: String(f.state ?? prev?.state ?? 'pending'),
|
|
1035
|
+
description: f.description !== undefined ? f.description : prev?.description, environment: f.environment ?? prev?.environment,
|
|
1036
|
+
environment_url: f.environment_url ?? prev?.environment_url, log_url: f.log_url ?? prev?.log_url, created_at: f.created_at ?? prev?.created_at,
|
|
1037
|
+
...(typeof (f.creator_login ?? prev?.creator_login) === 'string' ? { creator_login: String(f.creator_login ?? prev?.creator_login) } : {}),
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
else if (a.subject.type === 'environment') {
|
|
1041
|
+
if (f.deleted === true) {
|
|
1042
|
+
deletedEnvironments.add(a.subject.id);
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
const prev = environmentsById.get(a.subject.id);
|
|
1046
|
+
environmentsById.set(a.subject.id, {
|
|
1047
|
+
repository: String(f.repository ?? prev?.repository ?? ''), name: String(f.name ?? prev?.name ?? ''),
|
|
1048
|
+
wait_timer: f.wait_timer !== undefined ? Number(f.wait_timer) : prev?.wait_timer,
|
|
1049
|
+
reviewers: Array.isArray(f.reviewers) ? f.reviewers : prev?.reviewers,
|
|
1050
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
1051
|
+
...(typeof f.born_in === 'string' ? { born_in: f.born_in } : {}),
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
else if (a.subject.type === 'actions_secret') {
|
|
1055
|
+
if (f.deleted === true) {
|
|
1056
|
+
deletedActionsSecrets.add(a.subject.id);
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
const prev = actionsSecretsById.get(a.subject.id);
|
|
1060
|
+
actionsSecretsById.set(a.subject.id, {
|
|
1061
|
+
scope: String(f.scope ?? prev?.scope ?? 'repo'), container: String(f.container ?? prev?.container ?? ''), name: String(f.name ?? prev?.name ?? ''),
|
|
1062
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at, visibility: f.visibility ?? prev?.visibility,
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
else if (a.subject.type === 'actions_variable') {
|
|
1066
|
+
if (f.deleted === true) {
|
|
1067
|
+
deletedActionsVariables.add(a.subject.id);
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
const prev = actionsVariablesById.get(a.subject.id);
|
|
1071
|
+
actionsVariablesById.set(a.subject.id, {
|
|
1072
|
+
scope: String(f.scope ?? prev?.scope ?? 'repo'), container: String(f.container ?? prev?.container ?? ''), name: String(f.name ?? prev?.name ?? ''),
|
|
1073
|
+
value: String(f.value ?? prev?.value ?? ''), created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at, visibility: f.visibility ?? prev?.visibility,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
else if (a.subject.type === 'actions_cache') {
|
|
1077
|
+
if (f.deleted === true) {
|
|
1078
|
+
deletedActionsCaches.add(a.subject.id);
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
const prev = actionsCachesById.get(a.subject.id);
|
|
1082
|
+
const m = /^cache:(\d+)$/.exec(a.subject.id);
|
|
1083
|
+
actionsCachesById.set(a.subject.id, {
|
|
1084
|
+
id: m ? Number(m[1]) : (prev?.id ?? observedId(a.subject.id)), repository: String(f.repository ?? prev?.repository ?? ''),
|
|
1085
|
+
key: String(f.key ?? prev?.key ?? ''), ref: String(f.ref ?? prev?.ref ?? 'refs/heads/main'),
|
|
1086
|
+
size_in_bytes: f.size_in_bytes !== undefined ? Number(f.size_in_bytes) : (prev?.size_in_bytes ?? 0),
|
|
1087
|
+
created_at: f.created_at ?? prev?.created_at, last_accessed_at: f.last_accessed_at ?? prev?.last_accessed_at,
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
else if (a.subject.type === 'actions_artifact') {
|
|
1091
|
+
if (f.deleted === true) {
|
|
1092
|
+
deletedActionsArtifacts.add(a.subject.id);
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
const prev = actionsArtifactsById.get(a.subject.id);
|
|
1096
|
+
const m = /^artifact:(\d+)$/.exec(a.subject.id);
|
|
1097
|
+
actionsArtifactsById.set(a.subject.id, {
|
|
1098
|
+
id: m ? Number(m[1]) : (prev?.id ?? observedId(a.subject.id)), repository: String(f.repository ?? prev?.repository ?? ''),
|
|
1099
|
+
name: String(f.name ?? prev?.name ?? ''), size_in_bytes: f.size_in_bytes !== undefined ? Number(f.size_in_bytes) : (prev?.size_in_bytes ?? 0),
|
|
1100
|
+
run_id: f.run_id !== undefined ? Number(f.run_id) : prev?.run_id, expired: f.expired ?? prev?.expired ?? false, created_at: f.created_at ?? prev?.created_at,
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
else if (a.subject.type === 'star') {
|
|
1104
|
+
// subject id = `star:<login>:<owner>/<repo>` (the World's user when no login); `starred` is the last-write boolean.
|
|
1105
|
+
const m = /^star:(?:([^:/]+):)?(.+)$/.exec(a.subject.id);
|
|
1106
|
+
if (m)
|
|
1107
|
+
starsByRepo.set(`${m[1] ?? GITHUB_WORLD_USER}|${m[2]}`, f.starred !== false);
|
|
1108
|
+
}
|
|
1109
|
+
else if (a.subject.type === 'repo_subscription') {
|
|
1110
|
+
const m = /^watch:(?:([^:/]+):)?(.+)$/.exec(a.subject.id);
|
|
1111
|
+
if (m)
|
|
1112
|
+
watchingByRepo.set(`${m[1] ?? GITHUB_WORLD_USER}|${m[2]}`, f.subscribed !== false);
|
|
1113
|
+
}
|
|
1114
|
+
else if (a.subject.type === 'user_following') {
|
|
1115
|
+
// subject id = `following:<follower>:<login>` (the World's user when no follower); `following` is the last-write boolean.
|
|
1116
|
+
const m = /^following:(?:([^:]+):)?(.+)$/.exec(a.subject.id);
|
|
1117
|
+
if (m)
|
|
1118
|
+
followingByLogin.set(`${m[1] ?? GITHUB_WORLD_USER}|${m[2]}`, f.following !== false);
|
|
1119
|
+
}
|
|
1120
|
+
else if (a.subject.type === 'organization') {
|
|
1121
|
+
const prev = orgsById.get(a.subject.id);
|
|
1122
|
+
orgsById.set(a.subject.id, {
|
|
1123
|
+
login: String(f.login ?? prev?.login ?? a.subject.id.replace(/^org:/, '')),
|
|
1124
|
+
description: f.description !== undefined ? f.description : prev?.description,
|
|
1125
|
+
name: f.name !== undefined ? f.name : prev?.name,
|
|
1126
|
+
company: f.company !== undefined ? f.company : prev?.company,
|
|
1127
|
+
blog: f.blog !== undefined ? f.blog : prev?.blog,
|
|
1128
|
+
location: f.location !== undefined ? f.location : prev?.location,
|
|
1129
|
+
email: f.email !== undefined ? f.email : prev?.email,
|
|
1130
|
+
billing_email: f.billing_email !== undefined ? f.billing_email : prev?.billing_email,
|
|
1131
|
+
two_factor_requirement_enabled: typeof f.two_factor_requirement_enabled === 'boolean' ? f.two_factor_requirement_enabled : prev?.two_factor_requirement_enabled,
|
|
1132
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
else if (a.subject.type === 'org_membership') {
|
|
1136
|
+
if (f.deleted === true) {
|
|
1137
|
+
deletedOrgMembers.add(a.subject.id);
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
const prev = orgMembersById.get(a.subject.id);
|
|
1141
|
+
orgMembersById.set(a.subject.id, {
|
|
1142
|
+
org: String(f.org ?? prev?.org ?? ''), login: String(f.login ?? prev?.login ?? ''),
|
|
1143
|
+
role: String(f.role ?? prev?.role ?? 'member'), state: String(f.state ?? prev?.state ?? 'active'),
|
|
1144
|
+
created_at: f.created_at ?? prev?.created_at,
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
else if (a.subject.type === 'outside_collaborator') {
|
|
1148
|
+
if (f.deleted === true) {
|
|
1149
|
+
deletedOutsideCollabs.add(a.subject.id);
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
const prev = outsideCollabsById.get(a.subject.id);
|
|
1153
|
+
outsideCollabsById.set(a.subject.id, { org: String(f.org ?? prev?.org ?? ''), login: String(f.login ?? prev?.login ?? '') });
|
|
1154
|
+
}
|
|
1155
|
+
else if (a.subject.type === 'org_webhook') {
|
|
1156
|
+
if (f.deleted === true) {
|
|
1157
|
+
deletedOrgWebhooks.add(a.subject.id);
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
const m = /^orghook:(\d+)$/.exec(a.subject.id);
|
|
1161
|
+
const id = m ? Number(m[1]) : observedId(a.subject.id);
|
|
1162
|
+
const prev = orgWebhooksById.get(a.subject.id);
|
|
1163
|
+
orgWebhooksById.set(a.subject.id, {
|
|
1164
|
+
id, org: String(f.org ?? prev?.org ?? ''), name: 'web',
|
|
1165
|
+
active: f.active !== undefined ? Boolean(f.active) : (prev?.active ?? true),
|
|
1166
|
+
events: f.events !== undefined ? f.events : (prev?.events ?? ['push']),
|
|
1167
|
+
url: f.url ?? prev?.url, content_type: f.content_type ?? prev?.content_type,
|
|
1168
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
else if (a.subject.type === 'team') {
|
|
1172
|
+
if (f.deleted === true) {
|
|
1173
|
+
deletedTeams.add(a.subject.id);
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
const m = /^team:(\d+)$/.exec(a.subject.id);
|
|
1177
|
+
const id = m ? Number(m[1]) : observedId(a.subject.id);
|
|
1178
|
+
const prev = teamsById.get(a.subject.id);
|
|
1179
|
+
teamsById.set(a.subject.id, {
|
|
1180
|
+
id, org: String(f.org ?? prev?.org ?? ''), slug: String(f.slug ?? prev?.slug ?? ''),
|
|
1181
|
+
name: String(f.name ?? prev?.name ?? ''),
|
|
1182
|
+
description: f.description !== undefined ? f.description : prev?.description,
|
|
1183
|
+
privacy: f.privacy !== undefined ? String(f.privacy) : (prev?.privacy ?? 'secret'),
|
|
1184
|
+
permission: f.permission !== undefined ? String(f.permission) : (prev?.permission ?? 'pull'),
|
|
1185
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
else if (a.subject.type === 'team_membership') {
|
|
1189
|
+
if (f.deleted === true) {
|
|
1190
|
+
deletedTeamMembers.add(a.subject.id);
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
const prev = teamMembersById.get(a.subject.id);
|
|
1194
|
+
teamMembersById.set(a.subject.id, {
|
|
1195
|
+
org: String(f.org ?? prev?.org ?? ''), team_slug: String(f.team_slug ?? prev?.team_slug ?? ''),
|
|
1196
|
+
login: String(f.login ?? prev?.login ?? ''), role: String(f.role ?? prev?.role ?? 'member'),
|
|
1197
|
+
state: String(f.state ?? prev?.state ?? 'active'),
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
else if (a.subject.type === 'team_repo') {
|
|
1201
|
+
if (f.deleted === true) {
|
|
1202
|
+
deletedTeamRepos.add(a.subject.id);
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
const prev = teamReposById.get(a.subject.id);
|
|
1206
|
+
teamReposById.set(a.subject.id, {
|
|
1207
|
+
org: String(f.org ?? prev?.org ?? ''), team_slug: String(f.team_slug ?? prev?.team_slug ?? ''),
|
|
1208
|
+
repository: String(f.repository ?? prev?.repository ?? ''), permission: String(f.permission ?? prev?.permission ?? 'pull'),
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
else if (a.subject.type === 'commit_comment') {
|
|
1212
|
+
if (f.deleted === true) {
|
|
1213
|
+
deletedCommitComments.add(a.subject.id);
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
const m = /^commitcomment:(\d+)$/.exec(a.subject.id);
|
|
1217
|
+
const id = m ? Number(m[1]) : observedId(a.subject.id);
|
|
1218
|
+
const prev = commitCommentsById.get(a.subject.id);
|
|
1219
|
+
commitCommentsById.set(a.subject.id, {
|
|
1220
|
+
id, repository: String(f.repository ?? prev?.repository ?? ''), commit_sha: String(f.commit_sha ?? prev?.commit_sha ?? ''),
|
|
1221
|
+
body: f.body !== undefined ? f.body : prev?.body,
|
|
1222
|
+
path: f.path !== undefined ? f.path : prev?.path,
|
|
1223
|
+
line: f.line !== undefined ? f.line : prev?.line,
|
|
1224
|
+
position: f.position !== undefined ? f.position : prev?.position,
|
|
1225
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
else if (a.subject.type === 'issue_type') {
|
|
1229
|
+
if (f.deleted === true) {
|
|
1230
|
+
deletedIssueTypes.add(a.subject.id);
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1233
|
+
const m = /^issuetype:(\d+)$/.exec(a.subject.id);
|
|
1234
|
+
const id = m ? Number(m[1]) : observedId(a.subject.id);
|
|
1235
|
+
const prev = issueTypesById.get(a.subject.id);
|
|
1236
|
+
issueTypesById.set(a.subject.id, {
|
|
1237
|
+
id, org: String(f.org ?? prev?.org ?? ''), name: String(f.name ?? prev?.name ?? ''),
|
|
1238
|
+
description: f.description !== undefined ? f.description : prev?.description,
|
|
1239
|
+
color: f.color !== undefined ? f.color : prev?.color,
|
|
1240
|
+
is_enabled: f.is_enabled !== undefined ? Boolean(f.is_enabled) : (prev?.is_enabled ?? true),
|
|
1241
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
else if (a.subject.type === 'deploy_key') {
|
|
1245
|
+
if (f.deleted === true) {
|
|
1246
|
+
deletedDeployKeys.add(a.subject.id);
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
const m = /^deploykey:(\d+)$/.exec(a.subject.id);
|
|
1250
|
+
const prev = deployKeysById.get(a.subject.id);
|
|
1251
|
+
deployKeysById.set(a.subject.id, {
|
|
1252
|
+
id: m ? Number(m[1]) : observedId(a.subject.id), repository: String(f.repository ?? prev?.repository ?? ''),
|
|
1253
|
+
key: String(f.key ?? prev?.key ?? ''), title: String(f.title ?? prev?.title ?? ''),
|
|
1254
|
+
read_only: f.read_only !== undefined ? Boolean(f.read_only) : (prev?.read_only ?? true),
|
|
1255
|
+
created_at: f.created_at ?? prev?.created_at,
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
else if (a.subject.type === 'autolink') {
|
|
1259
|
+
if (f.deleted === true) {
|
|
1260
|
+
deletedAutolinks.add(a.subject.id);
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
const m = /^autolink:(\d+)$/.exec(a.subject.id);
|
|
1264
|
+
const prev = autolinksById.get(a.subject.id);
|
|
1265
|
+
autolinksById.set(a.subject.id, {
|
|
1266
|
+
id: m ? Number(m[1]) : observedId(a.subject.id), repository: String(f.repository ?? prev?.repository ?? ''),
|
|
1267
|
+
key_prefix: String(f.key_prefix ?? prev?.key_prefix ?? ''), url_template: String(f.url_template ?? prev?.url_template ?? ''),
|
|
1268
|
+
is_alphanumeric: f.is_alphanumeric !== undefined ? Boolean(f.is_alphanumeric) : (prev?.is_alphanumeric ?? true),
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
else if (a.subject.type === 'ruleset') {
|
|
1272
|
+
if (f.deleted === true) {
|
|
1273
|
+
deletedRulesets.add(a.subject.id);
|
|
1274
|
+
continue;
|
|
1275
|
+
}
|
|
1276
|
+
const m = /^ruleset:(\d+)$/.exec(a.subject.id);
|
|
1277
|
+
const prev = rulesetsById.get(a.subject.id);
|
|
1278
|
+
rulesetsById.set(a.subject.id, {
|
|
1279
|
+
id: m ? Number(m[1]) : observedId(a.subject.id), repository: String(f.repository ?? prev?.repository ?? ''),
|
|
1280
|
+
name: String(f.name ?? prev?.name ?? ''), target: String(f.target ?? prev?.target ?? 'branch'),
|
|
1281
|
+
enforcement: String(f.enforcement ?? prev?.enforcement ?? 'active'),
|
|
1282
|
+
rules: Array.isArray(f.rules) ? f.rules : (prev?.rules ?? []),
|
|
1283
|
+
conditions: f.conditions !== undefined ? f.conditions : prev?.conditions,
|
|
1284
|
+
bypass_actors: Array.isArray(f.bypass_actors) ? f.bypass_actors : prev?.bypass_actors,
|
|
1285
|
+
versions: Array.isArray(f.versions) ? f.versions : prev?.versions,
|
|
1286
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
else if (a.subject.type === 'rule_suite') {
|
|
1290
|
+
const m = /^rulesuite:(\d+)$/.exec(a.subject.id);
|
|
1291
|
+
ruleSuitesById.set(a.subject.id, { id: m ? Number(m[1]) : observedId(a.subject.id), repository: String(f.repository ?? ''), ref: String(f.ref ?? ''), actor_login: String(f.actor_login ?? ''),
|
|
1292
|
+
before_sha: String(f.before_sha ?? ''), after_sha: String(f.after_sha ?? ''), pushed_at: String(f.pushed_at ?? ''), result: (f.result ?? 'pass'),
|
|
1293
|
+
rules: Array.isArray(f.rules) ? f.rules : [] });
|
|
1294
|
+
}
|
|
1295
|
+
else if (a.subject.type === 'user_email') {
|
|
1296
|
+
if (f.deleted === true) {
|
|
1297
|
+
deletedUserEmails.add(a.subject.id);
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
const prev = userEmailsByAddr.get(a.subject.id);
|
|
1301
|
+
userEmailsByAddr.set(a.subject.id, {
|
|
1302
|
+
email: String(f.email ?? prev?.email ?? ''), primary: f.primary !== undefined ? Boolean(f.primary) : (prev?.primary ?? false),
|
|
1303
|
+
verified: f.verified !== undefined ? Boolean(f.verified) : (prev?.verified ?? true),
|
|
1304
|
+
visibility: f.visibility !== undefined ? f.visibility : (prev?.visibility ?? null),
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
else if (a.subject.type === 'user_ssh_key') {
|
|
1308
|
+
if (f.deleted === true) {
|
|
1309
|
+
deletedUserSshKeys.add(a.subject.id);
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
const m = /^sshkey:(\d+)$/.exec(a.subject.id);
|
|
1313
|
+
const prev = userSshKeysById.get(a.subject.id);
|
|
1314
|
+
userSshKeysById.set(a.subject.id, {
|
|
1315
|
+
id: m ? Number(m[1]) : observedId(a.subject.id), key: String(f.key ?? prev?.key ?? ''),
|
|
1316
|
+
title: String(f.title ?? prev?.title ?? ''), created_at: f.created_at ?? prev?.created_at,
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
else if (a.subject.type === 'user_gpg_key') {
|
|
1320
|
+
if (f.deleted === true) {
|
|
1321
|
+
deletedUserGpgKeys.add(a.subject.id);
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1324
|
+
const m = /^gpgkey:(\d+)$/.exec(a.subject.id);
|
|
1325
|
+
const prev = userGpgKeysById.get(a.subject.id);
|
|
1326
|
+
userGpgKeysById.set(a.subject.id, {
|
|
1327
|
+
id: m ? Number(m[1]) : observedId(a.subject.id), key_id: String(f.key_id ?? prev?.key_id ?? ''),
|
|
1328
|
+
raw_key: String(f.raw_key ?? prev?.raw_key ?? ''), created_at: f.created_at ?? prev?.created_at,
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
else if (a.subject.type === 'user_block') {
|
|
1332
|
+
// PUT sets, DELETE clears — boolean per login (last write per subject id wins).
|
|
1333
|
+
blockedByLogin.set(String(f.login ?? a.subject.id.replace(/^block:/, '')), f.deleted !== true);
|
|
1334
|
+
}
|
|
1335
|
+
else if (a.subject.type === 'user_profile') {
|
|
1336
|
+
// PATCH /user — overlay the authenticated user's editable profile fields.
|
|
1337
|
+
for (const k of ['name', 'bio', 'company', 'blog', 'location', 'email']) {
|
|
1338
|
+
if (f[k] !== undefined)
|
|
1339
|
+
userProfile[k] = f[k];
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
else if (a.subject.type === 'project_field' || a.subject.type === 'project_item' || a.subject.type === 'project_view') {
|
|
1343
|
+
// a project's fields, items and views: the fields as written, the id the subject's
|
|
1344
|
+
const parts = projectParts[a.subject.type];
|
|
1345
|
+
if (f.deleted === true) {
|
|
1346
|
+
parts.delete(a.subject.id);
|
|
1347
|
+
continue;
|
|
1348
|
+
}
|
|
1349
|
+
parts.set(a.subject.id, { ...(parts.get(a.subject.id) ?? {}), ...f, id: Number(a.subject.id.split(':').pop()) });
|
|
1350
|
+
}
|
|
1351
|
+
else if (a.subject.type === 'project') {
|
|
1352
|
+
if (f.deleted === true) {
|
|
1353
|
+
deletedProjects.add(a.subject.id);
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1356
|
+
const m = /^project:(\d+)$/.exec(a.subject.id);
|
|
1357
|
+
const prev = projectsById.get(a.subject.id);
|
|
1358
|
+
projectsById.set(a.subject.id, {
|
|
1359
|
+
id: m ? Number(m[1]) : observedId(a.subject.id), owner: String(f.owner ?? prev?.owner ?? ''),
|
|
1360
|
+
owner_type: String(f.owner_type ?? prev?.owner_type ?? 'organization'),
|
|
1361
|
+
number: f.number !== undefined ? Number(f.number) : (prev?.number ?? 0),
|
|
1362
|
+
title: String(f.title ?? prev?.title ?? ''),
|
|
1363
|
+
short_description: f.short_description !== undefined ? f.short_description : prev?.short_description,
|
|
1364
|
+
readme: f.readme !== undefined ? f.readme : prev?.readme,
|
|
1365
|
+
creator_login: f.creator_login ?? prev?.creator_login,
|
|
1366
|
+
public: f.public !== undefined ? Boolean(f.public) : prev?.public,
|
|
1367
|
+
closed: f.closed !== undefined ? Boolean(f.closed) : (prev?.closed ?? false),
|
|
1368
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
else if (a.subject.type === 'code_scanning_alert') {
|
|
1372
|
+
const m = /^csalert:(.+)#(\d+)$/.exec(a.subject.id);
|
|
1373
|
+
const prev = codeScanningAlertsById.get(a.subject.id);
|
|
1374
|
+
codeScanningAlertsById.set(a.subject.id, {
|
|
1375
|
+
number: f.number !== undefined ? Number(f.number) : (prev?.number ?? (m ? Number(m[2]) : 0)),
|
|
1376
|
+
repository: String(f.repository ?? prev?.repository ?? (m ? m[1] : '')), state: String(f.state ?? prev?.state ?? 'open'), rule_id: String(f.rule_id ?? prev?.rule_id ?? ''), rule_severity: String(f.rule_severity ?? prev?.rule_severity ?? 'warning'),
|
|
1377
|
+
rule_description: f.rule_description !== undefined ? String(f.rule_description) : prev?.rule_description,
|
|
1378
|
+
tool_name: String(f.tool_name ?? prev?.tool_name ?? 'CodeQL'), tool_version: f.tool_version !== undefined ? String(f.tool_version) : prev?.tool_version,
|
|
1379
|
+
dismissed_reason: f.dismissed_reason !== undefined ? f.dismissed_reason : prev?.dismissed_reason,
|
|
1380
|
+
dismissed_comment: f.dismissed_comment !== undefined ? f.dismissed_comment : prev?.dismissed_comment,
|
|
1381
|
+
dismissed_at: f.dismissed_at !== undefined ? f.dismissed_at : prev?.dismissed_at,
|
|
1382
|
+
dismissed_by_login: f.dismissed_by_login !== undefined ? f.dismissed_by_login : prev?.dismissed_by_login,
|
|
1383
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
1384
|
+
fixed_at: f.fixed_at !== undefined ? f.fixed_at : prev?.fixed_at,
|
|
1385
|
+
most_recent_instance: f.most_recent_instance !== undefined ? f.most_recent_instance : prev?.most_recent_instance,
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
else if (a.subject.type === 'code_scanning_analysis') {
|
|
1389
|
+
if (f.deleted === true) {
|
|
1390
|
+
deletedCodeScanningAnalyses.add(a.subject.id);
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
const m = /^csanalysis:(\d+)$/.exec(a.subject.id);
|
|
1394
|
+
const prev = codeScanningAnalysesById.get(a.subject.id);
|
|
1395
|
+
codeScanningAnalysesById.set(a.subject.id, {
|
|
1396
|
+
id: m ? Number(m[1]) : observedId(a.subject.id), repository: String(f.repository ?? prev?.repository ?? ''),
|
|
1397
|
+
ref: String(f.ref ?? prev?.ref ?? 'refs/heads/main'), commit_sha: String(f.commit_sha ?? prev?.commit_sha ?? ''),
|
|
1398
|
+
analysis_key: String(f.analysis_key ?? prev?.analysis_key ?? ''), environment: f.environment !== undefined ? String(f.environment) : prev?.environment,
|
|
1399
|
+
tool_name: String(f.tool_name ?? prev?.tool_name ?? 'CodeQL'), results_count: Number(f.results_count ?? prev?.results_count ?? 0),
|
|
1400
|
+
rules_count: Number(f.rules_count ?? prev?.rules_count ?? 0), sarif_id: f.sarif_id !== undefined ? String(f.sarif_id) : prev?.sarif_id,
|
|
1401
|
+
created_at: f.created_at ?? prev?.created_at, deletable: f.deletable !== undefined ? Boolean(f.deletable) : (prev?.deletable ?? true),
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
else if (a.subject.type === 'dependabot_alert') {
|
|
1405
|
+
const m = /^dependabot:(.+)#(\d+)$/.exec(a.subject.id);
|
|
1406
|
+
const prev = dependabotAlertsById.get(a.subject.id);
|
|
1407
|
+
dependabotAlertsById.set(a.subject.id, {
|
|
1408
|
+
number: f.number !== undefined ? Number(f.number) : (prev?.number ?? (m ? Number(m[2]) : 0)),
|
|
1409
|
+
repository: String(f.repository ?? prev?.repository ?? (m ? m[1] : '')), state: String(f.state ?? prev?.state ?? 'open'), package_name: String(f.package_name ?? prev?.package_name ?? ''), ecosystem: String(f.ecosystem ?? prev?.ecosystem ?? 'npm'), ghsa_id: String(f.ghsa_id ?? prev?.ghsa_id ?? ''), severity: String(f.severity ?? prev?.severity ?? 'high'),
|
|
1410
|
+
summary: f.summary !== undefined ? String(f.summary) : prev?.summary, manifest_path: f.manifest_path !== undefined ? String(f.manifest_path) : prev?.manifest_path,
|
|
1411
|
+
dismissed_reason: f.dismissed_reason !== undefined ? f.dismissed_reason : prev?.dismissed_reason,
|
|
1412
|
+
dismissed_comment: f.dismissed_comment !== undefined ? f.dismissed_comment : prev?.dismissed_comment,
|
|
1413
|
+
dismissed_at: f.dismissed_at !== undefined ? f.dismissed_at : prev?.dismissed_at,
|
|
1414
|
+
dismissed_by_login: f.dismissed_by_login !== undefined ? f.dismissed_by_login : prev?.dismissed_by_login,
|
|
1415
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
else if (a.subject.type === 'secret_scanning_alert') {
|
|
1419
|
+
const m = /^secretscan:(.+)#(\d+)$/.exec(a.subject.id);
|
|
1420
|
+
const prev = secretScanningAlertsById.get(a.subject.id);
|
|
1421
|
+
secretScanningAlertsById.set(a.subject.id, {
|
|
1422
|
+
number: f.number !== undefined ? Number(f.number) : (prev?.number ?? (m ? Number(m[2]) : 0)),
|
|
1423
|
+
repository: String(f.repository ?? prev?.repository ?? (m ? m[1] : '')), state: String(f.state ?? prev?.state ?? 'open'), secret_type: String(f.secret_type ?? prev?.secret_type ?? ''),
|
|
1424
|
+
secret_type_display_name: f.secret_type_display_name !== undefined ? String(f.secret_type_display_name) : prev?.secret_type_display_name,
|
|
1425
|
+
resolution: f.resolution !== undefined ? f.resolution : prev?.resolution,
|
|
1426
|
+
resolution_comment: f.resolution_comment !== undefined ? f.resolution_comment : prev?.resolution_comment,
|
|
1427
|
+
resolved_at: f.resolved_at !== undefined ? f.resolved_at : prev?.resolved_at,
|
|
1428
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
1429
|
+
locations: Array.isArray(f.locations) ? f.locations : prev?.locations,
|
|
1430
|
+
_secret: f._secret !== undefined ? String(f._secret) : prev?._secret,
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
else if (a.subject.type === 'app_installation') {
|
|
1434
|
+
if (f.deleted === true) {
|
|
1435
|
+
deletedAppInstallations.add(a.subject.id);
|
|
1436
|
+
continue;
|
|
1437
|
+
}
|
|
1438
|
+
const m = /^installation:(\d+)$/.exec(a.subject.id);
|
|
1439
|
+
const prev = appInstallationsById.get(a.subject.id);
|
|
1440
|
+
appInstallationsById.set(a.subject.id, {
|
|
1441
|
+
id: m ? Number(m[1]) : observedId(a.subject.id), account_login: String(f.account_login ?? prev?.account_login ?? ''),
|
|
1442
|
+
account_type: String(f.account_type ?? prev?.account_type ?? 'Organization'), app_id: Number(f.app_id ?? prev?.app_id ?? 0),
|
|
1443
|
+
app_slug: String(f.app_slug ?? prev?.app_slug ?? ''), target_type: String(f.target_type ?? prev?.target_type ?? 'Organization'),
|
|
1444
|
+
repository_selection: String(f.repository_selection ?? prev?.repository_selection ?? 'all'),
|
|
1445
|
+
permissions: f.permissions ?? prev?.permissions ?? {},
|
|
1446
|
+
events: Array.isArray(f.events) ? f.events : (prev?.events ?? []),
|
|
1447
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
1448
|
+
...(Array.isArray(f.repositories) ? { repositories: f.repositories } : prev?.repositories ? { repositories: prev.repositories } : {}),
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
else if (a.subject.type === 'oauth_grant') {
|
|
1452
|
+
if (f.deleted === true) {
|
|
1453
|
+
deletedOAuthGrants.add(a.subject.id);
|
|
1454
|
+
continue;
|
|
1455
|
+
}
|
|
1456
|
+
const prev = oauthGrantsById.get(a.subject.id);
|
|
1457
|
+
oauthGrantsById.set(a.subject.id, {
|
|
1458
|
+
token: String(f.token ?? prev?.token ?? ''), client_id: String(f.client_id ?? prev?.client_id ?? ''),
|
|
1459
|
+
scopes: Array.isArray(f.scopes) ? f.scopes : (prev?.scopes ?? []), created_at: f.created_at ?? prev?.created_at,
|
|
1460
|
+
...(typeof (f.login ?? prev?.login) === 'string' ? { login: String(f.login ?? prev?.login) } : {}),
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
else if (a.subject.type === 'package') {
|
|
1464
|
+
if (f.deleted === true) {
|
|
1465
|
+
deletedPackages.add(a.subject.id);
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1468
|
+
const m = /^package:(\d+)$/.exec(a.subject.id);
|
|
1469
|
+
const prev = packagesById.get(a.subject.id);
|
|
1470
|
+
packagesById.set(a.subject.id, {
|
|
1471
|
+
id: m ? Number(m[1]) : observedId(a.subject.id), name: String(f.name ?? prev?.name ?? ''),
|
|
1472
|
+
package_type: String(f.package_type ?? prev?.package_type ?? 'npm'), owner: String(f.owner ?? prev?.owner ?? ''),
|
|
1473
|
+
visibility: String(f.visibility ?? prev?.visibility ?? 'private'), created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
else if (a.subject.type === 'package_version') {
|
|
1477
|
+
if (f.deleted === true) {
|
|
1478
|
+
deletedPackageVersions.add(a.subject.id);
|
|
1479
|
+
continue;
|
|
1480
|
+
}
|
|
1481
|
+
const m = /^packageversion:(\d+)$/.exec(a.subject.id);
|
|
1482
|
+
const prev = packageVersionsById.get(a.subject.id);
|
|
1483
|
+
packageVersionsById.set(a.subject.id, {
|
|
1484
|
+
id: m ? Number(m[1]) : observedId(a.subject.id), package_name: String(f.package_name ?? prev?.package_name ?? ''),
|
|
1485
|
+
package_type: String(f.package_type ?? prev?.package_type ?? 'npm'), owner: String(f.owner ?? prev?.owner ?? ''),
|
|
1486
|
+
name: String(f.name ?? prev?.name ?? ''), tags: Array.isArray(f.tags) ? f.tags : prev?.tags,
|
|
1487
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
else if (a.subject.type === 'pages_site') {
|
|
1491
|
+
if (f.deleted === true) {
|
|
1492
|
+
deletedPagesSites.add(a.subject.id);
|
|
1493
|
+
continue;
|
|
1494
|
+
}
|
|
1495
|
+
// a site created again after a delete is live again
|
|
1496
|
+
deletedPagesSites.delete(a.subject.id);
|
|
1497
|
+
const prev = pagesSitesById.get(a.subject.id);
|
|
1498
|
+
pagesSitesById.set(a.subject.id, {
|
|
1499
|
+
repository: String(f.repository ?? prev?.repository ?? ''), cname: f.cname !== undefined ? f.cname : prev?.cname,
|
|
1500
|
+
cert_requested_at: f.cert_requested_at !== undefined ? f.cert_requested_at : prev?.cert_requested_at,
|
|
1501
|
+
status: String(f.status ?? prev?.status ?? 'built'), source_branch: String(f.source_branch ?? prev?.source_branch ?? 'main'),
|
|
1502
|
+
source_path: String(f.source_path ?? prev?.source_path ?? '/'), build_type: f.build_type !== undefined ? String(f.build_type) : (prev?.build_type ?? 'legacy'),
|
|
1503
|
+
public: f.public !== undefined ? Boolean(f.public) : (prev?.public ?? true), https_enforced: f.https_enforced !== undefined ? Boolean(f.https_enforced) : (prev?.https_enforced ?? true),
|
|
1504
|
+
html_url: f.html_url !== undefined ? String(f.html_url) : prev?.html_url, created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
else if (a.subject.type === 'pages_build') {
|
|
1508
|
+
const m = /^pagesbuild:(\d+)$/.exec(a.subject.id);
|
|
1509
|
+
const prev = pagesBuildsById.get(a.subject.id);
|
|
1510
|
+
pagesBuildsById.set(a.subject.id, {
|
|
1511
|
+
id: m ? Number(m[1]) : observedId(a.subject.id), repository: String(f.repository ?? prev?.repository ?? ''),
|
|
1512
|
+
status: String(f.status ?? prev?.status ?? 'built'), error_message: f.error_message !== undefined ? f.error_message : (prev?.error_message ?? null),
|
|
1513
|
+
commit: f.commit !== undefined ? String(f.commit) : prev?.commit, duration: f.duration !== undefined ? Number(f.duration) : prev?.duration,
|
|
1514
|
+
created_at: f.created_at ?? prev?.created_at,
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
else if (a.subject.type === 'codespace') {
|
|
1518
|
+
if (f.deleted === true) {
|
|
1519
|
+
deletedCodespaces.add(a.subject.id);
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
const prev = codespacesById.get(a.subject.id);
|
|
1523
|
+
codespacesById.set(a.subject.id, {
|
|
1524
|
+
name: String(f.name ?? prev?.name ?? ''), repository: String(f.repository ?? prev?.repository ?? ''),
|
|
1525
|
+
state: String(f.state ?? prev?.state ?? 'Available'), machine: String(f.machine ?? prev?.machine ?? 'basicLinux32gb'),
|
|
1526
|
+
git_branch: String(f.git_branch ?? prev?.git_branch ?? 'main'), owner: String(f.owner ?? prev?.owner ?? 'octocat'),
|
|
1527
|
+
created_at: f.created_at ?? prev?.created_at, updated_at: f.updated_at ?? prev?.updated_at,
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
else if (a.subject.type === 'pull_request_review') {
|
|
1531
|
+
const repository = String(f.repository ?? '');
|
|
1532
|
+
const number = Number(f.number);
|
|
1533
|
+
if (!repository || !number)
|
|
1534
|
+
continue;
|
|
1535
|
+
// A review exists only on a pull request — evidence that this number is one.
|
|
1536
|
+
ensure(`${repository}#${number}`, number, repository).is_pull_request = true;
|
|
1537
|
+
const m = /^review:(\d+)$/.exec(a.subject.id);
|
|
1538
|
+
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) } : {}), ...(f.user_type !== undefined ? { user_type: f.user_type } : {}), ...(f.author_association !== undefined ? { author_association: String(f.author_association) } : {}) });
|
|
1539
|
+
}
|
|
1540
|
+
else if (a.subject.type === 'pull_request_review_overlay') {
|
|
1541
|
+
// A dismiss / body-edit on a submitted review (keyed by review id). Overlaid below.
|
|
1542
|
+
const rid = Number(f.review_id ?? 0);
|
|
1543
|
+
const prev = reviewOverlaysById.get(rid);
|
|
1544
|
+
reviewOverlaysById.set(rid, {
|
|
1545
|
+
id: rid,
|
|
1546
|
+
state: f.state !== undefined ? String(f.state) : prev?.state,
|
|
1547
|
+
body: f.body !== undefined ? String(f.body) : prev?.body,
|
|
1548
|
+
dismissed_message: f.dismissed_message !== undefined ? String(f.dismissed_message) : prev?.dismissed_message,
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
else if (a.subject.type === 'issue_comment' || a.subject.type === 'pull_request_review_comment') {
|
|
1552
|
+
if (f.deleted === true) {
|
|
1553
|
+
deletedCommentSubjects.add(a.subject.id);
|
|
1554
|
+
continue;
|
|
1555
|
+
}
|
|
1556
|
+
const repository = String(f.repository ?? '');
|
|
1557
|
+
const number = Number(f.number);
|
|
1558
|
+
if (!repository || !number)
|
|
1559
|
+
continue;
|
|
1560
|
+
const shell = ensure(`${repository}#${number}`, number, repository);
|
|
1561
|
+
// A review comment is PR-only evidence (see the observed fold); an issue comment is not.
|
|
1562
|
+
if (a.subject.type === 'pull_request_review_comment')
|
|
1563
|
+
shell.is_pull_request = true;
|
|
1564
|
+
const m = /^comment:(\d+)$/.exec(a.subject.id);
|
|
1565
|
+
// An edit (or a delete) names the SAME subject as the create, so the comment the list
|
|
1566
|
+
// serves and the comment the per-comment door serves are one object, not two.
|
|
1567
|
+
const prev = localCommentBySubject.get(a.subject.id);
|
|
1568
|
+
const comment = prev ?? { id: m ? Number(m[1]) : observedId(a.subject.id), repository, number, kind: a.subject.type === 'pull_request_review_comment' ? 'review' : 'issue' };
|
|
1569
|
+
comment.subject_id = a.subject.id;
|
|
1570
|
+
if (typeof f.commit_id === 'string')
|
|
1571
|
+
comment.commit_id = f.commit_id;
|
|
1572
|
+
if (f.body !== undefined)
|
|
1573
|
+
comment.body = f.body;
|
|
1574
|
+
if (f.created_at !== undefined)
|
|
1575
|
+
comment.created_at = f.created_at;
|
|
1576
|
+
if (f.updated_at !== undefined)
|
|
1577
|
+
comment.updated_at = f.updated_at;
|
|
1578
|
+
// diff-position anchoring — LOCAL review-comment writes only.
|
|
1579
|
+
if (f.path !== undefined)
|
|
1580
|
+
comment.path = String(f.path);
|
|
1581
|
+
if (f.line !== undefined)
|
|
1582
|
+
comment.line = Number(f.line);
|
|
1583
|
+
if (f.side !== undefined)
|
|
1584
|
+
comment.side = String(f.side);
|
|
1585
|
+
if (f.start_line !== undefined)
|
|
1586
|
+
comment.start_line = Number(f.start_line);
|
|
1587
|
+
if (f.diff_hunk !== undefined)
|
|
1588
|
+
comment.diff_hunk = String(f.diff_hunk);
|
|
1589
|
+
if (f.in_reply_to !== undefined)
|
|
1590
|
+
comment.in_reply_to = Number(f.in_reply_to);
|
|
1591
|
+
// The review this comment was submitted under (an explicit one when the write named it,
|
|
1592
|
+
// otherwise the PR's implicit review) and WHO wrote it — both recorded at write time.
|
|
1593
|
+
if (f.review_id !== undefined)
|
|
1594
|
+
comment.review_id = Number(f.review_id);
|
|
1595
|
+
if (typeof f.user_login === 'string')
|
|
1596
|
+
comment.user_login = f.user_login;
|
|
1597
|
+
if (f.user_type !== undefined)
|
|
1598
|
+
comment.user_type = f.user_type;
|
|
1599
|
+
if (f.author_association !== undefined)
|
|
1600
|
+
comment.author_association = String(f.author_association);
|
|
1601
|
+
if (f.external_id !== undefined)
|
|
1602
|
+
comment.external_id = String(f.external_id);
|
|
1603
|
+
if (!prev) {
|
|
1604
|
+
comments.push(comment);
|
|
1605
|
+
localCommentBySubject.set(a.subject.id, comment);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
for (const [id, h] of transitionsOf('issue')) {
|
|
1610
|
+
const iss = issues.get(id);
|
|
1611
|
+
if (iss)
|
|
1612
|
+
iss.state_history = h;
|
|
1613
|
+
}
|
|
1614
|
+
for (const [id, h] of transitionsOf('pull_request')) {
|
|
1615
|
+
const pr = prs.get(id);
|
|
1616
|
+
if (pr)
|
|
1617
|
+
pr.state_history = h;
|
|
1618
|
+
}
|
|
1619
|
+
for (const [id, a] of activityOf('issue')) {
|
|
1620
|
+
const iss = issues.get(id);
|
|
1621
|
+
if (iss)
|
|
1622
|
+
iss.activity = a;
|
|
1623
|
+
}
|
|
1624
|
+
for (const [id, a] of activityOf('pull_request')) {
|
|
1625
|
+
const pr = prs.get(id);
|
|
1626
|
+
if (pr)
|
|
1627
|
+
pr.activity = a;
|
|
1628
|
+
}
|
|
1629
|
+
// Tombstoned comments leave the fold entirely — the list, the per-comment door and the
|
|
1630
|
+
// issue's comment count all stop seeing a deleted comment at the same moment.
|
|
1631
|
+
const tombstonedComments = new Set();
|
|
1632
|
+
for (const id of deletedCommentSubjects) {
|
|
1633
|
+
const c = localCommentBySubject.get(id);
|
|
1634
|
+
if (c)
|
|
1635
|
+
tombstonedComments.add(c);
|
|
1636
|
+
}
|
|
1637
|
+
const liveComments = tombstonedComments.size ? comments.filter((c) => !tombstonedComments.has(c)) : comments;
|
|
1638
|
+
// Apply review overlays (dismiss / body-edit) — last write per field wins on the matching id.
|
|
1639
|
+
for (const r of reviews) {
|
|
1640
|
+
const ov = reviewOverlaysById.get(r.id);
|
|
1641
|
+
if (!ov)
|
|
1642
|
+
continue;
|
|
1643
|
+
if (ov.state !== undefined)
|
|
1644
|
+
r.state = ov.state;
|
|
1645
|
+
if (ov.body !== undefined)
|
|
1646
|
+
r.body = ov.body;
|
|
1647
|
+
}
|
|
1648
|
+
// Derive the evidence counts the _twin block still reports from the folded objects.
|
|
1649
|
+
for (const r of reviews)
|
|
1650
|
+
ensure(`${r.repository}#${r.number}`, r.number, r.repository).review_count += 1;
|
|
1651
|
+
// THE IMPLICIT REVIEW IS ONE OF THEM. `GET /pulls/:n/reviews` synthesizes the wrapper from
|
|
1652
|
+
// the comments that name it and SERVES it, exactly as real GitHub does — and real GitHub
|
|
1653
|
+
// counts it. The count read only the STORED rows, so a PR whose reviews page answers one
|
|
1654
|
+
// review reported `review_count: 0` and the mirror rendered "Review count 0" over it. The
|
|
1655
|
+
// condition here is the handler's, verbatim, so the count and the list can never describe
|
|
1656
|
+
// different populations: one wrapper per PR that has at least one comment naming it.
|
|
1657
|
+
const wrappedPrs = new Set();
|
|
1658
|
+
for (const c of liveComments) {
|
|
1659
|
+
if (c.kind !== 'review' || c.review_id !== implicitReviewId(c.repository, c.number))
|
|
1660
|
+
continue;
|
|
1661
|
+
const key = `${c.repository}#${c.number}`;
|
|
1662
|
+
if (wrappedPrs.has(key))
|
|
1663
|
+
continue;
|
|
1664
|
+
wrappedPrs.add(key);
|
|
1665
|
+
ensure(key, c.number, c.repository).review_count += 1;
|
|
1666
|
+
}
|
|
1667
|
+
for (const c of liveComments)
|
|
1668
|
+
ensure(`${c.repository}#${c.number}`, c.number, c.repository).comment_count += 1;
|
|
1669
|
+
const byRepoNum = (a, b) => a.repository === b.repository ? a.number - b.number : a.repository < b.repository ? -1 : 1;
|
|
1670
|
+
// Releases: drop tombstoned ids; sort newest-first per repo (descending id — GitHub lists
|
|
1671
|
+
// releases most-recent-first). Assets: drop tombstoned + any whose release is gone.
|
|
1672
|
+
const releases = [...releasesById.entries()].filter(([k]) => !deletedReleases.has(k)).map(([, v]) => v)
|
|
1673
|
+
.sort((a, b) => (a.repository === b.repository ? b.id - a.id : a.repository < b.repository ? -1 : 1));
|
|
1674
|
+
const liveReleaseKeys = new Set(releases.map((r) => `${r.repository}#${r.id}`));
|
|
1675
|
+
const releaseAssets = [...assetsById.entries()].filter(([k]) => !deletedAssets.has(k)).map(([, v]) => v)
|
|
1676
|
+
.filter((a) => liveReleaseKeys.has(`${a.repository}#${a.release_id}`))
|
|
1677
|
+
.sort((a, b) => a.id - b.id);
|
|
1678
|
+
// Tags derived from PUBLISHED (non-draft) releases — a published release creates its tag.
|
|
1679
|
+
// The twin has no real git object, so the commit sha is deterministic (hash of repo+tag).
|
|
1680
|
+
const tagMap = new Map();
|
|
1681
|
+
for (const r of releases) {
|
|
1682
|
+
if (r.draft)
|
|
1683
|
+
continue;
|
|
1684
|
+
const key = `${r.repository}#${r.tag_name}`;
|
|
1685
|
+
if (!r.tag_name || tagMap.has(key))
|
|
1686
|
+
continue;
|
|
1687
|
+
tagMap.set(key, { name: r.tag_name, repository: r.repository, commit_sha: createHash('sha256').update(`${r.repository}:tag:${r.tag_name}`).digest('hex').slice(0, 40) });
|
|
1688
|
+
}
|
|
1689
|
+
const tags = [...tagMap.values()].sort((a, b) => (a.repository === b.repository ? (a.name < b.name ? -1 : 1) : a.repository < b.repository ? -1 : 1));
|
|
1690
|
+
// Discussions: drop tombstoned; sort newest-first per repo (descending number — GitHub
|
|
1691
|
+
// lists discussions most-recent-first). Comments: drop tombstoned + orphans (their
|
|
1692
|
+
// discussion is gone); sort oldest-first (thread order).
|
|
1693
|
+
const discussions = [...discussionsById.entries()].filter(([k]) => !deletedDiscussions.has(k)).map(([, v]) => v)
|
|
1694
|
+
.sort((a, b) => (a.repository === b.repository ? b.number - a.number : a.repository < b.repository ? -1 : 1));
|
|
1695
|
+
const liveDiscussionKeys = new Set(discussions.map((d) => `${d.repository}#${d.number}`));
|
|
1696
|
+
const discussionComments = [...discussionCommentsById.entries()].filter(([k]) => !deletedDiscussionComments.has(k)).map(([, v]) => v)
|
|
1697
|
+
.filter((c) => liveDiscussionKeys.has(`${c.repository}#${c.discussion_number}`))
|
|
1698
|
+
.sort((a, b) => a.id - b.id);
|
|
1699
|
+
// A discussion's accepted answer is DERIVED from its comments' is_answer flags (the single
|
|
1700
|
+
// source of truth) — so the answer pointer never needs a separate, dedup-prone discussion
|
|
1701
|
+
// write. The chosen answer is the answered comment with the highest id (last-marked wins).
|
|
1702
|
+
for (const d of discussions) {
|
|
1703
|
+
const answered = discussionComments.filter((c) => c.repository === d.repository && c.discussion_number === d.number && c.is_answer);
|
|
1704
|
+
d.answer_comment_id = answered.length ? answered[answered.length - 1].id : null;
|
|
1705
|
+
}
|
|
1706
|
+
// New LOCAL collections — drop tombstoned ids, sort deterministically.
|
|
1707
|
+
const reactions = [...reactionsById.entries()].filter(([k]) => !deletedReactions.has(k)).map(([, v]) => v).sort((a, b) => a.id - b.id);
|
|
1708
|
+
const labels = [...labelsById.entries()].filter(([k]) => !deletedLabels.has(k)).map(([, v]) => v)
|
|
1709
|
+
.sort((a, b) => (a.repository === b.repository ? (a.name < b.name ? -1 : 1) : a.repository < b.repository ? -1 : 1));
|
|
1710
|
+
const repos = [...reposById.entries()].filter(([k]) => !deletedRepos.has(k)).map(([, v]) => v)
|
|
1711
|
+
.sort((a, b) => (a.full_name < b.full_name ? -1 : a.full_name > b.full_name ? 1 : 0));
|
|
1712
|
+
const branches = [...branchesById.entries()].filter(([k]) => !deletedBranches.has(k)).map(([, v]) => v)
|
|
1713
|
+
.sort((a, b) => (a.repository === b.repository ? (a.name < b.name ? -1 : 1) : a.repository < b.repository ? -1 : 1));
|
|
1714
|
+
const collaborators = [...collaboratorsById.entries()].filter(([k]) => !deletedCollaborators.has(k)).map(([, v]) => v)
|
|
1715
|
+
.sort((a, b) => (a.repository === b.repository ? (a.login < b.login ? -1 : 1) : a.repository < b.repository ? -1 : 1));
|
|
1716
|
+
const webhooks = [...webhooksById.entries()].filter(([k]) => !deletedWebhooks.has(k)).map(([, v]) => v).sort((a, b) => a.id - b.id);
|
|
1717
|
+
const contents = [...contentsById.entries()].filter(([k]) => !deletedContents.has(k)).map(([, v]) => v)
|
|
1718
|
+
.sort((a, b) => (a.repository === b.repository ? (a.path < b.path ? -1 : 1) : a.repository < b.repository ? -1 : 1));
|
|
1719
|
+
const gists = [...gistsById.entries()].filter(([k]) => !deletedGists.has(k)).map(([, v]) => v)
|
|
1720
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
1721
|
+
const notifications = [...notificationsById.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
1722
|
+
// Git Data objects — drop tombstoned refs; sort deterministically.
|
|
1723
|
+
const gitRefs = [...gitRefsById.entries()].filter(([k]) => !deletedGitRefs.has(k)).map(([, v]) => v)
|
|
1724
|
+
.sort((a, b) => (a.repository === b.repository ? (a.ref < b.ref ? -1 : 1) : a.repository < b.repository ? -1 : 1));
|
|
1725
|
+
const gitCommits = [...gitCommitsById.values()].sort((a, b) => (a.sha < b.sha ? -1 : a.sha > b.sha ? 1 : 0));
|
|
1726
|
+
const gitTrees = [...gitTreesById.values()].sort((a, b) => (a.sha < b.sha ? -1 : a.sha > b.sha ? 1 : 0));
|
|
1727
|
+
const gitBlobs = [...gitBlobsById.values()].sort((a, b) => (a.sha < b.sha ? -1 : a.sha > b.sha ? 1 : 0));
|
|
1728
|
+
const gitTagObjects = [...gitTagObjectsById.values()].sort((a, b) => (a.sha < b.sha ? -1 : a.sha > b.sha ? 1 : 0));
|
|
1729
|
+
// Deployments newest-first per repo (descending id); statuses oldest-first (chronological).
|
|
1730
|
+
const deployments = [...deploymentsById.values()].sort((a, b) => (a.repository === b.repository ? b.id - a.id : a.repository < b.repository ? -1 : 1));
|
|
1731
|
+
const deploymentStatuses = [...deploymentStatusesById.values()].sort((a, b) => a.id - b.id);
|
|
1732
|
+
const environments = [...environmentsById.entries()].filter(([k]) => !deletedEnvironments.has(k)).map(([, v]) => v)
|
|
1733
|
+
.sort((a, b) => (a.repository === b.repository ? (a.name < b.name ? -1 : 1) : a.repository < b.repository ? -1 : 1));
|
|
1734
|
+
const actionsSecrets = [...actionsSecretsById.entries()].filter(([k]) => !deletedActionsSecrets.has(k)).map(([, v]) => v)
|
|
1735
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
1736
|
+
const actionsVariables = [...actionsVariablesById.entries()].filter(([k]) => !deletedActionsVariables.has(k)).map(([, v]) => v)
|
|
1737
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
1738
|
+
const actionsCaches = [...actionsCachesById.entries()].filter(([k]) => !deletedActionsCaches.has(k)).map(([, v]) => v).sort((a, b) => a.id - b.id);
|
|
1739
|
+
const actionsArtifacts = [...actionsArtifactsById.entries()].filter(([k]) => !deletedActionsArtifacts.has(k)).map(([, v]) => v).sort((a, b) => a.id - b.id);
|
|
1740
|
+
// Orgs / teams / followers / commit comments / issue types — drop tombstoned, sort deterministically.
|
|
1741
|
+
const orgs = [...orgsById.values()].sort((a, b) => (a.login < b.login ? -1 : a.login > b.login ? 1 : 0));
|
|
1742
|
+
const orgMembers = [...orgMembersById.entries()].filter(([k]) => !deletedOrgMembers.has(k)).map(([, v]) => v)
|
|
1743
|
+
.sort((a, b) => (a.org === b.org ? (a.login < b.login ? -1 : 1) : a.org < b.org ? -1 : 1));
|
|
1744
|
+
const outsideCollaborators = [...outsideCollabsById.entries()].filter(([k]) => !deletedOutsideCollabs.has(k)).map(([, v]) => v)
|
|
1745
|
+
.sort((a, b) => (a.org === b.org ? (a.login < b.login ? -1 : 1) : a.org < b.org ? -1 : 1));
|
|
1746
|
+
const orgWebhooks = [...orgWebhooksById.entries()].filter(([k]) => !deletedOrgWebhooks.has(k)).map(([, v]) => v).sort((a, b) => a.id - b.id);
|
|
1747
|
+
const teams = [...teamsById.entries()].filter(([k]) => !deletedTeams.has(k)).map(([, v]) => v)
|
|
1748
|
+
.sort((a, b) => (a.org === b.org ? (a.slug < b.slug ? -1 : 1) : a.org < b.org ? -1 : 1));
|
|
1749
|
+
const teamMembers = [...teamMembersById.entries()].filter(([k]) => !deletedTeamMembers.has(k)).map(([, v]) => v)
|
|
1750
|
+
.sort((a, b) => (a.team_slug === b.team_slug ? (a.login < b.login ? -1 : 1) : a.team_slug < b.team_slug ? -1 : 1));
|
|
1751
|
+
const teamRepos = [...teamReposById.entries()].filter(([k]) => !deletedTeamRepos.has(k)).map(([, v]) => v)
|
|
1752
|
+
.sort((a, b) => (a.team_slug === b.team_slug ? (a.repository < b.repository ? -1 : 1) : a.team_slug < b.team_slug ? -1 : 1));
|
|
1753
|
+
const commitComments = [...commitCommentsById.entries()].filter(([k]) => !deletedCommitComments.has(k)).map(([, v]) => v).sort((a, b) => a.id - b.id);
|
|
1754
|
+
const issueTypes = [...issueTypesById.entries()].filter(([k]) => !deletedIssueTypes.has(k)).map(([, v]) => v)
|
|
1755
|
+
.sort((a, b) => (a.org === b.org ? (a.name < b.name ? -1 : 1) : a.org < b.org ? -1 : 1));
|
|
1756
|
+
// Repos niche / user niche / projects — drop tombstoned, sort deterministically.
|
|
1757
|
+
const deployKeys = [...deployKeysById.entries()].filter(([k]) => !deletedDeployKeys.has(k)).map(([, v]) => v).sort((a, b) => a.id - b.id);
|
|
1758
|
+
const autolinks = [...autolinksById.entries()].filter(([k]) => !deletedAutolinks.has(k)).map(([, v]) => v).sort((a, b) => a.id - b.id);
|
|
1759
|
+
const rulesets = [...rulesetsById.entries()].filter(([k]) => !deletedRulesets.has(k)).map(([, v]) => v).sort((a, b) => a.id - b.id);
|
|
1760
|
+
const ruleSuites = [...ruleSuitesById.values()].sort((a, b) => a.id - b.id);
|
|
1761
|
+
const userEmails = [...userEmailsByAddr.entries()].filter(([k]) => !deletedUserEmails.has(k)).map(([, v]) => v)
|
|
1762
|
+
.sort((a, b) => (a.primary === b.primary ? (a.email < b.email ? -1 : 1) : a.primary ? -1 : 1));
|
|
1763
|
+
const userSshKeys = [...userSshKeysById.entries()].filter(([k]) => !deletedUserSshKeys.has(k)).map(([, v]) => v).sort((a, b) => a.id - b.id);
|
|
1764
|
+
const userGpgKeys = [...userGpgKeysById.entries()].filter(([k]) => !deletedUserGpgKeys.has(k)).map(([, v]) => v).sort((a, b) => a.id - b.id);
|
|
1765
|
+
const projects = [...projectsById.entries()].filter(([k]) => !deletedProjects.has(k)).map(([, v]) => v)
|
|
1766
|
+
.sort((a, b) => (a.owner === b.owner ? a.number - b.number : a.owner < b.owner ? -1 : 1));
|
|
1767
|
+
const liveProjects = new Set(projects.map((p) => p.id));
|
|
1768
|
+
const partsOf = (parts) => [...parts.values()].filter((x) => liveProjects.has(Number(x.project_id))).sort((a, b) => Number(a.id) - Number(b.id));
|
|
1769
|
+
const projectFields = partsOf(projectParts.project_field);
|
|
1770
|
+
const projectItems = partsOf(projectParts.project_item);
|
|
1771
|
+
const projectViews = partsOf(projectParts.project_view);
|
|
1772
|
+
// Security alerts: sort per-repo ascending by number (GitHub lists alerts oldest-first).
|
|
1773
|
+
const byRepoNumAsc = (a, b) => a.repository === b.repository ? a.number - b.number : a.repository < b.repository ? -1 : 1;
|
|
1774
|
+
const codeScanningAlerts = [...codeScanningAlertsById.values()].sort(byRepoNumAsc);
|
|
1775
|
+
const codeScanningAnalyses = [...codeScanningAnalysesById.entries()].filter(([k]) => !deletedCodeScanningAnalyses.has(k)).map(([, v]) => v)
|
|
1776
|
+
.sort((a, b) => (a.repository === b.repository ? b.id - a.id : a.repository < b.repository ? -1 : 1));
|
|
1777
|
+
const dependabotAlerts = [...dependabotAlertsById.values()].sort(byRepoNumAsc);
|
|
1778
|
+
const secretScanningAlerts = [...secretScanningAlertsById.values()].sort(byRepoNumAsc);
|
|
1779
|
+
const appInstallations = [...appInstallationsById.entries()].filter(([k]) => !deletedAppInstallations.has(k)).map(([, v]) => v).sort((a, b) => a.id - b.id);
|
|
1780
|
+
const oauthGrants = [...oauthGrantsById.entries()].filter(([k]) => !deletedOAuthGrants.has(k)).map(([, v]) => v);
|
|
1781
|
+
const packages = [...packagesById.entries()].filter(([k]) => !deletedPackages.has(k)).map(([, v]) => v)
|
|
1782
|
+
.sort((a, b) => (a.owner === b.owner ? (a.name < b.name ? -1 : 1) : a.owner < b.owner ? -1 : 1));
|
|
1783
|
+
const packageVersions = [...packageVersionsById.entries()].filter(([k]) => !deletedPackageVersions.has(k)).map(([, v]) => v).sort((a, b) => b.id - a.id);
|
|
1784
|
+
const pagesSites = [...pagesSitesById.entries()].filter(([k]) => !deletedPagesSites.has(k)).map(([, v]) => v)
|
|
1785
|
+
.sort((a, b) => (a.repository < b.repository ? -1 : a.repository > b.repository ? 1 : 0));
|
|
1786
|
+
const livePagesRepos = new Set(pagesSites.map((s) => s.repository));
|
|
1787
|
+
const pagesBuilds = [...pagesBuildsById.values()].filter((b) => livePagesRepos.has(b.repository)).sort((a, b) => (a.repository === b.repository ? b.id - a.id : a.repository < b.repository ? -1 : 1));
|
|
1788
|
+
const codespaces = [...codespacesById.entries()].filter(([k]) => !deletedCodespaces.has(k)).map(([, v]) => v)
|
|
1789
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
1790
|
+
const liveIssues = [...issues.values()];
|
|
1791
|
+
const livePrs = [...prs.values()];
|
|
1792
|
+
const starCount = countBy(edgesOf(starsByRepo).map((e) => e.to));
|
|
1793
|
+
const watchCount = countBy(edgesOf(watchingByRepo).map((e) => e.to));
|
|
1794
|
+
const forkCount = countBy(repos.filter((r) => r.parent_full_name).map((r) => r.parent_full_name));
|
|
1795
|
+
const openCount = countBy([...liveIssues.filter((i) => (i.state ?? 'open') === 'open').map((i) => i.repository), ...livePrs.filter((p) => p.is_pull_request && (p.state ?? 'open') === 'open').map((p) => p.repository)]);
|
|
1796
|
+
// a milestone counts the issues and pull requests in it, open and closed (docs.github.com/rest/issues/milestones)
|
|
1797
|
+
for (const ms of milestones.values()) {
|
|
1798
|
+
const inIt = [...liveIssues, ...livePrs.filter((p) => p.is_pull_request)].filter((x) => x.repository === ms.repository && Number(x.milestone) === ms.number);
|
|
1799
|
+
ms.open_issues = inIt.filter((x) => (x.state ?? 'open') === 'open').length;
|
|
1800
|
+
ms.closed_issues = inIt.length - ms.open_issues;
|
|
1801
|
+
}
|
|
1802
|
+
for (const r of repos) {
|
|
1803
|
+
if (r.facts)
|
|
1804
|
+
continue;
|
|
1805
|
+
const files = contents.filter((c) => c.repository === r.full_name && (c.branch ?? r.default_branch ?? 'main') === (r.default_branch ?? 'main'));
|
|
1806
|
+
const bytes = (c) => Math.floor(((c.content_b64 ?? '').replace(/\s/g, '').length * 3) / 4);
|
|
1807
|
+
r.facts = {
|
|
1808
|
+
stargazers_count: starCount.get(r.full_name) ?? 0, subscribers_count: watchCount.get(r.full_name) ?? 0,
|
|
1809
|
+
forks_count: forkCount.get(r.full_name) ?? 0, open_issues_count: openCount.get(r.full_name) ?? 0,
|
|
1810
|
+
pushed_at: pushedAt.get(r.full_name) ?? r.created_at ?? null,
|
|
1811
|
+
size: Math.ceil(files.reduce((n, c) => n + bytes(c), 0) / 1024),
|
|
1812
|
+
language: languageOf(files.map((c) => ({ path: c.path, bytes: bytes(c) }))),
|
|
1813
|
+
license: licenseOf(files.find((c) => /^(LICENSE|LICENCE|COPYING)(\.(md|txt))?$/i.test(c.path))),
|
|
1814
|
+
has_pages: pagesSites.some((s) => s.repository === r.full_name),
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
return {
|
|
1818
|
+
prs: [...prs.values()].sort(byRepoNum),
|
|
1819
|
+
issues: [...issues.values()].sort(byRepoNum),
|
|
1820
|
+
reviews,
|
|
1821
|
+
comments: liveComments,
|
|
1822
|
+
statuses,
|
|
1823
|
+
checkRuns: [...checkRunsById.values()].sort((a, b) => a.id - b.id),
|
|
1824
|
+
milestones: [...milestones.values()].sort(byRepoNum),
|
|
1825
|
+
workflows: [...workflows.values()].sort((a, b) => (a.repository === b.repository ? a.id - b.id : a.repository < b.repository ? -1 : 1)),
|
|
1826
|
+
workflowRuns: [...runsById.values()].sort((a, b) => (a.repository === b.repository ? a.id - b.id : a.repository < b.repository ? -1 : 1)),
|
|
1827
|
+
jobs: [...jobsById.values()].sort((a, b) => a.id - b.id),
|
|
1828
|
+
releases,
|
|
1829
|
+
releaseAssets,
|
|
1830
|
+
tags,
|
|
1831
|
+
discussions,
|
|
1832
|
+
discussionComments,
|
|
1833
|
+
reactions,
|
|
1834
|
+
labels,
|
|
1835
|
+
repos,
|
|
1836
|
+
branches,
|
|
1837
|
+
collaborators,
|
|
1838
|
+
webhooks,
|
|
1839
|
+
contents,
|
|
1840
|
+
gists,
|
|
1841
|
+
notifications,
|
|
1842
|
+
gitRefs,
|
|
1843
|
+
gitCommits,
|
|
1844
|
+
gitTrees,
|
|
1845
|
+
gitBlobs,
|
|
1846
|
+
gitTagObjects,
|
|
1847
|
+
deployments,
|
|
1848
|
+
deploymentStatuses,
|
|
1849
|
+
environments,
|
|
1850
|
+
actionsSecrets,
|
|
1851
|
+
actionsVariables,
|
|
1852
|
+
actionsCaches,
|
|
1853
|
+
actionsArtifacts,
|
|
1854
|
+
orgs,
|
|
1855
|
+
orgMembers,
|
|
1856
|
+
outsideCollaborators,
|
|
1857
|
+
orgWebhooks,
|
|
1858
|
+
teams,
|
|
1859
|
+
teamMembers,
|
|
1860
|
+
teamRepos,
|
|
1861
|
+
commitComments,
|
|
1862
|
+
issueTypes,
|
|
1863
|
+
deployKeys,
|
|
1864
|
+
autolinks,
|
|
1865
|
+
rulesets,
|
|
1866
|
+
ruleSuites,
|
|
1867
|
+
userEmails,
|
|
1868
|
+
userSshKeys,
|
|
1869
|
+
userGpgKeys,
|
|
1870
|
+
blockedUsers: new Set([...blockedByLogin.entries()].filter(([, on]) => on).map(([k]) => k)),
|
|
1871
|
+
userProfile,
|
|
1872
|
+
projects,
|
|
1873
|
+
projectFields,
|
|
1874
|
+
projectItems,
|
|
1875
|
+
projectViews,
|
|
1876
|
+
stars: pairsOf(starsByRepo, GITHUB_WORLD_USER), watching: pairsOf(watchingByRepo, GITHUB_WORLD_USER), following: pairsOf(followingByLogin, GITHUB_WORLD_USER),
|
|
1877
|
+
starEdges: edgesOf(starsByRepo), watchEdges: edgesOf(watchingByRepo), followEdges: edgesOf(followingByLogin),
|
|
1878
|
+
codeScanningAlerts,
|
|
1879
|
+
codeScanningAnalyses,
|
|
1880
|
+
dependabotAlerts,
|
|
1881
|
+
secretScanningAlerts,
|
|
1882
|
+
appInstallations,
|
|
1883
|
+
oauthGrants,
|
|
1884
|
+
packages,
|
|
1885
|
+
packageVersions,
|
|
1886
|
+
pagesSites,
|
|
1887
|
+
pagesBuilds,
|
|
1888
|
+
codespaces,
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
// GitHub takes labels by name on write and answers full label objects. A name the repository has no label
|
|
1892
|
+
// for is created with GitHub's defaults (grey, no description) when it is applied, and answers that way.
|
|
1893
|
+
export function toLabels(names, repository, labels = []) {
|
|
1894
|
+
return (names ?? []).map((name) => toGithubLabel(labels.find((l) => l.repository === repository && l.name === name) ?? { id: actionsId(`${repository}:label:${name}`), repository, name }));
|
|
1895
|
+
}
|
|
1896
|
+
// Build the GitHub simple-user array for assignees from bare logins. Same honesty note:
|
|
1897
|
+
// only the login is known, the rest of the simple-user object is declared scope (assignee.*).
|
|
1898
|
+
export function toAssignees(logins) {
|
|
1899
|
+
return (logins ?? []).map((login) => ({ login }));
|
|
1900
|
+
}
|
|
1901
|
+
// Map a milestone to GitHub's REST milestone shape, with the counts the fold made.
|
|
1902
|
+
export function toGithubMilestone(ms) {
|
|
1903
|
+
const api = `https://api.github.com/repos/${ms.repository}`;
|
|
1904
|
+
const out = {
|
|
1905
|
+
id: ms.id,
|
|
1906
|
+
node_id: nodeId('Milestone', ms.id),
|
|
1907
|
+
number: ms.number,
|
|
1908
|
+
creator: ms.creator_login ? toSimpleUser(ms.creator_login) : null,
|
|
1909
|
+
state: ms.state ?? 'open',
|
|
1910
|
+
title: ms.title ?? '',
|
|
1911
|
+
description: ms.description ?? null,
|
|
1912
|
+
url: `${api}/milestones/${ms.number}`,
|
|
1913
|
+
html_url: `https://github.com/${ms.repository}/milestone/${ms.number}`,
|
|
1914
|
+
labels_url: `${api}/milestones/${ms.number}/labels`,
|
|
1915
|
+
open_issues: ms.open_issues ?? 0,
|
|
1916
|
+
closed_issues: ms.closed_issues ?? 0,
|
|
1917
|
+
created_at: ms.created_at ?? null,
|
|
1918
|
+
updated_at: ms.updated_at ?? null,
|
|
1919
|
+
closed_at: ms.state === 'closed' ? ms.updated_at ?? null : null,
|
|
1920
|
+
due_on: ms.due_on ?? null,
|
|
1921
|
+
};
|
|
1922
|
+
return out;
|
|
1923
|
+
}
|
|
1924
|
+
// Map a twin PR to GitHub's REST pull-request shape (fields we have; observed-only
|
|
1925
|
+
// title/body stay null unless a local write supplied them).
|
|
1926
|
+
export function toGithubRest(pr, milestones) {
|
|
1927
|
+
const repo = pr.repository;
|
|
1928
|
+
const n = pr.number;
|
|
1929
|
+
const api = `https://api.github.com/repos/${repo}`;
|
|
1930
|
+
const ms = pr.milestone !== undefined ? (milestones ?? []).find((m) => m.repository === repo && m.number === pr.milestone) : undefined;
|
|
1931
|
+
const out = {
|
|
1932
|
+
number: n,
|
|
1933
|
+
// Stable modeled REST identity, using the same namespace mapping as observed records.
|
|
1934
|
+
id: observedId(`pull_request:${pr.born_in ?? repo}#${n}`),
|
|
1935
|
+
...(pr.updated_at ? { updated_at: pr.updated_at } : {}),
|
|
1936
|
+
// The same synthetic node identity accepted by the twin's GraphQL API.
|
|
1937
|
+
node_id: `PR_${repo}#${n}`,
|
|
1938
|
+
...(pr.user_login ? { user: { login: pr.user_login, ...(pr.user_type ? { type: pr.user_type } : {}) } } : {}),
|
|
1939
|
+
state: pr.state ?? 'open',
|
|
1940
|
+
author_association: pr.author_association ?? 'NONE',
|
|
1941
|
+
merged: pr.merged ?? false,
|
|
1942
|
+
maintainer_can_modify: pr.maintainer_can_modify ?? false,
|
|
1943
|
+
_links: {
|
|
1944
|
+
self: { href: `${api}/pulls/${n}` }, html: { href: `https://github.com/${repo}/pull/${n}` }, issue: { href: `${api}/issues/${n}` },
|
|
1945
|
+
comments: { href: `${api}/issues/${n}/comments` }, review_comments: { href: `${api}/pulls/${n}/comments` },
|
|
1946
|
+
review_comment: { href: `${api}/pulls/comments{/number}` }, commits: { href: `${api}/pulls/${n}/commits` },
|
|
1947
|
+
statuses: { href: `${api}/statuses/${pr.head_sha ?? ''}` },
|
|
1948
|
+
},
|
|
1949
|
+
// URL templates — derivable from repo+number, so faithfully emitted (R1).
|
|
1950
|
+
url: `${api}/pulls/${n}`,
|
|
1951
|
+
html_url: `https://github.com/${repo}/pull/${n}`,
|
|
1952
|
+
diff_url: `https://github.com/${repo}/pull/${n}.diff`,
|
|
1953
|
+
patch_url: `https://github.com/${repo}/pull/${n}.patch`,
|
|
1954
|
+
issue_url: `${api}/issues/${n}`,
|
|
1955
|
+
commits_url: `${api}/pulls/${n}/commits`,
|
|
1956
|
+
review_comments_url: `${api}/pulls/${n}/comments`,
|
|
1957
|
+
review_comment_url: `${api}/pulls/comments{/number}`,
|
|
1958
|
+
comments_url: `${api}/issues/${n}/comments`,
|
|
1959
|
+
// Faithful empties/nulls — the real API returns these for a PR with none set.
|
|
1960
|
+
body: pr.body ?? null,
|
|
1961
|
+
labels: toLabels(pr.labels, pr.repository),
|
|
1962
|
+
locked: pr.locked ?? false,
|
|
1963
|
+
active_lock_reason: pr.active_lock_reason ?? null,
|
|
1964
|
+
assignee: pr.assignees && pr.assignees.length ? { login: pr.assignees[0] } : null,
|
|
1965
|
+
assignees: toAssignees(pr.assignees),
|
|
1966
|
+
// requested_reviewers: bare-login simple-user array (like assignees). Empty for
|
|
1967
|
+
// observed-only PRs (no review-REQUEST evidence). The rest of the user object is
|
|
1968
|
+
// declared scope (requestedReviewer.*). requested_teams is not modeled (scope).
|
|
1969
|
+
requested_reviewers: toAssignees(pr.requested_reviewers),
|
|
1970
|
+
requested_teams: [],
|
|
1971
|
+
// milestone: full object when a local PATCH set one and the milestone exists; else null
|
|
1972
|
+
// (the faithful empty real GitHub returns for a PR with no milestone).
|
|
1973
|
+
milestone: ms ? toGithubMilestone(ms) : null,
|
|
1974
|
+
// draft: a PR opened/edited as a draft (LOCAL writes). Defaults to false (real GitHub
|
|
1975
|
+
// returns the boolean). auto_merge: a {merge_method,...} object when enabled, else null.
|
|
1976
|
+
draft: pr.draft ?? false,
|
|
1977
|
+
auto_merge: null,
|
|
1978
|
+
merge_commit_sha: null,
|
|
1979
|
+
merged_at: null,
|
|
1980
|
+
closed_at: null,
|
|
1981
|
+
// mergeable: an observed pull request's reported mergeable_state (clean→true, dirty→false), else null; one with
|
|
1982
|
+
// history has it computed when read (semantics/pulls.ts)
|
|
1983
|
+
// (real GitHub returns null until it computes mergeability asynchronously).
|
|
1984
|
+
mergeable: pr.mergeable_state === 'clean' ? true : pr.mergeable_state === 'dirty' ? false : null,
|
|
1985
|
+
mergeable_state: pr.mergeable_state ?? 'unknown',
|
|
1986
|
+
merged_by: null,
|
|
1987
|
+
// The base repository is the PR's stored repository. Other repository fields,
|
|
1988
|
+
// branch-owner identity and base.sha remain declared scope.
|
|
1989
|
+
base: { ...(pr.base_ref !== undefined ? { ref: pr.base_ref } : {}), repo: { full_name: repo } },
|
|
1990
|
+
// head.ref (the branch) + head.label (owner:ref) are emitted for LOCAL PRs that recorded the
|
|
1991
|
+
// branch; observed PRs carry head_sha only (the declared head.ref/head.label deviation). A pull request from a
|
|
1992
|
+
// fork names the fork: its owner in the label and its repository as the head's.
|
|
1993
|
+
head: (pr.head_sha !== undefined || pr.head_ref !== undefined)
|
|
1994
|
+
? {
|
|
1995
|
+
...(pr.head_ref !== undefined ? { label: `${(pr.head_repo ?? pr.repository).split('/')[0]}:${pr.head_ref}`, ref: pr.head_ref } : {}),
|
|
1996
|
+
...(pr.head_repo !== undefined ? { repo: { full_name: pr.head_repo } } : {}),
|
|
1997
|
+
...(pr.head_sha !== undefined ? { sha: pr.head_sha } : {}),
|
|
1998
|
+
}
|
|
1999
|
+
: {},
|
|
2000
|
+
};
|
|
2001
|
+
if (pr.head_sha !== undefined)
|
|
2002
|
+
out.statuses_url = `${api}/statuses/${pr.head_sha}`;
|
|
2003
|
+
// Content + counts: present only when observed/written; emitted correctly typed
|
|
2004
|
+
// when known, omitted (and declared scope) when not — never a wrong-typed null.
|
|
2005
|
+
if (pr.title !== undefined)
|
|
2006
|
+
out.title = pr.title;
|
|
2007
|
+
if (pr.changed_files !== undefined)
|
|
2008
|
+
out.changed_files = pr.changed_files;
|
|
2009
|
+
if (pr.commits !== undefined)
|
|
2010
|
+
out.commits = pr.commits;
|
|
2011
|
+
// additions/deletions: roll up from a carried file list (LOCAL writes). Omitted (and
|
|
2012
|
+
// declared scope) when no diff was supplied — observed PRs never carry line counts.
|
|
2013
|
+
if (pr.additions !== undefined)
|
|
2014
|
+
out.additions = pr.additions;
|
|
2015
|
+
if (pr.deletions !== undefined)
|
|
2016
|
+
out.deletions = pr.deletions;
|
|
2017
|
+
// Merge outcome: emitted (correctly typed) once a local merge write lands; before
|
|
2018
|
+
// that `merged` stays declared evidence-twin scope (omitted, not a faked boolean).
|
|
2019
|
+
if (pr.merged !== undefined)
|
|
2020
|
+
out.merged = pr.merged;
|
|
2021
|
+
if (pr.merged_at !== undefined) {
|
|
2022
|
+
out.merged_at = pr.merged_at;
|
|
2023
|
+
out.closed_at = pr.merged_at;
|
|
2024
|
+
}
|
|
2025
|
+
if (pr.merge_commit_sha !== undefined)
|
|
2026
|
+
out.merge_commit_sha = pr.merge_commit_sha;
|
|
2027
|
+
if (pr.merged_by_login)
|
|
2028
|
+
out.merged_by = toSimpleUser(pr.merged_by_login);
|
|
2029
|
+
if (pr.created_at)
|
|
2030
|
+
out.created_at = pr.created_at;
|
|
2031
|
+
return out;
|
|
2032
|
+
}
|
|
2033
|
+
// ── WHO a write is by when its token names no personal account ──────────────────────────
|
|
2034
|
+
// On GitHub a token has one owner and every write it makes is that owner's. The World's shared token
|
|
2035
|
+
// authenticates the World's default user (GET /user answers octocat for it), so its writes are octocat's
|
|
2036
|
+
// too: a comment, a review or a webhook's sender names the same person the reads do. A personal token
|
|
2037
|
+
// minted at POST /_twin/users/:login/tokens names its own user (callerLogin). A PULLED review carries the
|
|
2038
|
+
// login real GitHub named instead.
|
|
2039
|
+
export const GITHUB_WORLD_USER = 'octocat';
|
|
2040
|
+
/** The user a request's token authenticates, as GitHub attributes a write to the token's owner: a personal token
|
|
2041
|
+
* minted at `POST /_twin/users/:login/tokens` names its user, an app installation's token its bot (`<app>[bot]`).
|
|
2042
|
+
* Any other token (the World's shared fake) names no one, and the twin keeps its deterministic defaults. */
|
|
2043
|
+
export function callerLogin(token, root) {
|
|
2044
|
+
if (!token)
|
|
2045
|
+
return undefined;
|
|
2046
|
+
// every request asks, several times: the logins are read once per World state from the grant rows
|
|
2047
|
+
const store = getActiveWorldStore();
|
|
2048
|
+
const memos = loginMemos.get(store) ?? loginMemos.set(store, new Map()).get(store);
|
|
2049
|
+
const stamp = treeStamp(SERVICE, root);
|
|
2050
|
+
let held = memos.get(root ?? '');
|
|
2051
|
+
if (!held || held.stamp !== stamp) {
|
|
2052
|
+
const logins = new Map();
|
|
2053
|
+
for (const r of twinResources(SERVICE, root)) {
|
|
2054
|
+
if (r.deleted === true || typeof r.token !== 'string')
|
|
2055
|
+
continue;
|
|
2056
|
+
if (r.type === 'oauth_grant' && typeof r.login === 'string')
|
|
2057
|
+
logins.set(r.token, r.login);
|
|
2058
|
+
// an installation token acts as the app's bot
|
|
2059
|
+
if (r.type === 'app_token' && typeof r.app_slug === 'string')
|
|
2060
|
+
logins.set(r.token, `${r.app_slug}[bot]`);
|
|
2061
|
+
}
|
|
2062
|
+
held = { stamp, logins };
|
|
2063
|
+
memos.set(root ?? '', held);
|
|
2064
|
+
}
|
|
2065
|
+
return held.logins.get(token);
|
|
2066
|
+
}
|
|
2067
|
+
const loginMemos = new WeakMap();
|
|
2068
|
+
/** Whether a token GitHub issued has stopped working (an installation token expired with its run, a grant revoked). It is
|
|
2069
|
+
* refused, never taken as the World's own user: "Authenticating with invalid credentials will initially return a
|
|
2070
|
+
* `401 Unauthorized` response" (https://docs.github.com/rest/authentication/authenticating-to-the-rest-api); the message,
|
|
2071
|
+
* "Bad credentials", is GitHub's live answer, which its pages do not quote. A token the twin never issued is the World's
|
|
2072
|
+
* shared token, and is not refused. */
|
|
2073
|
+
export function revokedToken(token, root) {
|
|
2074
|
+
if (!token || callerLogin(token, root) !== undefined)
|
|
2075
|
+
return false;
|
|
2076
|
+
return twinResources(SERVICE, root).some((r) => r.deleted === true && r.token === token && (r.type === 'oauth_grant' || r.type === 'app_token'));
|
|
2077
|
+
}
|
|
2078
|
+
// Deterministic account id per login (never random, never Date.now), in its own id band so it
|
|
2079
|
+
// cannot collide with an observed subject id (0x40000000…) or a check-suite id (0x70000000…).
|
|
2080
|
+
export function twinUserId(login) {
|
|
2081
|
+
return 0x10000000 + (parseInt(createHash('sha256').update(`user:${login}`).digest('hex').slice(0, 8), 16) % 0x0fffffff);
|
|
2082
|
+
}
|
|
2083
|
+
// GitHub's simple-user object for a login the twin actually knows. Only `login` and `type`
|
|
2084
|
+
// are FACTS; the URLs are derived shape and the unmodeled remainder is declared scope
|
|
2085
|
+
// (`user.*` in github-known-deviations.json), exactly like `toAssignees`.
|
|
2086
|
+
/** How GitHub describes a person's relation to a repository (author_association): its owner, a member of the
|
|
2087
|
+
* organization that owns it, a collaborator, someone whose pull request it merged, or none of these. */
|
|
2088
|
+
export function authorAssociation(st, repo, login) {
|
|
2089
|
+
if (!login)
|
|
2090
|
+
return 'NONE';
|
|
2091
|
+
const owner = repo.split('/')[0];
|
|
2092
|
+
if (login === owner)
|
|
2093
|
+
return 'OWNER';
|
|
2094
|
+
if (st.orgMembers.some((m) => m.org === owner && m.login === login && m.state === 'active'))
|
|
2095
|
+
return 'MEMBER';
|
|
2096
|
+
if (st.collaborators.some((c) => c.repository === repo && c.login === login))
|
|
2097
|
+
return 'COLLABORATOR';
|
|
2098
|
+
if (st.prs.some((p) => p.repository === repo && p.user_login === login && p.merged))
|
|
2099
|
+
return 'CONTRIBUTOR';
|
|
2100
|
+
return 'NONE';
|
|
2101
|
+
}
|
|
2102
|
+
/** A GitHub node id in the legacy form GitHub still issues and accepts: base64 of the type name's length, the type
|
|
2103
|
+
* name and the object's id (`MDU6SXNzdWUx` is `05:Issue1`). Deterministic, so a replay mints the same one. */
|
|
2104
|
+
export const nodeId = (type, id) => Buffer.from(`${String(type.length).padStart(2, '0')}:${type}${id}`).toString('base64');
|
|
2105
|
+
/** The links GitHub gives every account, as it writes them; the avatar is served by avatars.githubusercontent.com. */
|
|
2106
|
+
export function userLinks(login, id) {
|
|
2107
|
+
const at = (path) => `https://api.github.com/users/${login}/${path}`;
|
|
2108
|
+
return {
|
|
2109
|
+
avatar_url: `https://avatars.githubusercontent.com/u/${id}?v=4`, gravatar_id: '', url: `https://api.github.com/users/${login}`, html_url: `https://github.com/${login}`,
|
|
2110
|
+
followers_url: at('followers'), following_url: at('following{/other_user}'), gists_url: at('gists{/gist_id}'), starred_url: at('starred{/owner}{/repo}'),
|
|
2111
|
+
subscriptions_url: at('subscriptions'), organizations_url: at('orgs'), repos_url: at('repos'), events_url: at('events{/privacy}'), received_events_url: at('received_events'),
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
export function toSimpleUser(login, type = 'User') {
|
|
2115
|
+
return {
|
|
2116
|
+
login,
|
|
2117
|
+
id: twinUserId(login),
|
|
2118
|
+
node_id: nodeId('User', twinUserId(login)),
|
|
2119
|
+
type,
|
|
2120
|
+
site_admin: false,
|
|
2121
|
+
...userLinks(login, twinUserId(login)),
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
// THE IMPLICIT REVIEW. Real GitHub wraps EVERY inline comment in a review: a comment posted
|
|
2125
|
+
// through `POST .../pulls/:n/comments` with no review of its own still comes back naming a
|
|
2126
|
+
// `pull_request_review_id`, and that wrapper review appears on `GET .../pulls/:n/reviews`.
|
|
2127
|
+
// The twin used to serve `pull_request_review_id: null` on every review comment, which made
|
|
2128
|
+
// the join a consumer does (comment → its review) DEAD against the twin's own data. So the
|
|
2129
|
+
// wrapper is minted DETERMINISTICALLY per (repo, PR): one implicit review per PR, the same id
|
|
2130
|
+
// every time. It is synthesized on the reviews READ from the comments that name it — never
|
|
2131
|
+
// stored as a review of its own, because it is a wrapper, not a verdict — and `githubState`
|
|
2132
|
+
// counts it into the pull request's review count under exactly the condition the read uses, so the count
|
|
2133
|
+
// and the list describe one population.
|
|
2134
|
+
//
|
|
2135
|
+
// WHY IT CANNOT COLLIDE, precisely — the band is NOT the reason. Its offset (0x80000000…)
|
|
2136
|
+
// separates it from the twin's own two id spaces, the local sequence (small integers) and
|
|
2137
|
+
// `observedId`'s hash band (0x40000000…), and that is all it does: real GitHub's own review
|
|
2138
|
+
// ids have been in the 0x80000000 range since ~2024, so a band argument against VENDOR ids
|
|
2139
|
+
// would be false. The actual invariant is that a vendor id is never a twin id: every pulled
|
|
2140
|
+
// review and comment is REHASHED through `observedId` before it is served (nothing arrives
|
|
2141
|
+
// carrying its raw GitHub id), and no review id is ever sent back OUT — a push addresses a
|
|
2142
|
+
// vendor comment by the `external_id` the pull recorded, never by a minted one.
|
|
2143
|
+
export function implicitReviewId(repository, number) {
|
|
2144
|
+
return 0x80000000 + (parseInt(createHash('sha256').update(`implicit-review:${repository}#${number}`).digest('hex').slice(0, 8), 16) % 0x3fffffff);
|
|
2145
|
+
}
|
|
2146
|
+
// Map a submitted review to GitHub's pull-request-review shape. `id` is an INTEGER
|
|
2147
|
+
// (real GitHub ids are integers; the twin's internal subject id stays `review:N`).
|
|
2148
|
+
// node_id/_links/author_association are declared evidence-twin scope (not fabricated).
|
|
2149
|
+
// Real GitHub takes a submit VERB on POST .../reviews (`event`) and stores a review STATE —
|
|
2150
|
+
// they are deliberately different words: APPROVE→APPROVED, REQUEST_CHANGES→CHANGES_REQUESTED,
|
|
2151
|
+
// COMMENT→COMMENTED. A twin that stores the verb serves `state: "APPROVE"`, and every consumer
|
|
2152
|
+
// that gates on the vendor's own vocabulary (`state === 'APPROVED'`, a merge gate counting
|
|
2153
|
+
// CHANGES_REQUESTED, the GraphQL PullRequestReviewState enum) reads it as no approval at all.
|
|
2154
|
+
// The mapping is applied ONCE, at submission, so the state the reviews API serves, the state
|
|
2155
|
+
// the timeline lower-cases and the state a merge gate counts are the same stored string.
|
|
2156
|
+
// Already-vendor-shaped states (APPROVED, DISMISSED, PENDING, an observed review's own state)
|
|
2157
|
+
// pass through untouched, which also makes this idempotent.
|
|
2158
|
+
const REVIEW_EVENT_STATES = { APPROVE: 'APPROVED', REQUEST_CHANGES: 'CHANGES_REQUESTED', COMMENT: 'COMMENTED' };
|
|
2159
|
+
export function reviewStateForEvent(event) {
|
|
2160
|
+
if (event === undefined || event === null || event === '')
|
|
2161
|
+
return 'COMMENTED';
|
|
2162
|
+
const verb = String(event);
|
|
2163
|
+
return REVIEW_EVENT_STATES[verb.toUpperCase()] ?? verb;
|
|
2164
|
+
}
|
|
2165
|
+
export function toGithubReview(a) {
|
|
2166
|
+
const api = `https://api.github.com/repos/${a.repository}`;
|
|
2167
|
+
const out = {
|
|
2168
|
+
id: a.id,
|
|
2169
|
+
node_id: nodeId('PullRequestReview', a.id),
|
|
2170
|
+
// WHO reviewed — the twin's own login on a local write, the vendor's on a pulled one,
|
|
2171
|
+
// and `null` only when the author is genuinely unknown.
|
|
2172
|
+
user: a.user_login === undefined ? null : toSimpleUser(a.user_login, a.user_type ?? 'User'),
|
|
2173
|
+
body: a.body ?? '',
|
|
2174
|
+
state: a.state ?? 'COMMENTED',
|
|
2175
|
+
// The head sha the review bound to at submission (exact-head re-earn); null only for
|
|
2176
|
+
// legacy/observed reviews whose submission head was never recorded.
|
|
2177
|
+
commit_id: a.commit_id ?? null,
|
|
2178
|
+
html_url: `https://github.com/${a.repository}/pull/${a.number}#pullrequestreview-${a.id}`,
|
|
2179
|
+
pull_request_url: `${api}/pulls/${a.number}`,
|
|
2180
|
+
author_association: a.author_association ?? 'NONE',
|
|
2181
|
+
_links: { html: { href: `https://github.com/${a.repository}/pull/${a.number}#pullrequestreview-${a.id}` }, pull_request: { href: `${api}/pulls/${a.number}` } },
|
|
2182
|
+
};
|
|
2183
|
+
if (a.submitted_at !== undefined)
|
|
2184
|
+
out.submitted_at = a.submitted_at;
|
|
2185
|
+
return out;
|
|
2186
|
+
}
|
|
2187
|
+
// Map a created issue comment to GitHub's issue-comment shape. `id` is an INTEGER;
|
|
2188
|
+
// node_id is declared evidence-twin scope. created_at/updated_at use the write time.
|
|
2189
|
+
export function toGithubComment(a) {
|
|
2190
|
+
const api = `https://api.github.com/repos/${a.repository}`;
|
|
2191
|
+
const out = {
|
|
2192
|
+
id: a.id,
|
|
2193
|
+
node_id: nodeId('IssueComment', a.id),
|
|
2194
|
+
user: a.user_login === undefined ? null : toSimpleUser(a.user_login, a.user_type ?? 'User'),
|
|
2195
|
+
body: a.body ?? '',
|
|
2196
|
+
url: `${api}/issues/comments/${a.id}`,
|
|
2197
|
+
html_url: `https://github.com/${a.repository}/issues/${a.number}#issuecomment-${a.id}`,
|
|
2198
|
+
issue_url: `${api}/issues/${a.number}`,
|
|
2199
|
+
};
|
|
2200
|
+
// Observed comments carry no timestamp — omit (declared scope) rather than fake an
|
|
2201
|
+
// empty-string date. Local writes supply `at`, so they emit real created_at/updated_at.
|
|
2202
|
+
// An EDIT moves updated_at off created_at, which is the only way a consumer can tell a
|
|
2203
|
+
// comment has been edited, so the two are carried separately once they differ.
|
|
2204
|
+
if (a.at !== undefined)
|
|
2205
|
+
out.created_at = a.at;
|
|
2206
|
+
if (a.updated_at !== undefined || a.at !== undefined)
|
|
2207
|
+
out.updated_at = a.updated_at ?? a.at;
|
|
2208
|
+
return out;
|
|
2209
|
+
}
|
|
2210
|
+
// The ONE mapping from a stored issue comment to the vendor's shape — shared by the thread
|
|
2211
|
+
// list (GET .../issues/:n/comments) and the per-comment door (GET/PATCH .../issues/comments/:id)
|
|
2212
|
+
// so the two can never disagree about the same comment.
|
|
2213
|
+
export function serializeIssueComment(c) {
|
|
2214
|
+
return toGithubComment({
|
|
2215
|
+
id: c.id, repository: c.repository, number: c.number, body: c.body,
|
|
2216
|
+
...(c.user_login !== undefined ? { user_login: c.user_login } : {}),
|
|
2217
|
+
...(c.user_type !== undefined ? { user_type: c.user_type } : {}),
|
|
2218
|
+
...(c.created_at !== undefined ? { at: c.created_at } : {}),
|
|
2219
|
+
...(c.updated_at !== undefined ? { updated_at: c.updated_at } : {}),
|
|
2220
|
+
});
|
|
2221
|
+
}
|
|
2222
|
+
// Map a created review comment to GitHub's pull-request review-comment shape. Distinct
|
|
2223
|
+
// from an issue comment: it carries pull_request_url + pull_request_review_id. The
|
|
2224
|
+
// diff-position fields (path/line/side/start_line/diff_hunk) are emitted for LOCAL writes
|
|
2225
|
+
// that anchored the comment to the diff; for OBSERVED review comments (no diff evidence)
|
|
2226
|
+
// they are omitted and stay declared scope (reviewComment.*). `id` is an INTEGER.
|
|
2227
|
+
export function toGithubReviewComment(a) {
|
|
2228
|
+
const api = `https://api.github.com/repos/${a.repository}`;
|
|
2229
|
+
const out = {
|
|
2230
|
+
id: a.id,
|
|
2231
|
+
node_id: nodeId('PullRequestReviewComment', a.id),
|
|
2232
|
+
...(a.commit_id !== undefined ? { commit_id: a.commit_id } : {}),
|
|
2233
|
+
user: a.user_login === undefined ? null : toSimpleUser(a.user_login, a.user_type ?? 'User'),
|
|
2234
|
+
body: a.body ?? '',
|
|
2235
|
+
url: `${api}/pulls/comments/${a.id}`,
|
|
2236
|
+
html_url: `https://github.com/${a.repository}/pull/${a.number}#discussion_r${a.id}`,
|
|
2237
|
+
pull_request_url: `${api}/pulls/${a.number}`,
|
|
2238
|
+
author_association: a.author_association ?? 'NONE',
|
|
2239
|
+
// the commit it was first written against: the same one until an outdated comment is carried forward
|
|
2240
|
+
original_commit_id: a.commit_id ?? '',
|
|
2241
|
+
_links: { self: { href: `${api}/pulls/comments/${a.id}` }, html: { href: `https://github.com/${a.repository}/pull/${a.number}#discussion_r${a.id}` }, pull_request: { href: `${api}/pulls/${a.number}` } },
|
|
2242
|
+
// THE JOIN. Every inline comment names the review that wraps it — the review it was
|
|
2243
|
+
// submitted under, or the PR's implicit review when it was posted on its own. `null`
|
|
2244
|
+
// only for a row whose review is genuinely unknown; it is no longer the constant answer.
|
|
2245
|
+
pull_request_review_id: a.review_id ?? null,
|
|
2246
|
+
};
|
|
2247
|
+
// Observed review comments carry no timestamp — omit rather than fake an empty date.
|
|
2248
|
+
if (a.at !== undefined)
|
|
2249
|
+
out.created_at = a.at;
|
|
2250
|
+
if (a.updated_at !== undefined || a.at !== undefined)
|
|
2251
|
+
out.updated_at = a.updated_at ?? a.at;
|
|
2252
|
+
if (a.in_reply_to !== undefined)
|
|
2253
|
+
out.in_reply_to_id = a.in_reply_to;
|
|
2254
|
+
// Diff anchoring — present only when a LOCAL write supplied it. diff_hunk defaults to ''
|
|
2255
|
+
// (the real API always returns the string) once a path is anchored; line/side per write.
|
|
2256
|
+
if (a.path !== undefined) {
|
|
2257
|
+
out.path = a.path;
|
|
2258
|
+
out.diff_hunk = a.diff_hunk ?? '';
|
|
2259
|
+
out.line = a.line ?? null;
|
|
2260
|
+
out.side = a.side ?? 'RIGHT';
|
|
2261
|
+
if (a.start_line !== undefined) {
|
|
2262
|
+
out.start_line = a.start_line;
|
|
2263
|
+
out.start_side = a.side ?? 'RIGHT';
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
return out;
|
|
2267
|
+
}
|
|
2268
|
+
// The ONE mapping from a stored review comment to the vendor's shape — shared by the diff-thread
|
|
2269
|
+
// list (GET .../pulls/:n/comments) and the per-comment door (GET/PATCH .../pulls/comments/:id).
|
|
2270
|
+
export function serializeReviewComment(c) {
|
|
2271
|
+
return toGithubReviewComment({
|
|
2272
|
+
...(c.commit_id !== undefined ? { commit_id: c.commit_id } : {}),
|
|
2273
|
+
...(c.review_id !== undefined ? { review_id: c.review_id } : {}),
|
|
2274
|
+
...(c.in_reply_to !== undefined ? { in_reply_to: c.in_reply_to } : {}),
|
|
2275
|
+
...(c.user_login !== undefined ? { user_login: c.user_login } : {}),
|
|
2276
|
+
...(c.user_type !== undefined ? { user_type: c.user_type } : {}),
|
|
2277
|
+
...(c.author_association !== undefined ? { author_association: c.author_association } : {}),
|
|
2278
|
+
id: c.id, repository: c.repository, number: c.number, body: c.body,
|
|
2279
|
+
...(c.created_at !== undefined ? { at: c.created_at } : {}),
|
|
2280
|
+
...(c.updated_at !== undefined ? { updated_at: c.updated_at } : {}),
|
|
2281
|
+
...(c.path !== undefined ? { path: c.path } : {}),
|
|
2282
|
+
...(c.line !== undefined ? { line: c.line } : {}),
|
|
2283
|
+
...(c.side !== undefined ? { side: c.side } : {}),
|
|
2284
|
+
...(c.start_line !== undefined ? { start_line: c.start_line } : {}),
|
|
2285
|
+
...(c.diff_hunk !== undefined ? { diff_hunk: c.diff_hunk } : {}),
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
// Map a per-file diff (LOCAL PR write) to GitHub's pull-request file shape. blob/raw/contents
|
|
2289
|
+
// url templates and the file sha are declared scope when not supplied (pullFile.*); the twin
|
|
2290
|
+
// emits the diff data it was actually given (filename/status/additions/deletions/patch).
|
|
2291
|
+
export function toGithubPullFile(repository, sha, f) {
|
|
2292
|
+
const out = {
|
|
2293
|
+
filename: f.filename,
|
|
2294
|
+
status: f.status,
|
|
2295
|
+
additions: f.additions,
|
|
2296
|
+
deletions: f.deletions,
|
|
2297
|
+
changes: f.changes,
|
|
2298
|
+
sha: f.sha ?? createHash('sha256').update(`${repository}:${f.filename}:${sha}`).digest('hex').slice(0, 40),
|
|
2299
|
+
blob_url: `https://github.com/${repository}/blob/${sha}/${f.filename}`,
|
|
2300
|
+
raw_url: `https://github.com/${repository}/raw/${sha}/${f.filename}`,
|
|
2301
|
+
contents_url: `https://api.github.com/repos/${repository}/contents/${f.filename}?ref=${sha}`,
|
|
2302
|
+
};
|
|
2303
|
+
if (f.patch !== undefined)
|
|
2304
|
+
out.patch = f.patch;
|
|
2305
|
+
if (f.previous_filename !== undefined)
|
|
2306
|
+
out.previous_filename = f.previous_filename;
|
|
2307
|
+
return out;
|
|
2308
|
+
}
|
|
2309
|
+
// Map a twin issue to GitHub's REST issue shape. A LOCAL construct (issues are not
|
|
2310
|
+
// in the observed evidence fold), so title/body/timestamps come from the write. The
|
|
2311
|
+
// same unmodeled sub-objects as PRs (id, node_id, user, etc.) are declared scope in
|
|
2312
|
+
// github-known-deviations.json — never fabricated.
|
|
2313
|
+
// `comments` is the COUNT of issue comments on this issue — real GitHub's issue object
|
|
2314
|
+
// carries it, and every consumer that decides whether a thread is worth opening reads it.
|
|
2315
|
+
// It is derived from the same stored comments the thread list serves (the whole `comments`
|
|
2316
|
+
// store is passed in rather than a pre-computed number, so the count and the list cannot
|
|
2317
|
+
// disagree); a caller with no comment store to hand gets the honest 0 of a world with none.
|
|
2318
|
+
export function issueCommentCount(repository, number, comments) {
|
|
2319
|
+
if (!comments)
|
|
2320
|
+
return 0;
|
|
2321
|
+
let n = 0;
|
|
2322
|
+
for (const c of comments)
|
|
2323
|
+
if (c.repository === repository && c.number === number && c.kind === 'issue')
|
|
2324
|
+
n += 1;
|
|
2325
|
+
return n;
|
|
2326
|
+
}
|
|
2327
|
+
/** An issue's numeric id: GitHub's `id`, distinct from its number, what the sub-issue API names a child by. */
|
|
2328
|
+
export function githubIssueId(repo, number) {
|
|
2329
|
+
return 0x40000000 + (parseInt(createHash('sha256').update(`${repo}#issue:${number}`).digest('hex').slice(0, 8), 16) % 0x0fffffff);
|
|
2330
|
+
}
|
|
2331
|
+
export function toGithubIssue(iss, milestones, pull, comments) {
|
|
2332
|
+
const repo = iss.repository;
|
|
2333
|
+
const n = iss.number;
|
|
2334
|
+
const api = `https://api.github.com/repos/${repo}`;
|
|
2335
|
+
const ms = iss.milestone !== undefined ? (milestones ?? []).find((m) => m.repository === repo && m.number === iss.milestone) : undefined;
|
|
2336
|
+
const id = githubIssueId(iss.born_in ?? repo, n);
|
|
2337
|
+
return {
|
|
2338
|
+
id,
|
|
2339
|
+
node_id: `I_kwDO${id.toString(36)}`,
|
|
2340
|
+
number: n,
|
|
2341
|
+
user: toSimpleUser(iss.user_login ?? GITHUB_WORLD_USER),
|
|
2342
|
+
state: iss.state ?? 'open',
|
|
2343
|
+
state_reason: iss.state_reason ?? null,
|
|
2344
|
+
title: iss.title ?? '',
|
|
2345
|
+
body: iss.body ?? null,
|
|
2346
|
+
labels: toLabels(iss.labels, iss.repository),
|
|
2347
|
+
locked: iss.locked ?? false,
|
|
2348
|
+
active_lock_reason: iss.active_lock_reason ?? null,
|
|
2349
|
+
assignee: iss.assignees && iss.assignees.length ? { login: iss.assignees[0] } : null,
|
|
2350
|
+
assignees: toAssignees(iss.assignees),
|
|
2351
|
+
milestone: ms ? toGithubMilestone(ms) : null,
|
|
2352
|
+
comments: issueCommentCount(repo, n, comments),
|
|
2353
|
+
url: `${api}/issues/${n}`,
|
|
2354
|
+
repository_url: api,
|
|
2355
|
+
labels_url: `${api}/issues/${n}/labels{/name}`,
|
|
2356
|
+
comments_url: `${api}/issues/${n}/comments`,
|
|
2357
|
+
events_url: `${api}/issues/${n}/events`,
|
|
2358
|
+
html_url: `https://github.com/${repo}/issues/${n}`,
|
|
2359
|
+
created_at: iss.created_at ?? null,
|
|
2360
|
+
updated_at: iss.updated_at ?? null,
|
|
2361
|
+
closed_at: iss.state === 'closed' ? iss.updated_at ?? null : null,
|
|
2362
|
+
author_association: iss.author_association ?? 'NONE',
|
|
2363
|
+
// Every pull request is an issue, and the `pull_request` key is how every real consumer
|
|
2364
|
+
// tells the two apart in an issue list. It is present ONLY on a pull request — an issue
|
|
2365
|
+
// that carries it would be misread as a PR, which is the whole point of the key.
|
|
2366
|
+
...(pull ? { pull_request: { url: `${api}/pulls/${n}`, html_url: `https://github.com/${repo}/pull/${n}`, diff_url: `https://github.com/${repo}/pull/${n}.diff`, patch_url: `https://github.com/${repo}/pull/${n}.patch`, merged_at: pull.merged_at ?? null } } : {}),
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
2369
|
+
// A pull request's ISSUE face — the row `GET .../issues` returns for it. Real GitHub stores
|
|
2370
|
+
// one object; this twin stores PRs and issues as two constructs, so the issue list projects
|
|
2371
|
+
// each pull request through here and serializes it with the `pull_request` key attached.
|
|
2372
|
+
// The PR construct carries no created_at/updated_at (both are declared deviations), so the
|
|
2373
|
+
// projected row's timestamps are null — the same honest empty the PR object itself returns.
|
|
2374
|
+
export function pullRequestAsIssue(pr) {
|
|
2375
|
+
return {
|
|
2376
|
+
id: `${pr.repository}#issue:${pr.number}`,
|
|
2377
|
+
number: pr.number,
|
|
2378
|
+
repository: pr.repository,
|
|
2379
|
+
title: pr.title,
|
|
2380
|
+
body: pr.body,
|
|
2381
|
+
state: pr.merged ? 'closed' : (pr.state ?? 'open'),
|
|
2382
|
+
labels: pr.labels,
|
|
2383
|
+
assignees: pr.assignees,
|
|
2384
|
+
milestone: pr.milestone,
|
|
2385
|
+
locked: pr.locked,
|
|
2386
|
+
active_lock_reason: pr.active_lock_reason,
|
|
2387
|
+
// The open↔closed transitions come with it: the timeline served at the issue address is
|
|
2388
|
+
// built from the issue face, and a pull request that was closed and reopened must say so
|
|
2389
|
+
// there for exactly the reason an issue must.
|
|
2390
|
+
state_history: pr.state_history,
|
|
2391
|
+
activity: pr.activity,
|
|
2392
|
+
};
|
|
2393
|
+
}
|
|
2394
|
+
// Map a commit status to GitHub's status shape. `id` is an INTEGER; creator/node_id and
|
|
2395
|
+
// avatar_url are declared scope (status.*). The twin has the state/context it was written.
|
|
2396
|
+
export function toGithubStatus(s) {
|
|
2397
|
+
const api = `https://api.github.com/repos/${s.repository}`;
|
|
2398
|
+
const out = {
|
|
2399
|
+
id: s.id,
|
|
2400
|
+
node_id: nodeId('StatusContext', s.id),
|
|
2401
|
+
state: s.state,
|
|
2402
|
+
context: s.context ?? 'default',
|
|
2403
|
+
description: s.description ?? null,
|
|
2404
|
+
target_url: s.target_url ?? null,
|
|
2405
|
+
url: `${api}/statuses/${s.sha}`,
|
|
2406
|
+
avatar_url: null,
|
|
2407
|
+
creator: null,
|
|
2408
|
+
};
|
|
2409
|
+
if (s.created_at !== undefined)
|
|
2410
|
+
out.created_at = s.created_at;
|
|
2411
|
+
if (s.updated_at !== undefined)
|
|
2412
|
+
out.updated_at = s.updated_at;
|
|
2413
|
+
return out;
|
|
2414
|
+
}
|
|
2415
|
+
// Combine commit statuses into GitHub's combined-status shape (GET .../commits/:ref/status).
|
|
2416
|
+
// The combined state is failure > pending > success precedence (GitHub's rollup); with no
|
|
2417
|
+
// statuses GitHub reports 'pending'. Only the latest status per context counts toward the rollup.
|
|
2418
|
+
export function combineStatuses(repository, sha, statuses) {
|
|
2419
|
+
const api = `https://api.github.com/repos/${repository}`;
|
|
2420
|
+
const forSha = statuses.filter((s) => s.repository === repository && s.sha === sha);
|
|
2421
|
+
// latest per context (later writes have a higher sequence id)
|
|
2422
|
+
const latest = new Map();
|
|
2423
|
+
for (const s of forSha) {
|
|
2424
|
+
const key = s.context ?? 'default';
|
|
2425
|
+
const prev = latest.get(key);
|
|
2426
|
+
if (!prev || s.id > prev.id)
|
|
2427
|
+
latest.set(key, s);
|
|
2428
|
+
}
|
|
2429
|
+
const rolled = [...latest.values()];
|
|
2430
|
+
let state = 'pending';
|
|
2431
|
+
if (rolled.length) {
|
|
2432
|
+
if (rolled.some((s) => s.state === 'failure' || s.state === 'error'))
|
|
2433
|
+
state = 'failure';
|
|
2434
|
+
else if (rolled.some((s) => s.state === 'pending'))
|
|
2435
|
+
state = 'pending';
|
|
2436
|
+
else if (rolled.every((s) => s.state === 'success'))
|
|
2437
|
+
state = 'success';
|
|
2438
|
+
}
|
|
2439
|
+
return {
|
|
2440
|
+
state,
|
|
2441
|
+
total_count: rolled.length,
|
|
2442
|
+
sha,
|
|
2443
|
+
statuses: rolled.sort((a, b) => a.id - b.id).map(toGithubStatus),
|
|
2444
|
+
repository_url: api,
|
|
2445
|
+
commit_url: `${api}/commits/${sha}`,
|
|
2446
|
+
url: `${api}/commits/${sha}/status`,
|
|
2447
|
+
};
|
|
2448
|
+
}
|
|
2449
|
+
// Map a check run to GitHub's check-run shape. `id` is an INTEGER; app/node_id/output and
|
|
2450
|
+
// the check_suite link are declared scope (checkRun.*). conclusion is null until completed.
|
|
2451
|
+
export function toGithubCheckRun(c, st) {
|
|
2452
|
+
const suiteId = c.check_suite_id ?? checkSuiteId(c.repository, c.head_sha, c.app_slug);
|
|
2453
|
+
const annCount = c.annotations?.length ?? 0;
|
|
2454
|
+
const out = {
|
|
2455
|
+
id: c.id,
|
|
2456
|
+
node_id: nodeId('CheckRun', c.id),
|
|
2457
|
+
head_sha: c.head_sha,
|
|
2458
|
+
name: c.name,
|
|
2459
|
+
status: c.status ?? 'queued',
|
|
2460
|
+
conclusion: c.conclusion ?? null,
|
|
2461
|
+
details_url: c.details_url ?? null,
|
|
2462
|
+
external_id: c.external_id ?? '',
|
|
2463
|
+
pull_requests: pullRequestsOn(st, c.repository, c.head_sha),
|
|
2464
|
+
url: `https://api.github.com/repos/${c.repository}/check-runs/${c.id}`,
|
|
2465
|
+
html_url: `https://github.com/${c.repository}/runs/${c.id}`,
|
|
2466
|
+
// The suite this run belongs to (the suite link real GitHub returns on a check run).
|
|
2467
|
+
check_suite: { id: suiteId },
|
|
2468
|
+
app: c.app_slug ? { slug: c.app_slug, name: c.app_slug } : null,
|
|
2469
|
+
// output object: title/summary/text + annotations count (the annotations themselves are
|
|
2470
|
+
// fetched via .../check-runs/:id/annotations). Empty (nulls/0) until a write supplies output.
|
|
2471
|
+
output: {
|
|
2472
|
+
title: c.output_title ?? null,
|
|
2473
|
+
summary: c.output_summary ?? null,
|
|
2474
|
+
text: c.output_text ?? null,
|
|
2475
|
+
annotations_count: annCount,
|
|
2476
|
+
annotations_url: `https://api.github.com/repos/${c.repository}/check-runs/${c.id}/annotations`,
|
|
2477
|
+
},
|
|
2478
|
+
};
|
|
2479
|
+
if (c.started_at !== undefined)
|
|
2480
|
+
out.started_at = c.started_at;
|
|
2481
|
+
out.completed_at = c.completed_at ?? null;
|
|
2482
|
+
return out;
|
|
2483
|
+
}
|
|
2484
|
+
// Map a check-run annotation to GitHub's annotation shape (GET .../check-runs/:id/annotations).
|
|
2485
|
+
export function toGithubAnnotation(c, an) {
|
|
2486
|
+
return {
|
|
2487
|
+
path: an.path,
|
|
2488
|
+
start_line: an.start_line,
|
|
2489
|
+
end_line: an.end_line,
|
|
2490
|
+
start_column: an.start_column ?? null,
|
|
2491
|
+
end_column: an.end_column ?? null,
|
|
2492
|
+
annotation_level: an.annotation_level,
|
|
2493
|
+
title: an.title ?? null,
|
|
2494
|
+
message: an.message,
|
|
2495
|
+
raw_details: an.raw_details ?? null,
|
|
2496
|
+
blob_href: `https://github.com/${c.repository}/blob/${c.head_sha}/${an.path}`,
|
|
2497
|
+
};
|
|
2498
|
+
}
|
|
2499
|
+
// Map a check SUITE (GET .../check-suites/:id, GET .../commits/:ref/check-suites): one app's runs on one
|
|
2500
|
+
// commit, rolled up into the suite's status/conclusion.
|
|
2501
|
+
export function toGithubCheckSuite(repository, headSha, runs, app, st) {
|
|
2502
|
+
const id = checkSuiteId(repository, headSha, app);
|
|
2503
|
+
const suiteRuns = runs.filter((r) => r.repository === repository && r.head_sha === headSha && r.app_slug === app);
|
|
2504
|
+
const allCompleted = suiteRuns.length > 0 && suiteRuns.every((r) => (r.status ?? 'queued') === 'completed');
|
|
2505
|
+
const status = suiteRuns.length === 0 ? 'queued' : allCompleted ? 'completed' : (suiteRuns.some((r) => r.status === 'in_progress') ? 'in_progress' : 'queued');
|
|
2506
|
+
let conclusion = null;
|
|
2507
|
+
if (allCompleted) {
|
|
2508
|
+
if (suiteRuns.some((r) => r.conclusion === 'failure' || r.conclusion === 'timed_out'))
|
|
2509
|
+
conclusion = 'failure';
|
|
2510
|
+
else if (suiteRuns.some((r) => r.conclusion === 'cancelled'))
|
|
2511
|
+
conclusion = 'cancelled';
|
|
2512
|
+
else if (suiteRuns.every((r) => r.conclusion === 'success' || r.conclusion === 'neutral' || r.conclusion === 'skipped'))
|
|
2513
|
+
conclusion = 'success';
|
|
2514
|
+
}
|
|
2515
|
+
const api = `https://api.github.com/repos/${repository}`;
|
|
2516
|
+
return {
|
|
2517
|
+
id,
|
|
2518
|
+
node_id: nodeId('CheckSuite', id),
|
|
2519
|
+
head_sha: headSha,
|
|
2520
|
+
head_branch: null,
|
|
2521
|
+
status,
|
|
2522
|
+
conclusion,
|
|
2523
|
+
url: `${api}/check-suites/${id}`,
|
|
2524
|
+
latest_check_runs_count: suiteRuns.length,
|
|
2525
|
+
check_runs_url: `${api}/check-suites/${id}/check-runs`,
|
|
2526
|
+
before: null,
|
|
2527
|
+
after: null,
|
|
2528
|
+
pull_requests: pullRequestsOn(st, repository, headSha),
|
|
2529
|
+
app: app ? { slug: app, name: app } : null,
|
|
2530
|
+
// it is created with its first run and changes with its last
|
|
2531
|
+
created_at: suiteRuns.map((r) => r.started_at).filter(Boolean).sort()[0] ?? null,
|
|
2532
|
+
updated_at: suiteRuns.map((r) => r.completed_at ?? r.started_at).filter(Boolean).sort().at(-1) ?? null,
|
|
2533
|
+
...(st?.repos.some((x) => x.full_name === repository) ? { repository: Object.fromEntries(Object.entries(toGithubRepo(st.repos.find((x) => x.full_name === repository))).filter(([k]) => MINIMAL_REPOSITORY.has(k))) } : {}),
|
|
2534
|
+
};
|
|
2535
|
+
}
|
|
2536
|
+
// Map a reaction to GitHub's reaction shape.
|
|
2537
|
+
export function toGithubReaction(r) {
|
|
2538
|
+
const out = { id: r.id, node_id: nodeId('Reaction', r.id), content: r.content, user: null };
|
|
2539
|
+
if (r.created_at !== undefined)
|
|
2540
|
+
out.created_at = r.created_at;
|
|
2541
|
+
return out;
|
|
2542
|
+
}
|
|
2543
|
+
// Map a repo label to GitHub's label shape.
|
|
2544
|
+
export function toGithubLabel(l) {
|
|
2545
|
+
return {
|
|
2546
|
+
id: l.id,
|
|
2547
|
+
name: l.name,
|
|
2548
|
+
color: l.color ?? 'ededed',
|
|
2549
|
+
description: l.description ?? null,
|
|
2550
|
+
default: l.default ?? false,
|
|
2551
|
+
url: `https://api.github.com/repos/${l.repository}/labels/${encodeURIComponent(l.name)}`,
|
|
2552
|
+
node_id: nodeId('Label', l.id),
|
|
2553
|
+
archived_at: null,
|
|
2554
|
+
archived_by: null,
|
|
2555
|
+
};
|
|
2556
|
+
}
|
|
2557
|
+
// Map a repository to GitHub's repository shape (the fields the twin models; the rich
|
|
2558
|
+
// owner/permissions/license sub-objects are declared scope — not fabricated).
|
|
2559
|
+
export function toGithubRepo(r) {
|
|
2560
|
+
const [owner] = r.full_name.split('/');
|
|
2561
|
+
const api = `https://api.github.com`;
|
|
2562
|
+
const repoApi = `${api}/repos/${r.full_name}`;
|
|
2563
|
+
const facts = r.facts ?? { stargazers_count: 0, subscribers_count: 0, forks_count: 0, open_issues_count: 0, pushed_at: r.created_at ?? null, size: 0, language: null, license: null, has_pages: false };
|
|
2564
|
+
return {
|
|
2565
|
+
...repoUrls(repoApi),
|
|
2566
|
+
clone_url: `https://github.com/${r.full_name}.git`, git_url: `git://github.com/${r.full_name}.git`,
|
|
2567
|
+
ssh_url: `git@github.com:${r.full_name}.git`, svn_url: `https://github.com/${r.full_name}`, mirror_url: null,
|
|
2568
|
+
stargazers_count: facts.stargazers_count, watchers_count: facts.stargazers_count, watchers: facts.stargazers_count,
|
|
2569
|
+
subscribers_count: facts.subscribers_count, network_count: facts.forks_count, forks_count: facts.forks_count, forks: facts.forks_count,
|
|
2570
|
+
open_issues_count: facts.open_issues_count, open_issues: facts.open_issues_count,
|
|
2571
|
+
pushed_at: facts.pushed_at, size: facts.size, language: facts.language, license: facts.license,
|
|
2572
|
+
has_pages: facts.has_pages, has_downloads: true,
|
|
2573
|
+
id: r.id,
|
|
2574
|
+
name: r.name,
|
|
2575
|
+
full_name: r.full_name,
|
|
2576
|
+
owner: toSimpleUser(owner),
|
|
2577
|
+
private: r.private ?? false,
|
|
2578
|
+
description: r.description ?? null,
|
|
2579
|
+
fork: r.fork ?? false,
|
|
2580
|
+
default_branch: r.default_branch ?? 'main',
|
|
2581
|
+
topics: r.topics ?? [],
|
|
2582
|
+
homepage: r.homepage ?? null,
|
|
2583
|
+
has_issues: r.has_issues ?? true,
|
|
2584
|
+
has_projects: r.has_projects ?? true,
|
|
2585
|
+
has_wiki: r.has_wiki ?? true,
|
|
2586
|
+
has_discussions: r.has_discussions ?? false,
|
|
2587
|
+
archived: r.archived ?? false,
|
|
2588
|
+
disabled: false,
|
|
2589
|
+
html_url: `https://github.com/${r.full_name}`,
|
|
2590
|
+
url: `${api}/repos/${r.full_name}`,
|
|
2591
|
+
...(r.parent_full_name ? { parent: { full_name: r.parent_full_name, url: `${api}/repos/${r.parent_full_name}` } } : {}),
|
|
2592
|
+
created_at: r.created_at ?? null,
|
|
2593
|
+
updated_at: r.updated_at ?? null,
|
|
2594
|
+
node_id: nodeId('Repository', r.id),
|
|
2595
|
+
};
|
|
2596
|
+
}
|
|
2597
|
+
/** The API links GitHub gives every repository, as it writes them (templated where it templates them). */
|
|
2598
|
+
function repoUrls(repoApi) {
|
|
2599
|
+
const at = (path) => `${repoApi}/${path}`;
|
|
2600
|
+
return {
|
|
2601
|
+
archive_url: at('{archive_format}{/ref}'), assignees_url: at('assignees{/user}'), blobs_url: at('git/blobs{/sha}'), branches_url: at('branches{/branch}'),
|
|
2602
|
+
collaborators_url: at('collaborators{/collaborator}'), comments_url: at('comments{/number}'), commits_url: at('commits{/sha}'), compare_url: at('compare/{base}...{head}'),
|
|
2603
|
+
contents_url: at('contents/{+path}'), contributors_url: at('contributors'), deployments_url: at('deployments'), downloads_url: at('downloads'), events_url: at('events'),
|
|
2604
|
+
forks_url: at('forks'), git_commits_url: at('git/commits{/sha}'), git_refs_url: at('git/refs{/sha}'), git_tags_url: at('git/tags{/sha}'), hooks_url: at('hooks'),
|
|
2605
|
+
issue_comment_url: at('issues/comments{/number}'), issue_events_url: at('issues/events{/number}'), issues_url: at('issues{/number}'), keys_url: at('keys{/key_id}'),
|
|
2606
|
+
labels_url: at('labels{/name}'), languages_url: at('languages'), merges_url: at('merges'), milestones_url: at('milestones{/number}'),
|
|
2607
|
+
notifications_url: at('notifications{?since,all,participating}'), pulls_url: at('pulls{/number}'), releases_url: at('releases{/id}'), stargazers_url: at('stargazers'),
|
|
2608
|
+
statuses_url: at('statuses/{sha}'), subscribers_url: at('subscribers'), subscription_url: at('subscription'), tags_url: at('tags'), teams_url: at('teams'), trees_url: at('git/trees{/sha}'),
|
|
2609
|
+
};
|
|
2610
|
+
}
|
|
2611
|
+
// Map a branch to GitHub's branch shape (GET .../branches[/:name]). The protection summary
|
|
2612
|
+
// carries the PERSISTED required-check contexts + the real enforcement_level semantics
|
|
2613
|
+
// (everyone when enforce_admins, non_admins otherwise, off when unprotected) — this summary
|
|
2614
|
+
// is what merge-gate scripts read their required contexts from.
|
|
2615
|
+
export function toGithubBranch(b) {
|
|
2616
|
+
const api = `https://api.github.com/repos/${b.repository}`;
|
|
2617
|
+
const rsc = b.protection_config?.required_status_checks;
|
|
2618
|
+
// enforcement_level is about REQUIRED STATUS CHECKS: 'off' when the branch has none
|
|
2619
|
+
// configured (even while otherwise protected), 'everyone' under enforce_admins,
|
|
2620
|
+
// 'non_admins' otherwise — real GitHub's branch-summary semantics.
|
|
2621
|
+
const enforcement = !(b.protected ?? false) || !rsc ? 'off' : (b.protection_config?.enforce_admins ? 'everyone' : 'non_admins');
|
|
2622
|
+
return {
|
|
2623
|
+
name: b.name,
|
|
2624
|
+
commit: { sha: b.commit_sha, url: `${api}/commits/${b.commit_sha}` },
|
|
2625
|
+
protected: b.protected ?? false,
|
|
2626
|
+
protection: {
|
|
2627
|
+
enabled: b.protected ?? false,
|
|
2628
|
+
required_status_checks: { enforcement_level: enforcement, contexts: rsc?.contexts ?? [], checks: rsc?.checks ?? [] },
|
|
2629
|
+
},
|
|
2630
|
+
protection_url: `${api}/branches/${encodeURIComponent(b.name)}/protection`,
|
|
2631
|
+
};
|
|
2632
|
+
}
|
|
2633
|
+
// Render the FULL classic protection object (GET/PUT .../branches/:b/protection).
|
|
2634
|
+
export function toGithubBranchProtection(b) {
|
|
2635
|
+
const base = `https://api.github.com/repos/${b.repository}/branches/${encodeURIComponent(b.name)}/protection`;
|
|
2636
|
+
const cfg = b.protection_config ?? {};
|
|
2637
|
+
const out = { url: base, enabled: true };
|
|
2638
|
+
if (cfg.required_status_checks) {
|
|
2639
|
+
out.required_status_checks = {
|
|
2640
|
+
url: `${base}/required_status_checks`,
|
|
2641
|
+
strict: cfg.required_status_checks.strict,
|
|
2642
|
+
contexts: cfg.required_status_checks.contexts,
|
|
2643
|
+
contexts_url: `${base}/required_status_checks/contexts`,
|
|
2644
|
+
checks: cfg.required_status_checks.checks,
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
2647
|
+
out.enforce_admins = { url: `${base}/enforce_admins`, enabled: cfg.enforce_admins ?? false };
|
|
2648
|
+
if (cfg.required_pull_request_reviews) {
|
|
2649
|
+
out.required_pull_request_reviews = { url: `${base}/required_pull_request_reviews`, ...cfg.required_pull_request_reviews };
|
|
2650
|
+
}
|
|
2651
|
+
if (cfg.restrictions)
|
|
2652
|
+
out.restrictions = restrictionsOf(base, cfg.restrictions);
|
|
2653
|
+
return out;
|
|
2654
|
+
}
|
|
2655
|
+
/** A protection's push restrictions, with their url. */
|
|
2656
|
+
function restrictionsOf(base, restrictions) {
|
|
2657
|
+
return { url: `${base}/restrictions`, ...restrictions };
|
|
2658
|
+
}
|
|
2659
|
+
// Normalize a PUT .../protection body's required_status_checks into the persisted shape.
|
|
2660
|
+
// Real GitHub accepts `contexts` (deprecated) or `checks` [{context, app_id}]; both are kept
|
|
2661
|
+
// in sync so either read-back form answers.
|
|
2662
|
+
export function normalizeRequiredStatusChecks(v) {
|
|
2663
|
+
if (v === null || v === undefined || typeof v !== 'object')
|
|
2664
|
+
return null;
|
|
2665
|
+
const o = v;
|
|
2666
|
+
const checksIn = Array.isArray(o.checks) ? o.checks : undefined;
|
|
2667
|
+
const contextsIn = Array.isArray(o.contexts) ? o.contexts.map(String) : undefined;
|
|
2668
|
+
const checks = checksIn
|
|
2669
|
+
? 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) }))
|
|
2670
|
+
: (contextsIn ?? []).map((context) => ({ context, app_id: null }));
|
|
2671
|
+
const contexts = contextsIn ?? checks.map((c) => c.context);
|
|
2672
|
+
return { strict: o.strict === true, contexts, checks };
|
|
2673
|
+
}
|
|
2674
|
+
// Map a collaborator to GitHub's collaborator simple-user shape (with a permissions block).
|
|
2675
|
+
export function toGithubCollaborator(c) {
|
|
2676
|
+
const perm = c.permission;
|
|
2677
|
+
return {
|
|
2678
|
+
login: c.login,
|
|
2679
|
+
id: twinUserId(c.login),
|
|
2680
|
+
...userLinks(c.login, twinUserId(c.login)),
|
|
2681
|
+
site_admin: false,
|
|
2682
|
+
type: 'User',
|
|
2683
|
+
role_name: perm,
|
|
2684
|
+
permissions: {
|
|
2685
|
+
pull: true,
|
|
2686
|
+
triage: ['triage', 'push', 'maintain', 'admin'].includes(perm),
|
|
2687
|
+
push: ['push', 'maintain', 'admin'].includes(perm),
|
|
2688
|
+
maintain: ['maintain', 'admin'].includes(perm),
|
|
2689
|
+
admin: perm === 'admin',
|
|
2690
|
+
},
|
|
2691
|
+
node_id: nodeId('User', twinUserId(c.login)),
|
|
2692
|
+
url: `https://api.github.com/users/${c.login}`,
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
// Both hook read doors share one configuration serializer. Never expose signing material.
|
|
2696
|
+
export function toGithubWebhookConfig(h) {
|
|
2697
|
+
return { url: h.url ?? '', content_type: h.content_type ?? 'form', insecure_ssl: '0', ...(h.secret ? { secret: '********' } : {}) };
|
|
2698
|
+
}
|
|
2699
|
+
export function hookConfigOverlaps(hooks, candidate, excluding) {
|
|
2700
|
+
return hooks.some(h => h.id !== excluding && h.repository === candidate.repository
|
|
2701
|
+
&& h.url === candidate.url && (h.content_type ?? 'form') === (candidate.content_type ?? 'form') && (h.secret ?? '') === (candidate.secret ?? '')
|
|
2702
|
+
&& (h.events.includes('*') || candidate.events.includes('*') || h.events.some(event => candidate.events.includes(event))));
|
|
2703
|
+
}
|
|
2704
|
+
export function validateGithubWebhook(body, configOnly, creating) {
|
|
2705
|
+
const invalid = (field) => ({ status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'Hook', field, code: 'invalid' }] } });
|
|
2706
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
2707
|
+
return invalid('body');
|
|
2708
|
+
if (!configOnly) {
|
|
2709
|
+
if (body.name !== undefined && body.name !== 'web')
|
|
2710
|
+
return invalid('name');
|
|
2711
|
+
if (body.active !== undefined && typeof body.active !== 'boolean')
|
|
2712
|
+
return invalid('active');
|
|
2713
|
+
for (const field of ['events', 'add_events', 'remove_events']) {
|
|
2714
|
+
if (body[field] !== undefined && (!Array.isArray(body[field]) || body[field].some((event) => typeof event !== 'string' || !event.trim())))
|
|
2715
|
+
return invalid(field);
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
const cfg = configOnly ? body : (body.config === undefined ? {} : body.config);
|
|
2719
|
+
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg))
|
|
2720
|
+
return invalid('config');
|
|
2721
|
+
if (creating || cfg.url !== undefined) {
|
|
2722
|
+
if (typeof cfg.url !== 'string')
|
|
2723
|
+
return invalid('config.url');
|
|
2724
|
+
try {
|
|
2725
|
+
if (!['http:', 'https:'].includes(new URL(cfg.url).protocol))
|
|
2726
|
+
return invalid('config.url');
|
|
2727
|
+
}
|
|
2728
|
+
catch {
|
|
2729
|
+
return invalid('config.url');
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
if (cfg.content_type !== undefined && cfg.content_type !== 'json' && cfg.content_type !== 'form')
|
|
2733
|
+
return invalid('config.content_type');
|
|
2734
|
+
if (cfg.secret !== undefined && typeof cfg.secret !== 'string')
|
|
2735
|
+
return invalid('config.secret');
|
|
2736
|
+
if (cfg.insecure_ssl !== undefined && !['0', '1', 0, 1].includes(cfg.insecure_ssl))
|
|
2737
|
+
return invalid('config.insecure_ssl');
|
|
2738
|
+
if (String(cfg.insecure_ssl) === '1')
|
|
2739
|
+
return { status: 501, body: { message: 'This twin does not support disabling webhook TLS verification' } };
|
|
2740
|
+
return undefined;
|
|
2741
|
+
}
|
|
2742
|
+
// Map a webhook to GitHub's hook shape (GET .../hooks[/:id]).
|
|
2743
|
+
export function toGithubWebhook(h) {
|
|
2744
|
+
const api = `https://api.github.com/repos/${h.repository}`;
|
|
2745
|
+
const out = {
|
|
2746
|
+
id: h.id,
|
|
2747
|
+
name: h.name,
|
|
2748
|
+
active: h.active,
|
|
2749
|
+
events: h.events,
|
|
2750
|
+
config: toGithubWebhookConfig(h),
|
|
2751
|
+
url: `${api}/hooks/${h.id}`,
|
|
2752
|
+
test_url: `${api}/hooks/${h.id}/test`,
|
|
2753
|
+
ping_url: `${api}/hooks/${h.id}/pings`,
|
|
2754
|
+
deliveries_url: `${api}/hooks/${h.id}/deliveries`,
|
|
2755
|
+
type: 'Repository',
|
|
2756
|
+
// a new hook's; the webhook handlers answer its last delivery's (github-events.ts hookLastResponse)
|
|
2757
|
+
last_response: { code: null, status: 'unused', message: null },
|
|
2758
|
+
};
|
|
2759
|
+
if (h.created_at !== undefined)
|
|
2760
|
+
out.created_at = h.created_at;
|
|
2761
|
+
if (h.updated_at !== undefined)
|
|
2762
|
+
out.updated_at = h.updated_at;
|
|
2763
|
+
return out;
|
|
2764
|
+
}
|
|
2765
|
+
// Map a contents file to GitHub's contents shape (GET/PUT .../contents/:path). The twin
|
|
2766
|
+
// honors small content the caller PUT (base64), and serves it back; raw bytes for large
|
|
2767
|
+
// blobs remain the declared Non-goal.
|
|
2768
|
+
// ── Git-plane-backed READS for the content surfaces real GitHub serves from the same objects a
|
|
2769
|
+
// `git push` wrote: GET contents/*, GET readme, GET commits/:ref. A connector-pulled (REST-stored)
|
|
2770
|
+
// file is used only without a Git repository; a Git-backed repository reads its objects at the requested ref.
|
|
2771
|
+
export function planeQueryRef(query) {
|
|
2772
|
+
if (!query)
|
|
2773
|
+
return undefined;
|
|
2774
|
+
const v = new URLSearchParams(query.startsWith('?') ? query.slice(1) : query).get('ref');
|
|
2775
|
+
return v && v.length ? v : undefined;
|
|
2776
|
+
}
|
|
2777
|
+
export async function resolvePlaneRef(repo, ref, defaultBranch, root) {
|
|
2778
|
+
const want = ref && ref.length ? ref : defaultBranch;
|
|
2779
|
+
if (/^[0-9a-f]{40}$/.test(want) && await objectExistsInPlane(repo, want, root))
|
|
2780
|
+
return want;
|
|
2781
|
+
const refs = await listGitPlaneRefs(repo, root);
|
|
2782
|
+
const hit = refs.find((r) => r.ref === want || r.ref === `refs/heads/${want}` || r.ref === `refs/tags/${want}`);
|
|
2783
|
+
if (!hit)
|
|
2784
|
+
return null;
|
|
2785
|
+
if (hit.object_type === 'tag')
|
|
2786
|
+
return (await readGitTagFromPlane(repo, hit.object_sha, root))?.object_sha ?? null;
|
|
2787
|
+
return hit.object_sha;
|
|
2788
|
+
}
|
|
2789
|
+
export async function planeEntryAt(repo, commitSha, path, root) {
|
|
2790
|
+
const commit = await readGitCommitFromPlane(repo, commitSha, root);
|
|
2791
|
+
if (!commit)
|
|
2792
|
+
return null;
|
|
2793
|
+
let treeSha = commit.tree_sha;
|
|
2794
|
+
const segs = path ? path.split('/').filter(Boolean) : [];
|
|
2795
|
+
for (let i = 0;; i += 1) {
|
|
2796
|
+
if (i === segs.length)
|
|
2797
|
+
return directoryAt(repo, treeSha, root);
|
|
2798
|
+
const entries = await readGitTreeFromPlane(repo, treeSha, root);
|
|
2799
|
+
const e = entries?.find((x) => x.path === segs[i]);
|
|
2800
|
+
if (!e)
|
|
2801
|
+
return null;
|
|
2802
|
+
if (e.type === 'blob' || e.type === 'commit')
|
|
2803
|
+
return i === segs.length - 1 ? { kind: e.type === 'blob' ? 'file' : 'submodule', entry: e } : null;
|
|
2804
|
+
if (e.type !== 'tree')
|
|
2805
|
+
return null;
|
|
2806
|
+
treeSha = e.sha;
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
/** A directory of the plane: its tree's entries. */
|
|
2810
|
+
async function directoryAt(repo, treeSha, root) {
|
|
2811
|
+
const entries = await readGitTreeFromPlane(repo, treeSha, root);
|
|
2812
|
+
return entries ? { kind: 'dir', entries } : null;
|
|
2813
|
+
}
|
|
2814
|
+
export async function planeFileContent(repo, ref, path, entry, root) {
|
|
2815
|
+
const blob = await readGitBlobFromPlane(repo, entry.sha, root);
|
|
2816
|
+
if (!blob)
|
|
2817
|
+
return null;
|
|
2818
|
+
return toGithubContent({ repository: repo, path, sha: entry.sha, size: blob.size, content_b64: blob.content_b64, branch: ref });
|
|
2819
|
+
}
|
|
2820
|
+
export async function planeContents(repo, ref, path, defaultBranch, root) {
|
|
2821
|
+
const sha = await resolvePlaneRef(repo, ref, defaultBranch, root);
|
|
2822
|
+
if (!sha)
|
|
2823
|
+
return null;
|
|
2824
|
+
const hit = await planeEntryAt(repo, sha, path, root);
|
|
2825
|
+
if (!hit)
|
|
2826
|
+
return null;
|
|
2827
|
+
const shownRef = ref ?? defaultBranch;
|
|
2828
|
+
if (hit.kind === 'submodule')
|
|
2829
|
+
return null;
|
|
2830
|
+
return hit.kind === 'file' ? planeFileAnswer(repo, shownRef, path, hit.entry, root) : directoryListing(path, hit.entries);
|
|
2831
|
+
}
|
|
2832
|
+
/** A file of the plane as the Contents API answers it. */
|
|
2833
|
+
async function planeFileAnswer(repo, ref, path, entry, root) {
|
|
2834
|
+
const body = await planeFileContent(repo, ref, path, entry, root);
|
|
2835
|
+
return body ? { status: 200, body } : null;
|
|
2836
|
+
}
|
|
2837
|
+
/** A directory's contents as the Contents API lists them. */
|
|
2838
|
+
function directoryListing(path, entries) {
|
|
2839
|
+
const prefix = path ? `${path}/` : '';
|
|
2840
|
+
return { status: 200, body: 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 })) };
|
|
2841
|
+
}
|
|
2842
|
+
/** A path's contents: from the git plane when the repository has one; otherwise the file the REST
|
|
2843
|
+
* writes stored, a directory listing of the stored files under it, or the plane's (a `git push`). */
|
|
2844
|
+
export async function githubContents(state, repo, p, query, root) {
|
|
2845
|
+
if (hasBareRepo(repo, root))
|
|
2846
|
+
return await planeContents(repo, planeQueryRef(query), p, state.repos.find(r => r.full_name === repo)?.default_branch ?? 'main', root) ?? githubNotFound();
|
|
2847
|
+
const inRepo = state.contents.filter((c) => c.repository === repo);
|
|
2848
|
+
const file = inRepo.find((c) => c.path === p);
|
|
2849
|
+
if (file)
|
|
2850
|
+
return { status: 200, body: toGithubContent(file) };
|
|
2851
|
+
// A directory: any stored file whose path is under this prefix → a dir listing array.
|
|
2852
|
+
const prefix = p ? `${p}/` : '';
|
|
2853
|
+
const children = inRepo.filter((c) => c.path.startsWith(prefix) && c.path !== p);
|
|
2854
|
+
if (children.length)
|
|
2855
|
+
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 })) };
|
|
2856
|
+
// Nothing REST-stored at this path: the bare repo (a real `git push`) is the truth.
|
|
2857
|
+
const planeHit = await planeContents(repo, planeQueryRef(query), p, state.repos.find((r) => r.full_name === repo)?.default_branch ?? 'main', root);
|
|
2858
|
+
if (planeHit)
|
|
2859
|
+
return planeHit;
|
|
2860
|
+
return githubNotFound();
|
|
2861
|
+
}
|
|
2862
|
+
// Every commit reachable from `sha` in the git plane (breadth-first over parents), with its commit date.
|
|
2863
|
+
export async function planeAncestors(repo, sha, root) {
|
|
2864
|
+
const out = new Map();
|
|
2865
|
+
const queue = [sha];
|
|
2866
|
+
while (queue.length) {
|
|
2867
|
+
const next = queue.shift();
|
|
2868
|
+
if (out.has(next))
|
|
2869
|
+
continue;
|
|
2870
|
+
const c = await readGitCommitFromPlane(repo, next, root);
|
|
2871
|
+
if (!c)
|
|
2872
|
+
continue;
|
|
2873
|
+
out.set(next, Date.parse(c.created_at ?? ''));
|
|
2874
|
+
queue.push(...c.parents);
|
|
2875
|
+
}
|
|
2876
|
+
return out;
|
|
2877
|
+
}
|
|
2878
|
+
/** The commits a repository's default branch reaches, read from its history: sha, message and author name (none in a
|
|
2879
|
+
* repository without history). */
|
|
2880
|
+
export async function defaultBranchCommits(state, repo, root) {
|
|
2881
|
+
const branch = state.repos.find((r) => r.full_name === repo)?.default_branch ?? 'main';
|
|
2882
|
+
const tip = hasBareRepo(repo, root) ? await resolvePlaneRef(repo, branch, branch, root) : null;
|
|
2883
|
+
const out = [];
|
|
2884
|
+
for (const sha of tip ? (await planeAncestors(repo, tip, root)).keys() : []) {
|
|
2885
|
+
const c = await readGitCommitFromPlane(repo, sha, root);
|
|
2886
|
+
if (c)
|
|
2887
|
+
out.push({ sha, message: c.message, author: c.author_name ?? '' });
|
|
2888
|
+
}
|
|
2889
|
+
return out;
|
|
2890
|
+
}
|
|
2891
|
+
export async function planeCommitBody(repo, sha, root) {
|
|
2892
|
+
const c = await readGitCommitFromPlane(repo, sha, root);
|
|
2893
|
+
if (!c)
|
|
2894
|
+
return null;
|
|
2895
|
+
const who = { name: c.author_name ?? 'github-twin', email: c.author_email ?? 'github-twin@localhost', date: c.created_at ?? new Date(0).toISOString() };
|
|
2896
|
+
return {
|
|
2897
|
+
sha: c.sha,
|
|
2898
|
+
node_id: `C_${c.sha.slice(0, 12)}`,
|
|
2899
|
+
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 },
|
|
2900
|
+
url: `https://api.github.com/repos/${repo}/commits/${c.sha}`,
|
|
2901
|
+
html_url: `https://github.com/${repo}/commit/${c.sha}`,
|
|
2902
|
+
comments_url: `https://api.github.com/repos/${repo}/commits/${c.sha}/comments`,
|
|
2903
|
+
author: null,
|
|
2904
|
+
committer: null,
|
|
2905
|
+
parents: c.parents.map((sha) => ({ sha, url: `https://api.github.com/repos/${repo}/commits/${sha}`, html_url: `https://github.com/${repo}/commit/${sha}` })),
|
|
2906
|
+
};
|
|
2907
|
+
}
|
|
2908
|
+
export function toGithubContent(f) {
|
|
2909
|
+
const api = `https://api.github.com/repos/${f.repository}`;
|
|
2910
|
+
const ref = f.branch ?? 'main';
|
|
2911
|
+
return {
|
|
2912
|
+
type: 'file',
|
|
2913
|
+
encoding: 'base64',
|
|
2914
|
+
name: f.path.split('/').pop() ?? f.path,
|
|
2915
|
+
path: f.path,
|
|
2916
|
+
sha: f.sha,
|
|
2917
|
+
size: f.size,
|
|
2918
|
+
content: f.content_b64 ?? '',
|
|
2919
|
+
url: `${api}/contents/${f.path}?ref=${ref}`,
|
|
2920
|
+
html_url: `https://github.com/${f.repository}/blob/${ref}/${f.path}`,
|
|
2921
|
+
download_url: `https://raw.githubusercontent.com/${f.repository}/${ref}/${f.path}`,
|
|
2922
|
+
git_url: `${api}/git/blobs/${f.sha}`,
|
|
2923
|
+
};
|
|
2924
|
+
}
|
|
2925
|
+
// Map a user to GitHub's simple/public-user shape (GET /users/:login, GET /user).
|
|
2926
|
+
export function toGithubUser(u) {
|
|
2927
|
+
return {
|
|
2928
|
+
login: u.login,
|
|
2929
|
+
id: u.id,
|
|
2930
|
+
type: u.type,
|
|
2931
|
+
name: u.name ?? null,
|
|
2932
|
+
bio: u.bio ?? null,
|
|
2933
|
+
company: u.company ?? null,
|
|
2934
|
+
blog: u.blog ?? null,
|
|
2935
|
+
location: u.location ?? null,
|
|
2936
|
+
email: u.email ?? null,
|
|
2937
|
+
...userLinks(u.login, u.id),
|
|
2938
|
+
site_admin: false,
|
|
2939
|
+
node_id: nodeId('User', u.id),
|
|
2940
|
+
};
|
|
2941
|
+
}
|
|
2942
|
+
// ── Orgs / Teams / commit-comments / issue-types serializers ───────────────────────────────
|
|
2943
|
+
/** An organization as GitHub answers it; with the World's state, its public repositories and followers counted. */
|
|
2944
|
+
export function toGithubOrg(o, st) {
|
|
2945
|
+
const api = `https://api.github.com`;
|
|
2946
|
+
const id = 0x20000000 + (parseInt(createHash('sha256').update(`org:${o.login}`).digest('hex').slice(0, 8), 16) % 0x0fffffff);
|
|
2947
|
+
return {
|
|
2948
|
+
login: o.login, id, node_id: nodeId('Organization', id), type: 'Organization',
|
|
2949
|
+
description: o.description ?? null, name: o.name ?? null, company: o.company ?? null,
|
|
2950
|
+
blog: o.blog ?? null, location: o.location ?? null, email: o.email ?? null,
|
|
2951
|
+
url: `${api}/orgs/${o.login}`, repos_url: `${api}/orgs/${o.login}/repos`, hooks_url: `${api}/orgs/${o.login}/hooks`,
|
|
2952
|
+
members_url: `${api}/orgs/${o.login}/members{/member}`, html_url: `https://github.com/${o.login}`,
|
|
2953
|
+
avatar_url: `https://avatars.githubusercontent.com/u/${id}?v=4`, events_url: `${api}/orgs/${o.login}/events`, issues_url: `${api}/orgs/${o.login}/issues`,
|
|
2954
|
+
public_members_url: `${api}/orgs/${o.login}/public_members{/member}`,
|
|
2955
|
+
public_repos: st ? st.repos.filter((r) => r.owner === o.login && !r.private).length : 0,
|
|
2956
|
+
followers: st ? st.followEdges.filter((e) => e.to === o.login).length : 0,
|
|
2957
|
+
// an organization follows no one and holds no gists
|
|
2958
|
+
following: 0, public_gists: 0,
|
|
2959
|
+
has_organization_projects: true, has_repository_projects: true, archived_at: null,
|
|
2960
|
+
...(o.billing_email !== undefined ? { billing_email: o.billing_email ?? null } : {}),
|
|
2961
|
+
created_at: o.created_at ?? null, updated_at: o.updated_at ?? null,
|
|
2962
|
+
};
|
|
2963
|
+
}
|
|
2964
|
+
export function toGithubOrgMembership(m) {
|
|
2965
|
+
const api = `https://api.github.com`;
|
|
2966
|
+
return {
|
|
2967
|
+
url: `${api}/orgs/${m.org}/memberships/${m.login}`, state: m.state, role: m.role,
|
|
2968
|
+
organization_url: `${api}/orgs/${m.org}`,
|
|
2969
|
+
organization: { login: m.org, url: `${api}/orgs/${m.org}` },
|
|
2970
|
+
user: toGithubUser(seedUser(m.login)),
|
|
2971
|
+
};
|
|
2972
|
+
}
|
|
2973
|
+
export function toGithubOrgWebhook(h) {
|
|
2974
|
+
const api = `https://api.github.com`;
|
|
2975
|
+
return {
|
|
2976
|
+
id: h.id, type: 'Organization', name: h.name, active: h.active, events: h.events,
|
|
2977
|
+
config: { url: h.url ?? null, content_type: h.content_type ?? 'form', insecure_ssl: '0' },
|
|
2978
|
+
url: `${api}/orgs/${h.org}/hooks/${h.id}`, ping_url: `${api}/orgs/${h.org}/hooks/${h.id}/pings`,
|
|
2979
|
+
created_at: h.created_at ?? null, updated_at: h.updated_at ?? null,
|
|
2980
|
+
};
|
|
2981
|
+
}
|
|
2982
|
+
/** A team as GitHub answers it; with the World's state, its members and repositories counted (team-full). */
|
|
2983
|
+
export function toGithubTeam(t, st) {
|
|
2984
|
+
const api = `https://api.github.com`;
|
|
2985
|
+
return {
|
|
2986
|
+
id: t.id, node_id: nodeId('Team', t.id), slug: t.slug, name: t.name, description: t.description ?? null,
|
|
2987
|
+
privacy: t.privacy ?? 'secret', permission: t.permission ?? 'pull', notification_setting: 'notifications_enabled',
|
|
2988
|
+
url: `${api}/teams/${t.id}`, html_url: `https://github.com/orgs/${t.org}/teams/${t.slug}`,
|
|
2989
|
+
members_url: `${api}/teams/${t.id}/members{/member}`, repositories_url: `${api}/teams/${t.id}/repos`,
|
|
2990
|
+
parent: null, organization: { login: t.org, url: `${api}/orgs/${t.org}` }, type: 'organization',
|
|
2991
|
+
...(st ? { members_count: st.teamMembers.filter((m) => m.org === t.org && m.team_slug === t.slug && m.state === 'active').length, repos_count: st.teamRepos.filter((r) => r.org === t.org && r.team_slug === t.slug).length } : {}),
|
|
2992
|
+
created_at: t.created_at ?? null, updated_at: t.updated_at ?? null,
|
|
2993
|
+
};
|
|
2994
|
+
}
|
|
2995
|
+
/** A team membership as GitHub answers it, addressed under its organization's and team's ids
|
|
2996
|
+
* (docs.github.com/rest/teams/members#get-team-membership-for-a-user). */
|
|
2997
|
+
export function toGithubTeamMembership(m, st) {
|
|
2998
|
+
const api = `https://api.github.com`;
|
|
2999
|
+
const org = st?.orgs.find((o) => o.login === m.org);
|
|
3000
|
+
const team = st?.teams.find((t) => t.org === m.org && t.slug === m.team_slug);
|
|
3001
|
+
return { url: org && team ? `${api}/organizations/${toGithubOrg(org).id}/team/${team.id}/memberships/${m.login}` : `${api}/orgs/${m.org}/teams/${m.team_slug}/memberships/${m.login}`, role: m.role, state: m.state };
|
|
3002
|
+
}
|
|
3003
|
+
export function toGithubCommitComment(c) {
|
|
3004
|
+
const api = `https://api.github.com`;
|
|
3005
|
+
return {
|
|
3006
|
+
id: c.id, node_id: nodeId('CommitComment', c.id), commit_id: c.commit_sha, body: c.body ?? '',
|
|
3007
|
+
path: c.path ?? null, line: c.line ?? null, position: c.position ?? null,
|
|
3008
|
+
url: `${api}/repos/${c.repository}/comments/${c.id}`,
|
|
3009
|
+
html_url: `https://github.com/${c.repository}/commit/${c.commit_sha}#commitcomment-${c.id}`,
|
|
3010
|
+
user: toGithubUser(seedUser('octocat')), author_association: 'OWNER',
|
|
3011
|
+
created_at: c.created_at ?? null, updated_at: c.updated_at ?? null,
|
|
3012
|
+
};
|
|
3013
|
+
}
|
|
3014
|
+
export function toGithubIssueType(t) {
|
|
3015
|
+
return {
|
|
3016
|
+
id: t.id, node_id: nodeId('IssueType', t.id), name: t.name, description: t.description ?? null,
|
|
3017
|
+
color: t.color ?? null, is_enabled: t.is_enabled ?? true,
|
|
3018
|
+
created_at: t.created_at ?? null, updated_at: t.updated_at ?? null,
|
|
3019
|
+
};
|
|
3020
|
+
}
|
|
3021
|
+
// Map a gist to GitHub's gist shape (GET /gists/:id).
|
|
3022
|
+
export function toGithubGist(g) {
|
|
3023
|
+
const api = `https://api.github.com`;
|
|
3024
|
+
const files = {};
|
|
3025
|
+
for (const f of g.files) {
|
|
3026
|
+
const content = f.content ?? '';
|
|
3027
|
+
files[f.filename] = { filename: f.filename, language: f.language ?? null, size: f.size ?? content.length, content, type: 'text/plain', truncated: false };
|
|
3028
|
+
}
|
|
3029
|
+
const out = {
|
|
3030
|
+
id: g.id,
|
|
3031
|
+
description: g.description ?? null,
|
|
3032
|
+
public: g.public ?? false,
|
|
3033
|
+
files,
|
|
3034
|
+
url: `${api}/gists/${g.id}`,
|
|
3035
|
+
html_url: `https://gist.github.com/${g.id}`,
|
|
3036
|
+
forks_url: `${api}/gists/${g.id}/forks`, commits_url: `${api}/gists/${g.id}/commits`, comments_url: `${api}/gists/${g.id}/comments`,
|
|
3037
|
+
git_pull_url: `https://gist.github.com/${g.id}.git`, git_push_url: `https://gist.github.com/${g.id}.git`,
|
|
3038
|
+
comments: 0,
|
|
3039
|
+
owner: toSimpleUser(g.owner ?? GITHUB_WORLD_USER),
|
|
3040
|
+
user: null,
|
|
3041
|
+
node_id: nodeId('Gist', g.id),
|
|
3042
|
+
};
|
|
3043
|
+
if (g.created_at !== undefined)
|
|
3044
|
+
out.created_at = g.created_at;
|
|
3045
|
+
if (g.updated_at !== undefined)
|
|
3046
|
+
out.updated_at = g.updated_at;
|
|
3047
|
+
out.history = (g.history ?? []).map((v) => ({ version: v.version, committed_at: v.committed_at, change_status: { total: v.additions + v.deletions, additions: v.additions, deletions: v.deletions }, url: `${api}/gists/${g.id}/${v.version}`, user: toSimpleUser(v.user_login) }));
|
|
3048
|
+
return out;
|
|
3049
|
+
}
|
|
3050
|
+
// Map a notification thread to GitHub's notification shape (GET /notifications).
|
|
3051
|
+
export function toGithubNotification(n) {
|
|
3052
|
+
const api = `https://api.github.com`;
|
|
3053
|
+
return {
|
|
3054
|
+
id: n.id,
|
|
3055
|
+
unread: n.unread,
|
|
3056
|
+
reason: n.reason,
|
|
3057
|
+
repository: { full_name: n.repository, url: `${api}/repos/${n.repository}` },
|
|
3058
|
+
subject: { title: n.subject_title, type: n.subject_type, url: n.subject_url ?? null, latest_comment_url: null },
|
|
3059
|
+
url: `${api}/notifications/threads/${n.id}`,
|
|
3060
|
+
subscription_url: `${api}/notifications/threads/${n.id}/subscription`,
|
|
3061
|
+
updated_at: n.updated_at ?? null,
|
|
3062
|
+
last_read_at: null,
|
|
3063
|
+
};
|
|
3064
|
+
}
|
|
3065
|
+
// Map a workflow to GitHub's REST workflow shape (GET .../actions/workflows). node_id and
|
|
3066
|
+
// the badge/html url templates the twin doesn't track are declared scope (workflow.*); the
|
|
3067
|
+
// twin emits the id/name/path/state it was registered with + derivable url templates.
|
|
3068
|
+
export function toGithubWorkflow(w) {
|
|
3069
|
+
const api = `https://api.github.com/repos/${w.repository}`;
|
|
3070
|
+
const out = {
|
|
3071
|
+
id: w.id,
|
|
3072
|
+
node_id: nodeId('Workflow', w.id),
|
|
3073
|
+
name: w.name,
|
|
3074
|
+
path: w.path,
|
|
3075
|
+
state: w.state ?? 'active',
|
|
3076
|
+
url: `${api}/actions/workflows/${w.id}`,
|
|
3077
|
+
html_url: `https://github.com/${w.repository}/blob/main/${w.path}`,
|
|
3078
|
+
badge_url: `https://github.com/${w.repository}/workflows/${encodeURIComponent(w.name)}/badge.svg`,
|
|
3079
|
+
};
|
|
3080
|
+
if (w.created_at !== undefined)
|
|
3081
|
+
out.created_at = w.created_at;
|
|
3082
|
+
if (w.updated_at !== undefined)
|
|
3083
|
+
out.updated_at = w.updated_at;
|
|
3084
|
+
return out;
|
|
3085
|
+
}
|
|
3086
|
+
// Map a workflow run to GitHub's REST workflow_run shape (GET .../actions/runs). conclusion
|
|
3087
|
+
// is null until the run completes. The rich actor/commit/repository sub-objects and node_id
|
|
3088
|
+
// are declared scope (workflowRun.*) — the twin emits the run-lifecycle fields it models.
|
|
3089
|
+
/** The open pull requests whose head is a commit, as a check run or a workflow run names them. */
|
|
3090
|
+
function pullRequestsOn(st, repository, sha) {
|
|
3091
|
+
const api = `https://api.github.com/repos/${repository}`;
|
|
3092
|
+
const repo = st?.repos.find((x) => x.full_name === repository);
|
|
3093
|
+
const ref = { id: repo?.id ?? 0, url: api, name: repo?.name ?? repository.split('/')[1] };
|
|
3094
|
+
return (st?.prs ?? []).filter((p) => p.repository === repository && p.head_sha === sha && (p.state ?? 'open') === 'open').map((p) => ({
|
|
3095
|
+
id: observedId(`pull_request:${p.born_in ?? p.repository}#${p.number}`), number: p.number, url: `${api}/pulls/${p.number}`,
|
|
3096
|
+
head: { ref: p.head_ref ?? '', sha: p.head_sha ?? '', repo: ref }, base: { ref: p.base_ref ?? '', sha: '', repo: ref },
|
|
3097
|
+
}));
|
|
3098
|
+
}
|
|
3099
|
+
/** The fields of GitHub's minimal-repository, the form a workflow run names its repository in. */
|
|
3100
|
+
const MINIMAL_REPOSITORY = new Set(surface.resources.find((r) => r.name === 'minimal-repository')?.fields.map((f) => f.name) ?? []);
|
|
3101
|
+
export function toGithubWorkflowRun(r, st) {
|
|
3102
|
+
const api = `https://api.github.com/repos/${r.repository}`;
|
|
3103
|
+
const repo = st?.repos.find((x) => x.full_name === r.repository);
|
|
3104
|
+
const minimal = repo ? Object.fromEntries(Object.entries(toGithubRepo(repo)).filter(([k]) => MINIMAL_REPOSITORY.has(k))) : { full_name: r.repository, url: `https://api.github.com/repos/${r.repository}` };
|
|
3105
|
+
const workflow = st?.workflows.find((w) => w.id === r.workflow_id);
|
|
3106
|
+
const out = {
|
|
3107
|
+
artifacts_url: `${api}/actions/runs/${r.id}/artifacts`,
|
|
3108
|
+
check_suite_url: `${api}/check-suites/${checkSuiteId(r.repository, r.head_sha, 'github-actions')}`,
|
|
3109
|
+
logs_url: `${api}/actions/runs/${r.id}/logs`,
|
|
3110
|
+
display_title: r.name ?? workflow?.name ?? '',
|
|
3111
|
+
path: workflow?.path ?? '',
|
|
3112
|
+
head_commit: null,
|
|
3113
|
+
repository: minimal,
|
|
3114
|
+
// a pull request from a fork runs on the fork's commit: the head repository is the fork
|
|
3115
|
+
head_repository: r.head_repository ? (() => { const fork = st?.repos.find((x) => x.full_name === r.head_repository); return fork ? Object.fromEntries(Object.entries(toGithubRepo(fork)).filter(([k]) => MINIMAL_REPOSITORY.has(k))) : { full_name: r.head_repository, url: `https://api.github.com/repos/${r.head_repository}` }; })() : minimal,
|
|
3116
|
+
pull_requests: pullRequestsOn(st, r.repository, r.head_sha),
|
|
3117
|
+
id: r.id,
|
|
3118
|
+
node_id: nodeId('WorkflowRun', r.id),
|
|
3119
|
+
name: r.name ?? null,
|
|
3120
|
+
workflow_id: r.workflow_id,
|
|
3121
|
+
run_number: r.run_number,
|
|
3122
|
+
event: r.event,
|
|
3123
|
+
status: r.status,
|
|
3124
|
+
conclusion: r.conclusion ?? null,
|
|
3125
|
+
head_sha: r.head_sha,
|
|
3126
|
+
head_branch: r.head_branch ?? null,
|
|
3127
|
+
url: `${api}/actions/runs/${r.id}`,
|
|
3128
|
+
html_url: `https://github.com/${r.repository}/actions/runs/${r.id}`,
|
|
3129
|
+
jobs_url: `${api}/actions/runs/${r.id}/jobs`,
|
|
3130
|
+
workflow_url: `${api}/actions/workflows/${r.workflow_id}`,
|
|
3131
|
+
cancel_url: `${api}/actions/runs/${r.id}/cancel`,
|
|
3132
|
+
rerun_url: `${api}/actions/runs/${r.id}/rerun`,
|
|
3133
|
+
actor: r.actor_login ? toSimpleUser(r.actor_login, 'User') : null,
|
|
3134
|
+
triggering_actor: r.actor_login ? toSimpleUser(r.actor_login, 'User') : null,
|
|
3135
|
+
};
|
|
3136
|
+
if (r.created_at !== undefined)
|
|
3137
|
+
out.created_at = r.created_at;
|
|
3138
|
+
if (r.updated_at !== undefined)
|
|
3139
|
+
out.updated_at = r.updated_at;
|
|
3140
|
+
return out;
|
|
3141
|
+
}
|
|
3142
|
+
// Map a job to GitHub's REST job shape (GET .../runs/:id/jobs, GET .../actions/jobs/:id).
|
|
3143
|
+
// node_id/check_run_url/labels/runner-* are declared scope (job.*); the twin emits the
|
|
3144
|
+
// id/run_id/name/status/conclusion + step list it modeled.
|
|
3145
|
+
export function toGithubJob(j) {
|
|
3146
|
+
const api = `https://api.github.com/repos/${j.repository}`;
|
|
3147
|
+
const out = {
|
|
3148
|
+
check_run_url: `${api}/check-runs/${j.id}`,
|
|
3149
|
+
created_at: j.created_at ?? j.started_at ?? null,
|
|
3150
|
+
head_branch: j.head_branch ?? null,
|
|
3151
|
+
labels: j.labels ?? [],
|
|
3152
|
+
// the runner that ran it: a GitHub-hosted one the twin does not name
|
|
3153
|
+
runner_id: null, runner_name: null, runner_group_id: null, runner_group_name: null,
|
|
3154
|
+
workflow_name: j.workflow_name ?? null,
|
|
3155
|
+
started_at: j.started_at ?? null,
|
|
3156
|
+
id: j.id,
|
|
3157
|
+
node_id: nodeId('CheckRun', j.id),
|
|
3158
|
+
run_id: j.run_id,
|
|
3159
|
+
name: j.name,
|
|
3160
|
+
status: j.status,
|
|
3161
|
+
conclusion: j.conclusion ?? null,
|
|
3162
|
+
head_sha: j.head_sha,
|
|
3163
|
+
url: `${api}/actions/jobs/${j.id}`,
|
|
3164
|
+
html_url: `https://github.com/${j.repository}/actions/runs/${j.run_id}/job/${j.id}`,
|
|
3165
|
+
run_url: `${api}/actions/runs/${j.run_id}`,
|
|
3166
|
+
steps: j.steps.map((s) => ({ name: s.name, status: s.status, conclusion: s.conclusion ?? null, number: s.number })),
|
|
3167
|
+
};
|
|
3168
|
+
out.completed_at = j.completed_at ?? null;
|
|
3169
|
+
return out;
|
|
3170
|
+
}
|
|
3171
|
+
// Map a release asset to GitHub's release-asset shape (GET .../releases/:id/assets,
|
|
3172
|
+
// GET .../releases/assets/:id). METADATA only: the twin emits name/label/content_type/
|
|
3173
|
+
// size/state/download_count + derivable url templates. The browser_download_url points at
|
|
3174
|
+
// the asset path, but the twin serves NO real bytes (declared Non-goal: asset.content
|
|
3175
|
+
// binary). node_id/uploader simple-user are declared scope (asset.*).
|
|
3176
|
+
export function toGithubReleaseAsset(a) {
|
|
3177
|
+
const api = `https://api.github.com/repos/${a.repository}`;
|
|
3178
|
+
const out = {
|
|
3179
|
+
id: a.id,
|
|
3180
|
+
node_id: nodeId('ReleaseAsset', a.id),
|
|
3181
|
+
name: a.name,
|
|
3182
|
+
label: a.label ?? null,
|
|
3183
|
+
content_type: a.content_type ?? 'application/octet-stream',
|
|
3184
|
+
size: a.size ?? 0,
|
|
3185
|
+
state: a.state ?? 'uploaded',
|
|
3186
|
+
download_count: a.download_count ?? 0,
|
|
3187
|
+
url: `${api}/releases/assets/${a.id}`,
|
|
3188
|
+
browser_download_url: `https://github.com/${a.repository}/releases/download/${a.release_id}/${encodeURIComponent(a.name)}`,
|
|
3189
|
+
digest: null,
|
|
3190
|
+
uploader: a.uploader_login ? toSimpleUser(a.uploader_login, a.uploader_login.endsWith('[bot]') ? 'Bot' : 'User') : null,
|
|
3191
|
+
};
|
|
3192
|
+
if (a.created_at !== undefined)
|
|
3193
|
+
out.created_at = a.created_at;
|
|
3194
|
+
if (a.updated_at !== undefined)
|
|
3195
|
+
out.updated_at = a.updated_at;
|
|
3196
|
+
return out;
|
|
3197
|
+
}
|
|
3198
|
+
// Map a release to GitHub's release shape (GET .../releases, .../releases/:id, .../latest,
|
|
3199
|
+
// .../tags/:tag). The twin emits the tag_name/name/body/draft/prerelease + target_commitish
|
|
3200
|
+
// it was written with, derivable url templates, its author and the asset list. Reactions are declared scope
|
|
3201
|
+
// (release.*).
|
|
3202
|
+
export function toGithubRelease(r, assets) {
|
|
3203
|
+
const api = `https://api.github.com/repos/${r.repository}`;
|
|
3204
|
+
const relAssets = (assets ?? []).filter((a) => a.repository === r.repository && a.release_id === r.id);
|
|
3205
|
+
const out = {
|
|
3206
|
+
id: r.id,
|
|
3207
|
+
node_id: nodeId('Release', r.id),
|
|
3208
|
+
tag_name: r.tag_name,
|
|
3209
|
+
target_commitish: r.target_commitish ?? 'main',
|
|
3210
|
+
name: r.name ?? null,
|
|
3211
|
+
body: r.body ?? null,
|
|
3212
|
+
draft: r.draft ?? false,
|
|
3213
|
+
prerelease: r.prerelease ?? false,
|
|
3214
|
+
url: `${api}/releases/${r.id}`,
|
|
3215
|
+
html_url: `https://github.com/${r.repository}/releases/tag/${encodeURIComponent(r.tag_name)}`,
|
|
3216
|
+
assets_url: `${api}/releases/${r.id}/assets`,
|
|
3217
|
+
upload_url: `https://uploads.github.com/repos/${r.repository}/releases/${r.id}/assets{?name,label}`,
|
|
3218
|
+
tarball_url: r.draft ? null : `${api}/tarball/${encodeURIComponent(r.tag_name)}`,
|
|
3219
|
+
zipball_url: r.draft ? null : `${api}/zipball/${encodeURIComponent(r.tag_name)}`,
|
|
3220
|
+
// the person or bot that created it (docs.github.com/rest/releases/releases#get-a-release)
|
|
3221
|
+
author: r.author_login ? toSimpleUser(r.author_login, r.author_login.endsWith('[bot]') ? 'Bot' : 'User') : null,
|
|
3222
|
+
assets: relAssets.map(toGithubReleaseAsset),
|
|
3223
|
+
};
|
|
3224
|
+
if (r.created_at !== undefined)
|
|
3225
|
+
out.created_at = r.created_at;
|
|
3226
|
+
// published_at is null for a draft (real GitHub behavior) — a draft has no publish time.
|
|
3227
|
+
out.published_at = r.draft ? null : (r.published_at ?? null);
|
|
3228
|
+
return out;
|
|
3229
|
+
}
|
|
3230
|
+
// Map a derived git tag to GitHub's repository-tag shape (GET .../tags). The commit object
|
|
3231
|
+
// carries the sha + its api/html urls; the full commit object (author/message) is the git
|
|
3232
|
+
// Non-goal. node_id is declared scope (tag.*).
|
|
3233
|
+
export function toGithubTag(t) {
|
|
3234
|
+
const api = `https://api.github.com/repos/${t.repository}`;
|
|
3235
|
+
return {
|
|
3236
|
+
name: t.name,
|
|
3237
|
+
commit: { sha: t.commit_sha, url: `${api}/commits/${t.commit_sha}` },
|
|
3238
|
+
zipball_url: `${api}/zipball/refs/tags/${encodeURIComponent(t.name)}`,
|
|
3239
|
+
tarball_url: `${api}/tarball/refs/tags/${encodeURIComponent(t.name)}`,
|
|
3240
|
+
node_id: nodeId('Ref', `${t.repository}:refs/tags/${t.name}`),
|
|
3241
|
+
};
|
|
3242
|
+
}
|
|
3243
|
+
// ── Git Data API serializers ─────────────────────────────────────────────────────────────
|
|
3244
|
+
// Map a git ref to GitHub's ref shape (GET/POST .../git/refs[/:ref], .../git/ref/:ref).
|
|
3245
|
+
export function toGithubGitRef(r) {
|
|
3246
|
+
const api = `https://api.github.com/repos/${r.repository}`;
|
|
3247
|
+
return {
|
|
3248
|
+
ref: r.ref,
|
|
3249
|
+
node_id: nodeId('Ref', `${r.repository}:${r.ref}`),
|
|
3250
|
+
url: `${api}/git/${r.ref}`,
|
|
3251
|
+
object: { type: r.object_type, sha: r.object_sha, url: `${api}/git/${r.object_type === 'tag' ? 'tags' : 'commits'}/${r.object_sha}` },
|
|
3252
|
+
};
|
|
3253
|
+
}
|
|
3254
|
+
// Map a git commit object to GitHub's git-commit shape (GET .../git/commits/:sha).
|
|
3255
|
+
export function toGithubGitCommit(c) {
|
|
3256
|
+
const api = `https://api.github.com/repos/${c.repository}`;
|
|
3257
|
+
return {
|
|
3258
|
+
sha: c.sha,
|
|
3259
|
+
node_id: nodeId('Commit', c.sha),
|
|
3260
|
+
url: `${api}/git/commits/${c.sha}`,
|
|
3261
|
+
message: c.message,
|
|
3262
|
+
tree: { sha: c.tree_sha, url: `${api}/git/trees/${c.tree_sha}` },
|
|
3263
|
+
parents: c.parents.map((p) => ({ sha: p, url: `${api}/git/commits/${p}`, html_url: `https://github.com/${c.repository}/commit/${p}` })),
|
|
3264
|
+
author: { name: c.author_name ?? null, email: c.author_email ?? null, date: c.created_at ?? null },
|
|
3265
|
+
committer: { name: c.author_name ?? null, email: c.author_email ?? null, date: c.created_at ?? null },
|
|
3266
|
+
// Real responses always carry a verification block. The twin's objects are unsigned —
|
|
3267
|
+
// reported honestly (never a fabricated "verified": the signing GitHub's API does for
|
|
3268
|
+
// bot-created commits needs GitHub's key, which a local twin cannot have).
|
|
3269
|
+
verification: { verified: false, reason: 'unsigned', signature: null, payload: null },
|
|
3270
|
+
html_url: `https://github.com/${c.repository}/commit/${c.sha}`,
|
|
3271
|
+
};
|
|
3272
|
+
}
|
|
3273
|
+
// Map a git tree object to GitHub's git-tree shape (GET .../git/trees/:sha).
|
|
3274
|
+
export function toGithubGitTree(t) {
|
|
3275
|
+
const api = `https://api.github.com/repos/${t.repository}`;
|
|
3276
|
+
return {
|
|
3277
|
+
sha: t.sha,
|
|
3278
|
+
url: `${api}/git/trees/${t.sha}`,
|
|
3279
|
+
truncated: t.truncated ?? false,
|
|
3280
|
+
tree: t.tree.map((e) => ({ path: e.path, mode: e.mode, type: e.type, sha: e.sha, ...(e.size !== undefined ? { size: e.size } : {}), url: `${api}/git/${e.type === 'tree' ? 'trees' : 'blobs'}/${e.sha}` })),
|
|
3281
|
+
};
|
|
3282
|
+
}
|
|
3283
|
+
// Map a git blob object to GitHub's git-blob shape (GET .../git/blobs/:sha).
|
|
3284
|
+
export function toGithubGitBlob(b) {
|
|
3285
|
+
const api = `https://api.github.com/repos/${b.repository}`;
|
|
3286
|
+
return { sha: b.sha, node_id: null, size: b.size, encoding: b.encoding, content: b.content_b64, url: `${api}/git/blobs/${b.sha}` };
|
|
3287
|
+
}
|
|
3288
|
+
// Map an annotated tag object to GitHub's git-tag shape (GET .../git/tags/:sha).
|
|
3289
|
+
export function toGithubGitTagObject(t) {
|
|
3290
|
+
const api = `https://api.github.com/repos/${t.repository}`;
|
|
3291
|
+
return {
|
|
3292
|
+
sha: t.sha,
|
|
3293
|
+
node_id: nodeId('Tag', t.sha),
|
|
3294
|
+
tag: t.tag,
|
|
3295
|
+
message: t.message,
|
|
3296
|
+
url: `${api}/git/tags/${t.sha}`,
|
|
3297
|
+
tagger: { name: t.tagger_name ?? null, email: null, date: t.created_at ?? null },
|
|
3298
|
+
object: { type: t.object_type, sha: t.object_sha, url: `${api}/git/${t.object_type === 'tag' ? 'tags' : 'commits'}/${t.object_sha}` },
|
|
3299
|
+
// Honest unsigned verification block (see toGithubGitCommit).
|
|
3300
|
+
verification: { verified: false, reason: 'unsigned', signature: null, payload: null },
|
|
3301
|
+
};
|
|
3302
|
+
}
|
|
3303
|
+
// ── Deployments / Environments serializers ───────────────────────────────────────────────
|
|
3304
|
+
export function toGithubDeployment(d) {
|
|
3305
|
+
const api = `https://api.github.com/repos/${d.repository}`;
|
|
3306
|
+
return {
|
|
3307
|
+
id: d.id,
|
|
3308
|
+
node_id: nodeId('Deployment', d.id),
|
|
3309
|
+
sha: d.sha,
|
|
3310
|
+
ref: d.ref,
|
|
3311
|
+
task: d.task,
|
|
3312
|
+
environment: d.environment,
|
|
3313
|
+
description: d.description ?? null,
|
|
3314
|
+
transient_environment: d.transient_environment ?? false,
|
|
3315
|
+
production_environment: d.production_environment ?? false,
|
|
3316
|
+
payload: d.payload ?? {},
|
|
3317
|
+
url: `${api}/deployments/${d.id}`,
|
|
3318
|
+
statuses_url: `${api}/deployments/${d.id}/statuses`,
|
|
3319
|
+
repository_url: api,
|
|
3320
|
+
creator: d.creator_login ? toSimpleUser(d.creator_login, 'User') : null,
|
|
3321
|
+
created_at: d.created_at ?? null,
|
|
3322
|
+
updated_at: d.updated_at ?? null,
|
|
3323
|
+
};
|
|
3324
|
+
}
|
|
3325
|
+
export function toGithubDeploymentStatus(s) {
|
|
3326
|
+
const api = `https://api.github.com/repos/${s.repository}`;
|
|
3327
|
+
return {
|
|
3328
|
+
id: s.id,
|
|
3329
|
+
node_id: nodeId('DeploymentStatus', s.id),
|
|
3330
|
+
state: s.state,
|
|
3331
|
+
description: s.description ?? '',
|
|
3332
|
+
environment: s.environment ?? null,
|
|
3333
|
+
environment_url: s.environment_url ?? '',
|
|
3334
|
+
log_url: s.log_url ?? '',
|
|
3335
|
+
target_url: s.log_url ?? '',
|
|
3336
|
+
url: `${api}/deployments/${s.deployment_id}/statuses/${s.id}`,
|
|
3337
|
+
deployment_url: `${api}/deployments/${s.deployment_id}`,
|
|
3338
|
+
repository_url: api,
|
|
3339
|
+
creator: s.creator_login ? toSimpleUser(s.creator_login, 'User') : null,
|
|
3340
|
+
created_at: s.created_at ?? null,
|
|
3341
|
+
updated_at: s.created_at ?? null,
|
|
3342
|
+
};
|
|
3343
|
+
}
|
|
3344
|
+
export function toGithubEnvironment(e) {
|
|
3345
|
+
const api = `https://api.github.com/repos/${e.repository}`;
|
|
3346
|
+
const protection_rules = [];
|
|
3347
|
+
if (e.wait_timer)
|
|
3348
|
+
protection_rules.push({ id: 1, type: 'wait_timer', wait_timer: e.wait_timer });
|
|
3349
|
+
if (e.reviewers?.length)
|
|
3350
|
+
protection_rules.push({ id: 2, type: 'required_reviewers', reviewers: e.reviewers.map((login) => ({ type: 'User', reviewer: { login, type: 'User' } })) });
|
|
3351
|
+
return {
|
|
3352
|
+
id: actionsId(`${e.born_in ?? e.repository}:env:${e.name}`),
|
|
3353
|
+
node_id: nodeId('Environment', actionsId(`${e.born_in ?? e.repository}:env:${e.name}`)),
|
|
3354
|
+
name: e.name,
|
|
3355
|
+
url: `${api}/environments/${encodeURIComponent(e.name)}`,
|
|
3356
|
+
html_url: `https://github.com/${e.repository}/deployments/${encodeURIComponent(e.name)}`,
|
|
3357
|
+
created_at: e.created_at ?? null,
|
|
3358
|
+
updated_at: e.updated_at ?? null,
|
|
3359
|
+
protection_rules,
|
|
3360
|
+
deployment_branch_policy: null,
|
|
3361
|
+
};
|
|
3362
|
+
}
|
|
3363
|
+
// ── Repos niche serializers: deploy keys / autolinks / rulesets ──────────────────────────
|
|
3364
|
+
export function toGithubDeployKey(k) {
|
|
3365
|
+
return {
|
|
3366
|
+
id: k.id, key: k.key, title: k.title, read_only: k.read_only,
|
|
3367
|
+
url: `https://api.github.com/repos/${k.repository}/keys/${k.id}`,
|
|
3368
|
+
verified: true, created_at: k.created_at ?? null, added_by: 'octocat', last_used: null,
|
|
3369
|
+
};
|
|
3370
|
+
}
|
|
3371
|
+
export function toGithubAutolink(a) {
|
|
3372
|
+
return { id: a.id, key_prefix: a.key_prefix, url_template: a.url_template, is_alphanumeric: a.is_alphanumeric };
|
|
3373
|
+
}
|
|
3374
|
+
export function toGithubRuleset(r) {
|
|
3375
|
+
return {
|
|
3376
|
+
id: r.id, name: r.name, target: r.target, enforcement: r.enforcement,
|
|
3377
|
+
source_type: 'Repository', source: r.repository, node_id: nodeId('RepositoryRuleset', r.id),
|
|
3378
|
+
conditions: r.conditions ?? null, rules: r.rules, bypass_actors: r.bypass_actors ?? [],
|
|
3379
|
+
created_at: r.created_at ?? null, updated_at: r.updated_at ?? null,
|
|
3380
|
+
_links: { self: { href: `https://api.github.com/repos/${r.repository}/rulesets/${r.id}` } },
|
|
3381
|
+
};
|
|
3382
|
+
}
|
|
3383
|
+
// ── User niche serializers: emails / SSH keys / GPG keys ─────────────────────────────────
|
|
3384
|
+
export function toGithubUserEmail(e) {
|
|
3385
|
+
return { email: e.email, primary: e.primary, verified: e.verified, visibility: e.visibility };
|
|
3386
|
+
}
|
|
3387
|
+
export function toGithubUserSshKey(k) {
|
|
3388
|
+
return { id: k.id, key: k.key, title: k.title, url: `https://api.github.com/user/keys/${k.id}`, verified: true, read_only: false, created_at: k.created_at ?? null };
|
|
3389
|
+
}
|
|
3390
|
+
export function toGithubUserGpgKey(k) {
|
|
3391
|
+
return { id: k.id, key_id: k.key_id, raw_key: k.raw_key, public_key: k.raw_key, can_sign: true, can_encrypt_comms: true, can_encrypt_storage: true, can_certify: true, emails: [], subkeys: [], revoked: false, created_at: k.created_at ?? null, expires_at: null, primary_key_id: null };
|
|
3392
|
+
}
|
|
3393
|
+
// ── Projects v2 serializers ──────────────────────────────────────────────────────────────
|
|
3394
|
+
export function toGithubProject(p) {
|
|
3395
|
+
return {
|
|
3396
|
+
id: p.id, node_id: `PVT_${p.id}`, number: p.number, title: p.title,
|
|
3397
|
+
short_description: p.short_description ?? null, description: p.short_description ?? null,
|
|
3398
|
+
public: p.public ?? false, state: p.closed ? 'closed' : 'open', closed_at: p.closed ? (p.updated_at ?? null) : null,
|
|
3399
|
+
...(p.creator_login ? { creator: toSimpleUser(p.creator_login) } : {}),
|
|
3400
|
+
is_template: false, deleted_at: null, deleted_by: null, latest_status_update: null,
|
|
3401
|
+
owner: { login: p.owner, type: p.owner_type === 'user' ? 'User' : 'Organization' },
|
|
3402
|
+
created_at: p.created_at ?? null, updated_at: p.updated_at ?? null,
|
|
3403
|
+
};
|
|
3404
|
+
}
|
|
3405
|
+
// ── Security alert serializers (code scanning / dependabot / secret scanning) ───────────────
|
|
3406
|
+
export function toGithubCodeScanningAlert(a) {
|
|
3407
|
+
const [owner, repo] = a.repository.split('/');
|
|
3408
|
+
return {
|
|
3409
|
+
number: a.number, created_at: a.created_at ?? null, updated_at: a.updated_at ?? null,
|
|
3410
|
+
url: `https://api.github.com/repos/${a.repository}/code-scanning/alerts/${a.number}`,
|
|
3411
|
+
html_url: `https://github.com/${a.repository}/security/code-scanning/${a.number}`,
|
|
3412
|
+
state: a.state, fixed_at: a.state === 'fixed' ? (a.fixed_at ?? a.updated_at ?? null) : null,
|
|
3413
|
+
dismissed_at: a.dismissed_at ?? null, dismissed_reason: a.dismissed_reason ?? null, dismissed_comment: a.dismissed_comment ?? null,
|
|
3414
|
+
dismissed_by: a.dismissed_by_login ? toSimpleUser(a.dismissed_by_login) : null,
|
|
3415
|
+
instances_url: `https://api.github.com/repos/${a.repository}/code-scanning/alerts/${a.number}/instances`,
|
|
3416
|
+
rule: { id: a.rule_id, name: a.rule_id, severity: a.rule_severity, description: a.rule_description ?? a.rule_id, tags: [] },
|
|
3417
|
+
tool: { name: a.tool_name, version: a.tool_version ?? null, guid: null },
|
|
3418
|
+
most_recent_instance: a.most_recent_instance
|
|
3419
|
+
? { ref: a.most_recent_instance.ref, analysis_key: a.most_recent_instance.analysis_key, environment: '', category: '', state: a.most_recent_instance.state, commit_sha: a.most_recent_instance.commit_sha,
|
|
3420
|
+
message: { text: a.most_recent_instance.message }, location: { path: a.most_recent_instance.path, start_line: a.most_recent_instance.start_line, end_line: a.most_recent_instance.end_line, start_column: 1, end_column: 1 }, classifications: [] }
|
|
3421
|
+
: null,
|
|
3422
|
+
repository: { full_name: a.repository, owner: { login: owner }, name: repo },
|
|
3423
|
+
};
|
|
3424
|
+
}
|
|
3425
|
+
export function toGithubCodeScanningAnalysis(a) {
|
|
3426
|
+
return {
|
|
3427
|
+
id: a.id, ref: a.ref, commit_sha: a.commit_sha, analysis_key: a.analysis_key, environment: a.environment ?? '',
|
|
3428
|
+
created_at: a.created_at ?? null, results_count: a.results_count, rules_count: a.rules_count,
|
|
3429
|
+
sarif_id: a.sarif_id ?? null, tool: { name: a.tool_name, version: null, guid: null }, deletable: a.deletable ?? true, error: '', warning: '',
|
|
3430
|
+
url: `https://api.github.com/repos/${a.repository}/code-scanning/analyses/${a.id}`,
|
|
3431
|
+
};
|
|
3432
|
+
}
|
|
3433
|
+
export function toGithubDependabotAlert(a) {
|
|
3434
|
+
return {
|
|
3435
|
+
number: a.number, state: a.state, created_at: a.created_at ?? null, updated_at: a.updated_at ?? null,
|
|
3436
|
+
dismissed_at: a.dismissed_at ?? null, dismissed_reason: a.dismissed_reason ?? null, dismissed_comment: a.dismissed_comment ?? null,
|
|
3437
|
+
dismissed_by: a.dismissed_by_login ? toSimpleUser(a.dismissed_by_login) : null,
|
|
3438
|
+
fixed_at: a.state === 'fixed' ? (a.updated_at ?? null) : null,
|
|
3439
|
+
auto_dismissed_at: a.state === 'auto_dismissed' ? (a.updated_at ?? null) : null,
|
|
3440
|
+
dependency: { package: { ecosystem: a.ecosystem, name: a.package_name }, manifest_path: a.manifest_path ?? 'package.json', scope: 'runtime' },
|
|
3441
|
+
security_advisory: { ghsa_id: a.ghsa_id, severity: a.severity, summary: a.summary ?? `${a.package_name} vulnerability` },
|
|
3442
|
+
security_vulnerability: { package: { ecosystem: a.ecosystem, name: a.package_name }, severity: a.severity },
|
|
3443
|
+
url: `https://api.github.com/repos/${a.repository}/dependabot/alerts/${a.number}`,
|
|
3444
|
+
html_url: `https://github.com/${a.repository}/security/dependabot/${a.number}`,
|
|
3445
|
+
};
|
|
3446
|
+
}
|
|
3447
|
+
export function toGithubSecretScanningAlert(a) {
|
|
3448
|
+
return {
|
|
3449
|
+
number: a.number, state: a.state, created_at: a.created_at ?? null, updated_at: a.updated_at ?? null,
|
|
3450
|
+
secret_type: a.secret_type, secret_type_display_name: a.secret_type_display_name ?? a.secret_type,
|
|
3451
|
+
resolution: a.resolution ?? null, resolved_at: a.resolved_at ?? null, resolution_comment: a.resolution_comment ?? null,
|
|
3452
|
+
url: `https://api.github.com/repos/${a.repository}/secret-scanning/alerts/${a.number}`,
|
|
3453
|
+
html_url: `https://github.com/${a.repository}/security/secret-scanning/${a.number}`,
|
|
3454
|
+
};
|
|
3455
|
+
}
|
|
3456
|
+
// ── Apps / OAuth serializers ────────────────────────────────────────────────────────────────
|
|
3457
|
+
export function toGithubAppInstallation(i) {
|
|
3458
|
+
return {
|
|
3459
|
+
id: i.id, app_id: i.app_id, app_slug: i.app_slug, target_id: 0, target_type: i.target_type,
|
|
3460
|
+
account: { login: i.account_login, type: i.account_type },
|
|
3461
|
+
repository_selection: i.repository_selection, permissions: i.permissions, events: i.events,
|
|
3462
|
+
created_at: i.created_at ?? null, updated_at: i.updated_at ?? null,
|
|
3463
|
+
access_tokens_url: `https://api.github.com/app/installations/${i.id}/access_tokens`,
|
|
3464
|
+
repositories_url: 'https://api.github.com/installation/repositories',
|
|
3465
|
+
html_url: i.account_type === 'Organization' ? `https://github.com/organizations/${i.account_login}/settings/installations/${i.id}` : `https://github.com/settings/installations/${i.id}`,
|
|
3466
|
+
single_file_name: null, suspended_at: null, suspended_by: null,
|
|
3467
|
+
};
|
|
3468
|
+
}
|
|
3469
|
+
// ── Packages / Pages / Codespaces serializers ───────────────────────────────────────────────
|
|
3470
|
+
export function toGithubPackage(p, versionCount) {
|
|
3471
|
+
return {
|
|
3472
|
+
id: p.id, name: p.name, package_type: p.package_type, visibility: p.visibility, version_count: versionCount,
|
|
3473
|
+
owner: { login: p.owner }, created_at: p.created_at ?? null, updated_at: p.updated_at ?? null,
|
|
3474
|
+
url: `https://api.github.com/users/${p.owner}/packages/${p.package_type}/${p.name}`,
|
|
3475
|
+
html_url: `https://github.com/users/${p.owner}/packages/${p.package_type}/package/${p.name}`,
|
|
3476
|
+
};
|
|
3477
|
+
}
|
|
3478
|
+
export function toGithubPackageVersion(v) {
|
|
3479
|
+
return {
|
|
3480
|
+
id: v.id, name: v.name, created_at: v.created_at ?? null, updated_at: v.updated_at ?? null,
|
|
3481
|
+
metadata: { package_type: v.package_type, ...(v.tags ? { container: { tags: v.tags } } : {}) },
|
|
3482
|
+
url: `https://api.github.com/users/${v.owner}/packages/${v.package_type}/${v.package_name}/versions/${v.id}`,
|
|
3483
|
+
package_html_url: `https://github.com/users/${v.owner}/packages/${v.package_type}/package/${v.package_name}`,
|
|
3484
|
+
};
|
|
3485
|
+
}
|
|
3486
|
+
export function toGithubPagesSite(s) {
|
|
3487
|
+
return {
|
|
3488
|
+
url: `https://api.github.com/repos/${s.repository}/pages`, status: s.status, cname: s.cname ?? null,
|
|
3489
|
+
custom_404: false, html_url: s.html_url ?? `https://${s.repository.split('/')[0]}.github.io/${s.repository.split('/')[1]}/`,
|
|
3490
|
+
build_type: s.build_type ?? 'legacy', source: { branch: s.source_branch, path: s.source_path },
|
|
3491
|
+
public: s.public ?? true, https_enforced: s.https_enforced ?? true, https_certificate: null,
|
|
3492
|
+
};
|
|
3493
|
+
}
|
|
3494
|
+
export function toGithubPagesBuild(b) {
|
|
3495
|
+
return {
|
|
3496
|
+
url: `https://api.github.com/repos/${b.repository}/pages/builds/${b.id}`, status: b.status,
|
|
3497
|
+
error: { message: b.error_message ?? null }, pusher: null, commit: b.commit ?? null,
|
|
3498
|
+
duration: b.duration ?? 0, created_at: b.created_at ?? null, updated_at: b.created_at ?? null,
|
|
3499
|
+
};
|
|
3500
|
+
}
|
|
3501
|
+
export function toGithubCodespace(c) {
|
|
3502
|
+
const [owner, name] = c.repository.split('/');
|
|
3503
|
+
return {
|
|
3504
|
+
name: c.name, state: c.state, owner: { login: c.owner }, machine: { name: c.machine, display_name: c.machine },
|
|
3505
|
+
repository: { full_name: c.repository, owner: { login: owner }, name },
|
|
3506
|
+
git_status: { ref: c.git_branch, ahead: 0, behind: 0, has_unpushed_changes: false, has_uncommitted_changes: false },
|
|
3507
|
+
created_at: c.created_at ?? null, updated_at: c.updated_at ?? null,
|
|
3508
|
+
web_url: `https://${c.name}.github.dev`,
|
|
3509
|
+
id: actionsId(`codespace:${c.name}`), url: `https://api.github.com/user/codespaces/${c.name}`,
|
|
3510
|
+
machines_url: `https://api.github.com/user/codespaces/${c.name}/machines`, start_url: `https://api.github.com/user/codespaces/${c.name}/start`,
|
|
3511
|
+
stop_url: `https://api.github.com/user/codespaces/${c.name}/stop`, pulls_url: null, recent_folders: [], prebuild: false, environment_id: null,
|
|
3512
|
+
billable_owner: toSimpleUser(c.owner), last_used_at: c.updated_at ?? c.created_at ?? null,
|
|
3513
|
+
// GitHub's defaults: thirty idle minutes; the region is GitHub's choice by the person's location, and the
|
|
3514
|
+
// twin's is WestUs2 (where the documentation stops and the twin decides)
|
|
3515
|
+
idle_timeout_minutes: 30, location: 'WestUs2',
|
|
3516
|
+
};
|
|
3517
|
+
}
|
|
3518
|
+
// ── Actions secrets/variables/caches/artifacts serializers ───────────────────────────────
|
|
3519
|
+
// Secrets NEVER return a value (GitHub omits it) — only name + timestamps. The twin stores
|
|
3520
|
+
// nothing secret, mirroring that exactly.
|
|
3521
|
+
export function toGithubActionsSecret(s) {
|
|
3522
|
+
return { name: s.name, created_at: s.created_at ?? null, updated_at: s.updated_at ?? null, ...(s.scope === 'org' ? { visibility: s.visibility ?? 'private' } : {}) };
|
|
3523
|
+
}
|
|
3524
|
+
export function toGithubActionsVariable(v) {
|
|
3525
|
+
return { name: v.name, value: v.value, created_at: v.created_at ?? null, updated_at: v.updated_at ?? null, ...(v.scope === 'org' ? { visibility: v.visibility ?? 'private' } : {}) };
|
|
3526
|
+
}
|
|
3527
|
+
export function toGithubActionsCache(c) {
|
|
3528
|
+
return {
|
|
3529
|
+
id: c.id, ref: c.ref, key: c.key, version: createHash('sha256').update(`${c.key}:${c.ref}`).digest('hex'),
|
|
3530
|
+
last_accessed_at: c.last_accessed_at ?? c.created_at ?? null, created_at: c.created_at ?? null, size_in_bytes: c.size_in_bytes,
|
|
3531
|
+
};
|
|
3532
|
+
}
|
|
3533
|
+
export function toGithubActionsArtifact(a) {
|
|
3534
|
+
const api = `https://api.github.com/repos/${a.repository}`;
|
|
3535
|
+
return {
|
|
3536
|
+
id: a.id, node_id: nodeId('Artifact', a.id), name: a.name, size_in_bytes: a.size_in_bytes, expired: a.expired ?? false,
|
|
3537
|
+
url: `${api}/actions/artifacts/${a.id}`, archive_download_url: `${api}/actions/artifacts/${a.id}/zip`,
|
|
3538
|
+
created_at: a.created_at ?? null, updated_at: a.created_at ?? null,
|
|
3539
|
+
// an artifact is kept 90 days, GitHub's default retention
|
|
3540
|
+
expires_at: a.created_at ? new Date(Date.parse(a.created_at) + 90 * 86_400_000).toISOString().replace('.000Z', 'Z') : null,
|
|
3541
|
+
...(a.run_id !== undefined ? { workflow_run: { id: a.run_id } } : {}),
|
|
3542
|
+
};
|
|
3543
|
+
}
|
|
3544
|
+
// Build an issue's timeline as GitHub-shaped events, derived from the issue's CURRENT
|
|
3545
|
+
// modeled state (labels/assignees/milestone/state/lock + cross-references). The twin has no
|
|
3546
|
+
// per-field change history, so each present attribute yields one `*ed` event; ids are a
|
|
3547
|
+
// stable per-issue sequence. Unmodeled event fields (actor/created_at) are declared scope.
|
|
3548
|
+
//
|
|
3549
|
+
// A PULL REQUEST answers at its issue address, so its timeline is built here too, from its
|
|
3550
|
+
// issue face plus what only a pull request has: the `merged` event, the reviews submitted on
|
|
3551
|
+
// it, and (for issues and pull requests alike) the comments on the thread. Everything served
|
|
3552
|
+
// here is read out of stored state — nothing on a real GitHub timeline that this twin does
|
|
3553
|
+
// not hold is fabricated to fill the list out. What is NOT served, and why, is declared:
|
|
3554
|
+
// github.pulls.timeline_unmodeled.
|
|
3555
|
+
export function buildIssueTimeline(repository, iss, extra) {
|
|
3556
|
+
const events = [];
|
|
3557
|
+
let id = 1;
|
|
3558
|
+
const actor = (login) => (login ? { login, type: 'User' } : null);
|
|
3559
|
+
const pull = extra?.pull;
|
|
3560
|
+
// What was done to it, in order, with who and when: the activity its writes left (labels, assignees, the
|
|
3561
|
+
// milestone, the lock, closing, reopening, merging). A subject whose writes predate that record (observed
|
|
3562
|
+
// evidence) falls back to its current state and recorded transitions, with no actor to name.
|
|
3563
|
+
// a pull request's issue face already carries its own activity, merge included
|
|
3564
|
+
const activity = iss.activity?.some((x) => x.event === 'merged') ? iss.activity : [...(iss.activity ?? []), ...(pull?.activity ?? []).filter((x) => x.event === 'merged')];
|
|
3565
|
+
if (iss.activity?.length) {
|
|
3566
|
+
for (const x of activity) {
|
|
3567
|
+
const base = { id: id++, event: x.event, actor: actor(x.by), created_at: x.at ?? null };
|
|
3568
|
+
if (x.event === 'labeled' || x.event === 'unlabeled')
|
|
3569
|
+
events.push({ ...base, label: { name: x.label, color: 'ededed' } });
|
|
3570
|
+
else if (x.event === 'assigned' || x.event === 'unassigned')
|
|
3571
|
+
events.push({ ...base, assignee: { login: x.assignee } });
|
|
3572
|
+
else if (x.event === 'milestoned' || x.event === 'demilestoned')
|
|
3573
|
+
events.push({ ...base, milestone: { title: String(x.milestone ?? '') } });
|
|
3574
|
+
else if (x.event === 'locked')
|
|
3575
|
+
events.push({ ...base, lock_reason: x.lock_reason ?? null });
|
|
3576
|
+
else if (x.event === 'closed')
|
|
3577
|
+
events.push({ ...base, state_reason: x.state_reason ?? null });
|
|
3578
|
+
else if (x.event === 'merged')
|
|
3579
|
+
events.push({ ...base, commit_id: x.commit_id ?? pull?.merge_commit_sha ?? null });
|
|
3580
|
+
else
|
|
3581
|
+
events.push(base);
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3584
|
+
else {
|
|
3585
|
+
for (const label of iss.labels ?? [])
|
|
3586
|
+
events.push({ id: id++, event: 'labeled', actor: null, created_at: null, label: { name: label, color: 'ededed' } });
|
|
3587
|
+
for (const login of iss.assignees ?? [])
|
|
3588
|
+
events.push({ id: id++, event: 'assigned', actor: null, created_at: null, assignee: { login } });
|
|
3589
|
+
if (iss.milestone !== undefined)
|
|
3590
|
+
events.push({ id: id++, event: 'milestoned', actor: null, created_at: null, milestone: { title: '' } });
|
|
3591
|
+
const history = iss.state_history ?? [];
|
|
3592
|
+
if (history.length) {
|
|
3593
|
+
for (const t of history) {
|
|
3594
|
+
events.push(t.state === 'closed'
|
|
3595
|
+
? { id: id++, event: 'closed', actor: null, created_at: t.at ?? null, state_reason: t.state_reason ?? null }
|
|
3596
|
+
: { id: id++, event: 'reopened', actor: null, created_at: t.at ?? null });
|
|
3597
|
+
}
|
|
3598
|
+
}
|
|
3599
|
+
else if (iss.state === 'closed') {
|
|
3600
|
+
events.push({ id: id++, event: 'closed', actor: null, created_at: null, state_reason: iss.state_reason ?? null });
|
|
3601
|
+
}
|
|
3602
|
+
if (pull?.merged)
|
|
3603
|
+
events.push({ id: id++, event: 'merged', actor: null, created_at: pull.merged_at ?? null, commit_id: pull.merge_commit_sha ?? null });
|
|
3604
|
+
if (iss.locked)
|
|
3605
|
+
events.push({ id: id++, event: 'locked', actor: null, created_at: null, lock_reason: iss.active_lock_reason ?? null });
|
|
3606
|
+
}
|
|
3607
|
+
// REVIEWED — the reviews submitted on this pull request, each with the verdict and the
|
|
3608
|
+
// words. Real GitHub lower-cases the review state on the timeline (`approved`), while the
|
|
3609
|
+
// reviews API returns it upper-case (`APPROVED`); both come off the one stored review.
|
|
3610
|
+
for (const r of extra?.reviews ?? [])
|
|
3611
|
+
events.push({
|
|
3612
|
+
id: id++, event: 'reviewed', actor: actor(r.user_login), created_at: r.submitted_at ?? null,
|
|
3613
|
+
state: (r.state ?? 'commented').toLowerCase(), body: r.body ?? null, submitted_at: r.submitted_at ?? null,
|
|
3614
|
+
});
|
|
3615
|
+
// COMMENTED — the thread itself. An issue and a pull request both take issue comments, and
|
|
3616
|
+
// the timeline is where a consumer reads the conversation in order.
|
|
3617
|
+
for (const c of extra?.comments ?? [])
|
|
3618
|
+
events.push({
|
|
3619
|
+
id: id++, event: 'commented', actor: actor(c.user_login), created_at: c.created_at ?? null,
|
|
3620
|
+
updated_at: c.updated_at ?? c.created_at ?? null, body: c.body ?? null,
|
|
3621
|
+
});
|
|
3622
|
+
// the timeline reads in the order things happened, once they carry their instants
|
|
3623
|
+
if (iss.activity?.length)
|
|
3624
|
+
events.sort((a, b) => String(a.created_at ?? '').localeCompare(String(b.created_at ?? '')));
|
|
3625
|
+
// an issue event is GitHub's own record, with an id unique in the repository (issues/get-event reads it by that
|
|
3626
|
+
// id) and the fields every event carries; comments and cross-references are timeline items, not events
|
|
3627
|
+
const api = `https://api.github.com/repos/${repository}`;
|
|
3628
|
+
return events.map((e) => {
|
|
3629
|
+
if (['commented', 'reviewed', 'cross-referenced', 'committed'].includes(String(e.event)))
|
|
3630
|
+
return e;
|
|
3631
|
+
const eventId = actionsId(`${repository}#${iss.number}:event:${String(e.id)}`);
|
|
3632
|
+
const commit = typeof e.commit_id === 'string' ? e.commit_id : null;
|
|
3633
|
+
return { ...e, id: eventId, node_id: nodeId('IssueEvent', eventId), url: `${api}/issues/events/${eventId}`, commit_id: commit, commit_url: commit ? `${api}/commits/${commit}` : null, performed_via_github_app: null };
|
|
3634
|
+
});
|
|
3635
|
+
}
|
|
3636
|
+
// GitHub's vendor-exact "Not Found" envelope (R-V3 / D2 fidelity). Real GitHub's 404s —
|
|
3637
|
+
// both a missing resource on a modeled route AND the catch-all response for a route the
|
|
3638
|
+
// API doesn't recognize at all — share this literal body: {message, documentation_url,
|
|
3639
|
+
// status} (the OpenAPI "basic-error" schema). The twin previously leaked its own twin
|
|
3640
|
+
// identity in the catch-all fallthrough message and omitted documentation_url/status on
|
|
3641
|
+
// every interior 404, which is a fidelity seam for any caller that surfaces or
|
|
3642
|
+
// string-matches GitHub's error envelope. Used for BOTH cases so an octokit consumer
|
|
3643
|
+
// cannot distinguish "modeled resource missing" from "route not modeled" — exactly like
|
|
3644
|
+
// the real vendor, which also never reveals that distinction.
|
|
3645
|
+
const GITHUB_NOT_FOUND_DOCUMENTATION_URL = 'https://docs.github.com/rest';
|
|
3646
|
+
export function githubNotFound() {
|
|
3647
|
+
return { status: 404, body: { message: 'Not Found', documentation_url: GITHUB_NOT_FOUND_DOCUMENTATION_URL, status: '404' } };
|
|
3648
|
+
}
|
|
3649
|
+
// GitHub list pagination: per_page default 30, max 100; page is 1-based. Callers that
|
|
3650
|
+
// pass no params get page 1 / per_page 30 — identical to the un-paginated behavior for
|
|
3651
|
+
// any list of <= 30 items.
|
|
3652
|
+
export function paginate(items, query) {
|
|
3653
|
+
const params = new URLSearchParams((query ?? '').replace(/^\?/, ''));
|
|
3654
|
+
let perPage = Number(params.get('per_page'));
|
|
3655
|
+
if (!Number.isFinite(perPage) || perPage <= 0)
|
|
3656
|
+
perPage = 30;
|
|
3657
|
+
perPage = Math.min(Math.floor(perPage), 100);
|
|
3658
|
+
let page = Number(params.get('page'));
|
|
3659
|
+
if (!Number.isFinite(page) || page <= 0)
|
|
3660
|
+
page = 1;
|
|
3661
|
+
page = Math.floor(page);
|
|
3662
|
+
const start = (page - 1) * perPage;
|
|
3663
|
+
return items.slice(start, start + perPage);
|
|
3664
|
+
}
|
|
3665
|
+
// Derive the Activity event stream (GET /events, /users/:u/events, /users/:u/received_events)
|
|
3666
|
+
// from the modeled world — each first-class object the twin tracks maps to the GitHub event
|
|
3667
|
+
// type it would have fired (PRs→PullRequestEvent, issues→IssuesEvent, releases→ReleaseEvent,
|
|
3668
|
+
// pushes-as-contents→PushEvent, etc.). Honest: only events for objects the twin actually
|
|
3669
|
+
// models; ids are deterministic (hash of the natural key — never random/time). The stream is
|
|
3670
|
+
// the SAME projection the REST twin serves, surfaced as the public activity timeline.
|
|
3671
|
+
export function githubActivityEvents(state) {
|
|
3672
|
+
const ev = (type, key, repo, payload, at) => {
|
|
3673
|
+
const [owner] = repo.split('/');
|
|
3674
|
+
const id = String(0x60000000 + (parseInt(createHash('sha256').update(`event:${type}:${key}`).digest('hex').slice(0, 8), 16) % 0x0fffffff));
|
|
3675
|
+
return { id, type, actor: { login: owner, display_login: owner }, repo: { name: repo }, payload, public: true, created_at: at ?? null };
|
|
3676
|
+
};
|
|
3677
|
+
const events = [];
|
|
3678
|
+
// A `PullRequestEvent` for a row that is not a pull request is the pulls-list defect in
|
|
3679
|
+
// another door: `state.prs` also holds the SHELLS `ensure()` materializes for the
|
|
3680
|
+
// review/comment counters, which hang off issue numbers, and an issue that was commented on
|
|
3681
|
+
// is not a pull request that was opened. The feed reads the same marker the pulls list does.
|
|
3682
|
+
for (const pr of state.prs.filter((p) => p.is_pull_request))
|
|
3683
|
+
events.push(ev('PullRequestEvent', pr.id, pr.repository, { action: pr.merged ? 'closed' : (pr.state === 'closed' ? 'closed' : 'opened'), number: pr.number, pull_request: { number: pr.number, title: pr.title, merged: pr.merged ?? false } }, pr.merged_at));
|
|
3684
|
+
for (const iss of state.issues)
|
|
3685
|
+
events.push(ev('IssuesEvent', iss.id, iss.repository, { action: iss.state === 'closed' ? 'closed' : 'opened', issue: { number: iss.number, title: iss.title } }, iss.created_at));
|
|
3686
|
+
for (const r of state.releases)
|
|
3687
|
+
if (!r.draft)
|
|
3688
|
+
events.push(ev('ReleaseEvent', `${r.repository}#${r.id}`, r.repository, { action: 'published', release: { tag_name: r.tag_name, name: r.name } }, r.published_at));
|
|
3689
|
+
for (const c of state.comments)
|
|
3690
|
+
events.push(ev(c.kind === 'review' ? 'PullRequestReviewCommentEvent' : 'IssueCommentEvent', `${c.repository}#${c.id}`, c.repository, { action: 'created', comment: { body: c.body } }, c.created_at));
|
|
3691
|
+
return events.sort((a, b) => (String(a.id) < String(b.id) ? -1 : 1));
|
|
3692
|
+
}
|
|
3693
|
+
// Deterministic next number in the per-repo SHARED issue/PR number space (real GitHub
|
|
3694
|
+
// behavior): issues and PRs draw from ONE counter, so the next number is max(existing PR
|
|
3695
|
+
// or issue number in the repo) + 1. Used for both PR-create and issue-create.
|
|
3696
|
+
export function nextRepoNumber(st, repo) {
|
|
3697
|
+
let max = 0;
|
|
3698
|
+
for (const p of st.prs)
|
|
3699
|
+
if (p.repository === repo)
|
|
3700
|
+
max = Math.max(max, p.number);
|
|
3701
|
+
for (const i of st.issues)
|
|
3702
|
+
if (i.repository === repo)
|
|
3703
|
+
max = Math.max(max, i.number);
|
|
3704
|
+
// discussions are numbered in the same sequence (docs.github.com/discussions/collaborating-with-your-community-using-discussions/about-discussions)
|
|
3705
|
+
for (const d of st.discussions)
|
|
3706
|
+
if (d.repository === repo)
|
|
3707
|
+
max = Math.max(max, d.number);
|
|
3708
|
+
return max + 1;
|
|
3709
|
+
}
|
|
3710
|
+
// Coerce a labels write payload (string[] or {name}[] — both accepted by real GitHub)
|
|
3711
|
+
// into the bare-name list the twin stores.
|
|
3712
|
+
export function normalizeLabels(input) {
|
|
3713
|
+
if (!Array.isArray(input))
|
|
3714
|
+
return [];
|
|
3715
|
+
return input.map((l) => (typeof l === 'string' ? l : String(l?.name ?? ''))).filter(Boolean);
|
|
3716
|
+
}
|
|
3717
|
+
// Coerce an assignees write payload (string[] of logins) into a bare-login list.
|
|
3718
|
+
export function normalizeLogins(input) {
|
|
3719
|
+
if (!Array.isArray(input))
|
|
3720
|
+
return [];
|
|
3721
|
+
return input.map((u) => (typeof u === 'string' ? u : String(u?.login ?? ''))).filter(Boolean);
|
|
3722
|
+
}
|
|
3723
|
+
// The triage trio (labels / assignees / milestone) an issue or PR write may carry. Real
|
|
3724
|
+
// GitHub accepts all three on BOTH issue create (POST .../issues) and edit (PATCH
|
|
3725
|
+
// .../issues/:n), and on PR edit — one coercion, used by every one of them, so the paths
|
|
3726
|
+
// cannot diverge (they did: issue create silently dropped all three while edit honoured them).
|
|
3727
|
+
export function applyTriageFields(fields, body) {
|
|
3728
|
+
if (body.labels !== undefined)
|
|
3729
|
+
fields.labels = normalizeLabels(body.labels);
|
|
3730
|
+
if (body.assignees !== undefined)
|
|
3731
|
+
fields.assignees = normalizeLogins(body.assignees);
|
|
3732
|
+
if (body.milestone !== undefined)
|
|
3733
|
+
fields.milestone = body.milestone === null ? null : Number(body.milestone);
|
|
3734
|
+
}
|
|
3735
|
+
export function nextSeq(prefix, root) {
|
|
3736
|
+
let max = 0;
|
|
3737
|
+
for (const r of twinResources(SERVICE, root)) {
|
|
3738
|
+
const m = new RegExp(`^${prefix}:(\\d+)$`).exec(r.id);
|
|
3739
|
+
if (m)
|
|
3740
|
+
max = Math.max(max, Number(m[1]));
|
|
3741
|
+
}
|
|
3742
|
+
return max + 1;
|
|
3743
|
+
}
|
|
3744
|
+
// Deterministic positive-integer id for an Actions object derived from a stable key (never
|
|
3745
|
+
// random / time-based — the repo forbids Math.random/Date.now in non-runtime code). Offset
|
|
3746
|
+
// above the local sequence space so it never collides with sequence ids like `run:1`.
|
|
3747
|
+
export function actionsId(key) {
|
|
3748
|
+
return 0x50000000 + (parseInt(createHash('sha256').update(key).digest('hex').slice(0, 8), 16) % 0x0fffffff);
|
|
3749
|
+
}
|
|
3750
|
+
// Deterministic seeded profile for any login (so GET /users/:login round-trips a faithful
|
|
3751
|
+
// simple-user object even when no profile was explicitly written). An org login (ends with
|
|
3752
|
+
// '-org' or is all-lowercase 'octo'-style) is treated as a User by default. Never random.
|
|
3753
|
+
export function seedUser(login) {
|
|
3754
|
+
const id = 0x10000000 + (parseInt(createHash('sha256').update(`user:${login}`).digest('hex').slice(0, 8), 16) % 0x0fffffff);
|
|
3755
|
+
return { login, id, type: 'User', name: null, bio: null, company: null, blog: null, location: null, email: null };
|
|
3756
|
+
}
|
|
3757
|
+
/**
|
|
3758
|
+
* Handle a GitHub REST WRITE as a local action (R5/R18). Supports:
|
|
3759
|
+
* POST /repos/:o/:r/pulls → create PR (pull_request: opened)
|
|
3760
|
+
* PATCH /repos/:o/:r/pulls/:n → edit PR (pull_request: edited/closed)
|
|
3761
|
+
* PUT /repos/:o/:r/pulls/:n/merge → merge PR (pull_request: closed; 405 if not mergeable)
|
|
3762
|
+
* POST /repos/:o/:r/pulls/:n/reviews → submit review (pull_request_review)
|
|
3763
|
+
* POST /repos/:o/:r/issues → create issue (issues: opened)
|
|
3764
|
+
* PATCH /repos/:o/:r/issues/:n → edit issue (issues: edited/closed)
|
|
3765
|
+
* POST /repos/:o/:r/issues/:n/comments → comment (issue_comment)
|
|
3766
|
+
* Mirror mode rejects writes (R4). No real GitHub I/O (R16).
|
|
3767
|
+
*/
|
|
3768
|
+
/**
|
|
3769
|
+
* The PR's REAL changed-file list from the git plane: resolve the base branch tip + the PR
|
|
3770
|
+
* head sha; when both commits exist in the bare repo, `git diff-tree` computes the truth.
|
|
3771
|
+
* Null when either side is not in the plane (observed/simulated PRs keep their carried or
|
|
3772
|
+
* empty file lists — never fabricated). This is what merge-gate scope classification reads
|
|
3773
|
+
* (GET /pulls/:n/files + GraphQL changedFiles), so a pushed PR must answer with its actual
|
|
3774
|
+
* paths, not 0 files.
|
|
3775
|
+
*/
|
|
3776
|
+
export async function prPlaneDiffFiles(state, repo, pr, root) {
|
|
3777
|
+
const headSha = pr.head_sha;
|
|
3778
|
+
if (!headSha || !/^[0-9a-f]{40}$/i.test(headSha))
|
|
3779
|
+
return null;
|
|
3780
|
+
const baseBranch = pr.base_ref;
|
|
3781
|
+
if (!baseBranch)
|
|
3782
|
+
return null;
|
|
3783
|
+
// A merged pull request keeps the diff it was merged with: its base is the merge commit's first parent,
|
|
3784
|
+
// since the base branch's tip now holds the head and would diff to nothing.
|
|
3785
|
+
const mergedFrom = pr.merged && pr.merge_commit_sha ? (await readGitCommitFromPlane(repo, pr.merge_commit_sha, root))?.parents[0] : undefined;
|
|
3786
|
+
const baseSha = mergedFrom
|
|
3787
|
+
?? state.gitRefs.find((x) => x.repository === repo && x.ref === `refs/heads/${baseBranch}`)?.object_sha
|
|
3788
|
+
?? state.branches.find((b) => b.repository === repo && b.name === baseBranch)?.commit_sha;
|
|
3789
|
+
if (!baseSha || !/^[0-9a-f]{40}$/i.test(baseSha))
|
|
3790
|
+
return null;
|
|
3791
|
+
const diff = await diffFilesInPlane(repo, baseSha, headSha, root, { patch: true });
|
|
3792
|
+
if (!diff)
|
|
3793
|
+
return null;
|
|
3794
|
+
// Real post-image blob shas and the unified patch GitHub serves per text file (none for a binary one).
|
|
3795
|
+
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 } : {}), ...(f.patch !== undefined ? { patch: f.patch } : {}) }));
|
|
3796
|
+
}
|
|
3797
|
+
/**
|
|
3798
|
+
* GitHub closes open PRs whose HEAD BRANCH is deleted. Shared by the receive-pack reconcile
|
|
3799
|
+
* and the REST ref delete so both paths converge. Returns the closed PR numbers. A pull request's head branch may
|
|
3800
|
+
* live in a fork (`repo` is where the branch was, the callback names the pull request's own repository).
|
|
3801
|
+
*/
|
|
3802
|
+
export async function closePrsForDeletedBranch(repo, branch, root, occurredAt, onChanged, atomically, by) {
|
|
3803
|
+
const changed = [];
|
|
3804
|
+
// the close is the pull request's move by the deletion (manifest.ts), on the wire that made it
|
|
3805
|
+
const { manifest } = await import("./manifest.js");
|
|
3806
|
+
const machine = manifest.resources['pull-request'].state.state;
|
|
3807
|
+
for (const candidate of githubState(root).prs) {
|
|
3808
|
+
if ((candidate.head_repo ?? candidate.repository) !== repo || candidate.head_ref !== branch)
|
|
3809
|
+
continue;
|
|
3810
|
+
const snapshot = await atomically(() => {
|
|
3811
|
+
const state = githubState(root);
|
|
3812
|
+
const current = state.prs.find(pr => pr.id === candidate.id);
|
|
3813
|
+
return !current || current.merged || !transitionFor('state', machine, by.operation, current.state ?? 'open', 'closed', current.id, by.actor).move ? { value: undefined } : closedWithItsBranch(current, state, occurredAt);
|
|
3814
|
+
});
|
|
3815
|
+
if (snapshot) {
|
|
3816
|
+
changed.push(Number(snapshot.number));
|
|
3817
|
+
onChanged?.(snapshot, candidate.repository);
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
return changed;
|
|
3821
|
+
}
|
|
3822
|
+
/** An open pull request closed because its head branch was deleted: its answer and the write. */
|
|
3823
|
+
function closedWithItsBranch(current, state, occurredAt) {
|
|
3824
|
+
const fields = { number: current.number, repository: current.repository, state: 'closed' };
|
|
3825
|
+
return { value: toGithubRest({ ...current, ...fields, updated_at: occurredAt }, state.milestones), write: { operation: 'pull_request.update', subjectType: 'pull_request', subjectId: current.id, fields } };
|
|
3826
|
+
}
|
|
3827
|
+
/**
|
|
3828
|
+
* Re-point every OPEN PR whose head branch is `branch` at the branch's new tip — GitHub's
|
|
3829
|
+
* `synchronize` semantic: a push (receive-pack or an API ref update) moves the PR head, so
|
|
3830
|
+
* statuses/checks/reviews earned on the old head no longer match. Returns the PR numbers
|
|
3831
|
+
* moved (the receive-pack path emits `pull_request synchronize` webhooks for them).
|
|
3832
|
+
*/
|
|
3833
|
+
export async function syncPrHeadsToBranchTip(repo, branch, sha, root, occurredAt, onChanged, atomically = kernelAtomic(occurredAt, root)) {
|
|
3834
|
+
const changed = [];
|
|
3835
|
+
for (const candidate of githubState(root).prs) {
|
|
3836
|
+
if ((candidate.head_repo ?? candidate.repository) !== repo || candidate.head_ref !== branch)
|
|
3837
|
+
continue;
|
|
3838
|
+
const snapshot = await atomically(() => {
|
|
3839
|
+
const state = githubState(root);
|
|
3840
|
+
const current = state.prs.find(pr => pr.id === candidate.id);
|
|
3841
|
+
if (!current || current.merged || (current.state ?? 'open') !== 'open' || current.head_sha === sha)
|
|
3842
|
+
return { value: undefined };
|
|
3843
|
+
const fields = { number: current.number, repository: current.repository, head_sha: sha };
|
|
3844
|
+
return { value: toGithubRest({ ...current, ...fields, updated_at: occurredAt }, state.milestones), write: { operation: 'pull_request.update', subjectType: 'pull_request', subjectId: current.id, fields } };
|
|
3845
|
+
});
|
|
3846
|
+
if (snapshot) {
|
|
3847
|
+
changed.push(Number(snapshot.number));
|
|
3848
|
+
onChanged?.(snapshot, candidate.repository);
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
3851
|
+
return changed;
|
|
3852
|
+
}
|
|
3853
|
+
/** Register a GitHub App: {slug, name, owner, permissions, events, setup_url?, callback_url?, request_oauth_on_install?,
|
|
3854
|
+
* hook_attributes?: {url}, webhook_secret?, description?}. GitHub registers apps in its settings UI (the twin's door,
|
|
3855
|
+
* POST /_twin/apps) or from a manifest (src/screens/app-manifest.tsx). A slug that is not one, or is taken, is 422. */
|
|
3856
|
+
export async function registerGithubApp(body, occurredAt, root) {
|
|
3857
|
+
const req = { root };
|
|
3858
|
+
const slug = String(body.slug ?? '');
|
|
3859
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,33})$/.test(slug))
|
|
3860
|
+
return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'Integration', field: 'slug', code: 'invalid' }] } } };
|
|
3861
|
+
const apps = githubApps(req.root);
|
|
3862
|
+
if (apps.some((a) => a.slug === slug))
|
|
3863
|
+
return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'Integration', field: 'slug', code: 'already_exists' }] } } };
|
|
3864
|
+
const id = apps.reduce((n, a) => Math.max(n, a.id), 0) + 1;
|
|
3865
|
+
const hook = body.hook_attributes && typeof body.hook_attributes === 'object' ? body.hook_attributes.url : undefined;
|
|
3866
|
+
const fields = {
|
|
3867
|
+
id, slug, name: String(body.name ?? slug), owner: String(body.owner ?? 'octocat'), client_id: `Iv1.${createHash('sha256').update(`app:${slug}`).digest('hex').slice(0, 16)}`,
|
|
3868
|
+
permissions: body.permissions && typeof body.permissions === 'object' ? Object.fromEntries(Object.entries(body.permissions).map(([k, v]) => [k, String(v)])) : {},
|
|
3869
|
+
events: Array.isArray(body.events) ? body.events.map(String) : [], request_oauth_on_install: body.request_oauth_on_install === true, description: body.description ?? null, created_at: occurredAt,
|
|
3870
|
+
...(typeof body.setup_url === 'string' ? { setup_url: body.setup_url } : {}), ...(typeof body.callback_url === 'string' ? { callback_url: body.callback_url } : {}),
|
|
3871
|
+
...(typeof hook === 'string' ? { hook_url: hook } : {}), ...(typeof body.webhook_secret === 'string' ? { webhook_secret: body.webhook_secret } : {}), ...(typeof body.url === 'string' ? { url: body.url } : {}),
|
|
3872
|
+
};
|
|
3873
|
+
await applyTwinWrite(SERVICE, { operation: 'app_registration.create', subjectType: 'app_registration', subjectId: `app:${slug}`, fields, occurredAt, actor: { kind: 'agent' } }, req.root);
|
|
3874
|
+
return { response: { status: 201, body: toGithubApp(fields, 0, githubState(req.root)) }, app: fields };
|
|
3875
|
+
}
|
|
3876
|
+
/** The twin's own doors under /_twin/: what stands in for an act GitHub does in its web UI or its runners, which have
|
|
3877
|
+
* no API. A request to any other path is not a door's (undefined). */
|
|
3878
|
+
export async function handleGithubDoor(request, scope) {
|
|
3879
|
+
const seg = new URL(request.url).pathname.replace(/\/+$/, '').replace(/^\/+/, '').split('/');
|
|
3880
|
+
if (seg[0] !== '_twin')
|
|
3881
|
+
return undefined;
|
|
3882
|
+
const req = { root: scope.root };
|
|
3883
|
+
const occurredAt = scope.now();
|
|
3884
|
+
// GET /_twin/users/:login/totp → {code}: the person's authenticator app, showing the code of their two-factor secret for
|
|
3885
|
+
// the World clock's now (the secret POST /_twin/users/:login/two-factor set). GitHub has no such door; it is their phone.
|
|
3886
|
+
if (request.method === 'GET' && seg[1] === 'users' && seg[2] && seg[3] === 'totp' && !seg[4]) {
|
|
3887
|
+
const login = decodeURIComponent(seg[2]).toLowerCase();
|
|
3888
|
+
const factor = twinResources(SERVICE, req.root).find((r) => r.type === 'user_two_factor' && String(r.login).toLowerCase() === login && r.deleted !== true);
|
|
3889
|
+
if (!factor)
|
|
3890
|
+
return Response.json(githubNotFound().body, { status: 404 });
|
|
3891
|
+
return Response.json({ code: totpCode(String(factor.secret), totpStep(occurredAt)) }, { status: 200 });
|
|
3892
|
+
}
|
|
3893
|
+
if (request.method !== 'POST')
|
|
3894
|
+
return undefined;
|
|
3895
|
+
// the new-organization page (POST /_twin/orgs below) sends a visitor not signed in to the sign-in, returning them there
|
|
3896
|
+
const newOrganization = seg[1] === 'orgs' && !seg[2];
|
|
3897
|
+
const { signedIn, toSignIn } = await import("./screens/session.js");
|
|
3898
|
+
const person = newOrganization ? signedIn(request, req.root) : undefined;
|
|
3899
|
+
if (newOrganization && !person)
|
|
3900
|
+
return toSignIn(request, undefined, '/account/organizations/new');
|
|
3901
|
+
let body = {};
|
|
3902
|
+
try {
|
|
3903
|
+
body = JSON.parse((await request.text()) || '{}');
|
|
3904
|
+
}
|
|
3905
|
+
catch {
|
|
3906
|
+
return Response.json({ message: 'invalid JSON body' }, { status: 400 });
|
|
3907
|
+
}
|
|
3908
|
+
const answer = async () => {
|
|
3909
|
+
// POST /_twin/apps → register a GitHub App (GitHub makes them in its settings UI, which has no API):
|
|
3910
|
+
// {slug, name, owner, permissions, events, setup_url?, callback_url?, request_oauth_on_install?, hook_attributes?: {url},
|
|
3911
|
+
// webhook_secret?}. It is installed through its page, github.com/apps/{slug}/installations/new (src/screens/app-installation.tsx).
|
|
3912
|
+
if (seg[1] === 'apps' && !seg[2])
|
|
3913
|
+
return registerGithubApp(body, occurredAt, req.root);
|
|
3914
|
+
// POST /_twin/users/:login/tokens → a personal access token for that user (GitHub mints these in its settings UI,
|
|
3915
|
+
// which has no API). Requests carrying it are that user's: writes are attributed to them and GET /user names them.
|
|
3916
|
+
if (seg[1] === 'users' && seg[2] && seg[3] === 'tokens' && !seg[4]) {
|
|
3917
|
+
const login = decodeURIComponent(seg[2]);
|
|
3918
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(login))
|
|
3919
|
+
return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'User', field: 'login', code: 'invalid' }] } } };
|
|
3920
|
+
const token = `ghp_${createHash('sha256').update(`pat:${login}:${nextSeq('pat', req.root)}`).digest('hex').slice(0, 36)}`;
|
|
3921
|
+
const scopes = Array.isArray(body.scopes) ? body.scopes.map(String) : ['repo', 'read:org'];
|
|
3922
|
+
await applyTwinWrite(SERVICE, { operation: 'oauth_grant.create', subjectType: 'oauth_grant', subjectId: `oauthgrant:${token}`, fields: { token, client_id: 'personal-access-token', scopes, login, created_at: occurredAt }, occurredAt, actor: { kind: 'agent' } }, req.root);
|
|
3923
|
+
return { response: { status: 201, body: { token, login, scopes } } };
|
|
3924
|
+
}
|
|
3925
|
+
// POST /_twin/users/:login/password → {password}: the password the World gives a person, with which they sign in at
|
|
3926
|
+
// github.com/login (src/screens/session.tsx). GitHub's sign-up and password pages are not modelled; the World keeps
|
|
3927
|
+
// the password's hash, never the password.
|
|
3928
|
+
if (seg[1] === 'users' && seg[2] && seg[3] === 'password' && !seg[4]) {
|
|
3929
|
+
const login = decodeURIComponent(seg[2]);
|
|
3930
|
+
const password = typeof body.password === 'string' ? body.password : '';
|
|
3931
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(login) || password.length < 8)
|
|
3932
|
+
return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'User', field: password.length < 8 ? 'password' : 'login', code: 'invalid' }] } } };
|
|
3933
|
+
await applyTwinWrite(SERVICE, { operation: 'user_credential.set', subjectType: 'user_credential', subjectId: `credential:${login.toLowerCase()}`, fields: { login, hash: passwordHash(login, password), set_at: occurredAt }, occurredAt, actor: { kind: 'agent' } }, req.root);
|
|
3934
|
+
return { response: { status: 204, body: undefined } };
|
|
3935
|
+
}
|
|
3936
|
+
// POST /_twin/users/:login/two-factor → {secret}: the person turns on two-factor authentication with a TOTP app, as
|
|
3937
|
+
// GitHub's settings page does (docs.github.com/authentication/securing-your-account-with-two-factor-authentication-2fa/
|
|
3938
|
+
// configuring-two-factor-authentication): the answer is the setup key (base32; SHA1, 6 digits, 30 seconds) their
|
|
3939
|
+
// authenticator saves. From then on the sign-in asks for its code after the password (src/screens/session.tsx).
|
|
3940
|
+
// GitHub's verify-a-first-code and recovery-code steps are not modelled.
|
|
3941
|
+
if (seg[1] === 'users' && seg[2] && seg[3] === 'two-factor' && !seg[4]) {
|
|
3942
|
+
const login = decodeURIComponent(seg[2]);
|
|
3943
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(login))
|
|
3944
|
+
return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'User', field: 'login', code: 'invalid' }] } } };
|
|
3945
|
+
const secret = base32(createHash('sha256').update(`totp:${login.toLowerCase()}:${occurredAt}:${nextSeq('totp', req.root)}`).digest().subarray(0, 20));
|
|
3946
|
+
await applyTwinWrite(SERVICE, { operation: 'user_two_factor.set', subjectType: 'user_two_factor', subjectId: `twofactor:${login.toLowerCase()}`, fields: { login, secret, set_at: occurredAt }, occurredAt, actor: { kind: 'human', id: login } }, req.root);
|
|
3947
|
+
return { response: { status: 201, body: { secret } } };
|
|
3948
|
+
}
|
|
3949
|
+
// POST /_twin/orgs → {login, name?}: GitHub's new-organization page (github.com/account/organizations/new; GitHub has
|
|
3950
|
+
// no API to create one). Its creator is the person signed in (the request's session cookie, src/screens/session.tsx),
|
|
3951
|
+
// who becomes its first admin member; a visitor not signed in is sent to the sign-in, as the page sends them.
|
|
3952
|
+
if (newOrganization && person) {
|
|
3953
|
+
const owner = person;
|
|
3954
|
+
const login = String(body.login ?? '');
|
|
3955
|
+
const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
3956
|
+
if (!LOGIN.test(login))
|
|
3957
|
+
return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'Organization', field: 'login', code: 'invalid' }] } } };
|
|
3958
|
+
if (githubState(req.root).orgs.some((o) => o.login.toLowerCase() === login.toLowerCase()))
|
|
3959
|
+
return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'Organization', field: 'login', code: 'already_exists' }] } } };
|
|
3960
|
+
await applyTwinWrite(SERVICE, { operation: 'organization.create', subjectType: 'organization', subjectId: `org:${login}`, fields: { login, ...(body.name !== undefined ? { name: String(body.name) } : {}), created_at: occurredAt, updated_at: occurredAt }, occurredAt, actor: { kind: 'human', id: owner } }, req.root);
|
|
3961
|
+
await applyTwinWrite(SERVICE, { operation: 'org_membership.upsert', subjectType: 'org_membership', subjectId: `orgmember:${login}#${owner}`, fields: { org: login, login: owner, role: 'admin', state: 'active', created_at: occurredAt }, occurredAt, actor: { kind: 'human', id: owner } }, req.root);
|
|
3962
|
+
return { response: { status: 201, body: toGithubOrg(githubState(req.root).orgs.find((o) => o.login === login)) } };
|
|
3963
|
+
}
|
|
3964
|
+
// POST /_twin/repos/:o/:r/actions/runs/:id/start → the runner picking up a queued run (GitHub has no API for it): the
|
|
3965
|
+
// run and its jobs go in progress, and the job is handed its GITHUB_TOKEN, an installation token of the GitHub
|
|
3966
|
+
// Actions app for this repository only, with the permissions the workflow grants it, that expires when the job
|
|
3967
|
+
// finishes (docs.github.com/actions/security-for-github-actions/security-guides/automatic-token-authentication).
|
|
3968
|
+
// Answers {token, permissions}. Writes made with it are github-actions[bot]'s.
|
|
3969
|
+
if (seg[1] === 'repos' && seg[4] === 'actions' && seg[5] === 'runs' && seg[6] && seg[7] === 'start' && !seg[8]) {
|
|
3970
|
+
const repo = `${decodeURIComponent(seg[2])}/${decodeURIComponent(seg[3])}`;
|
|
3971
|
+
const id = Number(decodeURIComponent(seg[6]));
|
|
3972
|
+
const st = githubState(req.root);
|
|
3973
|
+
const run = st.workflowRuns.find((r) => r.repository === repo && r.id === id);
|
|
3974
|
+
if (!run)
|
|
3975
|
+
return { response: githubNotFound() };
|
|
3976
|
+
if (run.status !== 'queued')
|
|
3977
|
+
return { response: { status: 409, body: { message: run.status === 'completed' ? 'The run has already completed' : 'The run has already started' } } };
|
|
3978
|
+
const wf = st.workflows.find((w) => w.id === run.workflow_id);
|
|
3979
|
+
const { tokenPermissions } = await import("./semantics/push-reactions.js");
|
|
3980
|
+
const file = wf ? await planeEntryAt(repo, run.head_sha, wf.path, req.root) : undefined;
|
|
3981
|
+
const blob = file?.kind === 'file' ? await readGitBlobFromPlane(repo, file.entry.sha, req.root) : null;
|
|
3982
|
+
const permissions = tokenPermissions(blob ? Buffer.from(blob.content_b64, 'base64').toString('utf8') : '');
|
|
3983
|
+
await applyTwinWrite(SERVICE, { operation: 'workflow_run.start', subjectType: 'workflow_run', subjectId: `run:${id}`, fields: { repository: repo, status: 'in_progress', updated_at: occurredAt }, occurredAt, actor: { kind: 'agent' } }, req.root);
|
|
3984
|
+
for (const job of st.jobs.filter((j) => j.repository === repo && j.run_id === id)) {
|
|
3985
|
+
await applyTwinWrite(SERVICE, { operation: 'workflow_job.start', subjectType: 'workflow_job', subjectId: `job:${job.id}`, fields: { repository: repo, status: 'in_progress', started_at: occurredAt }, occurredAt, actor: { kind: 'agent' } }, req.root);
|
|
3986
|
+
await applyTwinWrite(SERVICE, { operation: 'check_run.update', subjectType: 'check_run', subjectId: `check:${job.id}`, fields: { repository: repo, status: 'in_progress', started_at: occurredAt }, occurredAt, actor: { kind: 'agent' } }, req.root);
|
|
3987
|
+
}
|
|
3988
|
+
// its steps run, uploading its artifacts (semantics/push-reactions.ts)
|
|
3989
|
+
const { runOutputs } = await import("./semantics/push-reactions.js");
|
|
3990
|
+
await runOutputs(repo, run, githubState(req.root), req.root, (w) => applyTwinWrite(SERVICE, { ...w, occurredAt, actor: { kind: 'agent' } }, req.root).then(() => undefined), (prefix) => nextSeq(prefix, req.root), occurredAt, { artifacts: true, caches: false });
|
|
3991
|
+
const token = `ghs_${createHash('sha256').update(`github_token:${repo}:${id}`).digest('hex').slice(0, 36)}`;
|
|
3992
|
+
await applyTwinWrite(SERVICE, { operation: 'app_token.create', subjectType: 'app_token', subjectId: `apptoken:${token}`, fields: { token, app_slug: 'github-actions', account_login: repo.split('/')[0], permissions, repository_selection: 'selected', repositories: [repo.split('/')[1]], run_id: id, repository: repo }, occurredAt, actor: { kind: 'agent' } }, req.root);
|
|
3993
|
+
return { response: { status: 200, body: { token, permissions } } };
|
|
3994
|
+
}
|
|
3995
|
+
// POST /_twin/repos/:o/:r/actions/runs/:id/complete → {conclusion}: the runner finishing a run (GitHub has no API for
|
|
3996
|
+
// it; its runners report it). A run whose gated environment was rejected cannot succeed.
|
|
3997
|
+
if (seg[1] === 'repos' && seg[4] === 'actions' && seg[5] === 'runs' && seg[6] && seg[7] === 'complete' && !seg[8]) {
|
|
3998
|
+
const repo = `${decodeURIComponent(seg[2])}/${decodeURIComponent(seg[3])}`;
|
|
3999
|
+
const id = Number(decodeURIComponent(seg[6]));
|
|
4000
|
+
const run = githubState(req.root).workflowRuns.find((r) => r.repository === repo && r.id === id);
|
|
4001
|
+
if (!run)
|
|
4002
|
+
return { response: githubNotFound() };
|
|
4003
|
+
const conclusion = String(body.conclusion ?? '');
|
|
4004
|
+
if (!['success', 'failure', 'neutral', 'skipped', 'timed_out', 'action_required'].includes(conclusion))
|
|
4005
|
+
return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'WorkflowRun', field: 'conclusion', code: 'invalid' }] } } };
|
|
4006
|
+
// the runner finishing it is GitHub's move on the run's machine, and on each of its jobs' (manifest.ts)
|
|
4007
|
+
const { manifest } = await import("./manifest.js");
|
|
4008
|
+
const finished = transitionFor('status', manifest.resources['workflow-run'].state.status, '', run.status, 'completed', String(id), 'vendor');
|
|
4009
|
+
if (finished.refusal)
|
|
4010
|
+
return { response: { status: finished.refusal.status, body: { message: finished.refusal.message } } };
|
|
4011
|
+
if (conclusion === 'success' && run.approvals?.some((x) => x.state === 'rejected'))
|
|
4012
|
+
return { response: { status: 409, body: { message: 'A gated environment of this run was rejected' } } };
|
|
4013
|
+
const runFields = { repository: repo, workflow_id: run.workflow_id, name: run.name, run_number: run.run_number, event: run.event, status: 'completed', conclusion, head_sha: run.head_sha, head_branch: run.head_branch, created_at: run.created_at, updated_at: occurredAt };
|
|
4014
|
+
await applyTwinWrite(SERVICE, { operation: 'workflow_run.complete', subjectType: 'workflow_run', subjectId: `run:${id}`, fields: runFields, occurredAt, actor: { kind: 'agent' } }, req.root);
|
|
4015
|
+
for (const job of githubState(req.root).jobs.filter((j) => j.repository === repo && j.run_id === id)) {
|
|
4016
|
+
if (!transitionFor('status', manifest.resources.job.state.status, '', job.status, 'completed', String(job.id), 'vendor').move)
|
|
4017
|
+
continue;
|
|
4018
|
+
const jobFields = { run_id: id, repository: repo, name: job.name, status: 'completed', conclusion, head_sha: job.head_sha, started_at: job.started_at ?? run.created_at, completed_at: occurredAt, steps: job.steps.map((s) => ({ ...s, status: 'completed', conclusion })) };
|
|
4019
|
+
await applyTwinWrite(SERVICE, { operation: 'workflow_job.complete', subjectType: 'workflow_job', subjectId: `job:${job.id}`, fields: jobFields, occurredAt, actor: { kind: 'agent' } }, req.root);
|
|
4020
|
+
// and reports its check run on the commit (semantics/push-reactions.ts)
|
|
4021
|
+
const { actionsCheckRun } = await import("./semantics/push-reactions.js");
|
|
4022
|
+
await applyTwinWrite(SERVICE, { ...actionsCheckRun(job, 'completed', conclusion, occurredAt), occurredAt, actor: { kind: 'agent' } }, req.root);
|
|
4023
|
+
}
|
|
4024
|
+
// its jobs' GITHUB_TOKEN expires with them
|
|
4025
|
+
for (const t of twinResources(SERVICE, req.root).filter((r) => r.type === 'app_token' && r.app_slug === 'github-actions' && r.run_id === id && r.repository === repo && r.deleted !== true)) {
|
|
4026
|
+
await applyTwinWrite(SERVICE, { operation: 'app_token.expire', subjectType: 'app_token', subjectId: String(t.id), fields: { deleted: true }, occurredAt, actor: { kind: 'agent' } }, req.root);
|
|
4027
|
+
}
|
|
4028
|
+
// what the run's steps uploaded stays with it: artifacts and caches (semantics/push-reactions.ts)
|
|
4029
|
+
// (a run the runner picked up uploaded its artifacts as it ran)
|
|
4030
|
+
const { runOutputs } = await import("./semantics/push-reactions.js");
|
|
4031
|
+
await runOutputs(repo, run, githubState(req.root), req.root, (w) => applyTwinWrite(SERVICE, { ...w, occurredAt, actor: { kind: 'agent' } }, req.root).then(() => undefined), (prefix) => nextSeq(prefix, req.root), occurredAt, { artifacts: run.status === 'queued', caches: true });
|
|
4032
|
+
return { response: { status: 200, body: { id, status: 'completed', conclusion } } };
|
|
4033
|
+
}
|
|
4034
|
+
return undefined;
|
|
4035
|
+
};
|
|
4036
|
+
const response = (await answer())?.response;
|
|
4037
|
+
// a door that answers 204 has no body, as HTTP says
|
|
4038
|
+
return response && (response.status === 204 ? new Response(null, { status: 204 }) : Response.json(response.body, { status: response.status }));
|
|
4039
|
+
}
|
|
4040
|
+
/** The subject id a write landed under: a head that performed it adopts the vendor's id (an issue's
|
|
4041
|
+
* number), recorded in the alias map. */
|
|
4042
|
+
export function adoptedSubjectId(type, id, root) {
|
|
4043
|
+
return resolveSubjectId(SERVICE, type, id, root);
|
|
4044
|
+
}
|
|
4045
|
+
/** A request's issue or pull-request number, resolved through the alias map: a caller on a branch
|
|
4046
|
+
* may still name one by the number it minted locally after the head adopted GitHub's
|
|
4047
|
+
* (docs/contributing/architecture.md#alias-aware-lookup-at-the-request-boundary). */
|
|
4048
|
+
export function resolveGithubNumber(owner, name, number, root) {
|
|
4049
|
+
return resolveSubjectId(SERVICE, 'issue', `${owner}/${name}#issue:${number}`, root).split('#issue:').pop();
|
|
4050
|
+
}
|
|
4051
|
+
/** A GitHub REST read made in-process, through the same fetch the server serves (the derived
|
|
4052
|
+
* dispatch, its semantics handlers, and the hand-written routes for what has not moved). */
|
|
4053
|
+
export async function handleGithubRequest(req) {
|
|
4054
|
+
return inProcess(req);
|
|
4055
|
+
}
|
|
4056
|
+
/** A GitHub REST write made in-process, stamped at `occurredAt` (the World clock when absent). A write
|
|
4057
|
+
* GitHub announces delivers its webhooks from the fetch, as it does over HTTP, and the events it
|
|
4058
|
+
* announced ride the answer: one as `webhook`, several as `webhooks`. */
|
|
4059
|
+
export async function applyGithubWrite(req) {
|
|
4060
|
+
const { collectAnnouncements } = await import("./semantics/shared.js");
|
|
4061
|
+
const { value: response, events } = await collectAnnouncements(() => inProcess(req));
|
|
4062
|
+
return { response, ...(events.length === 1 ? { webhook: events[0] } : events.length > 1 ? { webhooks: events } : {}) };
|
|
4063
|
+
}
|
|
4064
|
+
/** The origin an in-process call is answered at. */
|
|
4065
|
+
const GITHUB_API_ORIGIN = 'https://api.github.com';
|
|
4066
|
+
async function inProcess(req) {
|
|
4067
|
+
const { createGithubTwinFetch } = await import("./github-server.js");
|
|
4068
|
+
const twin = createGithubTwinFetch({ ...(req.root !== undefined ? { root: req.root } : {}), readOnly: req.readOnly ?? false, ...(req.occurredAt ? { clock: () => req.occurredAt } : {}) });
|
|
4069
|
+
const method = req.method.toUpperCase();
|
|
4070
|
+
// a page is asked for as its person's browser asks, with their session cookie (src/screens/session.tsx)
|
|
4071
|
+
const headers = { 'content-type': req.contentType ?? 'application/json', ...(req.token ? { authorization: `token ${req.token}` } : {}), ...(req.cookie ? { cookie: req.cookie } : {}) };
|
|
4072
|
+
// an absolute url is followed as given (a release's upload_url on uploads.github.com); a path is the API host's
|
|
4073
|
+
const url = /^https:\/\//.test(req.path) ? req.path : `${GITHUB_API_ORIGIN}${req.path.startsWith('/') ? req.path : `/${req.path}`}`;
|
|
4074
|
+
const response = await twin(new Request(url, { method, headers, ...(method !== 'GET' && method !== 'HEAD' && req.body ? { body: req.body } : {}) }));
|
|
4075
|
+
const text = await response.text();
|
|
4076
|
+
if (!text)
|
|
4077
|
+
return { status: response.status, body: undefined };
|
|
4078
|
+
try {
|
|
4079
|
+
return { status: response.status, body: JSON.parse(text) };
|
|
4080
|
+
}
|
|
4081
|
+
catch {
|
|
4082
|
+
return { status: response.status, body: text };
|
|
4083
|
+
}
|
|
4084
|
+
}
|