@tutar/graph-engineering 0.3.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/LICENSE +21 -0
- package/README.md +10 -0
- package/bin/graph-engineering.mjs +60 -0
- package/lib/check.mjs +102 -0
- package/lib/constants.mjs +17 -0
- package/lib/files.mjs +83 -0
- package/lib/init.mjs +29 -0
- package/lib/manifest.mjs +31 -0
- package/lib/migrate.mjs +101 -0
- package/lib/package-assets.mjs +39 -0
- package/migrations/loop-engineering-927bd961/development/.github/loop-engineering/development-worktree.sh +32 -0
- package/migrations/loop-engineering-927bd961/development/.github/loop-engineering/github-development-ticket.mjs +246 -0
- package/migrations/loop-engineering-927bd961/development/.github/loop-engineering/stop-hook-drain.mjs +20 -0
- package/migrations/loop-engineering-927bd961/development/.github/loop-engineering/thread-record.mjs +36 -0
- package/migrations/loop-engineering-927bd961/development/.github/workflows/github-development-ticket.yml +124 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/capture-review.mjs +16 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/check-publication.mjs +23 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/codex-compatibility-profile.json +67 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/codex-compatible-executor.mjs +130 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/github-api.mjs +20 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/pr-review-case.mjs +125 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/pr-review-config.json +19 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/pr-review-contract.mjs +2 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/prepare-review.mjs +50 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/publish-review.mjs +103 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/review-result.schema.json +93 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/route-review.mjs +117 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/workflows/github-pr-review.yml +138 -0
- package/package.json +38 -0
- package/release-metadata.json +4 -0
- package/templates/development/.github/graph-engineering/development-worktree.sh +47 -0
- package/templates/development/.github/graph-engineering/github-development-ticket.mjs +246 -0
- package/templates/development/.github/graph-engineering/stop-hook-drain.mjs +20 -0
- package/templates/development/.github/graph-engineering/thread-record.mjs +39 -0
- package/templates/development/.github/workflows/github-development-ticket.yml +124 -0
- package/templates/pr-review/.github/graph-engineering/capture-review.mjs +16 -0
- package/templates/pr-review/.github/graph-engineering/check-publication.mjs +23 -0
- package/templates/pr-review/.github/graph-engineering/codex-compatibility-profile.json +67 -0
- package/templates/pr-review/.github/graph-engineering/codex-compatible-executor.mjs +130 -0
- package/templates/pr-review/.github/graph-engineering/github-api.mjs +20 -0
- package/templates/pr-review/.github/graph-engineering/pr-review-case.mjs +125 -0
- package/templates/pr-review/.github/graph-engineering/pr-review-config.json +19 -0
- package/templates/pr-review/.github/graph-engineering/pr-review-contract.mjs +2 -0
- package/templates/pr-review/.github/graph-engineering/prepare-review.mjs +50 -0
- package/templates/pr-review/.github/graph-engineering/publish-review.mjs +103 -0
- package/templates/pr-review/.github/graph-engineering/review-result.schema.json +93 -0
- package/templates/pr-review/.github/graph-engineering/route-review.mjs +117 -0
- package/templates/pr-review/.github/workflows/github-pr-review.yml +138 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { SUPPORTED_ACTION_NAMES } from "./pr-review-contract.mjs";
|
|
4
|
+
|
|
5
|
+
const DEFINITION = "github-pr-review/current";
|
|
6
|
+
const PROFILE = "github-pr-review/codex/current";
|
|
7
|
+
const ACTION_SOURCE = "tutar/codex-action";
|
|
8
|
+
const ACTION_COMMIT = "f33581290086e62dc34d420a7f1862477fc2b503";
|
|
9
|
+
const ACTION_SOURCE_REF = "fix/runner-login-no-proxy";
|
|
10
|
+
const ACTION_UPSTREAM = {
|
|
11
|
+
source: "openai/codex-action",
|
|
12
|
+
commit: "86365089eb2b84e0a8fb0717b304f8bdcb13b20e",
|
|
13
|
+
};
|
|
14
|
+
const ACTION_PATCH = "read-proxy-server-info-only-with-openai-api-key";
|
|
15
|
+
const CODEX_VERSION = "0.153.4";
|
|
16
|
+
const RUNNER_LABELS = ["self-hosted", "Linux", "X64", "codex"];
|
|
17
|
+
const ACTION_USES = `${ACTION_SOURCE}@${ACTION_COMMIT}`;
|
|
18
|
+
|
|
19
|
+
export function resolveCompatibleExecution({ config, profile }) {
|
|
20
|
+
try {
|
|
21
|
+
validateProfile(profile);
|
|
22
|
+
validateConfiguration(config, profile);
|
|
23
|
+
return {
|
|
24
|
+
definition: config.definition,
|
|
25
|
+
profile: config.profile,
|
|
26
|
+
runnerLabels: [...RUNNER_LABELS],
|
|
27
|
+
events: structuredClone(config.events),
|
|
28
|
+
eventPrompt: config.eventPrompt.trim(),
|
|
29
|
+
action: {
|
|
30
|
+
uses: ACTION_USES,
|
|
31
|
+
codexVersion: CODEX_VERSION,
|
|
32
|
+
model: config.codex.model.trim(),
|
|
33
|
+
effort: config.codex.effort,
|
|
34
|
+
safetyStrategy: config.codex.safetyStrategy,
|
|
35
|
+
},
|
|
36
|
+
check: structuredClone(config.check),
|
|
37
|
+
};
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw new Error(`configuration-handoff: ${error.message}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function loadBundledCompatibility() {
|
|
44
|
+
const contracts = await readBundledContracts();
|
|
45
|
+
return resolveCompatibleExecution(contracts);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function readBundledContracts() {
|
|
49
|
+
const [config, profile] = await Promise.all([
|
|
50
|
+
readFile(new URL("./pr-review-config.json", import.meta.url), "utf8"),
|
|
51
|
+
readFile(new URL("./codex-compatibility-profile.json", import.meta.url), "utf8"),
|
|
52
|
+
]);
|
|
53
|
+
return { config: JSON.parse(config), profile: JSON.parse(profile) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validateProfile(profile) {
|
|
57
|
+
assertExactKeys(profile, ["definition", "profile", "executor", "runner", "authentication", "action", "mapping", "outputs", "permissions", "consumerConfiguration", "validation"], "Profile");
|
|
58
|
+
if (profile.definition !== DEFINITION) throw new Error("Profile definition does not match the Executor version");
|
|
59
|
+
if (profile.profile !== PROFILE) throw new Error("Profile identity does not match the Executor version");
|
|
60
|
+
assertExactKeys(profile.executor, ["id", "version"], "Profile executor");
|
|
61
|
+
if (profile.executor?.id !== "codex-compatible-executor" || profile.executor?.version !== "current") throw new Error("Profile executor does not match the bundled Executor");
|
|
62
|
+
assertJsonEqual(profile.runner, { labels: RUNNER_LABELS }, "runner");
|
|
63
|
+
assertJsonEqual(profile.authentication, { mode: "runner-backed-codex", credentialSource: "default-codex-home", repositorySecret: false }, "authentication");
|
|
64
|
+
if (profile.action?.source !== ACTION_SOURCE || profile.action?.sourceRef !== ACTION_SOURCE_REF) throw new Error("Profile Action source or source ref does not match the bundled Executor");
|
|
65
|
+
assertExactKeys(profile.action, ["source", "sourceRef", "commit", "upstream", "patch", "codexVersion", "requiredInputs", "acceptedInputs"], "Profile Action");
|
|
66
|
+
if (profile.action.commit !== ACTION_COMMIT) throw new Error("Profile Action commit does not match the bundled Executor");
|
|
67
|
+
assertJsonEqual(profile.action.upstream, ACTION_UPSTREAM, "Action upstream base");
|
|
68
|
+
if (profile.action.patch !== ACTION_PATCH) throw new Error("Profile Action patch does not match the bundled Executor");
|
|
69
|
+
if (profile.action.codexVersion !== CODEX_VERSION) throw new Error("Profile Codex version does not match the bundled Executor");
|
|
70
|
+
assertJsonEqual(profile.action.requiredInputs, ["prompt-file", "output-schema-file", "working-directory", "codex-version", "safety-strategy"], "required inputs");
|
|
71
|
+
assertJsonEqual(profile.action.acceptedInputs, ["prompt-file", "output-schema-file", "working-directory", "codex-version", "model", "effort", "permission-profile", "safety-strategy"], "accepted inputs");
|
|
72
|
+
assertJsonEqual(profile.mapping, {
|
|
73
|
+
goalPrompt: "prompt-file",
|
|
74
|
+
reviewSchema: "output-schema-file",
|
|
75
|
+
codexVersion: "codex-version",
|
|
76
|
+
workingDirectory: "working-directory",
|
|
77
|
+
model: "model",
|
|
78
|
+
reasoningEffort: "effort",
|
|
79
|
+
safetyStrategy: "safety-strategy",
|
|
80
|
+
terminal: "step-outcome",
|
|
81
|
+
finalMessage: "final-message",
|
|
82
|
+
}, "mapping");
|
|
83
|
+
assertJsonEqual(profile.outputs, ["final-message"], "outputs");
|
|
84
|
+
assertJsonEqual(profile.permissions.reviewJob, { contents: "read", "pull-requests": "read", checks: "read" }, "review permissions");
|
|
85
|
+
assertJsonEqual(profile.permissions.publishJob, { contents: "read", "pull-requests": "read", checks: "write" }, "publish permissions");
|
|
86
|
+
assertJsonEqual(profile.consumerConfiguration, {
|
|
87
|
+
pullRequestActions: SUPPORTED_ACTION_NAMES,
|
|
88
|
+
manualDispatch: "boolean",
|
|
89
|
+
includeDrafts: [false],
|
|
90
|
+
modelPattern: "^[A-Za-z0-9._-]*$",
|
|
91
|
+
reasoningEfforts: ["", "low", "medium", "high", "xhigh"],
|
|
92
|
+
safetyStrategies: ["read-only"],
|
|
93
|
+
checkTextMaxLength: 100,
|
|
94
|
+
}, "consumer configuration");
|
|
95
|
+
assertJsonEqual(profile.validation, {
|
|
96
|
+
failClosed: true,
|
|
97
|
+
resultSchema: "review-result.schema.json",
|
|
98
|
+
terminals: ["completed", "handoff", "failed", "cancelled"],
|
|
99
|
+
}, "validation rules");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validateConfiguration(config, profile) {
|
|
103
|
+
assertExactKeys(config, ["definition", "profile", "events", "eventPrompt", "codex", "check"], "configuration");
|
|
104
|
+
if (config.definition !== DEFINITION) throw new Error("configuration definition does not match this current task");
|
|
105
|
+
if (config.profile !== profile.profile) throw new Error("configuration profile does not match the bundled Profile");
|
|
106
|
+
assertExactKeys(config.events, ["pullRequestActions", "manualDispatch", "includeDrafts"], "events");
|
|
107
|
+
if (!Array.isArray(config.events.pullRequestActions) || !config.events.pullRequestActions.every((action) => profile.consumerConfiguration.pullRequestActions.includes(action)) || new Set(config.events.pullRequestActions).size !== config.events.pullRequestActions.length) throw new Error("events.pullRequestActions contains an unsupported or duplicate event");
|
|
108
|
+
if (typeof config.events.manualDispatch !== "boolean") throw new Error("events.manualDispatch must be boolean");
|
|
109
|
+
if (!profile.consumerConfiguration.includeDrafts.includes(config.events.includeDrafts)) throw new Error("events.includeDrafts must remain false for this current task");
|
|
110
|
+
if (typeof config.eventPrompt !== "string" || !config.eventPrompt.trim()) throw new Error("eventPrompt is required");
|
|
111
|
+
assertExactKeys(config.codex, ["model", "effort", "safetyStrategy"], "codex");
|
|
112
|
+
if (typeof config.codex.model !== "string" || !new RegExp(profile.consumerConfiguration.modelPattern).test(config.codex.model)) throw new Error("codex.model contains unsupported characters");
|
|
113
|
+
if (!profile.consumerConfiguration.reasoningEfforts.includes(config.codex.effort)) throw new Error("codex.effort is not allowed");
|
|
114
|
+
if (!profile.consumerConfiguration.safetyStrategies.includes(config.codex.safetyStrategy)) throw new Error("codex.safetyStrategy must remain read-only");
|
|
115
|
+
assertExactKeys(config.check, ["name", "title"], "check");
|
|
116
|
+
for (const key of ["name", "title"]) {
|
|
117
|
+
if (typeof config.check[key] !== "string" || !config.check[key].trim() || config.check[key].length > profile.consumerConfiguration.checkTextMaxLength) throw new Error(`check.${key} must be a non-empty string of at most ${profile.consumerConfiguration.checkTextMaxLength} characters`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function assertExactKeys(value, expected, label) {
|
|
122
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
123
|
+
const actual = Object.keys(value).sort();
|
|
124
|
+
const wanted = [...expected].sort();
|
|
125
|
+
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) throw new Error(`${label} contains missing or forbidden fields`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assertJsonEqual(actual, expected, label) {
|
|
129
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`Profile ${label} does not match the bundled Executor`);
|
|
130
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export async function githubRequest(path, { method = "GET", body, token = required("GH_TOKEN") } = {}) {
|
|
2
|
+
const response = await fetch(`${process.env.GITHUB_API_URL ?? "https://api.github.com"}${path}`, {
|
|
3
|
+
method,
|
|
4
|
+
headers: {
|
|
5
|
+
Accept: "application/vnd.github+json",
|
|
6
|
+
Authorization: `Bearer ${token}`,
|
|
7
|
+
"Content-Type": "application/json",
|
|
8
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
9
|
+
},
|
|
10
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
11
|
+
});
|
|
12
|
+
if (!response.ok) throw new Error(`GitHub API ${path} returned ${response.status}`);
|
|
13
|
+
return response.status === 204 ? null : response.json();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function required(name) {
|
|
17
|
+
const value = process.env[name]?.trim();
|
|
18
|
+
if (!value) throw new Error(`${name} is required`);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { CHECK_NAME } from "./check-publication.mjs";
|
|
2
|
+
|
|
3
|
+
export function formGoalPrompt({ eventPrompt, pullRequest }) {
|
|
4
|
+
return `${eventPrompt.trim()}
|
|
5
|
+
|
|
6
|
+
Use the Consumer Project's installed \`code-review\` Skill to review:
|
|
7
|
+
- repository: ${pullRequest.repository}
|
|
8
|
+
- PR #${pullRequest.number}
|
|
9
|
+
- base SHA: ${pullRequest.baseSha}
|
|
10
|
+
- head SHA: ${pullRequest.headSha}
|
|
11
|
+
- requested change: read \`pr-review-context.md\`
|
|
12
|
+
|
|
13
|
+
Report Standards and Spec as separate axes using the supplied candidate review output schema.
|
|
14
|
+
|
|
15
|
+
Always include standards, spec, and handoff. For completed review, provide both axis objects and handoff=null. For handoff, set both axes to null and provide a nonempty handoff summary. Never mix review axes with a handoff.
|
|
16
|
+
|
|
17
|
+
If the review cannot be completed from the available trusted facts, return the schema's handoff form with a concrete summary. Do not claim to publish, comment, label, push, or otherwise write to GitHub.
|
|
18
|
+
|
|
19
|
+
Completion Condition: finish only after both axes have a verdict and findings, and the result identifies the exact repository, PR, base SHA, and head SHA above.`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function mapActionExecution({ actionOutcome, finalMessage }, target) {
|
|
23
|
+
if (actionOutcome === "cancelled") {
|
|
24
|
+
return { terminal: "cancelled", diagnostic: "agent-action: execution was cancelled" };
|
|
25
|
+
}
|
|
26
|
+
if (actionOutcome !== "success") {
|
|
27
|
+
return { terminal: "failed", diagnostic: `agent-action: execution ended with ${actionOutcome || "an unknown outcome"}` };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let candidate;
|
|
31
|
+
try {
|
|
32
|
+
if (!finalMessage?.trim()) throw new Error("empty final-message");
|
|
33
|
+
candidate = JSON.parse(finalMessage);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return { terminal: "failed", diagnostic: `candidate-output: ${error.message}` };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
assertExactKeys(candidate, ["repository", "pullRequestNumber", "baseSha", "headSha", "standards", "spec", "handoff"]);
|
|
40
|
+
assertTrustedTarget(candidate, target);
|
|
41
|
+
if (candidate.handoff !== null) {
|
|
42
|
+
if (candidate.standards !== null || candidate.spec !== null) throw new Error("handoff must not contain review axes");
|
|
43
|
+
assertExactKeys(candidate.handoff, ["summary"]);
|
|
44
|
+
if (typeof candidate.handoff.summary !== "string" || !candidate.handoff.summary.trim()) {
|
|
45
|
+
throw new Error("handoff summary is missing");
|
|
46
|
+
}
|
|
47
|
+
return { terminal: "handoff", diagnostic: `goal-handoff: ${candidate.handoff.summary.trim()}` };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { handoff, ...reviewCandidate } = candidate;
|
|
51
|
+
assertCandidateReview(reviewCandidate, target);
|
|
52
|
+
return {
|
|
53
|
+
terminal: "completed",
|
|
54
|
+
review: {
|
|
55
|
+
...reviewCandidate,
|
|
56
|
+
runtime: {
|
|
57
|
+
terminal: "completed",
|
|
58
|
+
summary: "tutar/codex-action completed and returned a validated final-message.",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return { terminal: "failed", diagnostic: `candidate-output: ${error.message}` };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function assertReviewResult(review, target) {
|
|
68
|
+
assertExactKeys(review, ["repository", "pullRequestNumber", "baseSha", "headSha", "standards", "spec", "runtime"]);
|
|
69
|
+
assertTrustedTarget(review, target);
|
|
70
|
+
for (const axis of ["standards", "spec"]) {
|
|
71
|
+
if (!review[axis] || typeof review[axis] !== "object") {
|
|
72
|
+
throw new Error(`review output is missing the ${axis} axis`);
|
|
73
|
+
}
|
|
74
|
+
assertExactKeys(review[axis], ["verdict", "findings"]);
|
|
75
|
+
if (!["pass", "fail"].includes(review[axis].verdict)
|
|
76
|
+
|| !Array.isArray(review[axis].findings)
|
|
77
|
+
|| !review[axis].findings.every((finding) => typeof finding === "string" && finding.trim())) {
|
|
78
|
+
throw new Error(`review output is missing the ${axis} axis`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!review.runtime || review.runtime.terminal !== "completed" || !review.runtime.summary) {
|
|
82
|
+
throw new Error("Candidate review output Runtime terminal is not completed");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function assertCandidateReview(candidate, target) {
|
|
87
|
+
assertExactKeys(candidate, ["repository", "pullRequestNumber", "baseSha", "headSha", "standards", "spec"]);
|
|
88
|
+
assertReviewResult({
|
|
89
|
+
...candidate,
|
|
90
|
+
runtime: { terminal: "completed", summary: "validated" },
|
|
91
|
+
}, target);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function assertTrustedTarget(candidate, target) {
|
|
95
|
+
if (
|
|
96
|
+
candidate.repository !== target.repository
|
|
97
|
+
|| candidate.pullRequestNumber !== target.number
|
|
98
|
+
|| candidate.baseSha !== target.baseSha
|
|
99
|
+
|| candidate.headSha !== target.headSha
|
|
100
|
+
) throw new Error("target does not match trusted GitHub facts");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function assertExactKeys(value, expected) {
|
|
104
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected an object");
|
|
105
|
+
const actual = Object.keys(value).sort();
|
|
106
|
+
const wanted = [...expected].sort();
|
|
107
|
+
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
|
|
108
|
+
throw new Error(`unexpected fields: expected ${wanted.join(", ")}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function buildCheck(review, target, settings = { name: CHECK_NAME, title: "PR Review" }) {
|
|
113
|
+
assertReviewResult(review, target);
|
|
114
|
+
return {
|
|
115
|
+
name: settings.name,
|
|
116
|
+
title: settings.title,
|
|
117
|
+
repository: target.repository,
|
|
118
|
+
pullRequestNumber: target.number,
|
|
119
|
+
headSha: target.headSha,
|
|
120
|
+
conclusion: review.standards.verdict === "pass" && review.spec.verdict === "pass" ? "success" : "failure",
|
|
121
|
+
standards: review.standards,
|
|
122
|
+
spec: review.spec,
|
|
123
|
+
runtime: review.runtime,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"definition": "github-pr-review/current",
|
|
3
|
+
"profile": "github-pr-review/codex/current",
|
|
4
|
+
"events": {
|
|
5
|
+
"pullRequestActions": ["opened", "reopened", "synchronize", "ready_for_review"],
|
|
6
|
+
"manualDispatch": true,
|
|
7
|
+
"includeDrafts": false
|
|
8
|
+
},
|
|
9
|
+
"eventPrompt": "Review this change against the repository rules and its requested behavior.",
|
|
10
|
+
"codex": {
|
|
11
|
+
"model": "",
|
|
12
|
+
"effort": "",
|
|
13
|
+
"safetyStrategy": "read-only"
|
|
14
|
+
},
|
|
15
|
+
"check": {
|
|
16
|
+
"name": "Graph Engineering / PR Review",
|
|
17
|
+
"title": "PR Review"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { githubRequest } from "./github-api.mjs";
|
|
4
|
+
import { formGoalPrompt } from "./pr-review-case.mjs";
|
|
5
|
+
|
|
6
|
+
const repository = required("GITHUB_REPOSITORY");
|
|
7
|
+
const pullRequestNumber = Number.parseInt(required("PULL_REQUEST_NUMBER"), 10);
|
|
8
|
+
if (!Number.isInteger(pullRequestNumber) || pullRequestNumber < 1) throw new Error("PULL_REQUEST_NUMBER must be positive");
|
|
9
|
+
|
|
10
|
+
const pull = await githubRequest(`/repos/${repository}/pulls/${pullRequestNumber}`);
|
|
11
|
+
if (pull.head?.repo?.full_name !== repository) throw new Error("Only same-repository pull requests are supported");
|
|
12
|
+
const checks = await githubRequest(`/repos/${repository}/commits/${pull.head.sha}/check-runs`);
|
|
13
|
+
const target = {
|
|
14
|
+
repository,
|
|
15
|
+
number: pullRequestNumber,
|
|
16
|
+
baseSha: pull.base.sha,
|
|
17
|
+
headSha: pull.head.sha,
|
|
18
|
+
baseRef: pull.base.ref,
|
|
19
|
+
headRef: pull.head.ref,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
await writeFile("pr-review-target.json", `${JSON.stringify(target, null, 2)}\n`);
|
|
23
|
+
await writeFile("pr-review-context.md", [
|
|
24
|
+
`# PR #${pullRequestNumber}: ${pull.title}`,
|
|
25
|
+
"",
|
|
26
|
+
pull.body || "No pull request description was provided.",
|
|
27
|
+
"",
|
|
28
|
+
`Base branch: ${target.baseRef}`,
|
|
29
|
+
`Head branch: ${target.headRef}`,
|
|
30
|
+
`Existing Checks on head: ${(checks.check_runs ?? []).map((check) => `${check.name}=${check.conclusion ?? check.status}`).join(", ") || "none"}`,
|
|
31
|
+
"",
|
|
32
|
+
].join("\n"));
|
|
33
|
+
await writeFile("pr-review-goal.md", `${formGoalPrompt({
|
|
34
|
+
eventPrompt: required("EVENT_PROMPT"),
|
|
35
|
+
pullRequest: target,
|
|
36
|
+
})}\n`);
|
|
37
|
+
if (process.env.GITHUB_OUTPUT) {
|
|
38
|
+
await writeFile(process.env.GITHUB_OUTPUT, [
|
|
39
|
+
`base_sha=${target.baseSha}`,
|
|
40
|
+
`head_sha=${target.headSha}`,
|
|
41
|
+
`target_json=${JSON.stringify(target)}`,
|
|
42
|
+
"",
|
|
43
|
+
].join("\n"), { flag: "a" });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function required(name) {
|
|
47
|
+
const value = process.env[name]?.trim();
|
|
48
|
+
if (!value) throw new Error(`${name} is required`);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { githubRequest } from "./github-api.mjs";
|
|
4
|
+
import { loadBundledCompatibility } from "./codex-compatible-executor.mjs";
|
|
5
|
+
import { planCheckPublication } from "./check-publication.mjs";
|
|
6
|
+
import { buildCheck } from "./pr-review-case.mjs";
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
await publish();
|
|
10
|
+
} catch (error) {
|
|
11
|
+
await writeDiagnostic(error.message);
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function publish() {
|
|
16
|
+
const compatible = await loadBundledCompatibility();
|
|
17
|
+
const upstreamResult = process.env.UPSTREAM_RESULT ?? "success";
|
|
18
|
+
if (upstreamResult !== "success") {
|
|
19
|
+
const execution = await readOptionalJson("pr-review-artifact/review-execution.json");
|
|
20
|
+
throw new Error(execution?.diagnostic ?? `upstream-review: review job ended with ${upstreamResult}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const target = JSON.parse(await readFile("pr-review-artifact/pr-review-target.json", "utf8"));
|
|
24
|
+
const review = JSON.parse(await readFile("pr-review-artifact/review-result.json", "utf8"));
|
|
25
|
+
const check = buildCheck(review, target, compatible.check);
|
|
26
|
+
|
|
27
|
+
const current = await githubRequest(`/repos/${target.repository}/pulls/${target.number}`);
|
|
28
|
+
if (current.head.sha !== target.headSha) throw new Error("stale-target: PR head changed before publication");
|
|
29
|
+
const checks = await readCheckHistory(target, check.name);
|
|
30
|
+
const publication = planCheckPublication({ target, existingChecks: checks, checkName: check.name });
|
|
31
|
+
|
|
32
|
+
// History queries can take multiple requests. Recheck freshness before writing.
|
|
33
|
+
const beforeWrite = await githubRequest(`/repos/${target.repository}/pulls/${target.number}`);
|
|
34
|
+
if (beforeWrite.head.sha !== target.headSha) throw new Error("stale-target: PR head changed before publication");
|
|
35
|
+
|
|
36
|
+
// Nonsecret audit signal: never print tokens, headers or Agent contents.
|
|
37
|
+
console.log(JSON.stringify({
|
|
38
|
+
publicationMethod: publication.method,
|
|
39
|
+
externalId: publication.externalId,
|
|
40
|
+
existingCheckId: publication.method === "PATCH" ? Number(publication.path.split("/").at(-1)) : null,
|
|
41
|
+
historyCount: checks.length,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const summary = [
|
|
45
|
+
`Standards: ${review.standards.verdict}`,
|
|
46
|
+
...review.standards.findings.map((finding) => `- ${finding}`),
|
|
47
|
+
`Spec: ${review.spec.verdict}`,
|
|
48
|
+
...review.spec.findings.map((finding) => `- ${finding}`),
|
|
49
|
+
`Runtime: ${review.runtime.summary}`,
|
|
50
|
+
].join("\n");
|
|
51
|
+
const body = {
|
|
52
|
+
name: check.name,
|
|
53
|
+
external_id: publication.externalId,
|
|
54
|
+
status: "completed",
|
|
55
|
+
conclusion: check.conclusion,
|
|
56
|
+
output: { title: check.title, summary },
|
|
57
|
+
};
|
|
58
|
+
if (publication.method === "POST") body.head_sha = check.headSha;
|
|
59
|
+
|
|
60
|
+
await githubRequest(publication.path, { method: publication.method, body });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readCheckHistory(target, name) {
|
|
64
|
+
const history = [];
|
|
65
|
+
const ids = new Set();
|
|
66
|
+
for (let page = 1; page <= 100; page += 1) {
|
|
67
|
+
const response = await githubRequest(
|
|
68
|
+
`/repos/${target.repository}/commits/${target.headSha}/check-runs?check_name=${encodeURIComponent(name)}&filter=all&per_page=100&page=${page}`,
|
|
69
|
+
);
|
|
70
|
+
if (!Array.isArray(response.check_runs) || response.check_runs.length > 100) {
|
|
71
|
+
throw new Error("check-history: malformed Check listing");
|
|
72
|
+
}
|
|
73
|
+
for (const check of response.check_runs) {
|
|
74
|
+
if (!Number.isSafeInteger(check?.id) || check.id <= 0 || ids.has(check.id)) {
|
|
75
|
+
throw new Error("check-history: invalid or repeated Check ID across pages");
|
|
76
|
+
}
|
|
77
|
+
if (typeof check.name !== "string" || !check.name
|
|
78
|
+
|| check.head_sha !== target.headSha
|
|
79
|
+
|| !(check.external_id === null || typeof check.external_id === "string")) {
|
|
80
|
+
throw new Error("check-history: incomplete or mismatched Check identity fields");
|
|
81
|
+
}
|
|
82
|
+
ids.add(check.id);
|
|
83
|
+
history.push(check);
|
|
84
|
+
}
|
|
85
|
+
if (response.check_runs.length < 100) return history;
|
|
86
|
+
}
|
|
87
|
+
// Never interpret incomplete history as absence or create a new Check.
|
|
88
|
+
throw new Error("check-history: pagination limit reached before complete listing");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function readOptionalJson(path) {
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function writeDiagnostic(message) {
|
|
100
|
+
const text = `## PR Review diagnostic\n\n${message}\n\nNo Check Run was published.\n`;
|
|
101
|
+
await writeFile("review-diagnostic.md", text);
|
|
102
|
+
if (process.env.GITHUB_STEP_SUMMARY) await writeFile(process.env.GITHUB_STEP_SUMMARY, text, { flag: "a" });
|
|
103
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"additionalProperties": false,
|
|
5
|
+
"required": [
|
|
6
|
+
"repository",
|
|
7
|
+
"pullRequestNumber",
|
|
8
|
+
"baseSha",
|
|
9
|
+
"headSha",
|
|
10
|
+
"standards",
|
|
11
|
+
"spec",
|
|
12
|
+
"handoff"
|
|
13
|
+
],
|
|
14
|
+
"properties": {
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"minLength": 1
|
|
18
|
+
},
|
|
19
|
+
"pullRequestNumber": {
|
|
20
|
+
"type": "integer",
|
|
21
|
+
"minimum": 1
|
|
22
|
+
},
|
|
23
|
+
"baseSha": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"minLength": 1
|
|
26
|
+
},
|
|
27
|
+
"headSha": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"minLength": 1
|
|
30
|
+
},
|
|
31
|
+
"standards": {
|
|
32
|
+
"anyOf": [
|
|
33
|
+
{
|
|
34
|
+
"$ref": "#/$defs/axis"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"type": "null"
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"spec": {
|
|
42
|
+
"anyOf": [
|
|
43
|
+
{
|
|
44
|
+
"$ref": "#/$defs/axis"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"type": "null"
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"handoff": {
|
|
52
|
+
"type": [
|
|
53
|
+
"object",
|
|
54
|
+
"null"
|
|
55
|
+
],
|
|
56
|
+
"additionalProperties": false,
|
|
57
|
+
"required": [
|
|
58
|
+
"summary"
|
|
59
|
+
],
|
|
60
|
+
"properties": {
|
|
61
|
+
"summary": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"minLength": 1
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"$defs": {
|
|
69
|
+
"axis": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"additionalProperties": false,
|
|
72
|
+
"required": [
|
|
73
|
+
"verdict",
|
|
74
|
+
"findings"
|
|
75
|
+
],
|
|
76
|
+
"properties": {
|
|
77
|
+
"verdict": {
|
|
78
|
+
"enum": [
|
|
79
|
+
"pass",
|
|
80
|
+
"fail"
|
|
81
|
+
]
|
|
82
|
+
},
|
|
83
|
+
"findings": {
|
|
84
|
+
"type": "array",
|
|
85
|
+
"items": {
|
|
86
|
+
"type": "string",
|
|
87
|
+
"minLength": 1
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { readBundledContracts, resolveCompatibleExecution } from "./codex-compatible-executor.mjs";
|
|
6
|
+
import { githubRequest } from "./github-api.mjs";
|
|
7
|
+
import { SUPPORTED_ACTIONS } from "./pr-review-contract.mjs";
|
|
8
|
+
|
|
9
|
+
export { SUPPORTED_ACTIONS } from "./pr-review-contract.mjs";
|
|
10
|
+
|
|
11
|
+
// This deliberately repeats the Workflow trigger as a fail-closed runtime check.
|
|
12
|
+
// The template contract test requires exact parity between both boundaries.
|
|
13
|
+
export function routeReviewEvent({
|
|
14
|
+
eventName,
|
|
15
|
+
event,
|
|
16
|
+
eventConfiguration = { pullRequestActions: [...SUPPORTED_ACTIONS], manualDispatch: true, includeDrafts: false },
|
|
17
|
+
}) {
|
|
18
|
+
if (eventName === "workflow_dispatch") {
|
|
19
|
+
if (!eventConfiguration.manualDispatch) return { shouldStart: false, pullRequestNumber: null, reason: "manual-dispatch-disabled" };
|
|
20
|
+
const pullRequestNumber = Number.parseInt(event.inputs?.pull_request_number, 10);
|
|
21
|
+
if (!Number.isInteger(pullRequestNumber) || pullRequestNumber < 1) {
|
|
22
|
+
return { shouldStart: false, pullRequestNumber: null, reason: "invalid-manual-input" };
|
|
23
|
+
}
|
|
24
|
+
return { shouldStart: true, pullRequestNumber, reason: "manual-dispatch" };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (eventName !== "pull_request" || !SUPPORTED_ACTIONS.has(event.action) || !eventConfiguration.pullRequestActions.includes(event.action)) {
|
|
28
|
+
return { shouldStart: false, pullRequestNumber: null, reason: "unsupported-event" };
|
|
29
|
+
}
|
|
30
|
+
const pullRequestNumber = event.pull_request.number;
|
|
31
|
+
if (event.pull_request.draft && !eventConfiguration.includeDrafts) {
|
|
32
|
+
return { shouldStart: false, pullRequestNumber, reason: "draft-pull-request" };
|
|
33
|
+
}
|
|
34
|
+
return { shouldStart: true, pullRequestNumber, reason: "supported-pull-request-event" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function planReviewRun({ eventName, event, config, profile }) {
|
|
38
|
+
try {
|
|
39
|
+
const compatible = resolveCompatibleExecution({ config, profile });
|
|
40
|
+
return {
|
|
41
|
+
configurationStatus: "compatible",
|
|
42
|
+
...routeReviewEvent({ eventName, event, eventConfiguration: compatible.events }),
|
|
43
|
+
compatible,
|
|
44
|
+
};
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return {
|
|
47
|
+
configurationStatus: "handoff",
|
|
48
|
+
shouldStart: false,
|
|
49
|
+
pullRequestNumber: null,
|
|
50
|
+
reason: "configuration-handoff",
|
|
51
|
+
diagnostic: error.message,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function enforceManualPullRequestState(planned, { repository, request = githubRequest } = {}) {
|
|
57
|
+
if (!planned.shouldStart || planned.reason !== "manual-dispatch") return planned;
|
|
58
|
+
try {
|
|
59
|
+
const pullRequest = await request(`/repos/${repository}/pulls/${planned.pullRequestNumber}`);
|
|
60
|
+
if (pullRequest.state !== "open") return { ...planned, shouldStart: false, reason: "manual-pull-request-not-open" };
|
|
61
|
+
if (pullRequest.draft && !planned.compatible.events.includeDrafts) return { ...planned, shouldStart: false, reason: "draft-pull-request" };
|
|
62
|
+
return planned;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return { ...planned, shouldStart: false, reason: "manual-pull-request-unreadable", diagnostic: error.message };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function main() {
|
|
69
|
+
const event = JSON.parse(await readFile(required("GITHUB_EVENT_PATH"), "utf8"));
|
|
70
|
+
let contracts;
|
|
71
|
+
try {
|
|
72
|
+
contracts = await readBundledContracts();
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const diagnostic = `configuration-handoff: ${error.message}`;
|
|
75
|
+
await writeOutputs({ configurationStatus: "handoff", shouldStart: false, pullRequestNumber: null, reason: "configuration-handoff", diagnostic });
|
|
76
|
+
if (process.env.GITHUB_STEP_SUMMARY) await writeFile(process.env.GITHUB_STEP_SUMMARY, `## PR Review handoff\n\n${diagnostic}\n`, { flag: "a" });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const eventName = required("GITHUB_EVENT_NAME");
|
|
80
|
+
let planned = planReviewRun({ eventName, event, ...contracts });
|
|
81
|
+
if (planned.configurationStatus === "handoff") {
|
|
82
|
+
await writeOutputs(planned);
|
|
83
|
+
if (process.env.GITHUB_STEP_SUMMARY) await writeFile(process.env.GITHUB_STEP_SUMMARY, `## PR Review handoff\n\n${planned.diagnostic}\n`, { flag: "a" });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (eventName === "workflow_dispatch") planned = await enforceManualPullRequestState(planned, { repository: required("GITHUB_REPOSITORY") });
|
|
87
|
+
const manualPrompt = eventName === "workflow_dispatch" ? event.inputs?.event_prompt?.trim() : "";
|
|
88
|
+
await writeOutputs({
|
|
89
|
+
shouldStart: planned.shouldStart,
|
|
90
|
+
pullRequestNumber: planned.pullRequestNumber,
|
|
91
|
+
reason: planned.reason,
|
|
92
|
+
eventPrompt: manualPrompt || planned.compatible.eventPrompt,
|
|
93
|
+
codexModel: planned.compatible.action.model,
|
|
94
|
+
codexEffort: planned.compatible.action.effort,
|
|
95
|
+
safetyStrategy: planned.compatible.action.safetyStrategy,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function writeOutputs(values) {
|
|
100
|
+
if (process.env.GITHUB_OUTPUT) {
|
|
101
|
+
const lines = [];
|
|
102
|
+
for (const [key, value] of Object.entries(values)) {
|
|
103
|
+
const outputKey = key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
104
|
+
const delimiter = `loop_${randomUUID()}`;
|
|
105
|
+
lines.push(`${outputKey}<<${delimiter}`, String(value ?? ""), delimiter);
|
|
106
|
+
}
|
|
107
|
+
await writeFile(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`, { flag: "a" });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function required(name) {
|
|
112
|
+
const value = process.env[name]?.trim();
|
|
113
|
+
if (!value) throw new Error(`${name} is required`);
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
|