Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b665a4a
chore(codex): bootstrap PR for issue #123
github-actions[bot] Dec 24, 2025
b5e1512
Merge branch 'main' into codex/issue-123
stranske Dec 24, 2025
240ce5e
chore(codex-keepalive): apply updates (PR #124)
github-actions[bot] Dec 24, 2025
7307d4c
Trim CLI status summary details
Dec 24, 2025
f4fa571
chore(codex-keepalive): apply updates (PR #124)
github-actions[bot] Dec 24, 2025
4c8f371
Merge branch 'main' into codex/issue-123
stranske Dec 24, 2025
81ccce3
Merge branch 'main' into codex/issue-123
stranske Dec 24, 2025
0439872
Suppress keepalive instructions for CLI agents
Dec 24, 2025
dbe2ff0
chore(codex-keepalive): apply updates (PR #124)
github-actions[bot] Dec 24, 2025
7348647
Merge branch 'main' into codex/issue-123
stranske Dec 25, 2025
4f32dab
Merge branch 'main' into codex/issue-123
stranske Dec 25, 2025
b10e920
style: fix black formatting in test_keepalive_workflow.py
stranske Dec 25, 2025
cc50f75
chore(codex-keepalive): apply updates (PR #124)
github-actions[bot] Dec 25, 2025
ce8a5c4
feat(keepalive): emit step summary for agent runs
Dec 25, 2025
41f61e8
chore(codex-keepalive): apply updates (PR #124)
github-actions[bot] Dec 25, 2025
b4cf71e
test(keepalive): add per-run task delta to step summary
codex Dec 25, 2025
4ca6598
chore(codex-keepalive): apply updates (PR #124)
github-actions[bot] Dec 25, 2025
798f4b2
feat(pr-meta): infer agent type from labels
codex Dec 25, 2025
30345a3
chore(codex-keepalive): apply updates (PR #124)
github-actions[bot] Dec 25, 2025
d367b37
feat(gate-summary): skip CLI agent comments
Dec 25, 2025
cf6e4dd
chore(codex-keepalive): apply updates (PR #124)
github-actions[bot] Dec 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions .github/scripts/__tests__/agents-pr-meta-update-body.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const {
ensureChecklist,
extractBlock,
fetchConnectorCheckboxStates,
buildStatusBlock,
resolveAgentType,
} = require('../agents_pr_meta_update_body.js');

test('parseCheckboxStates extracts checked items from a checkbox list', () => {
Expand Down Expand Up @@ -318,3 +320,96 @@ test('fetchConnectorCheckboxStates handles comments with null user', async () =>
assert.strictEqual(states.size, 1);
assert.strictEqual(states.get('valid task'), true);
});

test('resolveAgentType prefers explicit inputs over labels', () => {
const agentType = resolveAgentType({
inputs: { agent_type: 'codex' },
env: { AGENT_TYPE: 'claude' },
pr: { labels: [{ name: 'agent:gemini' }] },
});

assert.strictEqual(agentType, 'codex');
});

test('resolveAgentType falls back to agent label when inputs are missing', () => {
const agentType = resolveAgentType({
inputs: {},
env: {},
pr: { labels: [{ name: 'priority:high' }, { name: 'agent:codex' }] },
});

assert.strictEqual(agentType, 'codex');
});

test('resolveAgentType returns empty string when no agent source is available', () => {
const agentType = resolveAgentType({
inputs: {},
env: {},
pr: { labels: [{ name: 'needs-human' }] },
});

assert.strictEqual(agentType, '');
});

test('buildStatusBlock hides workflow details for CLI agents', () => {
const workflowRuns = new Map([
['gate', {
name: 'Gate',
created_at: '2024-01-02T00:00:00Z',
status: 'completed',
conclusion: 'success',
html_url: 'https://example.com/run',
}],
]);

const output = buildStatusBlock({
scope: '- [ ] Scope item',
tasks: '- [ ] Task item',
acceptance: '- [ ] Acceptance item',
headSha: 'abc123',
workflowRuns,
requiredChecks: ['gate'],
existingBody: '',
connectorStates: new Map(),
core: null,
agentType: 'codex',
});

assert.ok(output.includes('## Automated Status Summary'));
assert.ok(output.includes('#### Scope'));
assert.ok(output.includes('#### Tasks'));
assert.ok(output.includes('#### Acceptance criteria'));
assert.ok(!output.includes('**Head SHA:**'));
assert.ok(!output.includes('**Latest Runs:**'));
assert.ok(!output.includes('**Required:**'));
assert.ok(!output.includes('| Workflow / Job |'));
});

test('buildStatusBlock includes workflow details for non-CLI agents', () => {
const workflowRuns = new Map([
['gate', {
name: 'Gate',
created_at: '2024-01-02T00:00:00Z',
status: 'completed',
conclusion: 'success',
html_url: 'https://example.com/run',
}],
]);

const output = buildStatusBlock({
scope: '- [ ] Scope item',
tasks: '- [ ] Task item',
acceptance: '- [ ] Acceptance item',
headSha: 'abc123',
workflowRuns,
requiredChecks: ['gate'],
existingBody: '',
connectorStates: new Map(),
core: null,
agentType: '',
});

assert.ok(output.includes('**Head SHA:** abc123'));
assert.ok(output.includes('**Required:** gate: ✅ success'));
assert.ok(output.includes('| Workflow / Job |'));
});
35 changes: 35 additions & 0 deletions .github/scripts/__tests__/comment-dedupe.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,38 @@ test('upsertAnchoredComment reads body from file and infers PR from anchor', asy

fs.unlinkSync(commentPath);
});

test('upsertAnchoredComment skips gate summary when agent label is present', async () => {
const actions = [];
const github = {
paginate: async (fn) => {
if (fn === github.rest.issues.listLabelsOnIssue) {
return [{ name: 'agent:codex' }];
}
throw new Error('listComments should not be called');
},
rest: {
issues: {
listLabelsOnIssue: async () => ({ data: [] }),
listComments: async () => ({ data: [] }),
updateComment: async () => actions.push('update'),
createComment: async () => {
actions.push('create');
return { data: { id: 1 } };
},
},
},
};

await upsertAnchoredComment({
github,
context: { repo: { owner: 'octo', repo: 'demo' } },
core: null,
prNumber: 12,
body: 'Gate summary\n<!-- gate-summary: pr=12 head=abc -->',
anchorPattern: /<!--\s*gate-summary:([^>]*)-->/i,
fallbackMarker: '<!-- gate-summary:',
});

assert.deepEqual(actions, []);
});
56 changes: 56 additions & 0 deletions .github/scripts/__tests__/keepalive-loop.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,62 @@ test('updateKeepaliveLoopSummary increments iteration and clears failures on suc
assert.match(github.actions[0].body, /"failure":\{\}/);
});

test('updateKeepaliveLoopSummary writes step summary for agent runs', async () => {
const summary = {
buffer: '',
written: false,
addRaw(text) {
this.buffer += text;
return this;
},
addEOL() {
this.buffer += '\n';
return this;
},
async write() {
this.written = true;
},
};
const core = { info() {}, summary };
const existingState = formatStateComment({
trace: 'trace-summary',
iteration: 0,
max_iterations: 5,
});
const github = buildGithubStub({
comments: [{ id: 55, body: existingState, html_url: 'https://example.com/55' }],
});

await updateKeepaliveLoopSummary({
github,
context: buildContext(789),
core,
inputs: {
prNumber: 789,
action: 'run',
runResult: 'success',
gateConclusion: 'success',
tasksTotal: 5,
tasksUnchecked: 3,
keepaliveEnabled: true,
autofixEnabled: false,
iteration: 0,
maxIterations: 5,
failureThreshold: 3,
trace: 'trace-summary',
agent_files_changed: 2,
},
});

assert.equal(summary.written, true);
assert.match(summary.buffer, /Keepalive iteration summary/);
assert.match(summary.buffer, /Iteration \| 1\/5/);
assert.match(summary.buffer, /Tasks completed \| 2\/5/);
assert.match(summary.buffer, /Tasks completed this run \| 0/);
assert.match(summary.buffer, /Files changed \| 2/);
assert.match(summary.buffer, /Outcome \| success/);
});

test('updateKeepaliveLoopSummary uses state iteration when inputs have stale value', async () => {
// Simulates race condition: evaluate ran with stale iteration=0, but state was updated to iteration=2
const existingState = formatStateComment({
Expand Down
97 changes: 64 additions & 33 deletions .github/scripts/agents_pr_meta_update_body.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ function normalizeWhitespace(text) {
.trim();
}

function normalise(value) {
return String(value ?? '').trim();
}

function extractSection(body, heading) {
if (!body || !heading) {
return '';
Expand Down Expand Up @@ -77,6 +81,26 @@ function parseCheckboxStates(block) {
return states;
}

function resolveAgentType({ inputs = {}, env = {}, pr = {} } = {}) {
const explicit = normalise(inputs.agent_type || inputs.agentType || env.AGENT_TYPE);
if (explicit) {
return explicit;
}
const labels = Array.isArray(pr.labels) ? pr.labels : [];
for (const label of labels) {
const name = typeof label === 'string' ? label : label?.name;
const trimmed = normalise(name);
if (!trimmed) {
continue;
}
const match = trimmed.match(/^agent\s*:\s*(.+)$/i);
if (match && match[1]) {
return match[1].trim().toLowerCase();
}
}
return '';
}

/**
* Merge checkbox states from existingStates into newContent.
* Only unchecked items `- [ ]` in newContent get their state restored.
Expand Down Expand Up @@ -316,8 +340,9 @@ function buildPreamble(sections) {
return lines.join('\n');
}

function buildStatusBlock({scope, tasks, acceptance, headSha, workflowRuns, requiredChecks, existingBody, connectorStates, core}) {
function buildStatusBlock({scope, tasks, acceptance, headSha, workflowRuns, requiredChecks, existingBody, connectorStates, core, agentType}) {
const statusLines = ['<!-- auto-status-summary:start -->', '## Automated Status Summary'];
const isCliAgent = Boolean(agentType && String(agentType).trim());

const existingBlock = extractBlock(existingBody || '', 'auto-status-summary');
const existingStates = parseCheckboxStates(existingBlock);
Expand Down Expand Up @@ -356,45 +381,47 @@ function buildStatusBlock({scope, tasks, acceptance, headSha, workflowRuns, requ
statusLines.push(acceptanceFormatted);
statusLines.push('');

statusLines.push(`**Head SHA:** ${headSha}`);
if (!isCliAgent) {
statusLines.push(`**Head SHA:** ${headSha}`);

const latestRuns = Array.from(workflowRuns.values()).sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
let latestLine = '—';
if (latestRuns.length > 0) {
const gate = latestRuns.find((run) => (run.name || '').toLowerCase() === 'gate');
const chosen = gate || latestRuns[0];
const status = combineStatus(chosen);
latestLine = `${status.icon} ${status.label} — ${chosen.name}`;
}
statusLines.push(`**Latest Runs:** ${latestLine}`);
const latestRuns = Array.from(workflowRuns.values()).sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
let latestLine = '—';
if (latestRuns.length > 0) {
const gate = latestRuns.find((run) => (run.name || '').toLowerCase() === 'gate');
const chosen = gate || latestRuns[0];
const status = combineStatus(chosen);
latestLine = `${status.icon} ${status.label} — ${chosen.name}`;
}
statusLines.push(`**Latest Runs:** ${latestLine}`);

const requiredParts = [];
for (const name of requiredChecks) {
const run = Array.from(workflowRuns.values()).find((item) => (item.name || '').toLowerCase() === name.toLowerCase());
if (!run) {
requiredParts.push(`${name}: ⏸️ not started`);
} else {
const status = combineStatus(run);
requiredParts.push(`${name}: ${status.icon} ${status.label}`);
const requiredParts = [];
for (const name of requiredChecks) {
const run = Array.from(workflowRuns.values()).find((item) => (item.name || '').toLowerCase() === name.toLowerCase());
if (!run) {
requiredParts.push(`${name}: ⏸️ not started`);
} else {
const status = combineStatus(run);
requiredParts.push(`${name}: ${status.icon} ${status.label}`);
}
}
}
statusLines.push(`**Required:** ${requiredParts.length > 0 ? requiredParts.join(', ') : '—'}`);
statusLines.push('');
statusLines.push(`**Required:** ${requiredParts.length > 0 ? requiredParts.join(', ') : '—'}`);
statusLines.push('');

const table = ['| Workflow / Job | Result | Logs |', '|----------------|--------|------|'];
const runs = Array.from(workflowRuns.values()).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const table = ['| Workflow / Job | Result | Logs |', '|----------------|--------|------|'];
const runs = Array.from(workflowRuns.values()).sort((a, b) => (a.name || '').localeCompare(b.name || ''));

if (runs.length === 0) {
table.push('| _(no workflow runs yet for this commit)_ | — | — |');
} else {
for (const run of runs) {
const status = combineStatus(run);
const link = run.html_url ? `[View run](${run.html_url})` : '—';
table.push(`| ${run.name || 'Unnamed workflow'} | ${status.icon} ${status.label} | ${link} |`);
if (runs.length === 0) {
table.push('| _(no workflow runs yet for this commit)_ | — | — |');
} else {
for (const run of runs) {
const status = combineStatus(run);
const link = run.html_url ? `[View run](${run.html_url})` : '—';
table.push(`| ${run.name || 'Unnamed workflow'} | ${status.icon} ${status.label} | ${link} |`);
}
}
}

statusLines.push(...table);
statusLines.push(...table);
}
statusLines.push('<!-- auto-status-summary:end -->');

return statusLines.join('\n');
Expand Down Expand Up @@ -658,6 +685,8 @@ async function run({github, context, core, inputs}) {
// Fetch checkbox states from connector bot comments to merge into status summary
const connectorStates = await fetchConnectorCheckboxStates(github, owner, repo, pr.number, core);

const agentType = resolveAgentType({ inputs, env: process.env, pr });

const statusBlock = buildStatusBlock({
scope,
tasks,
Expand All @@ -668,6 +697,7 @@ async function run({github, context, core, inputs}) {
existingBody: pr.body,
connectorStates,
core,
agentType,
});

const bodyWithPreamble = upsertBlock(pr.body || '', 'pr-preamble', preamble);
Expand Down Expand Up @@ -704,5 +734,6 @@ module.exports = {
buildPreamble,
buildStatusBlock,
withRetries,
resolveAgentType,
discoverPr,
};
Loading
Loading