@jevitate/cli 0.8.0 → 0.10.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/dist/anchor-baselines.d.ts +26 -0
- package/dist/anchor-baselines.js +25 -0
- package/dist/anchor-baselines.js.map +1 -0
- package/dist/approval-provenance.d.ts +48 -2
- package/dist/approval-provenance.js +110 -6
- package/dist/approval-provenance.js.map +1 -1
- package/dist/bin.js +25111 -17835
- package/dist/browser-cli.d.ts +10 -0
- package/dist/browser-cli.js +50 -0
- package/dist/browser-cli.js.map +1 -0
- package/dist/build-info.generated.js +2 -2
- package/dist/catalog-analysis.js +14 -0
- package/dist/catalog-analysis.js.map +1 -1
- package/dist/catalog-api.js +12 -4
- package/dist/catalog-api.js.map +1 -1
- package/dist/catalog-bundle-api.d.ts +73 -0
- package/dist/catalog-bundle-api.js +206 -0
- package/dist/catalog-bundle-api.js.map +1 -0
- package/dist/catalog-bundle-findings.d.ts +54 -0
- package/dist/catalog-bundle-findings.js +252 -0
- package/dist/catalog-bundle-findings.js.map +1 -0
- package/dist/catalog-bundle.d.ts +192 -0
- package/dist/catalog-bundle.js +578 -0
- package/dist/catalog-bundle.js.map +1 -0
- package/dist/catalog-cli.d.ts +6 -0
- package/dist/catalog-cli.js +180 -5
- package/dist/catalog-cli.js.map +1 -1
- package/dist/catalog-refs.d.ts +42 -0
- package/dist/catalog-refs.js +188 -0
- package/dist/catalog-refs.js.map +1 -0
- package/dist/catalog-review.d.ts +3 -1
- package/dist/catalog-review.js +61 -0
- package/dist/catalog-review.js.map +1 -1
- package/dist/catalog.d.ts +27 -3
- package/dist/catalog.js +46 -2
- package/dist/catalog.js.map +1 -1
- package/dist/change-context.d.ts +44 -0
- package/dist/change-context.js +203 -0
- package/dist/change-context.js.map +1 -0
- package/dist/check-api.d.ts +1 -1
- package/dist/check-api.js +206 -16
- package/dist/check-api.js.map +1 -1
- package/dist/check-cli.d.ts +2 -1
- package/dist/check-cli.js +117 -8
- package/dist/check-cli.js.map +1 -1
- package/dist/check-execute.d.ts +28 -1
- package/dist/check-execute.js +110 -27
- package/dist/check-execute.js.map +1 -1
- package/dist/check-plan.d.ts +14 -0
- package/dist/check-plan.js +24 -1
- package/dist/check-plan.js.map +1 -1
- package/dist/check-types.d.ts +137 -5
- package/dist/check-types.js +8 -0
- package/dist/check-types.js.map +1 -1
- package/dist/cli-output.d.ts +6 -0
- package/dist/cli-output.js +26 -1
- package/dist/cli-output.js.map +1 -1
- package/dist/cli-shared.d.ts +10 -0
- package/dist/cli-shared.js +0 -4
- package/dist/cli-shared.js.map +1 -1
- package/dist/exit-codes.d.ts +6 -3
- package/dist/exit-codes.js +24 -3
- package/dist/exit-codes.js.map +1 -1
- package/dist/explore-adversarial.js +2 -2
- package/dist/explore-adversarial.js.map +1 -1
- package/dist/explore-cli.js +3 -2
- package/dist/explore-cli.js.map +1 -1
- package/dist/explore-coverage.js +3 -3
- package/dist/explore-coverage.js.map +1 -1
- package/dist/explore-feature.js +3 -3
- package/dist/explore-feature.js.map +1 -1
- package/dist/explore-goal.js +11 -5
- package/dist/explore-goal.js.map +1 -1
- package/dist/findings-filing.d.ts +6 -0
- package/dist/findings-filing.js +15 -1
- package/dist/findings-filing.js.map +1 -1
- package/dist/forge-verify.d.ts +224 -0
- package/dist/forge-verify.js +649 -0
- package/dist/forge-verify.js.map +1 -0
- package/dist/heal-probe-guard.d.ts +9 -0
- package/dist/heal-probe-guard.js +113 -0
- package/dist/heal-probe-guard.js.map +1 -0
- package/dist/host-health-run.d.ts +32 -1
- package/dist/host-health-run.js +38 -1
- package/dist/host-health-run.js.map +1 -1
- package/dist/init-codeowners.d.ts +19 -0
- package/dist/init-codeowners.js +66 -0
- package/dist/init-codeowners.js.map +1 -1
- package/dist/job-draft-api.d.ts +52 -0
- package/dist/job-draft-api.js +110 -0
- package/dist/job-draft-api.js.map +1 -0
- package/dist/journeeze-api.d.ts +71 -0
- package/dist/journeeze-api.js +285 -0
- package/dist/journeeze-api.js.map +1 -0
- package/dist/journeeze-connect.d.ts +156 -0
- package/dist/journeeze-connect.js +357 -0
- package/dist/journeeze-connect.js.map +1 -0
- package/dist/journey-anchor-gate.d.ts +52 -0
- package/dist/journey-anchor-gate.js +101 -0
- package/dist/journey-anchor-gate.js.map +1 -0
- package/dist/journey-api.d.ts +43 -3
- package/dist/journey-api.js +226 -21
- package/dist/journey-api.js.map +1 -1
- package/dist/journey-cli.js +162 -20
- package/dist/journey-cli.js.map +1 -1
- package/dist/journey-heal.d.ts +74 -0
- package/dist/journey-heal.js +180 -0
- package/dist/journey-heal.js.map +1 -0
- package/dist/journey-lint-sarif.d.ts +3 -2
- package/dist/journey-lint-sarif.js +9 -2
- package/dist/journey-lint-sarif.js.map +1 -1
- package/dist/journey-migrate-api.d.ts +54 -0
- package/dist/journey-migrate-api.js +64 -0
- package/dist/journey-migrate-api.js.map +1 -0
- package/dist/journey-network-checks.d.ts +5 -1
- package/dist/journey-network-checks.js +15 -4
- package/dist/journey-network-checks.js.map +1 -1
- package/dist/journey-prefix.js +2 -2
- package/dist/journey-prefix.js.map +1 -1
- package/dist/journey-proposal-store.d.ts +109 -0
- package/dist/journey-proposal-store.js +365 -0
- package/dist/journey-proposal-store.js.map +1 -0
- package/dist/journey-result-record.d.ts +42 -0
- package/dist/journey-result-record.js +33 -0
- package/dist/journey-result-record.js.map +1 -0
- package/dist/journey-review-api.js +16 -1
- package/dist/journey-review-api.js.map +1 -1
- package/dist/journey-review.d.ts +18 -1
- package/dist/journey-review.js +116 -5
- package/dist/journey-review.js.map +1 -1
- package/dist/journey-stale-api.d.ts +40 -0
- package/dist/journey-stale-api.js +57 -0
- package/dist/journey-stale-api.js.map +1 -0
- package/dist/launch-armed.js +5 -1
- package/dist/launch-armed.js.map +1 -1
- package/dist/locator-health-api.d.ts +101 -0
- package/dist/locator-health-api.js +203 -0
- package/dist/locator-health-api.js.map +1 -0
- package/dist/locator-health.d.ts +195 -0
- package/dist/locator-health.js +419 -0
- package/dist/locator-health.js.map +1 -0
- package/dist/login-cli.d.ts +2 -0
- package/dist/login-cli.js +56 -11
- package/dist/login-cli.js.map +1 -1
- package/dist/mcp-api.d.ts +5 -1
- package/dist/mcp-api.js +53 -8
- package/dist/mcp-api.js.map +1 -1
- package/dist/mcp-cli-tools.d.ts +3 -1
- package/dist/mcp-cli-tools.js +80 -10
- package/dist/mcp-cli-tools.js.map +1 -1
- package/dist/multi-run.d.ts +7 -6
- package/dist/multi-run.js +5 -4
- package/dist/multi-run.js.map +1 -1
- package/dist/persona-login.d.ts +60 -3
- package/dist/persona-login.js +341 -31
- package/dist/persona-login.js.map +1 -1
- package/dist/program.js +2 -0
- package/dist/program.js.map +1 -1
- package/dist/project-config.d.ts +29 -0
- package/dist/project-config.js +76 -0
- package/dist/project-config.js.map +1 -0
- package/dist/report-api.d.ts +1 -0
- package/dist/report-api.js +1 -0
- package/dist/report-api.js.map +1 -1
- package/dist/resource-preflight.js +5 -3
- package/dist/resource-preflight.js.map +1 -1
- package/dist/run-metadata.d.ts +5 -2
- package/dist/run-metadata.js +10 -3
- package/dist/run-metadata.js.map +1 -1
- package/dist/run-tags-cli.d.ts +2 -0
- package/dist/run-tags-cli.js +8 -2
- package/dist/run-tags-cli.js.map +1 -1
- package/dist/self-heal-adapter.d.ts +43 -27
- package/dist/self-heal-adapter.js +196 -62
- package/dist/self-heal-adapter.js.map +1 -1
- package/dist/source-cli.js +5 -3
- package/dist/source-cli.js.map +1 -1
- package/dist/suite-explore-options.js +1 -0
- package/dist/suite-explore-options.js.map +1 -1
- package/dist/sweep-api.d.ts +23 -0
- package/dist/sweep-api.js +39 -8
- package/dist/sweep-api.js.map +1 -1
- package/dist/sweep-cli.js +3 -1
- package/dist/sweep-cli.js.map +1 -1
- package/dist/ux-usability.js +3 -2
- package/dist/ux-usability.js.map +1 -1
- package/package.json +17 -15
- package/skills/jevitate-ci-check/SKILL.md +25 -1
- package/skills/jevitate-getting-started/SKILL.md +9 -3
- package/skills/jevitate-mission-scope/SKILL.md +2 -2
- package/skills/jevitate-run-journey/SKILL.md +36 -5
- package/skills/jevitate-test-campaign/SKILL.md +13 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { redactText } from "@jevitate/ai-core";
|
|
6
|
+
import { clock } from "@jevitate/domain";
|
|
7
|
+
import { JourneySchema } from "@jevitate/journey";
|
|
8
|
+
import { catalogEntryHash } from "./catalog.js";
|
|
9
|
+
import { journeyReviewHash } from "./journey-review.js";
|
|
10
|
+
import { codeownersOf, GITHUB_CODEOWNERS_LOCATIONS, parseCodeownersFile } from "./init-codeowners.js";
|
|
11
|
+
import { findGitRoot } from "./project-dir.js";
|
|
12
|
+
/**
|
|
13
|
+
* #469 — the `pr-review` approval channel: verified through the forge, never claimed.
|
|
14
|
+
*
|
|
15
|
+
* An approval is recorded as `pr-review` ONLY when jevitate itself verifies, through the GitHub API
|
|
16
|
+
* with the CI environment's `GITHUB_TOKEN` (never an argument, never an MCP parameter, never the
|
|
17
|
+
* model's), that the commit that last changed the entry came from a merged pull request into the
|
|
18
|
+
* default branch, approved by someone other than its author:
|
|
19
|
+
*
|
|
20
|
+
* 1. the environment is GitHub Actions (`GITHUB_ACTIONS=true`, one of the CI markers approval
|
|
21
|
+
* provenance detects) on github.com — the API base is pinned to `https://api.github.com`; a
|
|
22
|
+
* `GITHUB_API_URL` / `GITHUB_SERVER_URL` naming anything else (GitHub Enterprise) is refused;
|
|
23
|
+
* 2. approving runs on the default branch (`GITHUB_REF`), never on a `pull_request*` event (a fork's
|
|
24
|
+
* pull request controls the files it would approve);
|
|
25
|
+
* 3. the entry's content on the default branch is the content being approved, and the commit that
|
|
26
|
+
* last changed it (walking the file's history; its first parent holds different content) is on
|
|
27
|
+
* the default branch;
|
|
28
|
+
* 4. that commit belongs to a MERGED pull request whose base is the default branch;
|
|
29
|
+
* 5. the pull request's APPROVED head (the commit its approving review is on) holds the same
|
|
30
|
+
* content of the entry — the reviewer approved exactly what is honoured, never an intermediate
|
|
31
|
+
* commit of the pull request or an "evil merge" resolution that differs from it;
|
|
32
|
+
* 6. the pull request has an APPROVED review (each reviewer's latest decisive review) on its head
|
|
33
|
+
* commit, by a person (not a bot) who is not the pull request's author, authored or committed
|
|
34
|
+
* none of its commits (logins and user ids compared), and has write access (collaborator
|
|
35
|
+
* permission admin / maintain / write);
|
|
36
|
+
* 7. optionally (`JEVITATE_PR_REVIEW_REQUIRE_CODEOWNER=1`), that reviewer is a CODEOWNER of the
|
|
37
|
+
* entry's file (the default branch's CODEOWNERS; a team owner needs an active membership).
|
|
38
|
+
*
|
|
39
|
+
* Any failure is a typed refusal with its reason — never a silent fall back to another channel.
|
|
40
|
+
* `check --require-approvals` RE-VERIFIES every recorded `pr-review` approval the same way
|
|
41
|
+
* (`reverifyPrReview`), so a hand-written `pr-review` provenance is a violation, never a pass.
|
|
42
|
+
*
|
|
43
|
+
* The token is used only in the Authorization header; every error text is scrubbed of it.
|
|
44
|
+
*/
|
|
45
|
+
/** The only GitHub API base jevitate talks to. GitHub Enterprise is out of scope (refused). */
|
|
46
|
+
export const GITHUB_API_BASE = "https://api.github.com";
|
|
47
|
+
const GITHUB_SERVER = "https://github.com";
|
|
48
|
+
/** How far back the file's history is walked to find the commit that last changed the entry. */
|
|
49
|
+
export const HISTORY_LIMIT = 30;
|
|
50
|
+
/** How long a positive re-verification is trusted from the cache (short: a dismissed review or a revoked reviewer stops passing within a day). */
|
|
51
|
+
export const PR_REVIEW_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
52
|
+
/** The env flag that requires the approving reviewer to be a CODEOWNER of the entry's file. */
|
|
53
|
+
export const REQUIRE_CODEOWNER_ENV = "JEVITATE_PR_REVIEW_REQUIRE_CODEOWNER";
|
|
54
|
+
/** A forge request that failed (network, status). Its message is already scrubbed of the token. */
|
|
55
|
+
export class ForgeError extends Error {
|
|
56
|
+
code = "E_FORGE";
|
|
57
|
+
}
|
|
58
|
+
const REPO_RE = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
|
|
59
|
+
const LOGIN_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})(?:\[bot\])?$/;
|
|
60
|
+
const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
61
|
+
function encPath(path) {
|
|
62
|
+
return path
|
|
63
|
+
.split("/")
|
|
64
|
+
.map((s) => encodeURIComponent(s))
|
|
65
|
+
.join("/");
|
|
66
|
+
}
|
|
67
|
+
/** The GitHub implementation of `ForgePort` (REST, `https://api.github.com` only). */
|
|
68
|
+
export class GitHubForge {
|
|
69
|
+
#repo;
|
|
70
|
+
#token;
|
|
71
|
+
#fetch;
|
|
72
|
+
constructor(repo, token, fetchFn = (url, init) => fetch(url, init)) {
|
|
73
|
+
if (!REPO_RE.test(repo))
|
|
74
|
+
throw new ForgeError(`not a GitHub repository name: ${JSON.stringify(repo.slice(0, 200))}`);
|
|
75
|
+
this.#repo = repo;
|
|
76
|
+
this.#token = token;
|
|
77
|
+
this.#fetch = fetchFn;
|
|
78
|
+
}
|
|
79
|
+
#scrub(text) {
|
|
80
|
+
return redactText(text, [this.#token]);
|
|
81
|
+
}
|
|
82
|
+
async #get(path, opts = {}) {
|
|
83
|
+
const url = `${GITHUB_API_BASE}${path}`;
|
|
84
|
+
let res;
|
|
85
|
+
try {
|
|
86
|
+
res = await this.#fetch(url, {
|
|
87
|
+
method: "GET",
|
|
88
|
+
headers: {
|
|
89
|
+
Authorization: `Bearer ${this.#token}`,
|
|
90
|
+
Accept: opts.raw === true ? "application/vnd.github.raw+json" : "application/vnd.github+json",
|
|
91
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
92
|
+
"User-Agent": "jevitate",
|
|
93
|
+
},
|
|
94
|
+
signal: AbortSignal.timeout(20_000),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
throw new ForgeError(this.#scrub(`GitHub API GET ${path} failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
99
|
+
}
|
|
100
|
+
const body = await res.text();
|
|
101
|
+
if (res.status === 404 && opts.allow404 === true)
|
|
102
|
+
return { status: 404, body: "" };
|
|
103
|
+
if (res.status < 200 || res.status >= 300)
|
|
104
|
+
throw new ForgeError(this.#scrub(`GitHub API ${res.status} on GET ${path}: ${body.slice(0, 200)}`));
|
|
105
|
+
return { status: res.status, body };
|
|
106
|
+
}
|
|
107
|
+
async #json(path) {
|
|
108
|
+
const { body } = await this.#get(path);
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(body);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
throw new ForgeError(`GitHub API GET ${path}: the response is not JSON`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
get #r() {
|
|
117
|
+
return `/repos/${this.#repo}`;
|
|
118
|
+
}
|
|
119
|
+
async defaultBranch() {
|
|
120
|
+
const j = (await this.#json(this.#r));
|
|
121
|
+
if (typeof j.default_branch !== "string" || j.default_branch === "")
|
|
122
|
+
throw new ForgeError("GitHub API: the repository has no default branch");
|
|
123
|
+
return j.default_branch;
|
|
124
|
+
}
|
|
125
|
+
async commitsTouching(path, ref, limit) {
|
|
126
|
+
const q = new URLSearchParams({ sha: ref, path, per_page: String(Math.min(100, limit)) });
|
|
127
|
+
const list = (await this.#json(`${this.#r}/commits?${q.toString()}`));
|
|
128
|
+
return Array.isArray(list) ? list.flatMap((c) => (typeof c.sha === "string" ? [c.sha] : [])).slice(0, limit) : [];
|
|
129
|
+
}
|
|
130
|
+
async commitParents(sha) {
|
|
131
|
+
const c = (await this.#json(`${this.#r}/commits/${encodeURIComponent(sha)}`));
|
|
132
|
+
return (c.parents ?? []).flatMap((p) => (typeof p.sha === "string" ? [p.sha] : []));
|
|
133
|
+
}
|
|
134
|
+
async fileAt(path, ref) {
|
|
135
|
+
const r = await this.#get(`${this.#r}/contents/${encPath(path)}?ref=${encodeURIComponent(ref)}`, { raw: true, allow404: true });
|
|
136
|
+
return r.status === 404 ? null : r.body;
|
|
137
|
+
}
|
|
138
|
+
async isAncestor(sha, ref) {
|
|
139
|
+
const c = (await this.#json(`${this.#r}/compare/${encodeURIComponent(sha)}...${encodeURIComponent(ref)}`));
|
|
140
|
+
return c.status === "ahead" || c.status === "identical";
|
|
141
|
+
}
|
|
142
|
+
async pullsForCommit(sha) {
|
|
143
|
+
const list = (await this.#json(`${this.#r}/commits/${encodeURIComponent(sha)}/pulls?per_page=100`));
|
|
144
|
+
if (!Array.isArray(list))
|
|
145
|
+
return [];
|
|
146
|
+
return list.flatMap((p) => {
|
|
147
|
+
const base = p.base;
|
|
148
|
+
const head = p.head;
|
|
149
|
+
const user = p.user;
|
|
150
|
+
if (typeof p.number !== "number" || typeof base?.ref !== "string" || typeof head?.sha !== "string" || typeof user?.login !== "string")
|
|
151
|
+
return [];
|
|
152
|
+
return [
|
|
153
|
+
{
|
|
154
|
+
number: p.number,
|
|
155
|
+
...(typeof p.html_url === "string" ? { url: p.html_url } : {}),
|
|
156
|
+
mergedAt: typeof p.merged_at === "string" ? p.merged_at : null,
|
|
157
|
+
baseRef: base.ref,
|
|
158
|
+
headSha: head.sha,
|
|
159
|
+
author: user.login,
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
async reviews(number) {
|
|
165
|
+
const out = [];
|
|
166
|
+
for (let page = 1; page <= 5; page++) {
|
|
167
|
+
const list = (await this.#json(`${this.#r}/pulls/${number}/reviews?per_page=100&page=${page}`));
|
|
168
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
169
|
+
break;
|
|
170
|
+
for (const r of list) {
|
|
171
|
+
const user = r.user;
|
|
172
|
+
if (typeof user?.login !== "string" || typeof r.state !== "string" || typeof r.commit_id !== "string")
|
|
173
|
+
continue;
|
|
174
|
+
out.push({
|
|
175
|
+
login: user.login,
|
|
176
|
+
...(typeof user.id === "number" ? { userId: user.id } : {}),
|
|
177
|
+
isBot: user.type === "Bot" || user.login.endsWith("[bot]"),
|
|
178
|
+
state: r.state,
|
|
179
|
+
commitId: r.commit_id,
|
|
180
|
+
...(typeof r.submitted_at === "string" ? { submittedAt: r.submitted_at } : {}),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (list.length < 100)
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
async pullCommits(number) {
|
|
189
|
+
const out = [];
|
|
190
|
+
// GitHub lists at most 250 commits of a pull request.
|
|
191
|
+
for (let page = 1; page <= 3; page++) {
|
|
192
|
+
const list = (await this.#json(`${this.#r}/pulls/${number}/commits?per_page=100&page=${page}`));
|
|
193
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
194
|
+
break;
|
|
195
|
+
for (const c of list) {
|
|
196
|
+
const a = c.author;
|
|
197
|
+
const m = c.committer;
|
|
198
|
+
out.push({
|
|
199
|
+
...(typeof a?.login === "string" ? { authorLogin: a.login } : {}),
|
|
200
|
+
...(typeof a?.id === "number" ? { authorId: a.id } : {}),
|
|
201
|
+
...(typeof m?.login === "string" ? { committerLogin: m.login } : {}),
|
|
202
|
+
...(typeof m?.id === "number" ? { committerId: m.id } : {}),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (list.length < 100)
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
async permission(login) {
|
|
211
|
+
if (!LOGIN_RE.test(login))
|
|
212
|
+
return "none";
|
|
213
|
+
const r = await this.#get(`${this.#r}/collaborators/${encodeURIComponent(login)}/permission`, { allow404: true });
|
|
214
|
+
if (r.status === 404)
|
|
215
|
+
return "none";
|
|
216
|
+
try {
|
|
217
|
+
const j = JSON.parse(r.body);
|
|
218
|
+
// `permission` maps maintain → write and triage → read; `role_name` keeps them (custom roles fall back).
|
|
219
|
+
const role = typeof j.role_name === "string" && ["admin", "maintain", "write", "triage", "read"].includes(j.role_name) ? j.role_name : j.permission;
|
|
220
|
+
return typeof role === "string" ? role : "none";
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return "none";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async teamMember(org, team, login) {
|
|
227
|
+
if (!LOGIN_RE.test(login))
|
|
228
|
+
return false;
|
|
229
|
+
try {
|
|
230
|
+
const r = await this.#get(`/orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(team)}/memberships/${encodeURIComponent(login)}`, { allow404: true });
|
|
231
|
+
if (r.status === 404)
|
|
232
|
+
return false;
|
|
233
|
+
return JSON.parse(r.body).state === "active";
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return false; // the CI token may not read org teams: then the team does not vouch for the reviewer
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function refuse(code, reason) {
|
|
241
|
+
return { ok: false, code, reason };
|
|
242
|
+
}
|
|
243
|
+
function isTrue(v) {
|
|
244
|
+
return v !== undefined && ["1", "true", "yes"].includes(v.trim().toLowerCase());
|
|
245
|
+
}
|
|
246
|
+
/** `JEVITATE_PR_REVIEW_REQUIRE_CODEOWNER=1`: the approving reviewer must be a CODEOWNER of the entry's file. */
|
|
247
|
+
export function requireCodeOwnerFromEnv(env) {
|
|
248
|
+
return isTrue(env[REQUIRE_CODEOWNER_ENV]);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* The CI context from the environment — GitHub Actions on github.com with a `GITHUB_TOKEN`, or the
|
|
252
|
+
* refusal. The token is read ONLY from the CI environment's `GITHUB_TOKEN` (never an argument, never
|
|
253
|
+
* jevitate's credential store, never an MCP parameter).
|
|
254
|
+
*/
|
|
255
|
+
export function forgeContextFromEnv(env) {
|
|
256
|
+
if (env.GITHUB_ACTIONS?.trim().toLowerCase() !== "true") {
|
|
257
|
+
return refuse("not-ci", "pr-review is verified only in GitHub Actions (GITHUB_ACTIONS=true), with the CI environment's GITHUB_TOKEN");
|
|
258
|
+
}
|
|
259
|
+
const api = env.GITHUB_API_URL?.trim().replace(/\/+$/, "");
|
|
260
|
+
const server = env.GITHUB_SERVER_URL?.trim().replace(/\/+$/, "");
|
|
261
|
+
if ((api !== undefined && api !== "" && api !== GITHUB_API_BASE) || (server !== undefined && server !== "" && server !== GITHUB_SERVER)) {
|
|
262
|
+
return refuse("not-github", `pr-review is verified against github.com only (GitHub Enterprise is not supported): GITHUB_API_URL/GITHUB_SERVER_URL name another host`);
|
|
263
|
+
}
|
|
264
|
+
const repo = env.GITHUB_REPOSITORY?.trim() ?? "";
|
|
265
|
+
if (!REPO_RE.test(repo))
|
|
266
|
+
return refuse("not-ci", "GITHUB_REPOSITORY is not set to <owner>/<repo>");
|
|
267
|
+
const token = env.GITHUB_TOKEN?.trim() ?? "";
|
|
268
|
+
if (token === "")
|
|
269
|
+
return refuse("no-token", "no GITHUB_TOKEN in the CI environment to verify the pull-request review with (pass `env: GITHUB_TOKEN: ${{ github.token }}` to the step)");
|
|
270
|
+
return { ok: true, ctx: { repo, token, event: env.GITHUB_EVENT_NAME?.trim() ?? "", ref: env.GITHUB_REF?.trim() ?? "" } };
|
|
271
|
+
}
|
|
272
|
+
export const githubForgeFactory = (ctx) => new GitHubForge(ctx.repo, ctx.token);
|
|
273
|
+
/** A persona's or job's entry in its catalog file (personas.json / jobs.json). */
|
|
274
|
+
export function catalogItemEntry(kind, file, id, contentHash) {
|
|
275
|
+
return { file, contentHash, hashIn: (text) => catalogEntryHash(kind, text, id) };
|
|
276
|
+
}
|
|
277
|
+
/** A Journey's entry: its own file in the journeys dir (a namespaced, shared Journey has none: null). */
|
|
278
|
+
export function journeyEntry(journeysDir, id, contentHash) {
|
|
279
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id))
|
|
280
|
+
return null;
|
|
281
|
+
return {
|
|
282
|
+
file: join(journeysDir, `${id}.json`),
|
|
283
|
+
contentHash,
|
|
284
|
+
hashIn: (text) => {
|
|
285
|
+
if (text === null)
|
|
286
|
+
return null;
|
|
287
|
+
try {
|
|
288
|
+
const parsed = JourneySchema.safeParse(JSON.parse(text));
|
|
289
|
+
return parsed.success && parsed.data.metadata.id === id ? journeyReviewHash(parsed.data) : null;
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/** The entry file's path relative to its git repository (`/`-separated), or null outside one. */
|
|
298
|
+
export function repoPathOf(file) {
|
|
299
|
+
const root = findGitRoot(dirname(file));
|
|
300
|
+
if (root === null)
|
|
301
|
+
return null;
|
|
302
|
+
const rel = relative(root, file);
|
|
303
|
+
if (rel === "" || rel.startsWith(".."))
|
|
304
|
+
return null;
|
|
305
|
+
return rel.split(sep).join("/");
|
|
306
|
+
}
|
|
307
|
+
const WRITE_ROLES = new Set(["admin", "maintain", "write"]);
|
|
308
|
+
/** Each reviewer's latest decisive review (APPROVED / CHANGES_REQUESTED / DISMISSED); comments do not count. */
|
|
309
|
+
function latestDecisive(reviews) {
|
|
310
|
+
const by = new Map();
|
|
311
|
+
for (const r of reviews)
|
|
312
|
+
if (["APPROVED", "CHANGES_REQUESTED", "DISMISSED"].includes(r.state))
|
|
313
|
+
by.set(r.login.toLowerCase(), r);
|
|
314
|
+
return [...by.values()];
|
|
315
|
+
}
|
|
316
|
+
async function isCodeOwner(forge, branch, path, login) {
|
|
317
|
+
let text = null;
|
|
318
|
+
for (const loc of GITHUB_CODEOWNERS_LOCATIONS) {
|
|
319
|
+
text = await forge.fileAt(loc, branch);
|
|
320
|
+
if (text !== null)
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
if (text === null)
|
|
324
|
+
return false;
|
|
325
|
+
for (const owner of codeownersOf(parseCodeownersFile(text), path)) {
|
|
326
|
+
if (!owner.startsWith("@"))
|
|
327
|
+
continue; // an email cannot be matched to a login
|
|
328
|
+
const name = owner.slice(1);
|
|
329
|
+
const slash = name.indexOf("/");
|
|
330
|
+
if (slash < 0) {
|
|
331
|
+
if (name.toLowerCase() === login.toLowerCase())
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
else if (await forge.teamMember(name.slice(0, slash), name.slice(slash + 1), login))
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
/** The pull request's qualifying approver (optionally only `onlyReviewer`), or the refusal. */
|
|
340
|
+
async function approverOf(forge, pull, branch, path, policy, onlyReviewer) {
|
|
341
|
+
const approvals = latestDecisive(await forge.reviews(pull.number)).filter((r) => r.state === "APPROVED");
|
|
342
|
+
const onHead = approvals.filter((r) => r.commitId === pull.headSha);
|
|
343
|
+
const people = onHead.filter((r) => !r.isBot && r.login.toLowerCase() !== pull.author.toLowerCase());
|
|
344
|
+
const candidates = onlyReviewer === undefined ? people : people.filter((r) => r.login.toLowerCase() === onlyReviewer.toLowerCase());
|
|
345
|
+
if (candidates.length === 0) {
|
|
346
|
+
const why = approvals.length === 0
|
|
347
|
+
? "it has no approving review"
|
|
348
|
+
: onHead.length === 0
|
|
349
|
+
? "no approval is on its final (head) commit"
|
|
350
|
+
: people.length === 0
|
|
351
|
+
? "its only approvals are by its author or a bot"
|
|
352
|
+
: `${onlyReviewer ?? "the reviewer"} did not approve it`;
|
|
353
|
+
return refuse("no-approving-review", `pull request #${pull.number}: ${why}`);
|
|
354
|
+
}
|
|
355
|
+
// A reviewer who authored or committed any commit of the pull request is not independent of it.
|
|
356
|
+
const commits = await forge.pullCommits(pull.number);
|
|
357
|
+
const contributed = (r) => commits.some((c) => [c.authorLogin, c.committerLogin].some((l) => l !== undefined && l.toLowerCase() === r.login.toLowerCase()) ||
|
|
358
|
+
(r.userId !== undefined && (c.authorId === r.userId || c.committerId === r.userId)));
|
|
359
|
+
const independent = candidates.filter((r) => !contributed(r));
|
|
360
|
+
if (independent.length === 0) {
|
|
361
|
+
return refuse("reviewer-contributed", `pull request #${pull.number}: every approving reviewer (${candidates.map((r) => r.login).join(", ")}) also authored or committed commits of it — not an independent review`);
|
|
362
|
+
}
|
|
363
|
+
let last = refuse("no-approving-review", `pull request #${pull.number}: no approving reviewer has write access`);
|
|
364
|
+
for (const r of independent) {
|
|
365
|
+
if (!WRITE_ROLES.has(await forge.permission(r.login))) {
|
|
366
|
+
last = refuse("no-approving-review", `pull request #${pull.number}: approving reviewer ${r.login} has no write access to the repository`);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (policy.requireCodeOwner === true) {
|
|
370
|
+
if (!(await isCodeOwner(forge, branch, path, r.login))) {
|
|
371
|
+
last = refuse("not-code-owner", `pull request #${pull.number}: approving reviewer ${r.login} is not a CODEOWNER of ${path} (${REQUIRE_CODEOWNER_ENV} is set)`);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
return { ok: true, review: r, codeOwner: true };
|
|
375
|
+
}
|
|
376
|
+
return { ok: true, review: r };
|
|
377
|
+
}
|
|
378
|
+
return last;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* The core of both verifications: `sha` is on the default branch, holds the entry's approved
|
|
382
|
+
* content, CHANGED it (its first parent holds something else), and came from a merged pull request
|
|
383
|
+
* into the default branch whose APPROVED head holds that same content, with a qualifying approval.
|
|
384
|
+
*/
|
|
385
|
+
async function verifyChange(forge, entry, path, branch, sha, policy, recorded) {
|
|
386
|
+
if (!SHA_RE.test(sha))
|
|
387
|
+
return refuse("not-on-default-branch", `${sha} is not a full commit sha`);
|
|
388
|
+
if (!(await forge.isAncestor(sha, branch)))
|
|
389
|
+
return refuse("not-on-default-branch", `commit ${sha.slice(0, 12)} is not on the default branch ${branch}`);
|
|
390
|
+
if (entry.hashIn(await forge.fileAt(path, sha)) !== entry.contentHash) {
|
|
391
|
+
return refuse("content-mismatch", `${path} at commit ${sha.slice(0, 12)} does not hold the approved content of the entry`);
|
|
392
|
+
}
|
|
393
|
+
const parent = (await forge.commitParents(sha))[0];
|
|
394
|
+
if (parent !== undefined && entry.hashIn(await forge.fileAt(path, parent)) === entry.contentHash) {
|
|
395
|
+
return refuse("not-changed-here", `commit ${sha.slice(0, 12)} did not change the entry (its parent already held the same content)`);
|
|
396
|
+
}
|
|
397
|
+
const pulls = (await forge.pullsForCommit(sha)).filter((p) => p.mergedAt !== null && p.baseRef === branch && (recorded === undefined || p.number === recorded.number));
|
|
398
|
+
if (pulls.length === 0) {
|
|
399
|
+
return recorded === undefined
|
|
400
|
+
? refuse("no-merged-pr", `commit ${sha.slice(0, 12)} did not come from a pull request merged into ${branch}`)
|
|
401
|
+
: refuse("pr-mismatch", `commit ${sha.slice(0, 12)} did not come from pull request #${recorded.number} merged into ${branch}`);
|
|
402
|
+
}
|
|
403
|
+
let first;
|
|
404
|
+
for (const pull of pulls) {
|
|
405
|
+
if (recorded?.author !== undefined && recorded.author.toLowerCase() !== pull.author.toLowerCase()) {
|
|
406
|
+
first ??= refuse("pr-mismatch", `pull request #${pull.number} was authored by ${pull.author}, not ${recorded.author}`);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const a = await approverOf(forge, pull, branch, path, policy, recorded?.reviewer);
|
|
410
|
+
if (!a.ok) {
|
|
411
|
+
first ??= a;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
// The reviewer approved the pull request's head (approverOf honours only reviews on it): that
|
|
415
|
+
// head must hold exactly the content being honoured. A merged commit's content that no reviewer
|
|
416
|
+
// saw (an intermediate commit later reverted, an "evil merge" resolution) is never approved.
|
|
417
|
+
const approvedHead = a.review.commitId;
|
|
418
|
+
if (!SHA_RE.test(approvedHead) || entry.hashIn(await forge.fileAt(path, approvedHead)) !== entry.contentHash) {
|
|
419
|
+
first ??= refuse("pr-mismatch", `pull request #${pull.number}: its approved head ${approvedHead.slice(0, 12)} does not hold the content of the entry that commit ${sha.slice(0, 12)} holds in ${path} — the reviewer did not approve that content`);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const pr = {
|
|
423
|
+
forge: "github",
|
|
424
|
+
number: pull.number,
|
|
425
|
+
...(pull.url === undefined ? {} : { url: pull.url }),
|
|
426
|
+
mergedSha: sha,
|
|
427
|
+
author: pull.author,
|
|
428
|
+
reviewer: a.review.login,
|
|
429
|
+
...(a.review.submittedAt === undefined ? {} : { reviewedAt: a.review.submittedAt }),
|
|
430
|
+
...(a.codeOwner === undefined ? {} : { codeOwner: a.codeOwner }),
|
|
431
|
+
};
|
|
432
|
+
return { ok: true, pr };
|
|
433
|
+
}
|
|
434
|
+
return first ?? refuse("no-merged-pr", `commit ${sha.slice(0, 12)}: no qualifying pull request`);
|
|
435
|
+
}
|
|
436
|
+
function forgeFailure(err, token) {
|
|
437
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
438
|
+
return refuse("forge-error", `the forge could not be asked: ${redactText(msg, [token])}`);
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Verifies an approval being made in CI (`job|persona approve`, `journey promote`): the context
|
|
442
|
+
* (GitHub Actions, the default branch, not a pull-request event), then the commit that last changed
|
|
443
|
+
* the entry and its merged, approved pull request. The `pr` of a success is what gets recorded.
|
|
444
|
+
*/
|
|
445
|
+
export async function verifyPrReviewForApproval(entry, env, factory = githubForgeFactory, policy = { requireCodeOwner: requireCodeOwnerFromEnv(env) }) {
|
|
446
|
+
const c = forgeContextFromEnv(env);
|
|
447
|
+
if (!c.ok)
|
|
448
|
+
return c;
|
|
449
|
+
const { ctx } = c;
|
|
450
|
+
if (ctx.event.startsWith("pull_request")) {
|
|
451
|
+
return refuse("pull-request-event", `pr-review is never granted on a ${ctx.event} event (the pull request controls the files it would approve): approve on a push to the default branch`);
|
|
452
|
+
}
|
|
453
|
+
const path = repoPathOf(entry.file);
|
|
454
|
+
if (path === null)
|
|
455
|
+
return refuse("not-repo-file", `${entry.file} is not inside a git repository`);
|
|
456
|
+
try {
|
|
457
|
+
const forge = factory(ctx);
|
|
458
|
+
const branch = await forge.defaultBranch();
|
|
459
|
+
if (ctx.ref !== `refs/heads/${branch}`)
|
|
460
|
+
return refuse("not-default-branch", `pr-review is granted only on the default branch (${branch}); this run is on ${ctx.ref === "" ? "an unknown ref" : ctx.ref}`);
|
|
461
|
+
const commits = await forge.commitsTouching(path, branch, HISTORY_LIMIT);
|
|
462
|
+
if (commits.length === 0)
|
|
463
|
+
return refuse("content-mismatch", `no commit on ${branch} touches ${path}`);
|
|
464
|
+
let candidate;
|
|
465
|
+
for (let i = 0; i < commits.length; i++) {
|
|
466
|
+
const sha = commits[i];
|
|
467
|
+
const here = entry.hashIn(await forge.fileAt(path, sha));
|
|
468
|
+
if (here !== entry.contentHash) {
|
|
469
|
+
if (i === 0)
|
|
470
|
+
return refuse("content-mismatch", `${path} on ${branch} does not hold the content being approved (approve from a checkout of the default branch's head)`);
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
candidate = sha;
|
|
474
|
+
if (i === commits.length - 1 && commits.length >= HISTORY_LIMIT) {
|
|
475
|
+
return refuse("history-too-deep", `the entry is unchanged in the last ${HISTORY_LIMIT} commits touching ${path}: its last change is too far back to verify`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return await verifyChange(forge, entry, path, branch, candidate, policy);
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
return forgeFailure(err, ctx.token);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* RE-verifies a recorded `pr-review` approval (`check --require-approvals`): the recorded merged
|
|
486
|
+
* commit is on the default branch, holds the approved content and changed it, and came from the
|
|
487
|
+
* recorded pull request, approved by the recorded reviewer, who still qualifies. Runs on any event
|
|
488
|
+
* (it reads only forge facts; a pull request's files cannot change them).
|
|
489
|
+
*/
|
|
490
|
+
export async function reverifyPrReview(entry, pr, env, factory = githubForgeFactory, policy = { requireCodeOwner: requireCodeOwnerFromEnv(env) }) {
|
|
491
|
+
const c = forgeContextFromEnv(env);
|
|
492
|
+
if (!c.ok)
|
|
493
|
+
return c;
|
|
494
|
+
const path = repoPathOf(entry.file);
|
|
495
|
+
if (path === null)
|
|
496
|
+
return refuse("not-repo-file", `${entry.file} is not inside a git repository`);
|
|
497
|
+
if (pr.forge !== undefined && pr.forge !== "github")
|
|
498
|
+
return refuse("not-github", `recorded forge '${String(pr.forge)}' is not supported`);
|
|
499
|
+
try {
|
|
500
|
+
const forge = factory(c.ctx);
|
|
501
|
+
const branch = await forge.defaultBranch();
|
|
502
|
+
const v = await verifyChange(forge, entry, path, branch, pr.mergedSha, policy, { number: pr.number, reviewer: pr.reviewer, ...(pr.author === undefined ? {} : { author: pr.author }) });
|
|
503
|
+
return v.ok ? { ok: true, pr: { ...pr, ...(v.pr.codeOwner === undefined ? {} : { codeOwner: v.pr.codeOwner }) } } : v;
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
return forgeFailure(err, c.ctx.token);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
export const gitTracksAnything = (dir) => new Promise((resolveTracked) => {
|
|
510
|
+
const root = findGitRoot(dir);
|
|
511
|
+
const rel = root === null ? "" : relative(root, resolve(dir));
|
|
512
|
+
if (root === null || rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
|
|
513
|
+
resolveTracked(true);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
let child;
|
|
517
|
+
try {
|
|
518
|
+
child = spawn("git", ["ls-files", "--", rel.split(sep).join("/")], { cwd: root, stdio: ["ignore", "pipe", "ignore"] });
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
resolveTracked(true);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
let out = "";
|
|
525
|
+
child.stdout?.on("data", (d) => (out += d.toString("utf8")));
|
|
526
|
+
// No git / a git error: untrusted.
|
|
527
|
+
child.on("error", () => resolveTracked(true));
|
|
528
|
+
child.on("close", (code) => resolveTracked(code !== 0 || out.trim() !== ""));
|
|
529
|
+
});
|
|
530
|
+
/**
|
|
531
|
+
* True when every path component from the git repository root down to `target` is a real
|
|
532
|
+
* directory or file (no symlink), so `target` cannot resolve outside the repository. Components
|
|
533
|
+
* that do not exist yet are fine (they will be created as real directories). Outside a repository,
|
|
534
|
+
* or on any other error: false.
|
|
535
|
+
*/
|
|
536
|
+
export async function noSymlinkFromRepoRoot(target) {
|
|
537
|
+
const abs = resolve(target);
|
|
538
|
+
const root = findGitRoot(abs);
|
|
539
|
+
if (root === null)
|
|
540
|
+
return false;
|
|
541
|
+
const rel = relative(root, abs);
|
|
542
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel))
|
|
543
|
+
return false;
|
|
544
|
+
let cur = root;
|
|
545
|
+
for (const seg of rel.split(sep)) {
|
|
546
|
+
cur = join(cur, seg);
|
|
547
|
+
try {
|
|
548
|
+
if ((await lstat(cur)).isSymbolicLink())
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
catch (err) {
|
|
552
|
+
if (err?.code === "ENOENT")
|
|
553
|
+
return true;
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
function cacheKey(k) {
|
|
560
|
+
return createHash("sha256")
|
|
561
|
+
.update(JSON.stringify({ v: 1, repo: k.repo, path: k.path, contentHash: k.contentHash, number: k.pr.number, mergedSha: k.pr.mergedSha, reviewer: k.pr.reviewer.toLowerCase(), author: k.pr.author ?? null, requireCodeOwner: k.requireCodeOwner }))
|
|
562
|
+
.digest("hex");
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Positive re-verifications, cached by merged sha in the project's gitignored cache dir
|
|
566
|
+
* (`<.jevitate>/cache/pr-review/<mergedSha>-<key>.json`) so CI does not re-ask the forge every run.
|
|
567
|
+
* Only a success is cached, for `PR_REVIEW_CACHE_TTL_MS`; the cache is ignored entirely (never read,
|
|
568
|
+
* never written) when any file in it is tracked by git or git cannot say (a committed "verified"
|
|
569
|
+
* entry is never trusted), or when any path component from the repository root down to a cache
|
|
570
|
+
* file is a symlink (a committed symlink could point it anywhere). It is never read nor written on
|
|
571
|
+
* a pull-request event.
|
|
572
|
+
*/
|
|
573
|
+
export class PrReviewCache {
|
|
574
|
+
dir;
|
|
575
|
+
gitTracked;
|
|
576
|
+
#trusted;
|
|
577
|
+
constructor(dir, gitTracked = gitTracksAnything) {
|
|
578
|
+
this.dir = dir;
|
|
579
|
+
this.gitTracked = gitTracked;
|
|
580
|
+
}
|
|
581
|
+
#file(k) {
|
|
582
|
+
return join(this.dir, `${k.pr.mergedSha}-${cacheKey(k).slice(0, 32)}.json`);
|
|
583
|
+
}
|
|
584
|
+
#isTrusted() {
|
|
585
|
+
const dir = this.dir;
|
|
586
|
+
this.#trusted ??= dir === null ? Promise.resolve(false) : noSymlinkFromRepoRoot(dir).then(async (safe) => safe && !(await this.gitTracked(dir)));
|
|
587
|
+
return this.#trusted;
|
|
588
|
+
}
|
|
589
|
+
/** The cache file for `k`, or null when the cache is untrusted or the file path is unsafe. */
|
|
590
|
+
async #safeFile(k) {
|
|
591
|
+
if (this.dir === null || !(await this.#isTrusted()))
|
|
592
|
+
return null;
|
|
593
|
+
const file = this.#file(k);
|
|
594
|
+
return (await noSymlinkFromRepoRoot(file)) ? file : null;
|
|
595
|
+
}
|
|
596
|
+
async hit(k) {
|
|
597
|
+
const file = await this.#safeFile(k);
|
|
598
|
+
if (file === null)
|
|
599
|
+
return false;
|
|
600
|
+
try {
|
|
601
|
+
const j = JSON.parse(await readFile(file, "utf8"));
|
|
602
|
+
if (j.key !== cacheKey(k) || typeof j.verifiedAtMs !== "number")
|
|
603
|
+
return false;
|
|
604
|
+
const age = clock.now() - j.verifiedAtMs;
|
|
605
|
+
return age >= 0 && age < PR_REVIEW_CACHE_TTL_MS;
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
async put(k) {
|
|
612
|
+
const file = await this.#safeFile(k);
|
|
613
|
+
if (file === null)
|
|
614
|
+
return;
|
|
615
|
+
try {
|
|
616
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
617
|
+
await writeFile(file, `${JSON.stringify({ key: cacheKey(k), verifiedAtMs: clock.now(), number: k.pr.number, reviewer: k.pr.reviewer })}\n`, { mode: 0o600 });
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
// a cache that cannot be written only costs a re-query next run
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/** The cache dir of a project data dir (`<.jevitate>/cache/pr-review`), or null outside a project. */
|
|
625
|
+
export function prReviewCacheDir(catalogDir) {
|
|
626
|
+
return catalogDir === null ? null : join(catalogDir, "cache", "pr-review");
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Re-verification through the cache: a cached success is honoured and a fresh success is cached —
|
|
630
|
+
* except on a pull-request event, where the cache is neither read nor written (always verified live).
|
|
631
|
+
*/
|
|
632
|
+
export async function reverifyPrReviewCached(entry, pr, env, cache, factory = githubForgeFactory) {
|
|
633
|
+
const c = forgeContextFromEnv(env);
|
|
634
|
+
if (!c.ok)
|
|
635
|
+
return c;
|
|
636
|
+
const path = repoPathOf(entry.file);
|
|
637
|
+
if (path === null)
|
|
638
|
+
return refuse("not-repo-file", `${entry.file} is not inside a git repository`);
|
|
639
|
+
const policy = { requireCodeOwner: requireCodeOwnerFromEnv(env) };
|
|
640
|
+
const key = { repo: c.ctx.repo, path, contentHash: entry.contentHash, pr, requireCodeOwner: policy.requireCodeOwner === true };
|
|
641
|
+
const prEvent = c.ctx.event.startsWith("pull_request");
|
|
642
|
+
if (!prEvent && (await cache.hit(key)))
|
|
643
|
+
return { ok: true, pr };
|
|
644
|
+
const v = await reverifyPrReview(entry, pr, env, factory, policy);
|
|
645
|
+
if (v.ok && !prEvent)
|
|
646
|
+
await cache.put(key);
|
|
647
|
+
return v;
|
|
648
|
+
}
|
|
649
|
+
//# sourceMappingURL=forge-verify.js.map
|