@tutar/graph-engineering 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +10 -0
- package/bin/graph-engineering.mjs +60 -0
- package/lib/check.mjs +102 -0
- package/lib/constants.mjs +17 -0
- package/lib/files.mjs +83 -0
- package/lib/init.mjs +29 -0
- package/lib/manifest.mjs +31 -0
- package/lib/migrate.mjs +101 -0
- package/lib/package-assets.mjs +39 -0
- package/migrations/loop-engineering-927bd961/development/.github/loop-engineering/development-worktree.sh +32 -0
- package/migrations/loop-engineering-927bd961/development/.github/loop-engineering/github-development-ticket.mjs +246 -0
- package/migrations/loop-engineering-927bd961/development/.github/loop-engineering/stop-hook-drain.mjs +20 -0
- package/migrations/loop-engineering-927bd961/development/.github/loop-engineering/thread-record.mjs +36 -0
- package/migrations/loop-engineering-927bd961/development/.github/workflows/github-development-ticket.yml +124 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/capture-review.mjs +16 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/check-publication.mjs +23 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/codex-compatibility-profile.json +67 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/codex-compatible-executor.mjs +130 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/github-api.mjs +20 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/pr-review-case.mjs +125 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/pr-review-config.json +19 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/pr-review-contract.mjs +2 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/prepare-review.mjs +50 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/publish-review.mjs +103 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/review-result.schema.json +93 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/route-review.mjs +117 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/workflows/github-pr-review.yml +138 -0
- package/package.json +38 -0
- package/release-metadata.json +4 -0
- package/templates/development/.github/graph-engineering/development-worktree.sh +47 -0
- package/templates/development/.github/graph-engineering/github-development-ticket.mjs +246 -0
- package/templates/development/.github/graph-engineering/stop-hook-drain.mjs +20 -0
- package/templates/development/.github/graph-engineering/thread-record.mjs +39 -0
- package/templates/development/.github/workflows/github-development-ticket.yml +124 -0
- package/templates/pr-review/.github/graph-engineering/capture-review.mjs +16 -0
- package/templates/pr-review/.github/graph-engineering/check-publication.mjs +23 -0
- package/templates/pr-review/.github/graph-engineering/codex-compatibility-profile.json +67 -0
- package/templates/pr-review/.github/graph-engineering/codex-compatible-executor.mjs +130 -0
- package/templates/pr-review/.github/graph-engineering/github-api.mjs +20 -0
- package/templates/pr-review/.github/graph-engineering/pr-review-case.mjs +125 -0
- package/templates/pr-review/.github/graph-engineering/pr-review-config.json +19 -0
- package/templates/pr-review/.github/graph-engineering/pr-review-contract.mjs +2 -0
- package/templates/pr-review/.github/graph-engineering/prepare-review.mjs +50 -0
- package/templates/pr-review/.github/graph-engineering/publish-review.mjs +103 -0
- package/templates/pr-review/.github/graph-engineering/review-result.schema.json +93 -0
- package/templates/pr-review/.github/graph-engineering/route-review.mjs +117 -0
- package/templates/pr-review/.github/workflows/github-pr-review.yml +138 -0
|
@@ -0,0 +1,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
|
+
}
|
package/migrations/loop-engineering-927bd961/development/.github/loop-engineering/thread-record.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const RECORD_KIND = "loop-engineering:github-development-ticket:v0.1.0";
|
|
2
|
+
|
|
3
|
+
export function formatThreadRecord({ repository, issueNumber, threadId }) {
|
|
4
|
+
return [
|
|
5
|
+
"Codex Goal Thread 已创建;相同 Ticket 的后续运行将恢复该 Thread。",
|
|
6
|
+
"",
|
|
7
|
+
`<!-- ${RECORD_KIND}`,
|
|
8
|
+
`repository=${repository}`,
|
|
9
|
+
`issue=${issueNumber}`,
|
|
10
|
+
`thread=${threadId}`,
|
|
11
|
+
"-->",
|
|
12
|
+
].join("\n");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseThreadRecord(body) {
|
|
16
|
+
if (typeof body !== "string") return null;
|
|
17
|
+
const pattern = new RegExp(
|
|
18
|
+
`<!--\\s*${escapeRegExp(RECORD_KIND)}\\s+repository=([^\\s]+)\\s+issue=(\\d+)\\s+thread=([^\\s]+)\\s*-->`,
|
|
19
|
+
);
|
|
20
|
+
const match = body.match(pattern);
|
|
21
|
+
if (!match) return null;
|
|
22
|
+
return { repository: match[1], issueNumber: Number(match[2]), threadId: match[3] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function findThreadRecord(comments, { repository, issueNumber }) {
|
|
26
|
+
for (const comment of [...comments].reverse()) {
|
|
27
|
+
if (comment?.user?.login !== "github-actions[bot]") continue;
|
|
28
|
+
const record = parseThreadRecord(comment.body);
|
|
29
|
+
if (record?.repository === repository && record.issueNumber === Number(issueNumber)) return record;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function escapeRegExp(value) {
|
|
35
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
36
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
name: Loop 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/loop-engineering/interrupted/${TARGET_BRANCH}"
|
|
60
|
+
interrupted_stash_ref="refs/loop-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 "loop-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/loop-engineering/development-worktree.sh prepare
|
|
89
|
+
|
|
90
|
+
- name: Restore interrupted Ticket work
|
|
91
|
+
run: bash .github/loop-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/loop-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
|
package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/capture-review.mjs
ADDED
|
@@ -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 = "Loop 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
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { SUPPORTED_ACTION_NAMES } from "./pr-review-contract.mjs";
|
|
4
|
+
|
|
5
|
+
const DEFINITION = "github-pr-review/current";
|
|
6
|
+
const PROFILE = "github-pr-review/codex/current";
|
|
7
|
+
const ACTION_SOURCE = "tutar/codex-action";
|
|
8
|
+
const ACTION_COMMIT = "f33581290086e62dc34d420a7f1862477fc2b503";
|
|
9
|
+
const ACTION_SOURCE_REF = "fix/runner-login-no-proxy";
|
|
10
|
+
const ACTION_UPSTREAM = {
|
|
11
|
+
source: "openai/codex-action",
|
|
12
|
+
commit: "86365089eb2b84e0a8fb0717b304f8bdcb13b20e",
|
|
13
|
+
};
|
|
14
|
+
const ACTION_PATCH = "read-proxy-server-info-only-with-openai-api-key";
|
|
15
|
+
const CODEX_VERSION = "0.153.4";
|
|
16
|
+
const RUNNER_LABELS = ["self-hosted", "Linux", "X64", "codex"];
|
|
17
|
+
const ACTION_USES = `${ACTION_SOURCE}@${ACTION_COMMIT}`;
|
|
18
|
+
|
|
19
|
+
export function resolveCompatibleExecution({ config, profile }) {
|
|
20
|
+
try {
|
|
21
|
+
validateProfile(profile);
|
|
22
|
+
validateConfiguration(config, profile);
|
|
23
|
+
return {
|
|
24
|
+
definition: config.definition,
|
|
25
|
+
profile: config.profile,
|
|
26
|
+
runnerLabels: [...RUNNER_LABELS],
|
|
27
|
+
events: structuredClone(config.events),
|
|
28
|
+
eventPrompt: config.eventPrompt.trim(),
|
|
29
|
+
action: {
|
|
30
|
+
uses: ACTION_USES,
|
|
31
|
+
codexVersion: CODEX_VERSION,
|
|
32
|
+
model: config.codex.model.trim(),
|
|
33
|
+
effort: config.codex.effort,
|
|
34
|
+
safetyStrategy: config.codex.safetyStrategy,
|
|
35
|
+
},
|
|
36
|
+
check: structuredClone(config.check),
|
|
37
|
+
};
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw new Error(`configuration-handoff: ${error.message}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function loadBundledCompatibility() {
|
|
44
|
+
const contracts = await readBundledContracts();
|
|
45
|
+
return resolveCompatibleExecution(contracts);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function readBundledContracts() {
|
|
49
|
+
const [config, profile] = await Promise.all([
|
|
50
|
+
readFile(new URL("./pr-review-config.json", import.meta.url), "utf8"),
|
|
51
|
+
readFile(new URL("./codex-compatibility-profile.json", import.meta.url), "utf8"),
|
|
52
|
+
]);
|
|
53
|
+
return { config: JSON.parse(config), profile: JSON.parse(profile) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validateProfile(profile) {
|
|
57
|
+
assertExactKeys(profile, ["definition", "profile", "executor", "runner", "authentication", "action", "mapping", "outputs", "permissions", "consumerConfiguration", "validation"], "Profile");
|
|
58
|
+
if (profile.definition !== DEFINITION) throw new Error("Profile definition does not match the Executor version");
|
|
59
|
+
if (profile.profile !== PROFILE) throw new Error("Profile identity does not match the Executor version");
|
|
60
|
+
assertExactKeys(profile.executor, ["id", "version"], "Profile executor");
|
|
61
|
+
if (profile.executor?.id !== "codex-compatible-executor" || profile.executor?.version !== "current") throw new Error("Profile executor does not match the bundled Executor");
|
|
62
|
+
assertJsonEqual(profile.runner, { labels: RUNNER_LABELS }, "runner");
|
|
63
|
+
assertJsonEqual(profile.authentication, { mode: "runner-backed-codex", credentialSource: "default-codex-home", repositorySecret: false }, "authentication");
|
|
64
|
+
if (profile.action?.source !== ACTION_SOURCE || profile.action?.sourceRef !== ACTION_SOURCE_REF) throw new Error("Profile Action source or source ref does not match the bundled Executor");
|
|
65
|
+
assertExactKeys(profile.action, ["source", "sourceRef", "commit", "upstream", "patch", "codexVersion", "requiredInputs", "acceptedInputs"], "Profile Action");
|
|
66
|
+
if (profile.action.commit !== ACTION_COMMIT) throw new Error("Profile Action commit does not match the bundled Executor");
|
|
67
|
+
assertJsonEqual(profile.action.upstream, ACTION_UPSTREAM, "Action upstream base");
|
|
68
|
+
if (profile.action.patch !== ACTION_PATCH) throw new Error("Profile Action patch does not match the bundled Executor");
|
|
69
|
+
if (profile.action.codexVersion !== CODEX_VERSION) throw new Error("Profile Codex version does not match the bundled Executor");
|
|
70
|
+
assertJsonEqual(profile.action.requiredInputs, ["prompt-file", "output-schema-file", "working-directory", "codex-version", "safety-strategy"], "required inputs");
|
|
71
|
+
assertJsonEqual(profile.action.acceptedInputs, ["prompt-file", "output-schema-file", "working-directory", "codex-version", "model", "effort", "permission-profile", "safety-strategy"], "accepted inputs");
|
|
72
|
+
assertJsonEqual(profile.mapping, {
|
|
73
|
+
goalPrompt: "prompt-file",
|
|
74
|
+
reviewSchema: "output-schema-file",
|
|
75
|
+
codexVersion: "codex-version",
|
|
76
|
+
workingDirectory: "working-directory",
|
|
77
|
+
model: "model",
|
|
78
|
+
reasoningEffort: "effort",
|
|
79
|
+
safetyStrategy: "safety-strategy",
|
|
80
|
+
terminal: "step-outcome",
|
|
81
|
+
finalMessage: "final-message",
|
|
82
|
+
}, "mapping");
|
|
83
|
+
assertJsonEqual(profile.outputs, ["final-message"], "outputs");
|
|
84
|
+
assertJsonEqual(profile.permissions.reviewJob, { contents: "read", "pull-requests": "read", checks: "read" }, "review permissions");
|
|
85
|
+
assertJsonEqual(profile.permissions.publishJob, { contents: "read", "pull-requests": "read", checks: "write" }, "publish permissions");
|
|
86
|
+
assertJsonEqual(profile.consumerConfiguration, {
|
|
87
|
+
pullRequestActions: SUPPORTED_ACTION_NAMES,
|
|
88
|
+
manualDispatch: "boolean",
|
|
89
|
+
includeDrafts: [false],
|
|
90
|
+
modelPattern: "^[A-Za-z0-9._-]*$",
|
|
91
|
+
reasoningEfforts: ["", "low", "medium", "high", "xhigh"],
|
|
92
|
+
safetyStrategies: ["read-only"],
|
|
93
|
+
checkTextMaxLength: 100,
|
|
94
|
+
}, "consumer configuration");
|
|
95
|
+
assertJsonEqual(profile.validation, {
|
|
96
|
+
failClosed: true,
|
|
97
|
+
resultSchema: "review-result.schema.json",
|
|
98
|
+
terminals: ["completed", "handoff", "failed", "cancelled"],
|
|
99
|
+
}, "validation rules");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validateConfiguration(config, profile) {
|
|
103
|
+
assertExactKeys(config, ["definition", "profile", "events", "eventPrompt", "codex", "check"], "configuration");
|
|
104
|
+
if (config.definition !== DEFINITION) throw new Error("configuration definition does not match this current task");
|
|
105
|
+
if (config.profile !== profile.profile) throw new Error("configuration profile does not match the bundled Profile");
|
|
106
|
+
assertExactKeys(config.events, ["pullRequestActions", "manualDispatch", "includeDrafts"], "events");
|
|
107
|
+
if (!Array.isArray(config.events.pullRequestActions) || !config.events.pullRequestActions.every((action) => profile.consumerConfiguration.pullRequestActions.includes(action)) || new Set(config.events.pullRequestActions).size !== config.events.pullRequestActions.length) throw new Error("events.pullRequestActions contains an unsupported or duplicate event");
|
|
108
|
+
if (typeof config.events.manualDispatch !== "boolean") throw new Error("events.manualDispatch must be boolean");
|
|
109
|
+
if (!profile.consumerConfiguration.includeDrafts.includes(config.events.includeDrafts)) throw new Error("events.includeDrafts must remain false for this current task");
|
|
110
|
+
if (typeof config.eventPrompt !== "string" || !config.eventPrompt.trim()) throw new Error("eventPrompt is required");
|
|
111
|
+
assertExactKeys(config.codex, ["model", "effort", "safetyStrategy"], "codex");
|
|
112
|
+
if (typeof config.codex.model !== "string" || !new RegExp(profile.consumerConfiguration.modelPattern).test(config.codex.model)) throw new Error("codex.model contains unsupported characters");
|
|
113
|
+
if (!profile.consumerConfiguration.reasoningEfforts.includes(config.codex.effort)) throw new Error("codex.effort is not allowed");
|
|
114
|
+
if (!profile.consumerConfiguration.safetyStrategies.includes(config.codex.safetyStrategy)) throw new Error("codex.safetyStrategy must remain read-only");
|
|
115
|
+
assertExactKeys(config.check, ["name", "title"], "check");
|
|
116
|
+
for (const key of ["name", "title"]) {
|
|
117
|
+
if (typeof config.check[key] !== "string" || !config.check[key].trim() || config.check[key].length > profile.consumerConfiguration.checkTextMaxLength) throw new Error(`check.${key} must be a non-empty string of at most ${profile.consumerConfiguration.checkTextMaxLength} characters`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function assertExactKeys(value, expected, label) {
|
|
122
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
123
|
+
const actual = Object.keys(value).sort();
|
|
124
|
+
const wanted = [...expected].sort();
|
|
125
|
+
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) throw new Error(`${label} contains missing or forbidden fields`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assertJsonEqual(actual, expected, label) {
|
|
129
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`Profile ${label} does not match the bundled Executor`);
|
|
130
|
+
}
|