oh-my-codex 0.13.1 → 0.13.2
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/Cargo.lock +5 -5
- package/Cargo.toml +1 -1
- package/README.md +2 -0
- package/crates/omx-explore/src/main.rs +221 -10
- package/dist/catalog/__tests__/generator.test.js +2 -0
- package/dist/catalog/__tests__/generator.test.js.map +1 -1
- package/dist/cli/__tests__/index.test.js +95 -1
- package/dist/cli/__tests__/index.test.js.map +1 -1
- package/dist/cli/__tests__/setup-skills-overwrite.test.js +41 -3
- package/dist/cli/__tests__/setup-skills-overwrite.test.js.map +1 -1
- package/dist/cli/__tests__/update.test.js +25 -1
- package/dist/cli/__tests__/update.test.js.map +1 -1
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +70 -8
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +15 -0
- package/dist/cli/setup.js.map +1 -1
- package/dist/cli/update.js +1 -1
- package/dist/cli/update.js.map +1 -1
- package/dist/hooks/__tests__/analyze-routing-contract.test.d.ts +2 -0
- package/dist/hooks/__tests__/analyze-routing-contract.test.d.ts.map +1 -0
- package/dist/hooks/__tests__/analyze-routing-contract.test.js +36 -0
- package/dist/hooks/__tests__/analyze-routing-contract.test.js.map +1 -0
- package/dist/hooks/__tests__/analyze-skill-contract.test.d.ts +2 -0
- package/dist/hooks/__tests__/analyze-skill-contract.test.d.ts.map +1 -0
- package/dist/hooks/__tests__/analyze-skill-contract.test.js +48 -0
- package/dist/hooks/__tests__/analyze-skill-contract.test.js.map +1 -0
- package/dist/hooks/__tests__/keyword-detector.test.js +32 -0
- package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-fallback-watcher.test.js +185 -8
- package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-session-idle-dedupe.test.js +26 -0
- package/dist/hooks/__tests__/notify-hook-session-idle-dedupe.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-session-scope.test.js +44 -0
- package/dist/hooks/__tests__/notify-hook-session-scope.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +126 -0
- package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
- package/dist/hooks/keyword-detector.d.ts.map +1 -1
- package/dist/hooks/keyword-detector.js +8 -1
- package/dist/hooks/keyword-detector.js.map +1 -1
- package/dist/hud/__tests__/state.test.js +55 -0
- package/dist/hud/__tests__/state.test.js.map +1 -1
- package/dist/hud/state.d.ts.map +1 -1
- package/dist/hud/state.js +23 -4
- package/dist/hud/state.js.map +1 -1
- package/dist/mcp/__tests__/bootstrap.test.js +38 -0
- package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
- package/dist/mcp/bootstrap.d.ts +1 -1
- package/dist/mcp/bootstrap.d.ts.map +1 -1
- package/dist/mcp/bootstrap.js +11 -3
- package/dist/mcp/bootstrap.js.map +1 -1
- package/dist/notifications/__tests__/reply-listener.test.js +34 -1
- package/dist/notifications/__tests__/reply-listener.test.js.map +1 -1
- package/dist/notifications/reply-listener.d.ts +1 -0
- package/dist/notifications/reply-listener.d.ts.map +1 -1
- package/dist/notifications/reply-listener.js +14 -2
- package/dist/notifications/reply-listener.js.map +1 -1
- package/dist/scripts/__tests__/codex-native-hook.test.js +178 -15
- package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
- package/dist/scripts/__tests__/generate-release-body.test.d.ts +2 -0
- package/dist/scripts/__tests__/generate-release-body.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/generate-release-body.test.js +144 -0
- package/dist/scripts/__tests__/generate-release-body.test.js.map +1 -0
- package/dist/scripts/codex-native-hook.d.ts.map +1 -1
- package/dist/scripts/codex-native-hook.js +23 -44
- package/dist/scripts/codex-native-hook.js.map +1 -1
- package/dist/scripts/generate-release-body.d.ts +34 -0
- package/dist/scripts/generate-release-body.d.ts.map +1 -0
- package/dist/scripts/generate-release-body.js +249 -0
- package/dist/scripts/generate-release-body.js.map +1 -0
- package/dist/scripts/notify-fallback-watcher.js +43 -20
- package/dist/scripts/notify-fallback-watcher.js.map +1 -1
- package/dist/scripts/notify-hook/active-team.d.ts.map +1 -1
- package/dist/scripts/notify-hook/active-team.js +2 -1
- package/dist/scripts/notify-hook/active-team.js.map +1 -1
- package/dist/scripts/notify-hook/ralph-session-resume.d.ts.map +1 -1
- package/dist/scripts/notify-hook/ralph-session-resume.js +17 -2
- package/dist/scripts/notify-hook/ralph-session-resume.js.map +1 -1
- package/dist/scripts/notify-hook/state-io.d.ts.map +1 -1
- package/dist/scripts/notify-hook/state-io.js +16 -0
- package/dist/scripts/notify-hook/state-io.js.map +1 -1
- package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-leader-nudge.js +26 -5
- package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
- package/dist/scripts/notify-hook.js +1 -7
- package/dist/scripts/notify-hook.js.map +1 -1
- package/dist/team/__tests__/model-contract.test.js +6 -0
- package/dist/team/__tests__/model-contract.test.js.map +1 -1
- package/dist/team/__tests__/tmux-session.test.js +1 -1
- package/dist/team/__tests__/tmux-session.test.js.map +1 -1
- package/dist/team/__tests__/worker-runtime-identity.test.d.ts +2 -0
- package/dist/team/__tests__/worker-runtime-identity.test.d.ts.map +1 -0
- package/dist/team/__tests__/worker-runtime-identity.test.js +250 -0
- package/dist/team/__tests__/worker-runtime-identity.test.js.map +1 -0
- package/dist/team/leader-activity.d.ts.map +1 -1
- package/dist/team/leader-activity.js +26 -15
- package/dist/team/leader-activity.js.map +1 -1
- package/dist/team/model-contract.d.ts.map +1 -1
- package/dist/team/model-contract.js.map +1 -1
- package/dist/team/runtime.d.ts.map +1 -1
- package/dist/team/runtime.js +9 -8
- package/dist/team/runtime.js.map +1 -1
- package/dist/team/scaling.d.ts.map +1 -1
- package/dist/team/scaling.js +10 -9
- package/dist/team/scaling.js.map +1 -1
- package/dist/team/tmux-session.d.ts.map +1 -1
- package/dist/team/tmux-session.js +3 -2
- package/dist/team/tmux-session.js.map +1 -1
- package/dist/verification/__tests__/explore-harness-release-workflow.test.js +3 -0
- package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
- package/dist/wiki/__tests__/slug-nonascii.test.js +11 -5
- package/dist/wiki/__tests__/slug-nonascii.test.js.map +1 -1
- package/dist/wiki/storage.d.ts.map +1 -1
- package/dist/wiki/storage.js +2 -1
- package/dist/wiki/storage.js.map +1 -1
- package/package.json +3 -1
- package/skills/analyze/SKILL.md +101 -134
- package/src/scripts/__tests__/codex-native-hook.test.ts +214 -17
- package/src/scripts/__tests__/generate-release-body.test.ts +166 -0
- package/src/scripts/codex-native-hook.ts +81 -61
- package/src/scripts/generate-release-body.ts +295 -0
- package/src/scripts/notify-fallback-watcher.ts +44 -21
- package/src/scripts/notify-hook/active-team.ts +2 -1
- package/src/scripts/notify-hook/ralph-session-resume.ts +17 -2
- package/src/scripts/notify-hook/state-io.ts +16 -0
- package/src/scripts/notify-hook/team-leader-nudge.ts +24 -4
- package/src/scripts/notify-hook.ts +1 -6
- package/templates/AGENTS.md +1 -1
- package/templates/catalog-manifest.json +2 -4
|
@@ -44,7 +44,6 @@ import type { HookEventEnvelope } from "../hooks/extensibility/types.js";
|
|
|
44
44
|
import { dispatchHookEvent } from "../hooks/extensibility/dispatcher.js";
|
|
45
45
|
import { reconcileHudForPromptSubmit } from "../hud/reconcile.js";
|
|
46
46
|
import { onSessionStart as buildWikiSessionStartContext } from "../wiki/lifecycle.js";
|
|
47
|
-
import { sessionModelInstructionsPath } from "../hooks/agents-overlay.js";
|
|
48
47
|
|
|
49
48
|
type CodexHookEventName =
|
|
50
49
|
| "SessionStart"
|
|
@@ -977,29 +976,6 @@ function readNativeStopSessionKey(
|
|
|
977
976
|
return resolveRepeatableStopSessionId(payload, canonicalSessionId) || readPayloadThreadId(payload) || "global";
|
|
978
977
|
}
|
|
979
978
|
|
|
980
|
-
function hasManagedStopSessionEnv(sessionIds: string[]): boolean {
|
|
981
|
-
const envSessionId = safeString(process.env.OMX_SESSION_ID).trim();
|
|
982
|
-
if (!envSessionId) return false;
|
|
983
|
-
return sessionIds.length === 0 || sessionIds.includes(envSessionId);
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
async function hasManagedStopContext(
|
|
987
|
-
cwd: string,
|
|
988
|
-
payload: CodexHookPayload,
|
|
989
|
-
canonicalSessionId: string,
|
|
990
|
-
): Promise<boolean> {
|
|
991
|
-
if (hasTeamWorkerContext()) return true;
|
|
992
|
-
|
|
993
|
-
const sessionIds = [...new Set([
|
|
994
|
-
canonicalSessionId,
|
|
995
|
-
readPayloadSessionId(payload),
|
|
996
|
-
].map((value) => safeString(value).trim()).filter(Boolean))];
|
|
997
|
-
|
|
998
|
-
if (hasManagedStopSessionEnv(sessionIds)) return true;
|
|
999
|
-
|
|
1000
|
-
return sessionIds.some((sessionId) => existsSync(sessionModelInstructionsPath(cwd, sessionId)));
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
979
|
function readPreviousNativeStopSignature(
|
|
1004
980
|
state: Record<string, unknown>,
|
|
1005
981
|
sessionKey: string,
|
|
@@ -1038,10 +1014,11 @@ async function maybeReturnRepeatableStopOutput(
|
|
|
1038
1014
|
signature: string,
|
|
1039
1015
|
output: Record<string, unknown> | null,
|
|
1040
1016
|
canonicalSessionId?: string,
|
|
1017
|
+
options: { allowRepeatDuringStopHook?: boolean } = {},
|
|
1041
1018
|
): Promise<Record<string, unknown> | null> {
|
|
1042
1019
|
if (!output) return null;
|
|
1043
1020
|
const stopHookActive = payload.stop_hook_active === true || payload.stopHookActive === true;
|
|
1044
|
-
if (stopHookActive) {
|
|
1021
|
+
if (stopHookActive && options.allowRepeatDuringStopHook !== true) {
|
|
1045
1022
|
const state = await readJsonIfExists(join(stateDir, NATIVE_STOP_STATE_FILE)) ?? {};
|
|
1046
1023
|
const previousSignature = readPreviousNativeStopSignature(
|
|
1047
1024
|
state,
|
|
@@ -1055,6 +1032,24 @@ async function maybeReturnRepeatableStopOutput(
|
|
|
1055
1032
|
return output;
|
|
1056
1033
|
}
|
|
1057
1034
|
|
|
1035
|
+
async function returnPersistentStopBlock(
|
|
1036
|
+
payload: CodexHookPayload,
|
|
1037
|
+
stateDir: string,
|
|
1038
|
+
signatureKind: string,
|
|
1039
|
+
signatureValue: string,
|
|
1040
|
+
output: Record<string, unknown> | null,
|
|
1041
|
+
canonicalSessionId?: string,
|
|
1042
|
+
): Promise<Record<string, unknown> | null> {
|
|
1043
|
+
return await maybeReturnRepeatableStopOutput(
|
|
1044
|
+
payload,
|
|
1045
|
+
stateDir,
|
|
1046
|
+
buildRepeatableStopSignature(payload, signatureKind, signatureValue, canonicalSessionId),
|
|
1047
|
+
output,
|
|
1048
|
+
canonicalSessionId,
|
|
1049
|
+
{ allowRepeatDuringStopHook: true },
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1058
1053
|
async function findCanonicalActiveTeamForSession(
|
|
1059
1054
|
cwd: string,
|
|
1060
1055
|
sessionId: string,
|
|
@@ -1287,20 +1282,45 @@ async function buildStopHookOutput(
|
|
|
1287
1282
|
const canonicalSessionId = await resolveInternalSessionIdForPayload(cwd, sessionId);
|
|
1288
1283
|
const threadId = readPayloadThreadId(payload);
|
|
1289
1284
|
const ralphState = await readActiveRalphState(stateDir, canonicalSessionId);
|
|
1290
|
-
const stopHookActive = payload.stop_hook_active === true || payload.stopHookActive === true;
|
|
1291
|
-
const managedStopContext = await hasManagedStopContext(cwd, payload, canonicalSessionId);
|
|
1292
1285
|
if (!ralphState) {
|
|
1293
1286
|
const teamWorkerOutput = await buildTeamWorkerStopOutput(cwd);
|
|
1294
|
-
if (
|
|
1287
|
+
if (hasTeamWorkerContext() && teamWorkerOutput) return teamWorkerOutput;
|
|
1295
1288
|
|
|
1296
1289
|
const autopilotOutput = await buildModeBasedStopOutput("autopilot", cwd, canonicalSessionId);
|
|
1297
|
-
if (
|
|
1290
|
+
if (autopilotOutput) {
|
|
1291
|
+
return await returnPersistentStopBlock(
|
|
1292
|
+
payload,
|
|
1293
|
+
stateDir,
|
|
1294
|
+
"autopilot-stop",
|
|
1295
|
+
safeString(autopilotOutput.stopReason),
|
|
1296
|
+
autopilotOutput,
|
|
1297
|
+
canonicalSessionId,
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1298
1300
|
|
|
1299
1301
|
const ultraworkOutput = await buildModeBasedStopOutput("ultrawork", cwd, canonicalSessionId);
|
|
1300
|
-
if (
|
|
1302
|
+
if (ultraworkOutput) {
|
|
1303
|
+
return await returnPersistentStopBlock(
|
|
1304
|
+
payload,
|
|
1305
|
+
stateDir,
|
|
1306
|
+
"ultrawork-stop",
|
|
1307
|
+
safeString(ultraworkOutput.stopReason),
|
|
1308
|
+
ultraworkOutput,
|
|
1309
|
+
canonicalSessionId,
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1301
1312
|
|
|
1302
1313
|
const ultraqaOutput = await buildModeBasedStopOutput("ultraqa", cwd, canonicalSessionId);
|
|
1303
|
-
if (
|
|
1314
|
+
if (ultraqaOutput) {
|
|
1315
|
+
return await returnPersistentStopBlock(
|
|
1316
|
+
payload,
|
|
1317
|
+
stateDir,
|
|
1318
|
+
"ultraqa-stop",
|
|
1319
|
+
safeString(ultraqaOutput.stopReason),
|
|
1320
|
+
ultraqaOutput,
|
|
1321
|
+
canonicalSessionId,
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1304
1324
|
|
|
1305
1325
|
const releaseReadinessFinalizeResult = await maybeBuildReleaseReadinessFinalizeStopOutput(
|
|
1306
1326
|
payload,
|
|
@@ -1312,16 +1332,11 @@ async function buildStopHookOutput(
|
|
|
1312
1332
|
|
|
1313
1333
|
const teamOutput = await buildTeamStopOutput(cwd, canonicalSessionId);
|
|
1314
1334
|
if (teamOutput) {
|
|
1315
|
-
|
|
1335
|
+
return await returnPersistentStopBlock(
|
|
1316
1336
|
payload,
|
|
1337
|
+
stateDir,
|
|
1317
1338
|
"team-stop",
|
|
1318
1339
|
safeString(teamOutput.stopReason),
|
|
1319
|
-
canonicalSessionId,
|
|
1320
|
-
);
|
|
1321
|
-
return await maybeReturnRepeatableStopOutput(
|
|
1322
|
-
payload,
|
|
1323
|
-
stateDir,
|
|
1324
|
-
teamSignature,
|
|
1325
1340
|
teamOutput,
|
|
1326
1341
|
canonicalSessionId,
|
|
1327
1342
|
);
|
|
@@ -1334,16 +1349,11 @@ async function buildStopHookOutput(
|
|
|
1334
1349
|
canonicalTeam.teamName,
|
|
1335
1350
|
canonicalTeam.phase,
|
|
1336
1351
|
);
|
|
1337
|
-
const
|
|
1352
|
+
const repeatedCanonicalTeamOutput = await returnPersistentStopBlock(
|
|
1338
1353
|
payload,
|
|
1354
|
+
stateDir,
|
|
1339
1355
|
"team-stop",
|
|
1340
1356
|
`${canonicalTeam.teamName}|${canonicalTeam.phase}`,
|
|
1341
|
-
canonicalSessionId,
|
|
1342
|
-
);
|
|
1343
|
-
const repeatedCanonicalTeamOutput = await maybeReturnRepeatableStopOutput(
|
|
1344
|
-
payload,
|
|
1345
|
-
stateDir,
|
|
1346
|
-
canonicalTeamSignature,
|
|
1347
1357
|
canonicalTeamOutput,
|
|
1348
1358
|
canonicalSessionId,
|
|
1349
1359
|
);
|
|
@@ -1351,12 +1361,18 @@ async function buildStopHookOutput(
|
|
|
1351
1361
|
}
|
|
1352
1362
|
|
|
1353
1363
|
const skillOutput = await buildSkillStopOutput(cwd, canonicalSessionId, threadId);
|
|
1354
|
-
if (
|
|
1364
|
+
if (skillOutput) {
|
|
1365
|
+
return await returnPersistentStopBlock(
|
|
1366
|
+
payload,
|
|
1367
|
+
stateDir,
|
|
1368
|
+
"skill-stop",
|
|
1369
|
+
safeString(skillOutput.stopReason),
|
|
1370
|
+
skillOutput,
|
|
1371
|
+
canonicalSessionId,
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1355
1374
|
}
|
|
1356
1375
|
|
|
1357
|
-
if (!managedStopContext) {
|
|
1358
|
-
return null;
|
|
1359
|
-
}
|
|
1360
1376
|
|
|
1361
1377
|
const lastAssistantMessage = safeString(
|
|
1362
1378
|
payload.last_assistant_message ?? payload.lastAssistantMessage,
|
|
@@ -1369,10 +1385,11 @@ async function buildStopHookOutput(
|
|
|
1369
1385
|
&& detectNativeStopStallPattern(lastAssistantMessage, autoNudgeConfig.patterns, autoNudgePhase)
|
|
1370
1386
|
) {
|
|
1371
1387
|
const effectiveResponse = resolveEffectiveAutoNudgeResponse(autoNudgeConfig.response);
|
|
1372
|
-
return await
|
|
1388
|
+
return await returnPersistentStopBlock(
|
|
1373
1389
|
payload,
|
|
1374
1390
|
stateDir,
|
|
1375
|
-
|
|
1391
|
+
"auto-nudge",
|
|
1392
|
+
lastAssistantMessage,
|
|
1376
1393
|
{
|
|
1377
1394
|
decision: "block",
|
|
1378
1395
|
reason: effectiveResponse,
|
|
@@ -1387,21 +1404,24 @@ async function buildStopHookOutput(
|
|
|
1387
1404
|
return null;
|
|
1388
1405
|
}
|
|
1389
1406
|
|
|
1390
|
-
if (stopHookActive) {
|
|
1391
|
-
return null;
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
1407
|
const currentPhase = safeString(ralphState?.current_phase).trim() || "executing";
|
|
1395
1408
|
const stopReason = `ralph_${currentPhase}`;
|
|
1396
1409
|
const systemMessage =
|
|
1397
1410
|
`OMX Ralph is still active (phase: ${currentPhase}); continue the task and gather fresh verification evidence before stopping.`;
|
|
1398
1411
|
|
|
1399
|
-
return
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1412
|
+
return await returnPersistentStopBlock(
|
|
1413
|
+
payload,
|
|
1414
|
+
stateDir,
|
|
1415
|
+
"ralph-stop",
|
|
1416
|
+
currentPhase,
|
|
1417
|
+
{
|
|
1418
|
+
decision: "block",
|
|
1419
|
+
reason: systemMessage,
|
|
1420
|
+
stopReason,
|
|
1421
|
+
systemMessage,
|
|
1422
|
+
},
|
|
1423
|
+
canonicalSessionId,
|
|
1424
|
+
);
|
|
1405
1425
|
}
|
|
1406
1426
|
|
|
1407
1427
|
export async function dispatchCodexNativeHook(
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export interface Contributor {
|
|
8
|
+
displayName: string;
|
|
9
|
+
login?: string;
|
|
10
|
+
url?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CompareCommit {
|
|
14
|
+
author?: {
|
|
15
|
+
login?: string;
|
|
16
|
+
html_url?: string;
|
|
17
|
+
} | null;
|
|
18
|
+
commit?: {
|
|
19
|
+
author?: {
|
|
20
|
+
name?: string;
|
|
21
|
+
} | null;
|
|
22
|
+
} | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface CompareResponse {
|
|
26
|
+
commits?: CompareCommit[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface GenerateReleaseBodyOptions {
|
|
30
|
+
templatePath: string;
|
|
31
|
+
outPath: string;
|
|
32
|
+
currentTag?: string;
|
|
33
|
+
previousTag?: string;
|
|
34
|
+
repo?: string;
|
|
35
|
+
githubToken?: string;
|
|
36
|
+
cwd?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function usage(): never {
|
|
40
|
+
console.error('Usage: node scripts/generate-release-body.mjs --template <path> --out <path> [--current-tag <tag>] [--previous-tag <tag>] [--repo <owner/name>]');
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function arg(name: string): string | undefined {
|
|
45
|
+
const index = process.argv.indexOf(name);
|
|
46
|
+
if (index === -1) return undefined;
|
|
47
|
+
return process.argv[index + 1];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function runGit(args: string[], cwd: string, allowFailure = false): string {
|
|
51
|
+
const result = spawnSync('git', args, {
|
|
52
|
+
cwd,
|
|
53
|
+
encoding: 'utf-8',
|
|
54
|
+
stdio: 'pipe',
|
|
55
|
+
});
|
|
56
|
+
if (result.status !== 0) {
|
|
57
|
+
if (allowFailure) return '';
|
|
58
|
+
throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`.trim());
|
|
59
|
+
}
|
|
60
|
+
return String(result.stdout || '').trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveRepositoryFromRemote(cwd: string): string | undefined {
|
|
64
|
+
const remote = runGit(['config', '--get', 'remote.origin.url'], cwd, true);
|
|
65
|
+
if (!remote) return undefined;
|
|
66
|
+
const httpsMatch = remote.match(/github\.com[:/](.+?)(?:\.git)?$/);
|
|
67
|
+
return httpsMatch?.[1];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function resolveCurrentTag(cwd: string, explicit?: string): string {
|
|
71
|
+
if (explicit) return explicit;
|
|
72
|
+
if (process.env.GITHUB_REF_NAME) return process.env.GITHUB_REF_NAME;
|
|
73
|
+
const described = runGit(['describe', '--tags', '--exact-match'], cwd, true);
|
|
74
|
+
if (described) return described;
|
|
75
|
+
throw new Error('unable to determine current release tag; pass --current-tag or set GITHUB_REF_NAME');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function resolvePreviousTag(cwd: string, currentTag: string, explicit?: string): string | undefined {
|
|
79
|
+
if (explicit) return explicit;
|
|
80
|
+
const tags = runGit(['tag', '--list', 'v*', '--sort=-v:refname'], cwd, true)
|
|
81
|
+
.split(/\r?\n/)
|
|
82
|
+
.map((line) => line.trim())
|
|
83
|
+
.filter(Boolean);
|
|
84
|
+
if (tags.length === 0) return undefined;
|
|
85
|
+
const currentIndex = tags.indexOf(currentTag);
|
|
86
|
+
if (currentIndex >= 0) return tags.slice(currentIndex + 1).find(Boolean);
|
|
87
|
+
return tags.find((tag) => tag !== currentTag);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeContributors(contributors: Contributor[]): Contributor[] {
|
|
91
|
+
const deduped = new Map<string, Contributor>();
|
|
92
|
+
for (const contributor of contributors) {
|
|
93
|
+
const login = contributor.login?.trim();
|
|
94
|
+
const displayName = contributor.displayName.trim();
|
|
95
|
+
if (!displayName && !login) continue;
|
|
96
|
+
const key = (login || displayName).toLowerCase();
|
|
97
|
+
if (!deduped.has(key)) {
|
|
98
|
+
deduped.set(key, {
|
|
99
|
+
displayName: displayName || `@${login}`,
|
|
100
|
+
...(login ? { login } : {}),
|
|
101
|
+
...(contributor.url ? { url: contributor.url } : {}),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return [...deduped.values()].sort((left, right) => {
|
|
106
|
+
const leftKey = (left.login || left.displayName).toLowerCase();
|
|
107
|
+
const rightKey = (right.login || right.displayName).toLowerCase();
|
|
108
|
+
return leftKey.localeCompare(rightKey);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function formatContributor(contributor: Contributor): string {
|
|
113
|
+
if (contributor.login && contributor.url) {
|
|
114
|
+
return `[@${contributor.login}](${contributor.url})`;
|
|
115
|
+
}
|
|
116
|
+
if (contributor.login) {
|
|
117
|
+
return `@${contributor.login}`;
|
|
118
|
+
}
|
|
119
|
+
if (contributor.url) {
|
|
120
|
+
return `[${contributor.displayName}](${contributor.url})`;
|
|
121
|
+
}
|
|
122
|
+
return contributor.displayName;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function joinHumanList(values: string[]): string {
|
|
126
|
+
if (values.length === 0) return 'the contributors';
|
|
127
|
+
if (values.length === 1) return values[0]!;
|
|
128
|
+
if (values.length === 2) return `${values[0]} and ${values[1]}`;
|
|
129
|
+
return `${values.slice(0, -1).join(', ')}, and ${values.at(-1)}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function renderContributorsSection(contributors: Contributor[]): string {
|
|
133
|
+
const normalized = normalizeContributors(contributors);
|
|
134
|
+
if (normalized.length === 0) {
|
|
135
|
+
return 'Thanks to the contributors who made this release possible.';
|
|
136
|
+
}
|
|
137
|
+
return `Thanks to ${joinHumanList(normalized.map((contributor) => formatContributor(contributor)))} for contributing to this release.`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function buildFullChangelogLine(repo: string, currentTag: string, previousTag?: string): string {
|
|
141
|
+
if (!repo) {
|
|
142
|
+
throw new Error('unable to determine GitHub repository; pass --repo or set GITHUB_REPOSITORY');
|
|
143
|
+
}
|
|
144
|
+
if (previousTag) {
|
|
145
|
+
return `**Full Changelog**: [\`${previousTag}...${currentTag}\`](https://github.com/${repo}/compare/${previousTag}...${currentTag})`;
|
|
146
|
+
}
|
|
147
|
+
return `**Full Changelog**: [\`${currentTag}\`](https://github.com/${repo}/releases/tag/${currentTag})`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function replaceTitle(markdown: string, currentTag: string): string {
|
|
151
|
+
if (!/^#\s+/m.test(markdown)) {
|
|
152
|
+
throw new Error('release body template is missing a top-level title');
|
|
153
|
+
}
|
|
154
|
+
return markdown.replace(/^#\s+.*$/m, `# oh-my-codex ${currentTag}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function findSectionEnd(lines: string[], startIndex: number): number {
|
|
158
|
+
for (let index = startIndex + 1; index < lines.length; index += 1) {
|
|
159
|
+
if (/^##\s+/.test(lines[index] ?? '')) return index;
|
|
160
|
+
if (/^\*\*Full Changelog\*\*:/.test(lines[index] ?? '')) return index;
|
|
161
|
+
}
|
|
162
|
+
return lines.length;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function replaceSectionBody(markdown: string, heading: string, body: string): string {
|
|
166
|
+
const lines = markdown.split(/\r?\n/);
|
|
167
|
+
const headingLine = `## ${heading}`;
|
|
168
|
+
const startIndex = lines.findIndex((line) => line.trim() === headingLine);
|
|
169
|
+
if (startIndex === -1) {
|
|
170
|
+
throw new Error(`release body template is missing section: ${headingLine}`);
|
|
171
|
+
}
|
|
172
|
+
const endIndex = findSectionEnd(lines, startIndex);
|
|
173
|
+
lines.splice(startIndex + 1, endIndex - startIndex - 1, '', ...body.split('\n'), '');
|
|
174
|
+
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function replaceFullChangelogLine(markdown: string, fullChangelogLine: string): string {
|
|
178
|
+
const lines = markdown.split(/\r?\n/);
|
|
179
|
+
const lineIndex = lines.findIndex((line) => /^\*\*Full Changelog\*\*:/.test(line));
|
|
180
|
+
if (lineIndex === -1) {
|
|
181
|
+
throw new Error('release body template is missing the Full Changelog line');
|
|
182
|
+
}
|
|
183
|
+
lines[lineIndex] = fullChangelogLine;
|
|
184
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseShortlogLine(line: string): Contributor | undefined {
|
|
188
|
+
const match = line.match(/^\s*\d+\s+(.+?)(?:\s+<[^>]+>)?$/);
|
|
189
|
+
if (!match) return undefined;
|
|
190
|
+
const displayName = match[1]?.trim();
|
|
191
|
+
if (!displayName) return undefined;
|
|
192
|
+
return { displayName };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function getGitContributors(cwd: string, currentTag: string, previousTag?: string): Contributor[] {
|
|
196
|
+
const range = previousTag ? `${previousTag}..${currentTag}` : currentTag;
|
|
197
|
+
const shortlog = runGit(['shortlog', '-sne', range], cwd, true);
|
|
198
|
+
if (!shortlog) return [];
|
|
199
|
+
return normalizeContributors(shortlog.split(/\r?\n/).map((line) => parseShortlogLine(line)).filter((value): value is Contributor => Boolean(value)));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function getGitHubCompareContributors(
|
|
203
|
+
repo: string,
|
|
204
|
+
currentTag: string,
|
|
205
|
+
previousTag: string,
|
|
206
|
+
githubToken: string,
|
|
207
|
+
fetchImpl: typeof fetch = fetch,
|
|
208
|
+
): Promise<Contributor[]> {
|
|
209
|
+
const response = await fetchImpl(`https://api.github.com/repos/${repo}/compare/${previousTag}...${currentTag}`, {
|
|
210
|
+
headers: {
|
|
211
|
+
Accept: 'application/vnd.github+json',
|
|
212
|
+
Authorization: `Bearer ${githubToken}`,
|
|
213
|
+
'User-Agent': 'oh-my-codex-release-body-generator',
|
|
214
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
if (!response.ok) {
|
|
218
|
+
throw new Error(`GitHub compare API failed (${response.status})`);
|
|
219
|
+
}
|
|
220
|
+
const payload = await response.json() as CompareResponse;
|
|
221
|
+
const contributors = (payload.commits ?? []).map((commit) => {
|
|
222
|
+
if (commit.author?.login) {
|
|
223
|
+
return {
|
|
224
|
+
displayName: `@${commit.author.login}`,
|
|
225
|
+
login: commit.author.login,
|
|
226
|
+
...(commit.author.html_url ? { url: commit.author.html_url } : {}),
|
|
227
|
+
} satisfies Contributor;
|
|
228
|
+
}
|
|
229
|
+
const name = commit.commit?.author?.name?.trim();
|
|
230
|
+
return name ? { displayName: name } satisfies Contributor : undefined;
|
|
231
|
+
}).filter((value): value is Contributor => Boolean(value));
|
|
232
|
+
return normalizeContributors(contributors);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function resolveContributors(options: {
|
|
236
|
+
cwd: string;
|
|
237
|
+
repo?: string;
|
|
238
|
+
currentTag: string;
|
|
239
|
+
previousTag?: string;
|
|
240
|
+
githubToken?: string;
|
|
241
|
+
}): Promise<Contributor[]> {
|
|
242
|
+
const { cwd, repo, currentTag, previousTag, githubToken } = options;
|
|
243
|
+
if (repo && previousTag && githubToken) {
|
|
244
|
+
try {
|
|
245
|
+
return await getGitHubCompareContributors(repo, currentTag, previousTag, githubToken);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
console.error(`[generate-release-body] falling back to git shortlog: ${error instanceof Error ? error.message : String(error)}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return getGitContributors(cwd, currentTag, previousTag);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function generateReleaseBody(options: GenerateReleaseBodyOptions): Promise<string> {
|
|
254
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
255
|
+
const templatePath = resolve(cwd, options.templatePath);
|
|
256
|
+
const outPath = resolve(cwd, options.outPath);
|
|
257
|
+
const currentTag = resolveCurrentTag(cwd, options.currentTag);
|
|
258
|
+
const previousTag = resolvePreviousTag(cwd, currentTag, options.previousTag);
|
|
259
|
+
const repo = options.repo || process.env.GITHUB_REPOSITORY || resolveRepositoryFromRemote(cwd);
|
|
260
|
+
const contributors = await resolveContributors({
|
|
261
|
+
cwd,
|
|
262
|
+
repo,
|
|
263
|
+
currentTag,
|
|
264
|
+
previousTag,
|
|
265
|
+
githubToken: options.githubToken || process.env.GITHUB_TOKEN,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
let markdown = readFileSync(templatePath, 'utf-8');
|
|
269
|
+
markdown = replaceTitle(markdown, currentTag);
|
|
270
|
+
markdown = replaceSectionBody(markdown, 'Contributors', renderContributorsSection(contributors));
|
|
271
|
+
markdown = replaceFullChangelogLine(markdown, buildFullChangelogLine(repo || '', currentTag, previousTag));
|
|
272
|
+
writeFileSync(outPath, markdown);
|
|
273
|
+
return markdown;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function main(): Promise<void> {
|
|
277
|
+
const templatePath = arg('--template');
|
|
278
|
+
const outPath = arg('--out');
|
|
279
|
+
if (!templatePath || !outPath) usage();
|
|
280
|
+
await generateReleaseBody({
|
|
281
|
+
templatePath,
|
|
282
|
+
outPath,
|
|
283
|
+
currentTag: arg('--current-tag'),
|
|
284
|
+
previousTag: arg('--previous-tag'),
|
|
285
|
+
repo: arg('--repo'),
|
|
286
|
+
});
|
|
287
|
+
console.log(resolve(process.cwd(), outPath));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
291
|
+
main().catch((error) => {
|
|
292
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
293
|
+
process.exit(1);
|
|
294
|
+
});
|
|
295
|
+
}
|