@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
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@tutar/graph-engineering",
3
+ "version": "0.3.0",
4
+ "description": "Install and inspect project-owned Graph Engineering workflows for GitHub software development.",
5
+ "type": "module",
6
+ "bin": {
7
+ "graph-engineering": "bin/graph-engineering.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/",
12
+ "templates/",
13
+ "migrations/",
14
+ "release-metadata.json",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "prepack": "node scripts/sync-package-assets.mjs",
19
+ "test": "node --test test/*.test.mjs"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "provenance": true
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/tutar/graph-engineering.git",
31
+ "directory": "packages/cli"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/tutar/graph-engineering/issues"
35
+ },
36
+ "homepage": "https://github.com/tutar/graph-engineering#readme",
37
+ "license": "MIT"
38
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "productVersion": "0.3.0",
3
+ "sourceCommit": "049fdb6a98ab9867572637a5728f91f4683124ce"
4
+ }
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ interrupted_ref="refs/graph-engineering/interrupted/${TARGET_BRANCH}"
5
+ interrupted_stash_ref="refs/graph-engineering/interrupted-stash/${TARGET_BRANCH}"
6
+ legacy_interrupted_ref="refs/loop-engineering/interrupted/${TARGET_BRANCH}"
7
+ legacy_interrupted_stash_ref="refs/loop-engineering/interrupted-stash/${TARGET_BRANCH}"
8
+
9
+ migrate_legacy_ref() {
10
+ local current="$1"
11
+ local legacy="$2"
12
+ if ! git show-ref --verify --quiet "${current}" && git show-ref --verify --quiet "${legacy}"; then
13
+ git update-ref "${current}" "${legacy}"
14
+ git update-ref -d "${legacy}"
15
+ fi
16
+ }
17
+
18
+ case "${1:-}" in
19
+ prepare)
20
+ migrate_legacy_ref "${interrupted_ref}" "${legacy_interrupted_ref}"
21
+ migrate_legacy_ref "${interrupted_stash_ref}" "${legacy_interrupted_stash_ref}"
22
+ if git show-ref --verify --quiet "${interrupted_ref}"; then
23
+ git switch --force-create "${TARGET_BRANCH}" "${interrupted_ref}"
24
+ git rebase "${BASE_BRANCH}"
25
+ elif git ls-remote --exit-code origin "refs/heads/${TARGET_BRANCH}" >/dev/null 2>&1; then
26
+ git fetch origin "${TARGET_BRANCH}"
27
+ git switch --create "${TARGET_BRANCH}" --track "origin/${TARGET_BRANCH}"
28
+ else
29
+ git switch --create "${TARGET_BRANCH}"
30
+ fi
31
+ ;;
32
+ restore)
33
+ migrate_legacy_ref "${interrupted_ref}" "${legacy_interrupted_ref}"
34
+ migrate_legacy_ref "${interrupted_stash_ref}" "${legacy_interrupted_stash_ref}"
35
+ if git show-ref --verify --quiet "${interrupted_stash_ref}"; then
36
+ git stash apply --index "${interrupted_stash_ref}"
37
+ git update-ref -d "${interrupted_stash_ref}"
38
+ fi
39
+ if git show-ref --verify --quiet "${interrupted_ref}"; then
40
+ git update-ref -d "${interrupted_ref}"
41
+ fi
42
+ ;;
43
+ *)
44
+ echo "usage: development-worktree.sh prepare|restore" >&2
45
+ exit 2
46
+ ;;
47
+ esac
@@ -0,0 +1,246 @@
1
+ import { spawn, execFileSync } from "node:child_process";
2
+ import { accessSync, constants, realpathSync } from "node:fs";
3
+ import { delimiter, join } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import {
6
+ STOP_HOOK_WAIT_TIMEOUT_MS,
7
+ expireStopHookTimeouts,
8
+ shouldAwaitStopHooks,
9
+ } from "./stop-hook-drain.mjs";
10
+ import { findThreadRecord, formatThreadRecord } from "./thread-record.mjs";
11
+
12
+ const repository = requiredEnv("GITHUB_REPOSITORY");
13
+ const issueNumber = Number(requiredEnv("ISSUE_NUMBER"));
14
+ const baseBranch = requiredEnv("BASE_BRANCH");
15
+ const tokenBudgetInput = process.env.GOAL_TOKEN_BUDGET ?? "500000";
16
+ const tokenBudget = tokenBudgetInput === "unlimited" ? null : Number(tokenBudgetInput);
17
+ if (!Number.isSafeInteger(issueNumber) || issueNumber <= 0) throw new Error("ISSUE_NUMBER must be a positive integer");
18
+ if (tokenBudget !== null && (!Number.isSafeInteger(tokenBudget) || tokenBudget <= 0)) {
19
+ throw new Error('GOAL_TOKEN_BUDGET must be a positive integer or "unlimited"');
20
+ }
21
+
22
+ const issueUrl = `https://github.com/${repository}/issues/${issueNumber}`;
23
+ const goalPrompt = `使用 Matt Skill $implement 完成 ${issueUrl};若 Issue 含 Acceptance Criteria,逐项依据实际验证结果,只将已满足项的复选框更新为已勾选;完成后将提交 push 到当前开发分支;如果不存在关联 PR,创建指向 ${baseBranch} 的 Draft PR。只有实现、自测、review、commit、push、Draft PR,以及适用时的验收条件状态同步都完成后才能结束 Goal。不得执行或输出认证状态、token、credential 或其他秘密。`;
24
+
25
+ function ghJson(args) {
26
+ return JSON.parse(execFileSync("gh", args, { encoding: "utf8", env: process.env }));
27
+ }
28
+
29
+ function getThreadRecord() {
30
+ const pages = ghJson(["api", `repos/${repository}/issues/${issueNumber}/comments`, "--paginate", "--slurp"]);
31
+ return findThreadRecord(pages.flat(), { repository, issueNumber });
32
+ }
33
+
34
+ function saveThreadRecord(threadId) {
35
+ execFileSync(
36
+ "gh",
37
+ ["api", "--method", "POST", `repos/${repository}/issues/${issueNumber}/comments`, "-f", `body=${formatThreadRecord({ repository, issueNumber, threadId })}`],
38
+ { stdio: "inherit", env: process.env },
39
+ );
40
+ }
41
+
42
+ function resolveExecutable(name) {
43
+ for (const directory of (process.env.PATH ?? "").split(delimiter)) {
44
+ if (!directory) continue;
45
+ const candidate = join(directory, name);
46
+ try {
47
+ accessSync(candidate, constants.X_OK);
48
+ return realpathSync(candidate);
49
+ } catch {}
50
+ }
51
+ throw new Error(`Unable to resolve ${name} from PATH`);
52
+ }
53
+
54
+ function requiredEnv(name) {
55
+ const value = process.env[name];
56
+ if (!value) throw new Error(`${name} is required`);
57
+ return value;
58
+ }
59
+
60
+ const codexExecutable = resolveExecutable("codex");
61
+ const ghExecutable = resolveExecutable("gh");
62
+ const nodeExecutable = resolveExecutable("node");
63
+ const homebrewLib = ghExecutable.includes("/Cellar/") ? `${ghExecutable.split("/Cellar/")[0]}/lib` : null;
64
+ const homebrewLibPermission = homebrewLib ? `,${JSON.stringify(homebrewLib)}="read"` : "";
65
+ const filesystemPermissions = `permissions.github-contributor.filesystem={glob_scan_max_depth=3,":minimal"="read",${JSON.stringify(codexExecutable)}="read",${JSON.stringify(ghExecutable)}="read",${JSON.stringify(nodeExecutable)}="read"${homebrewLibPermission},":workspace_roots"={"."="write",".git"="write",".codex"="read","**/*.env"="deny"}}`;
66
+ const gitContributorConfig = [
67
+ "-c", 'default_permissions="github-contributor"',
68
+ "-c", "features.network_proxy=true",
69
+ "-c", 'permissions.github-contributor.description="Write the current checkout including Git metadata and reach GitHub and Agent Skills"',
70
+ "-c", filesystemPermissions,
71
+ "-c", 'permissions.github-contributor.network={enabled=true,allow_upstream_proxy=true,allow_local_binding=true,domains={"github.com"="allow","api.github.com"="allow","uploads.github.com"="allow","objects.githubusercontent.com"="allow","raw.githubusercontent.com"="allow","agentskills.io"="allow"}}',
72
+ ];
73
+
74
+ const server = spawn("codex", ["app-server", ...gitContributorConfig, "--strict-config", "--listen", "stdio://"], {
75
+ cwd: process.cwd(),
76
+ env: process.env,
77
+ stdio: ["pipe", "pipe", "pipe"],
78
+ });
79
+ server.stderr.on("data", (chunk) => process.stderr.write(chunk));
80
+
81
+ let nextId = 1;
82
+ const pending = new Map();
83
+ const activeTurns = new Map();
84
+ const completedStopHooks = new Map();
85
+ const awaitingStopHooks = new Map();
86
+ let stopHookCount = 0;
87
+ let cancellationSignal = null;
88
+ let forcedCancellation = false;
89
+ let threadId = null;
90
+ let goal = null;
91
+
92
+ function send(message) {
93
+ server.stdin.write(`${JSON.stringify(message)}\n`);
94
+ }
95
+
96
+ function request(method, params) {
97
+ const id = nextId++;
98
+ send({ method, id, params });
99
+ return new Promise((resolve, reject) => pending.set(id, { method, resolve, reject }));
100
+ }
101
+
102
+ const lines = createInterface({ input: server.stdout });
103
+ lines.on("line", (line) => {
104
+ const message = JSON.parse(line);
105
+ if (message.id !== undefined && pending.has(message.id)) {
106
+ const entry = pending.get(message.id);
107
+ pending.delete(message.id);
108
+ if (message.error) entry.reject(new Error(`${entry.method}: ${JSON.stringify(message.error)}`));
109
+ else entry.resolve(message.result);
110
+ return;
111
+ }
112
+ if (message.method === "thread/goal/updated") {
113
+ goal = message.params.goal;
114
+ console.log(`GOAL status=${goal.status} tokens=${goal.tokensUsed} seconds=${goal.timeUsedSeconds}`);
115
+ } else if (message.method === "turn/started") {
116
+ activeTurns.set(message.params.turn.id, message.params.threadId);
117
+ console.log(`TURN_STARTED ${message.params.turn.id}`);
118
+ } else if (message.method === "turn/completed") {
119
+ activeTurns.delete(message.params.turn.id);
120
+ const completed = completedStopHooks.get(message.params.turn.id)?.size ?? 0;
121
+ if (shouldAwaitStopHooks({
122
+ turnThreadId: message.params.threadId,
123
+ goalThreadId: threadId,
124
+ completedHookCount: completed,
125
+ configuredHookCount: stopHookCount,
126
+ })) {
127
+ awaitingStopHooks.set(message.params.turn.id, Date.now() + STOP_HOOK_WAIT_TIMEOUT_MS);
128
+ }
129
+ console.log(`TURN_COMPLETED ${message.params.turn.id} ${message.params.turn.status}`);
130
+ } else if (message.method === "hook/completed" && message.params.run.eventName === "stop") {
131
+ const turnId = message.params.turnId;
132
+ if (turnId) {
133
+ const completed = completedStopHooks.get(turnId) ?? new Set();
134
+ completed.add(message.params.run.id);
135
+ completedStopHooks.set(turnId, completed);
136
+ if (completed.size >= stopHookCount) awaitingStopHooks.delete(turnId);
137
+ console.log(`STOP_HOOK_COMPLETED ${turnId} ${message.params.run.status}`);
138
+ }
139
+ }
140
+ });
141
+
142
+ function requestCancellation(signal) {
143
+ if (cancellationSignal) {
144
+ forcedCancellation = true;
145
+ server.kill("SIGTERM");
146
+ return;
147
+ }
148
+ cancellationSignal = signal;
149
+ console.log(`CANCEL_REQUESTED ${signal}`);
150
+ for (const [turnId, activeThreadId] of activeTurns) {
151
+ void request("turn/interrupt", { threadId: activeThreadId, turnId }).catch((error) => {
152
+ console.error(`TURN_INTERRUPT_FAILED ${turnId} ${error.message}`);
153
+ });
154
+ }
155
+ }
156
+
157
+ process.on("SIGINT", requestCancellation);
158
+ process.on("SIGTERM", requestCancellation);
159
+
160
+ function delay(ms) {
161
+ return new Promise((resolve) => setTimeout(resolve, ms));
162
+ }
163
+
164
+ async function waitForGoal() {
165
+ const deadline = Date.now() + 110 * 60 * 1000;
166
+ while (Date.now() < deadline) {
167
+ const expiredStopHooks = expireStopHookTimeouts(awaitingStopHooks);
168
+ for (const turnId of expiredStopHooks) console.warn(`STOP_HOOK_TIMEOUT ${turnId}`);
169
+ if (expiredStopHooks.length > 0 && goal?.status === "active" && activeTurns.size === 0) {
170
+ throw new Error("Stop hook timed out while Goal remains active; rerun the workflow to resume");
171
+ }
172
+ if (forcedCancellation) throw new Error(`Forced cancellation after ${cancellationSignal}`);
173
+ if (cancellationSignal && activeTurns.size === 0 && awaitingStopHooks.size === 0) {
174
+ console.log(`CANCEL_DRAINED ${cancellationSignal}`);
175
+ throw new Error(`Cancelled by ${cancellationSignal}`);
176
+ }
177
+ if (goal && goal.status !== "active" && activeTurns.size === 0 && awaitingStopHooks.size === 0) return goal;
178
+ await delay(500);
179
+ }
180
+ throw new Error("Timed out waiting for Goal terminal state");
181
+ }
182
+
183
+ try {
184
+ await request("initialize", {
185
+ clientInfo: { name: "github-development-ticket", version: "0.1.2" },
186
+ capabilities: { experimentalApi: true },
187
+ });
188
+ send({ method: "initialized" });
189
+
190
+ const hookList = await request("hooks/list", { cwds: [process.cwd()] });
191
+ stopHookCount = hookList.data
192
+ .flatMap((entry) => entry.hooks)
193
+ .filter((hook) => hook.enabled && hook.eventName === "stop" && ["managed", "trusted"].includes(hook.trustStatus))
194
+ .length;
195
+ console.log(`STOP_HOOKS_CONFIGURED ${stopHookCount}`);
196
+
197
+ const record = getThreadRecord();
198
+ if (record) {
199
+ threadId = record.threadId;
200
+ await request("thread/resume", {
201
+ threadId,
202
+ cwd: process.cwd(),
203
+ permissions: "github-contributor",
204
+ approvalPolicy: "never",
205
+ });
206
+ const recovered = await request("thread/goal/get", { threadId });
207
+ goal = recovered.goal;
208
+ console.log(`THREAD_RESUMED ${threadId} status=${goal?.status ?? "missing"}`);
209
+ if (goal?.status === "complete") process.exitCode = 0;
210
+ else {
211
+ const resized = await request("thread/goal/set", { threadId, tokenBudget });
212
+ goal = resized.goal;
213
+ console.log(`GOAL_BUDGET_UPDATED ${goal.tokensUsed}/${goal.tokenBudget ?? "unlimited"}`);
214
+ goal = { ...goal, status: "active" };
215
+ const activated = await request("thread/goal/set", { threadId, status: "active" });
216
+ goal = activated.goal;
217
+ }
218
+ } else {
219
+ const started = await request("thread/start", {
220
+ cwd: process.cwd(),
221
+ permissions: "github-contributor",
222
+ approvalPolicy: "never",
223
+ ephemeral: false,
224
+ });
225
+ threadId = started.thread.id;
226
+ await request("thread/goal/set", {
227
+ threadId,
228
+ objective: goalPrompt,
229
+ status: "active",
230
+ tokenBudget,
231
+ });
232
+ saveThreadRecord(threadId);
233
+ console.log(`THREAD_CREATED ${threadId}`);
234
+ }
235
+
236
+ if (goal?.status !== "complete") {
237
+ const result = await waitForGoal();
238
+ console.log(`FINAL_GOAL=${JSON.stringify(result)}`);
239
+ if (result.status !== "complete") throw new Error(`Goal stopped with ${result.status}`);
240
+ } else {
241
+ console.log(`FINAL_GOAL=${JSON.stringify(goal)}`);
242
+ }
243
+ } finally {
244
+ lines.close();
245
+ server.kill("SIGTERM");
246
+ }
@@ -0,0 +1,20 @@
1
+ export const STOP_HOOK_WAIT_TIMEOUT_MS = 60_000;
2
+
3
+ export function shouldAwaitStopHooks({
4
+ turnThreadId,
5
+ goalThreadId,
6
+ completedHookCount,
7
+ configuredHookCount,
8
+ }) {
9
+ return turnThreadId === goalThreadId && completedHookCount < configuredHookCount;
10
+ }
11
+
12
+ export function expireStopHookTimeouts(awaitingStopHooks, now = Date.now()) {
13
+ const expired = [];
14
+ for (const [turnId, deadline] of awaitingStopHooks) {
15
+ if (deadline > now) continue;
16
+ awaitingStopHooks.delete(turnId);
17
+ expired.push(turnId);
18
+ }
19
+ return expired;
20
+ }
@@ -0,0 +1,39 @@
1
+ export const RECORD_KIND = "graph-engineering:github-development-ticket:v0.3.0";
2
+ export const LEGACY_RECORD_KIND = "loop-engineering:github-development-ticket:v0.1.0";
3
+
4
+ export function formatThreadRecord({ repository, issueNumber, threadId }) {
5
+ return [
6
+ "Codex Goal Thread 已创建;相同 Ticket 的后续运行将恢复该 Thread。",
7
+ "",
8
+ `<!-- ${RECORD_KIND}`,
9
+ `repository=${repository}`,
10
+ `issue=${issueNumber}`,
11
+ `thread=${threadId}`,
12
+ "-->",
13
+ ].join("\n");
14
+ }
15
+
16
+ export function parseThreadRecord(body) {
17
+ if (typeof body !== "string") return null;
18
+ for (const kind of [RECORD_KIND, LEGACY_RECORD_KIND]) {
19
+ const pattern = new RegExp(
20
+ `<!--\\s*${escapeRegExp(kind)}\\s+repository=([^\\s]+)\\s+issue=(\\d+)\\s+thread=([^\\s]+)\\s*-->`,
21
+ );
22
+ const match = body.match(pattern);
23
+ if (match) return { repository: match[1], issueNumber: Number(match[2]), threadId: match[3] };
24
+ }
25
+ return null;
26
+ }
27
+
28
+ export function findThreadRecord(comments, { repository, issueNumber }) {
29
+ for (const comment of [...comments].reverse()) {
30
+ if (comment?.user?.login !== "github-actions[bot]") continue;
31
+ const record = parseThreadRecord(comment.body);
32
+ if (record?.repository === repository && record.issueNumber === Number(issueNumber)) return record;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function escapeRegExp(value) {
38
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
39
+ }
@@ -0,0 +1,124 @@
1
+ name: Graph Engineering - GitHub Development Ticket
2
+
3
+ on:
4
+ issues:
5
+ types: [labeled]
6
+ workflow_dispatch:
7
+ inputs:
8
+ issue_number:
9
+ description: Issue number to start or resume
10
+ required: true
11
+ type: number
12
+ goal_token_budget:
13
+ description: Total Goal token budget, or unlimited to clear the token limit
14
+ required: false
15
+ default: "500000"
16
+ type: string
17
+
18
+ permissions:
19
+ contents: write
20
+ issues: write
21
+ pull-requests: write
22
+
23
+ concurrency:
24
+ group: github-development-ticket-${{ github.repository }}-${{ github.event.issue.number || inputs.issue_number }}
25
+ cancel-in-progress: false
26
+
27
+ jobs:
28
+ implement:
29
+ if: >-
30
+ github.event_name == 'workflow_dispatch' ||
31
+ (github.event.action == 'labeled' && github.event.label.name == 'development-ticket')
32
+ runs-on: [self-hosted, Linux, X64]
33
+ timeout-minutes: 120
34
+ env:
35
+ GH_TOKEN: ${{ github.token }}
36
+ ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }}
37
+ BASE_BRANCH: ${{ github.event.repository.default_branch || 'main' }}
38
+ TARGET_BRANCH: agent/issue-${{ github.event.issue.number || inputs.issue_number }}
39
+ GOAL_TOKEN_BUDGET: ${{ inputs.goal_token_budget || '500000' }}
40
+ steps:
41
+ - name: Validate Development Ticket
42
+ shell: bash
43
+ run: |
44
+ set -euo pipefail
45
+ test "$(gh issue view "${ISSUE_NUMBER}" --repo "${GITHUB_REPOSITORY}" --json state --jq .state)" = "OPEN"
46
+ test "$(gh issue view "${ISSUE_NUMBER}" --repo "${GITHUB_REPOSITORY}" --json labels \
47
+ --jq 'any(.labels[]; .name == "ready-for-agent")')" = true
48
+ test "$(gh issue view "${ISSUE_NUMBER}" --repo "${GITHUB_REPOSITORY}" --json labels \
49
+ --jq 'any(.labels[]; .name == "development-ticket")')" = true
50
+
51
+ - name: Preserve interrupted Ticket work
52
+ shell: bash
53
+ run: |
54
+ set -euo pipefail
55
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
56
+ current_branch="$(git branch --show-current)"
57
+ test -n "${current_branch}"
58
+ if test "${current_branch}" = "${TARGET_BRANCH}"; then
59
+ interrupted_ref="refs/graph-engineering/interrupted/${TARGET_BRANCH}"
60
+ interrupted_stash_ref="refs/graph-engineering/interrupted-stash/${TARGET_BRANCH}"
61
+ git update-ref -d "${interrupted_ref}"
62
+ git update-ref -d "${interrupted_stash_ref}"
63
+ if git ls-remote --exit-code origin "refs/heads/${TARGET_BRANCH}" >/dev/null 2>&1; then
64
+ git fetch origin "${TARGET_BRANCH}"
65
+ if ! git merge-base --is-ancestor HEAD "origin/${TARGET_BRANCH}"; then
66
+ git update-ref "${interrupted_ref}" HEAD
67
+ fi
68
+ else
69
+ git update-ref "${interrupted_ref}" HEAD
70
+ fi
71
+ fi
72
+ if test -n "$(git status --porcelain)"; then
73
+ git stash push --include-untracked \
74
+ --message "graph-engineering:${GITHUB_REPOSITORY}:${current_branch}"
75
+ if test "${current_branch}" = "${TARGET_BRANCH}"; then
76
+ git update-ref "${interrupted_stash_ref}" refs/stash
77
+ fi
78
+ fi
79
+ fi
80
+
81
+ - name: Check out default branch
82
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
83
+ with:
84
+ ref: ${{ env.BASE_BRANCH }}
85
+ fetch-depth: 0
86
+
87
+ - name: Prepare development branch
88
+ run: bash .github/graph-engineering/development-worktree.sh prepare
89
+
90
+ - name: Restore interrupted Ticket work
91
+ run: bash .github/graph-engineering/development-worktree.sh restore
92
+
93
+ - name: Mark Ticket in progress
94
+ shell: bash
95
+ run: gh issue edit "${ISSUE_NUMBER}" --add-label in-progress
96
+
97
+ - name: Configure Git identity
98
+ shell: bash
99
+ run: |
100
+ git config user.name "codex[bot]"
101
+ git config user.email "codex[bot]@users.noreply.github.com"
102
+
103
+ - name: Start or resume Goal
104
+ shell: bash
105
+ run: node .github/graph-engineering/github-development-ticket.mjs
106
+
107
+ - name: Verify Codex delivery
108
+ shell: bash
109
+ run: |
110
+ set -euo pipefail
111
+ test -z "$(git status --porcelain)"
112
+ test "$(git rev-list --count "origin/${BASE_BRANCH}..HEAD")" -gt 0
113
+ test "$(git rev-parse HEAD)" = "$(git ls-remote origin "refs/heads/${TARGET_BRANCH}" | cut -f1)"
114
+ test "$(gh pr list --head "${TARGET_BRANCH}" --base "${BASE_BRANCH}" --state open \
115
+ --json url,isDraft,state \
116
+ --jq 'length == 1 and .[0].isDraft and .[0].state == "OPEN"')" = true
117
+
118
+ - name: Mark Ticket delivery complete
119
+ shell: bash
120
+ run: |
121
+ set -euo pipefail
122
+ gh issue edit "${ISSUE_NUMBER}" \
123
+ --remove-label development-ticket \
124
+ --remove-label in-progress
@@ -0,0 +1,16 @@
1
+ import { writeFile } from "node:fs/promises";
2
+
3
+ import { mapActionExecution } from "./pr-review-case.mjs";
4
+
5
+ const target = JSON.parse(process.env.TRUSTED_TARGET);
6
+ const execution = mapActionExecution({
7
+ actionOutcome: process.env.ACTION_OUTCOME ?? "success",
8
+ finalMessage: process.env.FINAL_MESSAGE,
9
+ }, target);
10
+
11
+ await writeFile("review-execution.json", `${JSON.stringify(execution, null, 2)}\n`);
12
+ if (execution.review) await writeFile("review-result.json", `${JSON.stringify(execution.review, null, 2)}\n`);
13
+ if (process.env.GITHUB_OUTPUT) {
14
+ await writeFile(process.env.GITHUB_OUTPUT, `terminal=${execution.terminal}\n`, { flag: "a" });
15
+ }
16
+ if (execution.terminal !== "completed") throw new Error(execution.diagnostic);
@@ -0,0 +1,23 @@
1
+ export const CHECK_NAME = "Graph Engineering / PR Review";
2
+ export const DEFINITION_ID = "github-pr-review/current";
3
+
4
+ export function checkExternalId(target) {
5
+ return `${DEFINITION_ID}:${target.repository}:pull-request:${target.number}:${target.headSha}`;
6
+ }
7
+
8
+ export function planCheckPublication({ target, existingChecks, checkName = CHECK_NAME }) {
9
+ const externalId = checkExternalId(target);
10
+ const matches = existingChecks.filter((check) => (
11
+ check.name === checkName
12
+ && check.head_sha === target.headSha
13
+ && check.external_id === externalId
14
+ ));
15
+ if (matches.length > 1) {
16
+ throw new Error(`duplicate-review-check: ${matches.length} Checks match ${externalId}`);
17
+ }
18
+ const existing = matches[0];
19
+
20
+ return existing
21
+ ? { method: "PATCH", path: `/repos/${target.repository}/check-runs/${existing.id}`, externalId }
22
+ : { method: "POST", path: `/repos/${target.repository}/check-runs`, externalId };
23
+ }
@@ -0,0 +1,67 @@
1
+ {
2
+ "definition": "github-pr-review/current",
3
+ "profile": "github-pr-review/codex/current",
4
+ "executor": {
5
+ "id": "codex-compatible-executor",
6
+ "version": "current"
7
+ },
8
+ "runner": {
9
+ "labels": ["self-hosted", "Linux", "X64", "codex"]
10
+ },
11
+ "authentication": {
12
+ "mode": "runner-backed-codex",
13
+ "credentialSource": "default-codex-home",
14
+ "repositorySecret": false
15
+ },
16
+ "action": {
17
+ "source": "tutar/codex-action",
18
+ "sourceRef": "fix/runner-login-no-proxy",
19
+ "commit": "f33581290086e62dc34d420a7f1862477fc2b503",
20
+ "upstream": {
21
+ "source": "openai/codex-action",
22
+ "commit": "86365089eb2b84e0a8fb0717b304f8bdcb13b20e"
23
+ },
24
+ "patch": "read-proxy-server-info-only-with-openai-api-key",
25
+ "codexVersion": "0.153.4",
26
+ "requiredInputs": ["prompt-file", "output-schema-file", "working-directory", "codex-version", "safety-strategy"],
27
+ "acceptedInputs": ["prompt-file", "output-schema-file", "working-directory", "codex-version", "model", "effort", "permission-profile", "safety-strategy"]
28
+ },
29
+ "mapping": {
30
+ "goalPrompt": "prompt-file",
31
+ "reviewSchema": "output-schema-file",
32
+ "codexVersion": "codex-version",
33
+ "workingDirectory": "working-directory",
34
+ "model": "model",
35
+ "reasoningEffort": "effort",
36
+ "safetyStrategy": "safety-strategy",
37
+ "terminal": "step-outcome",
38
+ "finalMessage": "final-message"
39
+ },
40
+ "outputs": ["final-message"],
41
+ "permissions": {
42
+ "reviewJob": {
43
+ "contents": "read",
44
+ "pull-requests": "read",
45
+ "checks": "read"
46
+ },
47
+ "publishJob": {
48
+ "contents": "read",
49
+ "pull-requests": "read",
50
+ "checks": "write"
51
+ }
52
+ },
53
+ "consumerConfiguration": {
54
+ "pullRequestActions": ["opened", "reopened", "synchronize", "ready_for_review"],
55
+ "manualDispatch": "boolean",
56
+ "includeDrafts": [false],
57
+ "modelPattern": "^[A-Za-z0-9._-]*$",
58
+ "reasoningEfforts": ["", "low", "medium", "high", "xhigh"],
59
+ "safetyStrategies": ["read-only"],
60
+ "checkTextMaxLength": 100
61
+ },
62
+ "validation": {
63
+ "failClosed": true,
64
+ "resultSchema": "review-result.schema.json",
65
+ "terminals": ["completed", "handoff", "failed", "cancelled"]
66
+ }
67
+ }