@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
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,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
|
+
}
|