@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -0
  3. package/bin/graph-engineering.mjs +60 -0
  4. package/lib/check.mjs +102 -0
  5. package/lib/constants.mjs +17 -0
  6. package/lib/files.mjs +83 -0
  7. package/lib/init.mjs +29 -0
  8. package/lib/manifest.mjs +31 -0
  9. package/lib/migrate.mjs +101 -0
  10. package/lib/package-assets.mjs +39 -0
  11. package/migrations/loop-engineering-927bd961/development/.github/loop-engineering/development-worktree.sh +32 -0
  12. package/migrations/loop-engineering-927bd961/development/.github/loop-engineering/github-development-ticket.mjs +246 -0
  13. package/migrations/loop-engineering-927bd961/development/.github/loop-engineering/stop-hook-drain.mjs +20 -0
  14. package/migrations/loop-engineering-927bd961/development/.github/loop-engineering/thread-record.mjs +36 -0
  15. package/migrations/loop-engineering-927bd961/development/.github/workflows/github-development-ticket.yml +124 -0
  16. package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/capture-review.mjs +16 -0
  17. package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/check-publication.mjs +23 -0
  18. package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/codex-compatibility-profile.json +67 -0
  19. package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/codex-compatible-executor.mjs +130 -0
  20. package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/github-api.mjs +20 -0
  21. package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/pr-review-case.mjs +125 -0
  22. package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/pr-review-config.json +19 -0
  23. package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/pr-review-contract.mjs +2 -0
  24. package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/prepare-review.mjs +50 -0
  25. package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/publish-review.mjs +103 -0
  26. package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/review-result.schema.json +93 -0
  27. package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/route-review.mjs +117 -0
  28. package/migrations/loop-engineering-927bd961/pr-review/.github/workflows/github-pr-review.yml +138 -0
  29. package/package.json +38 -0
  30. package/release-metadata.json +4 -0
  31. package/templates/development/.github/graph-engineering/development-worktree.sh +47 -0
  32. package/templates/development/.github/graph-engineering/github-development-ticket.mjs +246 -0
  33. package/templates/development/.github/graph-engineering/stop-hook-drain.mjs +20 -0
  34. package/templates/development/.github/graph-engineering/thread-record.mjs +39 -0
  35. package/templates/development/.github/workflows/github-development-ticket.yml +124 -0
  36. package/templates/pr-review/.github/graph-engineering/capture-review.mjs +16 -0
  37. package/templates/pr-review/.github/graph-engineering/check-publication.mjs +23 -0
  38. package/templates/pr-review/.github/graph-engineering/codex-compatibility-profile.json +67 -0
  39. package/templates/pr-review/.github/graph-engineering/codex-compatible-executor.mjs +130 -0
  40. package/templates/pr-review/.github/graph-engineering/github-api.mjs +20 -0
  41. package/templates/pr-review/.github/graph-engineering/pr-review-case.mjs +125 -0
  42. package/templates/pr-review/.github/graph-engineering/pr-review-config.json +19 -0
  43. package/templates/pr-review/.github/graph-engineering/pr-review-contract.mjs +2 -0
  44. package/templates/pr-review/.github/graph-engineering/prepare-review.mjs +50 -0
  45. package/templates/pr-review/.github/graph-engineering/publish-review.mjs +103 -0
  46. package/templates/pr-review/.github/graph-engineering/review-result.schema.json +93 -0
  47. package/templates/pr-review/.github/graph-engineering/route-review.mjs +117 -0
  48. package/templates/pr-review/.github/workflows/github-pr-review.yml +138 -0
@@ -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": "Loop Engineering / PR Review",
17
+ "title": "PR Review"
18
+ }
19
+ }
@@ -0,0 +1,2 @@
1
+ export const SUPPORTED_ACTION_NAMES = ["opened", "reopened", "synchronize", "ready_for_review"];
2
+ export const SUPPORTED_ACTIONS = new Set(SUPPORTED_ACTION_NAMES);
@@ -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();
@@ -0,0 +1,138 @@
1
+ name: Loop Engineering PR Review
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, reopened, synchronize, ready_for_review]
6
+ workflow_dispatch:
7
+ inputs:
8
+ pull_request_number:
9
+ description: Pull request number to review
10
+ required: true
11
+ type: number
12
+ event_prompt:
13
+ description: Review objective placed at the start of the Goal Prompt
14
+ required: false
15
+ default: Review this change against the repository rules and its requested behavior.
16
+ type: string
17
+
18
+ concurrency:
19
+ group: ${{ github.repository }}:pull-request:${{ github.event.pull_request.number || inputs.pull_request_number }}
20
+ cancel-in-progress: false
21
+
22
+ jobs:
23
+ route:
24
+ runs-on: ubuntu-24.04
25
+ permissions:
26
+ contents: read
27
+ pull-requests: read
28
+ outputs:
29
+ should_start: ${{ steps.route.outputs.should_start }}
30
+ pull_request_number: ${{ steps.route.outputs.pull_request_number }}
31
+ event_prompt: ${{ steps.route.outputs.event_prompt }}
32
+ codex_model: ${{ steps.route.outputs.codex_model }}
33
+ codex_effort: ${{ steps.route.outputs.codex_effort }}
34
+ safety_strategy: ${{ steps.route.outputs.safety_strategy }}
35
+ steps:
36
+ - name: Check out the trusted router
37
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
38
+ with:
39
+ ref: ${{ github.event.repository.default_branch }}
40
+ persist-credentials: false
41
+ - name: Route the GitHub Event
42
+ id: route
43
+ env:
44
+ GH_TOKEN: ${{ github.token }}
45
+ run: node .github/loop-engineering/route-review.mjs
46
+
47
+ review:
48
+ needs: route
49
+ if: >-
50
+ needs.route.outputs.should_start == 'true' &&
51
+ (github.event_name != 'workflow_dispatch' ||
52
+ github.ref == format('refs/heads/{0}', github.event.repository.default_branch))
53
+ runs-on: [self-hosted, Linux, X64, codex]
54
+ permissions:
55
+ contents: read
56
+ pull-requests: read
57
+ checks: read
58
+ steps:
59
+ - name: Check out the trusted review control plane
60
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
61
+ with:
62
+ ref: ${{ github.event.repository.default_branch }}
63
+ path: .loop-engineering-trusted
64
+ persist-credentials: false
65
+ - name: Read trusted PR facts and form the Goal Prompt
66
+ id: target
67
+ env:
68
+ GH_TOKEN: ${{ github.token }}
69
+ PULL_REQUEST_NUMBER: ${{ needs.route.outputs.pull_request_number }}
70
+ EVENT_PROMPT: ${{ needs.route.outputs.event_prompt }}
71
+ run: node .loop-engineering-trusted/.github/loop-engineering/prepare-review.mjs
72
+ - name: Check out the trusted PR target
73
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
74
+ with:
75
+ ref: ${{ steps.target.outputs.head_sha }}
76
+ path: review-workspace
77
+ fetch-depth: 0
78
+ persist-credentials: false
79
+ - name: Run the Codex Compatible Executor
80
+ id: codex
81
+ continue-on-error: true
82
+ uses: tutar/codex-action@f33581290086e62dc34d420a7f1862477fc2b503 # maintained runner-login patch
83
+ with:
84
+ prompt-file: ${{ github.workspace }}/pr-review-goal.md
85
+ output-schema-file: ${{ github.workspace }}/.loop-engineering-trusted/.github/loop-engineering/review-result.schema.json
86
+ working-directory: ${{ github.workspace }}/review-workspace
87
+ codex-version: 0.153.4
88
+ model: ${{ needs.route.outputs.codex_model }}
89
+ effort: ${{ needs.route.outputs.codex_effort }}
90
+ safety-strategy: ${{ needs.route.outputs.safety_strategy }}
91
+ - name: Map the Action terminal and final-message
92
+ if: always()
93
+ env:
94
+ ACTION_OUTCOME: ${{ steps.codex.outcome }}
95
+ FINAL_MESSAGE: ${{ steps.codex.outputs.final-message }}
96
+ TRUSTED_TARGET: ${{ steps.target.outputs.target_json }}
97
+ run: node .loop-engineering-trusted/.github/loop-engineering/capture-review.mjs
98
+ - name: Preserve the trusted target and candidate Review Result
99
+ if: always()
100
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
101
+ with:
102
+ name: pr-review-result
103
+ path: |
104
+ pr-review-target.json
105
+ pr-review-context.md
106
+ review-execution.json
107
+ review-result.json
108
+ if-no-files-found: error
109
+
110
+ publish:
111
+ needs: [route, review]
112
+ if: >-
113
+ always() &&
114
+ needs.route.result == 'success' &&
115
+ needs.route.outputs.should_start == 'true'
116
+ runs-on: ubuntu-24.04
117
+ permissions:
118
+ contents: read
119
+ pull-requests: read
120
+ checks: write
121
+ steps:
122
+ - name: Check out the trusted publisher
123
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
124
+ with:
125
+ ref: ${{ github.event.repository.default_branch }}
126
+ path: .loop-engineering-publisher
127
+ persist-credentials: false
128
+ - name: Download the candidate Review Result
129
+ continue-on-error: true
130
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
131
+ with:
132
+ name: pr-review-result
133
+ path: pr-review-artifact
134
+ - name: Validate and publish the Check Run
135
+ env:
136
+ GH_TOKEN: ${{ github.token }}
137
+ UPSTREAM_RESULT: ${{ needs.review.result }}
138
+ run: node .loop-engineering-publisher/.github/loop-engineering/publish-review.mjs