@volter/twin-github 0.1.2 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +232 -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 +104 -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 +170 -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 +225 -95
- package/src/github-git-http.ts +48 -84
- 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 +50 -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 +193 -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,97 @@
|
|
|
1
|
+
// Code scanning from an uploaded analysis: a scanner (CodeQL in Actions, or any tool that writes SARIF) uploads its
|
|
2
|
+
// results for a commit and ref, gzip-compressed and Base64-encoded, and GitHub processes them into an analysis and
|
|
3
|
+
// alerts: a result not seen before on that ref opens an alert, and an open alert the new analysis no longer reports is
|
|
4
|
+
// fixed (docs.github.com/rest/code-scanning/code-scanning#upload-an-analysis-as-sarif-data,
|
|
5
|
+
// docs.github.com/code-security/code-scanning/integrating-with-code-scanning/uploading-a-sarif-file-to-github).
|
|
6
|
+
// The upload answers 202 with the id its processing status is read by.
|
|
7
|
+
//
|
|
8
|
+
// Where the documentation stops and the twin decides: processing completes at once; an alert is keyed by its rule, file
|
|
9
|
+
// and line; SARIF's `level` (error, warning, note) is the rule's severity, `warning` when absent.
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { gunzipSync } from 'node:zlib';
|
|
12
|
+
import { githubNotFound } from "../github-twin.js";
|
|
13
|
+
import { gh, nextSeq, put, send } from "./shared.js";
|
|
14
|
+
const invalid = (ctx, message) => ctx.reply({ message, documentation_url: 'https://docs.github.com/rest/code-scanning/code-scanning#upload-an-analysis-as-sarif-data' }, 400);
|
|
15
|
+
/** The runs of an uploaded SARIF, gzip-compressed and Base64-encoded, or nothing when it is not. */
|
|
16
|
+
function sarifRuns(sarif) {
|
|
17
|
+
try {
|
|
18
|
+
const doc = JSON.parse(gunzipSync(Buffer.from(String(sarif ?? ''), 'base64')).toString('utf8'));
|
|
19
|
+
return Array.isArray(doc.runs) ? doc.runs : [];
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** GitHub's refusal of a sarif field that is not gzip-compressed and Base64-encoded SARIF. */
|
|
26
|
+
function notSarif(ctx) {
|
|
27
|
+
return invalid(ctx, 'The sarif field must be gzip-compressed and Base64-encoded SARIF.');
|
|
28
|
+
}
|
|
29
|
+
/** A result an alert already holds, found again: a fixed alert is open again, anything else keeps its state. */
|
|
30
|
+
async function stillFound(ctx, repo, existing, instance, occurredAt) {
|
|
31
|
+
await put(ctx, { operation: 'code_scanning_alert.update', subjectType: 'code_scanning_alert', subjectId: `csalert:${repo}#${existing.number}`, fields: { ...existing, state: existing.state === 'fixed' ? 'open' : existing.state, fixed_at: null, most_recent_instance: instance, updated_at: occurredAt } });
|
|
32
|
+
}
|
|
33
|
+
const upload = async (ctx) => {
|
|
34
|
+
const { repo, body, occurredAt, state } = gh(ctx);
|
|
35
|
+
if (!state().repos.some((r) => r.full_name === repo))
|
|
36
|
+
return send(ctx, githubNotFound());
|
|
37
|
+
const commit = String(body.commit_sha ?? '');
|
|
38
|
+
const ref = String(body.ref ?? '');
|
|
39
|
+
if (!/^[0-9a-f]{40}$/i.test(commit))
|
|
40
|
+
return ctx.reply({ message: 'Invalid request.', errors: ['commit_sha is not a 40-character SHA'] }, 422);
|
|
41
|
+
if (!/^refs\/(heads|tags|pull)\//.test(ref))
|
|
42
|
+
return ctx.reply({ message: 'Invalid request.', errors: ['ref must be refs/heads/<branch>, refs/tags/<tag> or refs/pull/<n>/merge'] }, 422);
|
|
43
|
+
const runs = sarifRuns(body.sarif);
|
|
44
|
+
if (!runs)
|
|
45
|
+
return notSarif(ctx);
|
|
46
|
+
const sarifId = createHash('sha256').update(`${repo}:${commit}:${ref}:${occurredAt}:${String(body.sarif).length}`).digest('hex').slice(0, 36);
|
|
47
|
+
const analysisIds = [];
|
|
48
|
+
for (const run of runs) {
|
|
49
|
+
const tool = String(run.tool?.driver?.name ?? body.tool_name ?? 'CodeQL');
|
|
50
|
+
const rules = new Map((run.tool?.driver?.rules ?? []).map((r) => [String(r.id), r]));
|
|
51
|
+
const results = run.results ?? [];
|
|
52
|
+
const key = run.automationDetails?.id ?? `${tool}:${ref}`;
|
|
53
|
+
const id = nextSeq(ctx, 'csanalysis');
|
|
54
|
+
await put(ctx, { operation: 'code_scanning_analysis.create', subjectType: 'code_scanning_analysis', subjectId: `csanalysis:${id}`,
|
|
55
|
+
fields: { repository: repo, ref, commit_sha: commit, analysis_key: key, tool_name: tool, results_count: results.length, rules_count: rules.size, sarif_id: sarifId, created_at: occurredAt } });
|
|
56
|
+
analysisIds.push(id);
|
|
57
|
+
const found = new Set();
|
|
58
|
+
for (const result of results) {
|
|
59
|
+
const ruleId = String(result.ruleId ?? 'unknown');
|
|
60
|
+
const loc = result.locations?.[0]?.physicalLocation;
|
|
61
|
+
const path = String(loc?.artifactLocation?.uri ?? '');
|
|
62
|
+
const start = Number(loc?.region?.startLine ?? 1);
|
|
63
|
+
const identity = `${ruleId}:${path}:${start}`;
|
|
64
|
+
found.add(identity);
|
|
65
|
+
const alerts = state().codeScanningAlerts.filter((a) => a.repository === repo && a.tool_name === tool);
|
|
66
|
+
const existing = alerts.find((a) => a.rule_id === ruleId && a.most_recent_instance?.path === path && a.most_recent_instance?.start_line === start);
|
|
67
|
+
const rule = rules.get(ruleId);
|
|
68
|
+
const instance = { ref, analysis_key: key, commit_sha: commit, state: 'open', message: String(result.message?.text ?? ''), path, start_line: start, end_line: Number(loc?.region?.endLine ?? start) };
|
|
69
|
+
if (existing) {
|
|
70
|
+
await stillFound(ctx, repo, existing, instance, occurredAt);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const number = Math.max(0, ...state().codeScanningAlerts.filter((a) => a.repository === repo).map((a) => a.number)) + 1;
|
|
74
|
+
await put(ctx, { operation: 'code_scanning_alert.create', subjectType: 'code_scanning_alert', subjectId: `csalert:${repo}#${number}`,
|
|
75
|
+
fields: { number, repository: repo, state: 'open', rule_id: ruleId, rule_severity: String(result.level ?? rule?.defaultConfiguration?.level ?? 'warning'), rule_description: rule?.shortDescription?.text, tool_name: tool, tool_version: run.tool?.driver?.version, most_recent_instance: instance, created_at: occurredAt, updated_at: occurredAt } });
|
|
76
|
+
}
|
|
77
|
+
// what this ref's analysis no longer reports is fixed
|
|
78
|
+
for (const a of state().codeScanningAlerts.filter((x) => x.repository === repo && x.tool_name === tool && x.state === 'open' && x.most_recent_instance?.ref === ref)) {
|
|
79
|
+
if (!found.has(`${a.rule_id}:${a.most_recent_instance?.path}:${a.most_recent_instance?.start_line}`)) {
|
|
80
|
+
await put(ctx, { operation: 'code_scanning_alert.update', subjectType: 'code_scanning_alert', subjectId: `csalert:${repo}#${a.number}`, fields: { ...a, state: 'fixed', fixed_at: occurredAt, updated_at: occurredAt } });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
await put(ctx, { operation: 'code_scanning_sarif.create', subjectType: 'code_scanning_sarif', subjectId: `sarif:${repo}#${sarifId}`, fields: { repository: repo, sarif_id: sarifId, analyses: analysisIds, created_at: occurredAt } });
|
|
85
|
+
return ctx.reply({ id: sarifId, url: `https://api.github.com/repos/${repo}/code-scanning/sarifs/${sarifId}` }, 202);
|
|
86
|
+
};
|
|
87
|
+
const status = async (ctx) => {
|
|
88
|
+
const { repo, seg } = gh(ctx);
|
|
89
|
+
const id = decodeURIComponent(seg[5]);
|
|
90
|
+
if (!ctx.rowsRaw('code_scanning_sarif').some((r) => r.id === `sarif:${repo}#${id}`))
|
|
91
|
+
return send(ctx, githubNotFound());
|
|
92
|
+
return ctx.reply({ processing_status: 'complete', analyses_url: `https://api.github.com/repos/${repo}/code-scanning/analyses?sarif_id=${id}`, errors: null });
|
|
93
|
+
};
|
|
94
|
+
export const codeScanningUpload = {
|
|
95
|
+
'code-scanning/upload-sarif': upload,
|
|
96
|
+
'code-scanning/get-sarif': status,
|
|
97
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// GitHub's codespaces family, for the authenticated user: the codespace resource and its lifecycle
|
|
2
|
+
// (create → Available, stop → Shutdown, start → Available, delete). The machine's compute is out of
|
|
3
|
+
// scope; its state is modeled.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { githubNotFound, paginate, toGithubCodespace } from "../github-twin.js";
|
|
6
|
+
import { gh, put, send } from "./shared.js";
|
|
7
|
+
/** start → Available, stop → Shutdown: from any state, as the twin has always answered. */
|
|
8
|
+
const move = (verb) => async (ctx) => {
|
|
9
|
+
const { seg, occurredAt, state } = gh(ctx);
|
|
10
|
+
const name = decodeURIComponent(seg[2]);
|
|
11
|
+
const existing = state().codespaces.find((c) => c.name === name);
|
|
12
|
+
if (!existing)
|
|
13
|
+
return send(ctx, githubNotFound());
|
|
14
|
+
await put(ctx, { operation: `codespace.${verb}`, subjectType: 'codespace', subjectId: `codespace:${name}`, fields: { name, repository: existing.repository, state: verb === 'start' ? 'Available' : 'Shutdown', updated_at: occurredAt } });
|
|
15
|
+
return ctx.reply(toGithubCodespace(state().codespaces.find((x) => x.name === name)));
|
|
16
|
+
};
|
|
17
|
+
export function codespacesSemantics() {
|
|
18
|
+
const codespacesListForAuthenticatedUser = async (ctx) => {
|
|
19
|
+
const { query, state } = gh(ctx);
|
|
20
|
+
const st = state();
|
|
21
|
+
return ctx.reply({ total_count: st.codespaces.length, codespaces: paginate(st.codespaces, query).map(toGithubCodespace) });
|
|
22
|
+
};
|
|
23
|
+
const codespacesGetForAuthenticatedUser = async (ctx) => {
|
|
24
|
+
const { seg, state } = gh(ctx);
|
|
25
|
+
const c = state().codespaces.find((x) => x.name === decodeURIComponent(seg[2]));
|
|
26
|
+
return send(ctx, c ? { status: 200, body: toGithubCodespace(c) } : githubNotFound());
|
|
27
|
+
};
|
|
28
|
+
const codespacesCreateForAuthenticatedUser = async (ctx) => {
|
|
29
|
+
const { body, occurredAt, state } = gh(ctx);
|
|
30
|
+
const repo = body.repository !== undefined ? String(body.repository) : body.repository_id !== undefined ? String(body.repository_id) : undefined;
|
|
31
|
+
if (!repo)
|
|
32
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Codespace', field: 'repository_id', code: 'missing_field' }] }, 422);
|
|
33
|
+
// one past every codespace stored, tombstones included: each create gets its own name
|
|
34
|
+
const seq = ctx.rowsRaw('codespace', { withDeleted: true }).length + 1;
|
|
35
|
+
const name = `octocat-${createHash('sha256').update(`codespace:${repo}:${seq}`).digest('hex').slice(0, 12)}`;
|
|
36
|
+
const fields = { name, repository: repo, state: 'Available', machine: String(body.machine ?? 'basicLinux32gb'), git_branch: String(body.ref ?? 'main'), owner: 'octocat', created_at: occurredAt, updated_at: occurredAt };
|
|
37
|
+
await put(ctx, { operation: 'codespace.create', subjectType: 'codespace', subjectId: `codespace:${name}`, fields });
|
|
38
|
+
return ctx.reply(toGithubCodespace(state().codespaces.find((x) => x.name === name)), 201);
|
|
39
|
+
};
|
|
40
|
+
const codespacesDeleteForAuthenticatedUser = async (ctx) => {
|
|
41
|
+
const { seg, state } = gh(ctx);
|
|
42
|
+
const name = decodeURIComponent(seg[2]);
|
|
43
|
+
if (!state().codespaces.find((c) => c.name === name))
|
|
44
|
+
return send(ctx, githubNotFound());
|
|
45
|
+
await put(ctx, { operation: 'codespace.delete', subjectType: 'codespace', subjectId: `codespace:${name}`, fields: { deleted: true, name } });
|
|
46
|
+
return ctx.raw(null, { status: 202 });
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
'codespaces/list-for-authenticated-user': codespacesListForAuthenticatedUser,
|
|
50
|
+
'codespaces/get-for-authenticated-user': codespacesGetForAuthenticatedUser,
|
|
51
|
+
// create (Available); needs a repository
|
|
52
|
+
'codespaces/create-for-authenticated-user': codespacesCreateForAuthenticatedUser,
|
|
53
|
+
'codespaces/start-for-authenticated-user': move('start'),
|
|
54
|
+
'codespaces/stop-for-authenticated-user': move('stop'),
|
|
55
|
+
// tombstone the codespace (202, as GitHub accepts a deletion)
|
|
56
|
+
'codespaces/delete-for-authenticated-user': codespacesDeleteForAuthenticatedUser,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { githubNotFound, hasBareRepo, isAncestorInPlane, paginate, planeAncestors, planeCommitBody, resolvePlaneRef, toGithubCommitComment, toGithubRest, } from "../github-twin.js";
|
|
2
|
+
import { announce, asView, gh, nextSeq, put, send } from "./shared.js";
|
|
3
|
+
const defaultBranchOf = (ctx) => {
|
|
4
|
+
const { repo, state } = gh(ctx);
|
|
5
|
+
return state().repos.find((r) => r.full_name === repo)?.default_branch ?? 'main';
|
|
6
|
+
};
|
|
7
|
+
const refOf = (ctx) => decodeURIComponent(gh(ctx).seg[4]);
|
|
8
|
+
export function commitsSemantics() {
|
|
9
|
+
const reposListCommits = async (ctx) => {
|
|
10
|
+
const { repo, query, root } = gh(ctx);
|
|
11
|
+
const params = new URLSearchParams(query ?? '');
|
|
12
|
+
const tip = await resolvePlaneRef(repo, params.get('sha') ?? undefined, defaultBranchOf(ctx), root);
|
|
13
|
+
if (!tip)
|
|
14
|
+
return send(ctx, githubNotFound());
|
|
15
|
+
const since = params.get('since') ? Date.parse(params.get('since')) : -Infinity;
|
|
16
|
+
const until = params.get('until') ? Date.parse(params.get('until')) : Infinity;
|
|
17
|
+
// newest first; commits made in the same instant keep history's order, a commit before its parents (as git log walks)
|
|
18
|
+
const found = [...(await planeAncestors(repo, tip, root))].map(([sha, at], walked) => ({ sha, at, walked })).filter(({ at }) => !(at < since) && !(at > until));
|
|
19
|
+
found.sort((a, b) => b.at - a.at || a.walked - b.walked);
|
|
20
|
+
const bodies = [];
|
|
21
|
+
for (const f of paginate(found, query)) {
|
|
22
|
+
const b = await planeCommitBody(repo, f.sha, root);
|
|
23
|
+
if (b)
|
|
24
|
+
bodies.push(b);
|
|
25
|
+
}
|
|
26
|
+
return ctx.reply(bodies);
|
|
27
|
+
};
|
|
28
|
+
const reposGetCommit = async (ctx) => {
|
|
29
|
+
const { repo, root } = gh(ctx);
|
|
30
|
+
const sha = await resolvePlaneRef(repo, refOf(ctx), defaultBranchOf(ctx), root);
|
|
31
|
+
const body = sha ? await planeCommitBody(repo, sha, root) : null;
|
|
32
|
+
return send(ctx, body ? { status: 200, body } : githubNotFound());
|
|
33
|
+
};
|
|
34
|
+
const reposListPullRequestsAssociatedWithCommit = async (ctx) => {
|
|
35
|
+
const { repo, query, root, state } = gh(ctx);
|
|
36
|
+
const st = state();
|
|
37
|
+
const ref = refOf(ctx);
|
|
38
|
+
const defaultBranch = defaultBranchOf(ctx);
|
|
39
|
+
const tip = hasBareRepo(repo, root) ? await resolvePlaneRef(repo, defaultBranch, defaultBranch, root) : null;
|
|
40
|
+
// A repository known only through the API has no history to walk: a pull request merged into the
|
|
41
|
+
// default branch put its commits there.
|
|
42
|
+
const onDefault = tip ? (tip === ref || await isAncestorInPlane(repo, ref, tip, root)) : null;
|
|
43
|
+
const items = st.prs.filter((p) => p.repository === repo && (p.head_sha === ref || p.merge_commit_sha === ref))
|
|
44
|
+
.filter((p) => ((onDefault ?? ((p.merged === true || !!p.merged_at) && p.base_ref === defaultBranch)) ? p.merged === true || !!p.merged_at : p.state === 'open'))
|
|
45
|
+
.sort((a, b) => a.number - b.number);
|
|
46
|
+
return ctx.reply(paginate(items, query).map((p) => asView('pull-request-simple', toGithubRest(p, st.milestones))));
|
|
47
|
+
};
|
|
48
|
+
const reposListCommentsForCommit = async (ctx) => {
|
|
49
|
+
const { repo, query, state } = gh(ctx);
|
|
50
|
+
const ref = refOf(ctx);
|
|
51
|
+
return ctx.reply(paginate(state().commitComments.filter((c) => c.repository === repo && c.commit_sha === ref), query).map(toGithubCommitComment));
|
|
52
|
+
};
|
|
53
|
+
const reposListCommitCommentsForRepo = async (ctx) => {
|
|
54
|
+
const { repo, query, state } = gh(ctx);
|
|
55
|
+
return ctx.reply(paginate(state().commitComments.filter((c) => c.repository === repo).sort((a, b) => a.id - b.id), query).map(toGithubCommitComment));
|
|
56
|
+
};
|
|
57
|
+
const reposGetCommitComment = async (ctx) => {
|
|
58
|
+
const { repo, seg, state } = gh(ctx);
|
|
59
|
+
const c = state().commitComments.find((x) => x.repository === repo && x.id === Number(decodeURIComponent(seg[4])));
|
|
60
|
+
return send(ctx, c ? { status: 200, body: toGithubCommitComment(c) } : githubNotFound());
|
|
61
|
+
};
|
|
62
|
+
const reposCreateCommitComment = async (ctx) => {
|
|
63
|
+
const { repo, body, occurredAt, state } = gh(ctx);
|
|
64
|
+
const sha = refOf(ctx);
|
|
65
|
+
if (!body.body)
|
|
66
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'CommitComment', field: 'body', code: 'missing_field' }] }, 422);
|
|
67
|
+
const seq = nextSeq(ctx, 'commitcomment');
|
|
68
|
+
const fields = { repository: repo, commit_sha: sha, body: String(body.body), path: body.path ?? null, line: body.line !== undefined ? Number(body.line) : null, position: body.position !== undefined ? Number(body.position) : null, created_at: occurredAt, updated_at: occurredAt };
|
|
69
|
+
await put(ctx, { operation: 'commit_comment.create', subjectType: 'commit_comment', subjectId: `commitcomment:${seq}`, fields });
|
|
70
|
+
const answer = toGithubCommitComment(state().commitComments.find((x) => x.repository === repo && x.id === seq));
|
|
71
|
+
await announce(ctx, [{ event: 'commit_comment', action: 'created', repository: repo, number: 0, body: String(body.body) }]);
|
|
72
|
+
return ctx.reply(answer, 201);
|
|
73
|
+
};
|
|
74
|
+
const reposUpdateCommitComment = async (ctx) => {
|
|
75
|
+
const { repo, seg, body, occurredAt, state } = gh(ctx);
|
|
76
|
+
const id = Number(decodeURIComponent(seg[4]));
|
|
77
|
+
const existing = state().commitComments.find((c) => c.repository === repo && c.id === id);
|
|
78
|
+
if (!existing)
|
|
79
|
+
return send(ctx, githubNotFound());
|
|
80
|
+
await put(ctx, { operation: 'commit_comment.update', subjectType: 'commit_comment', subjectId: `commitcomment:${id}`, fields: { repository: repo, commit_sha: existing.commit_sha, body: body.body !== undefined ? String(body.body) : existing.body, path: existing.path, line: existing.line, position: existing.position, created_at: existing.created_at, updated_at: occurredAt } });
|
|
81
|
+
return ctx.reply(toGithubCommitComment(state().commitComments.find((x) => x.repository === repo && x.id === id)));
|
|
82
|
+
};
|
|
83
|
+
const reposDeleteCommitComment = async (ctx) => {
|
|
84
|
+
const { repo, seg, state } = gh(ctx);
|
|
85
|
+
const id = Number(decodeURIComponent(seg[4]));
|
|
86
|
+
if (!state().commitComments.find((c) => c.repository === repo && c.id === id))
|
|
87
|
+
return send(ctx, githubNotFound());
|
|
88
|
+
await put(ctx, { operation: 'commit_comment.delete', subjectType: 'commit_comment', subjectId: `commitcomment:${id}`, fields: { deleted: true, repository: repo } });
|
|
89
|
+
return send(ctx, { status: 204, body: undefined });
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
// Commits reachable from ?sha (a branch, tag or sha; the default branch when absent), newest first by
|
|
93
|
+
// commit date, filtered by ?since / ?until. A repository with no history in the plane, or a ref it
|
|
94
|
+
// cannot resolve, answers 404 like GitHub's empty or unknown ref.
|
|
95
|
+
'repos/list-commits': reposListCommits,
|
|
96
|
+
// a single commit by sha, branch or tag
|
|
97
|
+
'repos/get-commit': reposGetCommit,
|
|
98
|
+
// A pull request is associated when its head or merge commit is the commit, or its carried commit
|
|
99
|
+
// list holds it; a commit reachable only through a PR's history in the plane is not found here.
|
|
100
|
+
'repos/list-pull-requests-associated-with-commit': reposListPullRequestsAssociatedWithCommit,
|
|
101
|
+
// oldest first
|
|
102
|
+
'repos/list-comments-for-commit': reposListCommentsForCommit,
|
|
103
|
+
'repos/list-commit-comments-for-repo': reposListCommitCommentsForRepo,
|
|
104
|
+
'repos/get-commit-comment': reposGetCommitComment,
|
|
105
|
+
'repos/create-commit-comment': reposCreateCommitComment,
|
|
106
|
+
'repos/update-commit-comment': reposUpdateCommitComment,
|
|
107
|
+
'repos/delete-commit-comment': reposDeleteCommitComment,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// A repository's contents (a file, or a directory's listing) and README, and file writes. A repository
|
|
2
|
+
// with a git plane reads and writes there: a file write is a commit on the branch (a push: the branch
|
|
3
|
+
// row follows, open pull requests riding it are re-pointed, push and synchronize announced); one known
|
|
4
|
+
// only through the API keeps the files its REST writes stored.
|
|
5
|
+
import { Buffer } from 'node:buffer';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { githubContents, githubNotFound, hasBareRepo, isValidBranchName, listGitPlaneRefs, planeContents, planeEntryAt, planeFileContent, planeQueryRef, readGitCommitFromPlane, resolvePlaneRef, syncPrHeadsToBranchTip, toGithubContent, updateGitRefInPlane, writeGitBlobToPlane, writeGitCommitToPlane, writeGitTreeToPlane, } from "../github-twin.js";
|
|
8
|
+
import { directPushRules } from "./push-reactions.js";
|
|
9
|
+
import { announce, atomic, gh, identityOf, put, send } from "./shared.js";
|
|
10
|
+
/** The file path a path names (the path may hold slashes). */
|
|
11
|
+
const pathOf = (ctx) => decodeURIComponent(gh(ctx).seg.slice(4).join('/'));
|
|
12
|
+
/** A file write (PUT creates or replaces, DELETE removes). */
|
|
13
|
+
async function writeFile(ctx, removing) {
|
|
14
|
+
const { repo, body, occurredAt, root, state } = gh(ctx);
|
|
15
|
+
const p = pathOf(ctx);
|
|
16
|
+
const method = removing ? 'DELETE' : 'PUT';
|
|
17
|
+
if (hasBareRepo(repo, root)) {
|
|
18
|
+
const defaultBranch = state().repos.find((r) => r.full_name === repo)?.default_branch ?? 'main';
|
|
19
|
+
const branch = String(body.branch ?? defaultBranch);
|
|
20
|
+
const invalid = (message) => ctx.reply({ message }, 422);
|
|
21
|
+
if (!isValidBranchName(branch) || p.split('/').some((part) => !part || part === '.' || part === '..' || part.includes('\0')) || typeof body.message !== 'string' || !body.message)
|
|
22
|
+
return invalid('A valid branch, path and commit message are required.');
|
|
23
|
+
for (const field of ['author', 'committer'])
|
|
24
|
+
if (body[field] !== undefined && (!body[field] || typeof body[field].name !== 'string' || !body[field].name || typeof body[field].email !== 'string' || !body[field].email))
|
|
25
|
+
return invalid(`${field} requires name and email`);
|
|
26
|
+
for (const field of ['author', 'committer'])
|
|
27
|
+
if (body[field]?.date !== undefined && (typeof body[field].date !== 'string' || !Number.isFinite(Date.parse(body[field].date))))
|
|
28
|
+
return invalid(`${field}.date must be a timestamp`);
|
|
29
|
+
// an active ruleset requiring pull requests refuses a direct push to its branch
|
|
30
|
+
const ruled = directPushRules(state(), repo, branch);
|
|
31
|
+
if (ruled.refused)
|
|
32
|
+
return ctx.reply({ message: `Repository rule violations found\n\n${ruled.refused}`, documentation_url: 'https://docs.github.com/rest/repos/contents#create-or-update-file-contents', status: '409' }, 409);
|
|
33
|
+
const refs = await listGitPlaneRefs(repo, root);
|
|
34
|
+
const parent = refs.find((ref) => ref.ref === `refs/heads/${branch}`)?.object_sha ?? null;
|
|
35
|
+
if (!parent && (refs.length || branch !== defaultBranch || method === 'DELETE'))
|
|
36
|
+
return send(ctx, githubNotFound());
|
|
37
|
+
const previous = parent ? await planeEntryAt(repo, parent, p, root) : null;
|
|
38
|
+
if (previous && previous.kind !== 'file')
|
|
39
|
+
return invalid('Path is a directory or submodule');
|
|
40
|
+
// Refuse file/directory collisions before constructing an invalid Git tree.
|
|
41
|
+
const parts = p.split('/');
|
|
42
|
+
for (let n = 1; parent && n < parts.length; n++) {
|
|
43
|
+
const ancestor = await planeEntryAt(repo, parent, parts.slice(0, n).join('/'), root);
|
|
44
|
+
if (ancestor && ancestor.kind !== 'dir')
|
|
45
|
+
return invalid('A parent path is a file or submodule');
|
|
46
|
+
}
|
|
47
|
+
if (previous && body.sha === undefined)
|
|
48
|
+
return invalid('sha is required when replacing a file');
|
|
49
|
+
if ((previous?.kind === 'file' && body.sha !== previous.entry.sha) || (!previous && body.sha))
|
|
50
|
+
return ctx.reply({ message: 'File SHA does not match the requested branch.' }, 409);
|
|
51
|
+
if (!previous && method === 'DELETE')
|
|
52
|
+
return send(ctx, githubNotFound());
|
|
53
|
+
if (method === 'PUT' && (typeof body.content !== 'string' || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(body.content.replace(/\s/g, ''))))
|
|
54
|
+
return invalid('content must be Base64 encoded');
|
|
55
|
+
const blob = method === 'PUT' ? await writeGitBlobToPlane(repo, body.content, root) : '';
|
|
56
|
+
const baseTree = parent ? (await readGitCommitFromPlane(repo, parent, root))?.tree_sha : undefined;
|
|
57
|
+
if (parent && !baseTree)
|
|
58
|
+
return ctx.reply({ message: 'Branch commit is unavailable.' }, 409);
|
|
59
|
+
const tree = await writeGitTreeToPlane(repo, [{ path: p, mode: previous?.kind === 'file' ? previous.entry.mode : '100644', type: 'blob', sha: blob }], baseTree, root);
|
|
60
|
+
const commit = await writeGitCommitToPlane(repo, { message: body.message, treeSha: tree, parents: parent ? [parent] : [], author: body.author ?? body.committer ?? identityOf(gh(ctx)), committer: body.committer ?? identityOf(gh(ctx)), occurredAt }, root);
|
|
61
|
+
if (!await updateGitRefInPlane(repo, `refs/heads/${branch}`, commit, root, parent))
|
|
62
|
+
return ctx.reply({ message: 'Branch moved while editing the file; read it again before retrying.' }, 409);
|
|
63
|
+
await put(ctx, { operation: 'git_ref.update', subjectType: 'git_ref', subjectId: `gitref:${repo}#refs/heads/${branch}`, fields: { repository: repo, ref: `refs/heads/${branch}`, object_sha: commit, object_type: 'commit' } });
|
|
64
|
+
await put(ctx, { operation: 'branch.create', subjectType: 'branch', subjectId: `branch:${repo}#${branch}`, fields: { repository: repo, name: branch, commit_sha: commit } });
|
|
65
|
+
const events = [{ event: 'push', action: '', repository: repo, number: 0, ref: `refs/heads/${branch}`, before: parent ?? '0'.repeat(40), after: commit }];
|
|
66
|
+
await syncPrHeadsToBranchTip(repo, branch, commit, root, occurredAt, (pr, base) => events.push({ event: 'pull_request', action: 'synchronize', repository: base, number: Number(pr.number), pr }), atomic(ctx));
|
|
67
|
+
const answer = { content: method === 'DELETE' ? null : (await planeContents(repo, commit, p, branch, root))?.body, commit: { sha: commit, message: body.message } };
|
|
68
|
+
await announce(ctx, events);
|
|
69
|
+
return ctx.reply(answer, previous ? 200 : 201);
|
|
70
|
+
}
|
|
71
|
+
const subjectId = `content:${repo}#${p}`;
|
|
72
|
+
if (method === 'DELETE') {
|
|
73
|
+
if (!state().contents.find((c) => c.repository === repo && c.path === p))
|
|
74
|
+
return send(ctx, githubNotFound());
|
|
75
|
+
await put(ctx, { operation: 'content_file.delete', subjectType: 'content_file', subjectId, fields: { deleted: true, repository: repo, path: p } });
|
|
76
|
+
const sha = createHash('sha256').update(`${repo}:${p}:deleted:${occurredAt}`).digest('hex').slice(0, 40);
|
|
77
|
+
return ctx.reply({ content: null, commit: { sha, message: body.message ?? `Delete ${p}` } });
|
|
78
|
+
}
|
|
79
|
+
if (body.content === undefined)
|
|
80
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Content', field: 'content', code: 'missing_field' }] }, 422);
|
|
81
|
+
const content_b64 = String(body.content);
|
|
82
|
+
let size = 0;
|
|
83
|
+
try {
|
|
84
|
+
size = Buffer.from(content_b64, 'base64').length;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
size = content_b64.length;
|
|
88
|
+
}
|
|
89
|
+
const sha = createHash('sha256').update(`${repo}:${p}:${content_b64}`).digest('hex').slice(0, 40);
|
|
90
|
+
const fields = { repository: repo, path: p, content_b64, sha, size, branch: body.branch ?? 'main' };
|
|
91
|
+
const existed = state().contents.some((c) => c.repository === repo && c.path === p);
|
|
92
|
+
await put(ctx, { operation: existed ? 'content_file.update' : 'content_file.create', subjectType: 'content_file', subjectId, fields });
|
|
93
|
+
const answer = { content: toGithubContent(state().contents.find((c) => c.repository === repo && c.path === p)), commit: { sha, message: body.message ?? `Add ${p}` } };
|
|
94
|
+
await announce(ctx, [{ event: 'push', action: '', repository: repo, number: 0 }]);
|
|
95
|
+
return ctx.reply(answer, existed ? 200 : 201);
|
|
96
|
+
}
|
|
97
|
+
export function contentsSemantics() {
|
|
98
|
+
return {
|
|
99
|
+
'repos/get-content': async (ctx) => {
|
|
100
|
+
const { repo, query, root, state } = gh(ctx);
|
|
101
|
+
return send(ctx, await githubContents(state(), repo, pathOf(ctx), query, root));
|
|
102
|
+
},
|
|
103
|
+
'repos/create-or-update-file-contents': async (ctx) => writeFile(ctx, false),
|
|
104
|
+
'repos/delete-file': async (ctx) => writeFile(ctx, true),
|
|
105
|
+
// The README at the root: the stored one (a repository without a plane), else the plane's at ?ref.
|
|
106
|
+
'repos/get-readme': async (ctx) => {
|
|
107
|
+
const { repo, query, root, state } = gh(ctx);
|
|
108
|
+
const st = state();
|
|
109
|
+
const readme = (hasBareRepo(repo, root) ? [] : st.contents).filter((c) => c.repository === repo && /^readme(\.[a-z]+)?$/i.test(c.path)).sort((a, b) => a.path.length - b.path.length)[0];
|
|
110
|
+
if (!readme) {
|
|
111
|
+
const defaultBranch = st.repos.find((r) => r.full_name === repo)?.default_branch ?? 'main';
|
|
112
|
+
const sha = await resolvePlaneRef(repo, planeQueryRef(query), defaultBranch, root);
|
|
113
|
+
const top = sha ? await planeEntryAt(repo, sha, '', root) : null;
|
|
114
|
+
const entry = top?.kind === 'dir' ? top.entries.find((e) => e.type === 'blob' && /^readme(\.[a-z]+)?$/i.test(e.path)) : undefined;
|
|
115
|
+
if (entry) {
|
|
116
|
+
const body = await planeFileContent(repo, planeQueryRef(query) ?? defaultBranch, entry.path, entry, root);
|
|
117
|
+
if (body)
|
|
118
|
+
return ctx.reply(body);
|
|
119
|
+
}
|
|
120
|
+
return send(ctx, githubNotFound());
|
|
121
|
+
}
|
|
122
|
+
return ctx.reply({
|
|
123
|
+
type: 'file', name: readme.path.split('/').pop(), path: readme.path, sha: readme.sha, size: readme.size ?? 0,
|
|
124
|
+
encoding: 'base64', content: readme.content_b64 ?? '',
|
|
125
|
+
url: `https://api.github.com/repos/${repo}/contents/${readme.path}`,
|
|
126
|
+
html_url: `https://github.com/${repo}/blob/${readme.branch ?? 'main'}/${readme.path}`,
|
|
127
|
+
download_url: `https://raw.githubusercontent.com/${repo}/${readme.branch ?? 'main'}/${readme.path}`,
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// A repository's deployments, their statuses, and its environments. A deployment's ref names a
|
|
2
|
+
// commit, resolved through the git plane when the repository has history there. Creating a
|
|
3
|
+
// deployment or a status announces its webhook.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { githubNotFound, hasBareRepo, paginate, resolvePlaneRef, toGithubDeployment, toGithubDeploymentStatus, toGithubEnvironment } from "../github-twin.js";
|
|
6
|
+
import { announce, gh, nextSeq, put, send } from "./shared.js";
|
|
7
|
+
const deploymentOf = (ctx) => {
|
|
8
|
+
const { repo, seg, state } = gh(ctx);
|
|
9
|
+
const id = Number(decodeURIComponent(seg[4]));
|
|
10
|
+
const st = state();
|
|
11
|
+
return { id, st, dep: st.deployments.find((d) => d.repository === repo && d.id === id) };
|
|
12
|
+
};
|
|
13
|
+
export function deploymentsSemantics() {
|
|
14
|
+
const reposListDeployments = async (ctx) => {
|
|
15
|
+
const { repo, query, state } = gh(ctx);
|
|
16
|
+
const params = new URLSearchParams((query ?? '').replace(/^\?/, ''));
|
|
17
|
+
let inRepo = state().deployments.filter((d) => d.repository === repo);
|
|
18
|
+
const env = params.get('environment');
|
|
19
|
+
if (env)
|
|
20
|
+
inRepo = inRepo.filter((d) => d.environment === env);
|
|
21
|
+
const ref = params.get('ref');
|
|
22
|
+
if (ref)
|
|
23
|
+
inRepo = inRepo.filter((d) => d.ref === ref);
|
|
24
|
+
const sha = params.get('sha');
|
|
25
|
+
if (sha)
|
|
26
|
+
inRepo = inRepo.filter((d) => d.sha === sha);
|
|
27
|
+
return ctx.reply(paginate(inRepo, query).map(toGithubDeployment));
|
|
28
|
+
};
|
|
29
|
+
const reposGetDeployment = async (ctx) => {
|
|
30
|
+
const { dep } = deploymentOf(ctx);
|
|
31
|
+
return send(ctx, dep ? { status: 200, body: toGithubDeployment(dep) } : githubNotFound());
|
|
32
|
+
};
|
|
33
|
+
const reposCreateDeployment = async (ctx) => {
|
|
34
|
+
const { repo, body, occurredAt, caller, root, state } = gh(ctx);
|
|
35
|
+
if (body.ref === undefined)
|
|
36
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Deployment', field: 'ref', code: 'missing_field' }] }, 422);
|
|
37
|
+
const id = nextSeq(ctx, 'deployment');
|
|
38
|
+
const ref = String(body.ref);
|
|
39
|
+
const plane = hasBareRepo(repo, root);
|
|
40
|
+
const planeSha = plane ? await resolvePlaneRef(repo, ref, state().repos.find((r) => r.full_name === repo)?.default_branch ?? 'main', root) : null;
|
|
41
|
+
if (plane && !planeSha)
|
|
42
|
+
return ctx.reply({ message: `No ref found for: ${ref}`, documentation_url: 'https://docs.github.com/rest/deployments/deployments#create-a-deployment' }, 422);
|
|
43
|
+
const sha = planeSha ?? (/^[0-9a-f]{40}$/i.test(ref) ? ref : createHash('sha256').update(`${repo}:dep:${ref}`).digest('hex').slice(0, 40));
|
|
44
|
+
const fields = { repository: repo, ref, sha, task: String(body.task ?? 'deploy'), environment: String(body.environment ?? 'production'), description: body.description ?? null, transient_environment: body.transient_environment === true, production_environment: body.production_environment === undefined ? String(body.environment ?? 'production') === 'production' : body.production_environment === true, created_at: occurredAt, updated_at: occurredAt, ...(caller ? { creator_login: caller } : {}) };
|
|
45
|
+
await put(ctx, { operation: 'deployment.create', subjectType: 'deployment', subjectId: `deployment:${id}`, fields });
|
|
46
|
+
const d = state().deployments.find((x) => x.id === id);
|
|
47
|
+
await announce(ctx, [{ event: 'deployment', action: 'created', repository: repo, number: 0 }]);
|
|
48
|
+
return ctx.reply(toGithubDeployment(d), 201);
|
|
49
|
+
};
|
|
50
|
+
const reposListDeploymentStatuses = async (ctx) => {
|
|
51
|
+
const { id, st, dep } = deploymentOf(ctx);
|
|
52
|
+
if (!dep)
|
|
53
|
+
return send(ctx, githubNotFound());
|
|
54
|
+
const { repo, query } = gh(ctx);
|
|
55
|
+
const items = st.deploymentStatuses.filter((s) => s.repository === repo && s.deployment_id === id).sort((a, b) => b.id - a.id);
|
|
56
|
+
return ctx.reply(paginate(items, query).map(toGithubDeploymentStatus));
|
|
57
|
+
};
|
|
58
|
+
const reposGetDeploymentStatus = async (ctx) => {
|
|
59
|
+
const { id, st, dep } = deploymentOf(ctx);
|
|
60
|
+
if (!dep)
|
|
61
|
+
return send(ctx, githubNotFound());
|
|
62
|
+
const { repo, seg } = gh(ctx);
|
|
63
|
+
const s = st.deploymentStatuses.find((x) => x.repository === repo && x.deployment_id === id && x.id === Number(decodeURIComponent(seg[6])));
|
|
64
|
+
return send(ctx, s ? { status: 200, body: toGithubDeploymentStatus(s) } : githubNotFound());
|
|
65
|
+
};
|
|
66
|
+
const reposCreateDeploymentStatus = async (ctx) => {
|
|
67
|
+
const { id: depId, dep } = deploymentOf(ctx);
|
|
68
|
+
if (!dep)
|
|
69
|
+
return send(ctx, githubNotFound());
|
|
70
|
+
const { repo, body, occurredAt, caller, state } = gh(ctx);
|
|
71
|
+
if (body.state === undefined)
|
|
72
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'DeploymentStatus', field: 'state', code: 'missing_field' }] }, 422);
|
|
73
|
+
if (!['error', 'failure', 'inactive', 'in_progress', 'queued', 'pending', 'success'].includes(String(body.state)))
|
|
74
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'DeploymentStatus', field: 'state', code: 'invalid' }] }, 422);
|
|
75
|
+
const id = nextSeq(ctx, 'depstatus');
|
|
76
|
+
const fields = { repository: repo, deployment_id: depId, state: String(body.state), description: body.description ?? null, environment: body.environment ?? dep.environment, environment_url: body.environment_url, log_url: body.log_url, created_at: occurredAt, ...(caller ? { creator_login: caller } : {}) };
|
|
77
|
+
await put(ctx, { operation: 'deployment_status.create', subjectType: 'deployment_status', subjectId: `depstatus:${id}`, fields });
|
|
78
|
+
const s = state().deploymentStatuses.find((x) => x.id === id);
|
|
79
|
+
await announce(ctx, [{ event: 'deployment_status', action: 'created', repository: repo, number: 0 }]);
|
|
80
|
+
return ctx.reply(toGithubDeploymentStatus(s), 201);
|
|
81
|
+
};
|
|
82
|
+
const reposGetAllEnvironments = async (ctx) => {
|
|
83
|
+
const { repo, query, state } = gh(ctx);
|
|
84
|
+
const inRepo = state().environments.filter((e) => e.repository === repo);
|
|
85
|
+
return ctx.reply({ total_count: inRepo.length, environments: paginate(inRepo, query).map(toGithubEnvironment) });
|
|
86
|
+
};
|
|
87
|
+
const reposGetEnvironment = async (ctx) => {
|
|
88
|
+
const { repo, seg, state } = gh(ctx);
|
|
89
|
+
const e = state().environments.find((x) => x.repository === repo && x.name === decodeURIComponent(seg[4]));
|
|
90
|
+
return send(ctx, e ? { status: 200, body: toGithubEnvironment(e) } : githubNotFound());
|
|
91
|
+
};
|
|
92
|
+
const reposCreateOrUpdateEnvironment = async (ctx) => {
|
|
93
|
+
const { repo, seg, body, occurredAt, state } = gh(ctx);
|
|
94
|
+
const name = decodeURIComponent(seg[4]);
|
|
95
|
+
const reviewers = Array.isArray(body.reviewers) ? body.reviewers.map((r) => String(r.reviewer?.login ?? r.login ?? r)) : undefined;
|
|
96
|
+
const fields = { repository: repo, name, wait_timer: body.wait_timer !== undefined ? Number(body.wait_timer) : undefined, reviewers, created_at: occurredAt, updated_at: occurredAt };
|
|
97
|
+
await put(ctx, { operation: 'environment.upsert', subjectType: 'environment', subjectId: `env:${repo}#${name}`, fields });
|
|
98
|
+
return ctx.reply(toGithubEnvironment(state().environments.find((x) => x.repository === repo && x.name === name)));
|
|
99
|
+
};
|
|
100
|
+
const reposDeleteAnEnvironment = async (ctx) => {
|
|
101
|
+
const { repo, seg, state } = gh(ctx);
|
|
102
|
+
const name = decodeURIComponent(seg[4]);
|
|
103
|
+
if (!state().environments.find((e) => e.repository === repo && e.name === name))
|
|
104
|
+
return send(ctx, githubNotFound());
|
|
105
|
+
await put(ctx, { operation: 'environment.delete', subjectType: 'environment', subjectId: `env:${repo}#${name}`, fields: { deleted: true, repository: repo, name } });
|
|
106
|
+
return send(ctx, { status: 204, body: undefined });
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
// ?environment= / ?ref= / ?sha= filter, as GitHub's do
|
|
110
|
+
'repos/list-deployments': reposListDeployments,
|
|
111
|
+
'repos/get-deployment': reposGetDeployment,
|
|
112
|
+
// 422 without `ref`. A ref names a commit: resolved through the git plane when the repository has
|
|
113
|
+
// history there, where GitHub refuses a ref (or full sha) it cannot find; a repository known only
|
|
114
|
+
// through the API keeps a deterministic stand-in. A short sha is not expanded (the plane's object
|
|
115
|
+
// store has no prefix lookup) and is refused as unknown.
|
|
116
|
+
'repos/create-deployment': reposCreateDeployment,
|
|
117
|
+
// newest first, as GitHub lists them
|
|
118
|
+
'repos/list-deployment-statuses': reposListDeploymentStatuses,
|
|
119
|
+
'repos/get-deployment-status': reposGetDeploymentStatus,
|
|
120
|
+
'repos/create-deployment-status': reposCreateDeploymentStatus,
|
|
121
|
+
'repos/get-all-environments': reposGetAllEnvironments,
|
|
122
|
+
'repos/get-environment': reposGetEnvironment,
|
|
123
|
+
// create or update (200); reviewers are stored as logins
|
|
124
|
+
'repos/create-or-update-environment': reposCreateOrUpdateEnvironment,
|
|
125
|
+
'repos/delete-an-environment': reposDeleteAnEnvironment,
|
|
126
|
+
};
|
|
127
|
+
}
|