Skip to content

Commit 7be7eea

Browse files
authored
fix(delivery): support custom pull request bodies (#1012)
Fixes #1011. ## Summary - add `--pr-body <template>` for run and finish, with deterministic issue tokens - persist the unrendered body through detached and resumed runs - correct resumed issue-number lookup and suppress unknown/N/A references - shell-quote custom bodies and legacy finish metadata across GitHub, GitLab, and Azure DevOps ## Validation - 63 focused tests pass - lint passes with repository baseline warnings only - full TypeScript typecheck passes - introduced-change Opcore validation passes - Opcore Zero reports no introduced cycles, duplicates, interface findings, or documentation requirements A local full-suite attempt was stopped after the shared tmpfs filled and cascaded into ENOSPC failures; required GitHub CI is the authoritative clean full-suite run.
1 parent 7475fa3 commit 7be7eea

15 files changed

Lines changed: 487 additions & 54 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ Destructive commands (need permission): `zeroshot kill`, `zeroshot clear`, `zero
8686
| Hosted capsule image | `docker/zeroshot-oecp/`, `scripts/hosted-oecp-image.js` |
8787
| Docker mounts/env | `lib/docker-config.js` |
8888
| Container lifecycle | `src/isolation-manager.js` |
89+
| Pull-request body templates | `src/pr-body-template.js`, `src/agents/git-pusher-template.js` |
8990
| Settings | `lib/settings.js` |
9091
| Legacy settings property selection | `src/repo-settings-access.ts` |
9192
| Cluster wire/domain types | `crates/openengine-cluster-protocol/` |

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ zeroshot run <input> # issue, URL, markdown file, or inline text
106106
zeroshot run 123 --docker # container isolation
107107
zeroshot run 123 --pr # worktree + pull request
108108
zeroshot run 123 --ship # worktree + PR + merge after approval
109+
zeroshot run 123 --pr --pr-body $'## Summary\n\nCustom text\n\n{{issue_reference}}'
109110
zeroshot run 123 -d # background run
110111

111112
zeroshot list # tasks and clusters (--json)
@@ -120,6 +121,11 @@ zeroshot settings # effective settings
120121
zeroshot agents list # available agents
121122
```
122123

124+
`--pr-body` supplies a deterministic pull-request body for `--pr` and `--ship` runs. The
125+
template supports `{{issue_number}}`, `{{issue_title}}`, and `{{issue_reference}}`; all three
126+
expand to empty text for tasks without an issue, so manual runs never emit `Closes #unknown`.
127+
The unrendered template is retained for detached and resumed runs.
128+
123129
</details>
124130

125131
<details>

cli/index.js

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ const {
107107
runLegacyUpdateIfRequested,
108108
} = require('./lib/update-checker');
109109
const { checkBinDirOnPath, printPathWarning } = require('../lib/path-check');
110+
const { quoteShellArgument } = require('../lib/git-remote-utils');
111+
const { renderPullRequestBody, resolveIssueContext } = require('../src/pr-body-template');
110112
const { StatusFooter, AGENT_STATE, ACTIVE_STATES } = require('../src/status-footer');
111113
const { EVENT_COPY, formatMergeStatus } = require('./event-copy');
112114

@@ -2012,9 +2014,25 @@ function buildContextSummary({
20122014
return contextSummary;
20132015
}
20142016

2015-
function buildCompletionPrompt({ contextSummary, taskText, issueNumber, issueTitle }) {
2016-
const mergeGoal = 'CREATE PR AND MERGE IT';
2017-
const mergeStep = `
2017+
function buildDefaultFinishPrBody({ taskText, issueNumber, issueTitle }) {
2018+
const issueReference = renderPullRequestBody(undefined, { issueNumber, issueTitle });
2019+
return [
2020+
issueReference,
2021+
issueReference ? '' : null,
2022+
'## Summary',
2023+
`${String(taskText || 'Unknown task').slice(0, 200)}...`,
2024+
'',
2025+
'## Changes',
2026+
'- Implementation complete',
2027+
'- All validations addressed',
2028+
'',
2029+
'🤖 Generated with zeroshot finish',
2030+
]
2031+
.filter((line) => line !== null)
2032+
.join('\n');
2033+
}
2034+
2035+
const FINISH_MERGE_STEP = `
20182036
8. MERGE THE PR - THIS IS MANDATORY:
20192037
\`\`\`bash
20202038
gh pr merge --merge --auto
@@ -2029,7 +2047,29 @@ function buildCompletionPrompt({ contextSummary, taskText, issueNumber, issueTit
20292047
20302048
REPEAT UNTIL MERGED. DO NOT GIVE UP.`;
20312049

2032-
return `# YOUR MISSION: ${mergeGoal}
2050+
function buildFinishCommandArguments({ taskText, issueNumber, issueTitle, prBody }) {
2051+
const issueContext = resolveIssueContext({ issueNumber, issueTitle });
2052+
const resolvedTitle = issueContext.issueTitle;
2053+
const resolvedBody =
2054+
typeof prBody === 'string'
2055+
? renderPullRequestBody(prBody, { issueNumber, issueTitle: resolvedTitle })
2056+
: buildDefaultFinishPrBody({ taskText, issueNumber, issueTitle: resolvedTitle });
2057+
return {
2058+
commitMessage: quoteShellArgument(resolvedTitle || 'feat: implement task'),
2059+
branch: quoteShellArgument(
2060+
issueContext.issueNumber !== 'unknown'
2061+
? `issue-${issueContext.issueNumber}`
2062+
: 'feature/implementation'
2063+
),
2064+
prTitle: quoteShellArgument(resolvedTitle),
2065+
prBody: quoteShellArgument(resolvedBody),
2066+
};
2067+
}
2068+
2069+
function buildCompletionPrompt({ contextSummary, taskText, issueNumber, issueTitle, prBody }) {
2070+
const args = buildFinishCommandArguments({ taskText, issueNumber, issueTitle, prBody });
2071+
2072+
return `# YOUR MISSION: CREATE PR AND MERGE IT
20332073
20342074
${contextSummary}
20352075
@@ -2050,12 +2090,12 @@ You are the FINISHER. Your ONLY job is to take this cluster's work and push it a
20502090
2. COMMIT ALL CHANGES - Stage and commit everything:
20512091
\`\`\`bash
20522092
git add .
2053-
git commit -m "${issueTitle || 'feat: implement task'}"
2093+
git commit -m ${args.commitMessage}
20542094
\`\`\`
20552095
20562096
3. CREATE BRANCH - Use issue number if available:
20572097
\`\`\`bash
2058-
${issueNumber ? `git checkout -b issue-${issueNumber}` : 'git checkout -b feature/implementation'}
2098+
git checkout -b ${args.branch}
20592099
\`\`\`
20602100
20612101
4. PUSH TO REMOTE:
@@ -2065,16 +2105,7 @@ You are the FINISHER. Your ONLY job is to take this cluster's work and push it a
20652105
20662106
5. CREATE PULL REQUEST:
20672107
\`\`\`bash
2068-
gh pr create --title "${issueTitle || 'Implementation'}" --body "Closes #${issueNumber || 'N/A'}
2069-
2070-
## Summary
2071-
${taskText.slice(0, 200)}...
2072-
2073-
## Changes
2074-
- Implementation complete
2075-
- All validations addressed
2076-
2077-
🤖 Generated with zeroshot finish"
2108+
gh pr create --title ${args.prTitle} --body ${args.prBody}
20782109
\`\`\`
20792110
20802111
6. GET PR URL:
@@ -2083,7 +2114,7 @@ ${taskText.slice(0, 200)}...
20832114
\`\`\`
20842115
20852116
7. OUTPUT THE PR URL - Print it clearly so user can see it
2086-
${mergeStep}
2117+
${FINISH_MERGE_STEP}
20872118
20882119
## RULES
20892120
@@ -2707,6 +2738,10 @@ program
27072738
'Full automation: worktree isolation + PR + auto-merge (use --docker for Docker)'
27082739
)
27092740
.option('--pr-base <branch>', 'Target branch for PRs (default: repo default branch)')
2741+
.option(
2742+
'--pr-body <template>',
2743+
'PR body template; supports {{issue_number}}, {{issue_title}}, and {{issue_reference}}'
2744+
)
27102745
.option('--merge-queue', 'Use GitHub merge queue instead of direct merge')
27112746
.option(
27122747
'--close-issue <mode>',
@@ -3714,6 +3749,10 @@ program
37143749
.helpGroup('Control:')
37153750
.description('Take existing cluster and create completion-focused task (creates PR and merges)')
37163751
.option('-y, --yes', 'Skip confirmation if cluster is running')
3752+
.option(
3753+
'--pr-body <template>',
3754+
'PR body template; supports {{issue_number}}, {{issue_title}}, and {{issue_reference}}'
3755+
)
37173756
.action(async (id, options) => {
37183757
try {
37193758
const orchestrator = await getOrchestrator();
@@ -3732,6 +3771,7 @@ program
37323771
taskText: context.taskText,
37333772
issueNumber: context.issueNumber,
37343773
issueTitle: context.issueTitle,
3774+
prBody: options.prBody ?? cluster.prOptions?.prBody,
37353775
});
37363776
printCompletionPromptPreview(completionPrompt);
37373777

@@ -6190,6 +6230,9 @@ module.exports = {
61906230
isStartupUpdateEligible,
61916231
handleNoArgumentInvocation,
61926232
shouldRunInitialSetup,
6233+
extractFinishContext,
6234+
buildDefaultFinishPrBody,
6235+
buildCompletionPrompt,
61936236
resolveRunMode,
61946237
killRunningClusters,
61956238
};

src/agents/git-pusher-template.js

Lines changed: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ return hasSufficientEvidence;`;
277277
const { readRepoSettings } = require('../../lib/repo-settings');
278278
const { normalizeGitRemoteName, quoteShellArgument } = require('../../lib/git-remote-utils');
279279
const { resolveRequiredQualityGates } = require('../quality-gates');
280+
const { renderPullRequestBody, resolveIssueContext } = require('../pr-body-template');
280281

281282
function getSafeBranchName(value) {
282283
if (typeof value !== 'string') {
@@ -314,35 +315,6 @@ function normalizeCloseIssueMode(value) {
314315
return null;
315316
}
316317

317-
function normalizeIssueNumber(value) {
318-
const candidate = typeof value === 'number' ? String(value) : value;
319-
if (typeof candidate !== 'string') return 'unknown';
320-
const trimmed = candidate.trim();
321-
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed) ? trimmed : 'unknown';
322-
}
323-
324-
function normalizeIssueTitle(value) {
325-
if (typeof value !== 'string' || value.trim() === '') return 'Implementation';
326-
const normalized = [...value]
327-
.map((character) => {
328-
const codePoint = character.codePointAt(0);
329-
return codePoint < 0x20 || codePoint === 0x7f ? ' ' : character;
330-
})
331-
.join('')
332-
.trim();
333-
return normalized || 'Implementation';
334-
}
335-
336-
function resolveIssueContext(options) {
337-
const issueNumber = normalizeIssueNumber(options.issueNumber);
338-
const issueTitle = normalizeIssueTitle(options.issueTitle);
339-
const issueReference =
340-
options.includeIssueReference === false || issueNumber === 'unknown'
341-
? ''
342-
: `Closes #${issueNumber}`;
343-
return { issueNumber, issueTitle, issueReference };
344-
}
345-
346318
/**
347319
* Resolve GitHub configuration from CLI options and repo settings.
348320
* Priority: CLI options > repo settings (.zeroshot/settings.json) > defaults
@@ -355,6 +327,7 @@ function resolveIssueContext(options) {
355327
* @param {string|number} [options.issueNumber] - Typed issue identifier for prompt commands
356328
* @param {string} [options.issueTitle] - Typed issue title for prompt commands
357329
* @param {boolean} [options.includeIssueReference] - Include the closing reference in PR text
330+
* @param {string} [options.prBody] - Literal PR body template with supported issue tokens
358331
* @returns {Object} Resolved configuration
359332
*/
360333
function resolveGitHubConfig(options = {}) {
@@ -385,16 +358,23 @@ function resolveGitHubConfig(options = {}) {
385358
options.autoMerge === true ||
386359
(options.autoMerge !== false && parseBool(repoGithub.autoMerge) === true);
387360

361+
const issueContext = resolveIssueContext(options);
362+
388363
return {
389364
prBase,
390365
useMergeQueue,
391366
closeIssueMode,
392367
autoMerge,
393368
gitRemote,
394-
issueContext: resolveIssueContext(options),
369+
issueContext,
370+
prBody: renderPullRequestBody(options.prBody, options),
395371
};
396372
}
397373

374+
function resolvedPrBody(config, issueContext) {
375+
return typeof config.prBody === 'string' ? config.prBody : issueContext.issueReference;
376+
}
377+
398378
/**
399379
* Generate platform-specific configuration based on resolved GitHub config.
400380
*
@@ -406,13 +386,15 @@ function getPlatformConfig(platform, config = {}) {
406386
const { prBase, useMergeQueue, closeIssueMode, autoMerge, gitRemote } = config;
407387
const issueContext = config.issueContext || resolveIssueContext({});
408388
const issueTitleArgument = quoteShellArgument(`feat: ${issueContext.issueTitle}`);
409-
const issueReferenceArgument = quoteShellArgument(issueContext.issueReference);
389+
const prBodyArgument = quoteShellArgument(resolvedPrBody(config, issueContext));
410390

411391
const PLATFORM_CONFIGS = {
412392
github: {
413393
prName: 'PR',
414394
prNameLower: 'pull request',
415-
createCmd: `gh pr create${prBase ? ` --base ${prBase}` : ''} --title ${issueTitleArgument} --body ${issueReferenceArgument}`,
395+
createCmd:
396+
`gh pr create${prBase ? ` --base ${prBase}` : ''} ` +
397+
`--title ${issueTitleArgument} --body ${prBodyArgument}`,
416398
mergeCmd: useMergeQueue
417399
? `PR_ID="$(timeout 30 gh pr view --json id --jq .id)"
418400
gh api graphql -f query='mutation($id:ID!){enqueuePullRequest(input:{pullRequestId:$id}){mergeQueueEntry{state}}}' -f id="$PR_ID"
@@ -434,7 +416,7 @@ for i in $(seq 1 90); do if timeout 30 gh pr view --json mergedAt --jq .mergedAt
434416
gitlab: {
435417
prName: 'MR',
436418
prNameLower: 'merge request',
437-
createCmd: `glab mr create --title ${issueTitleArgument} --description ${issueReferenceArgument}`,
419+
createCmd: `glab mr create --title ${issueTitleArgument} --description ${prBodyArgument}`,
438420
mergeCmd: 'glab mr merge --auto-merge',
439421
mergeFallbackCmd: 'glab mr merge',
440422
prUrlExample: 'https://gitlab.com/owner/repo/-/merge_requests/123',
@@ -447,7 +429,7 @@ for i in $(seq 1 90); do if timeout 30 gh pr view --json mergedAt --jq .mergedAt
447429
'azure-devops': {
448430
prName: 'PR',
449431
prNameLower: 'pull request',
450-
createCmd: `az repos pr create --title ${issueTitleArgument} --description ${issueReferenceArgument}`,
432+
createCmd: `az repos pr create --title ${issueTitleArgument} --description ${prBodyArgument}`,
451433
mergeCmd: 'az repos pr update --id <PR_ID> --auto-complete true',
452434
mergeFallbackCmd: 'az repos pr update --id <PR_ID> --status completed',
453435
prUrlExample: 'https://dev.azure.com/org/project/_git/repo/pullrequest/123',
@@ -803,6 +785,7 @@ If blocked before creating a ${prName}, output:
803785
* @param {string|number} [options.issueNumber] - Typed issue identifier for prompt commands
804786
* @param {string} [options.issueTitle] - Typed issue title for prompt commands
805787
* @param {boolean} [options.includeIssueReference] - Include the closing reference in PR text
788+
* @param {string} [options.prBody] - Literal PR body template with supported issue tokens
806789
* @param {Array} [options.requiredQualityGates] - Required handoff quality gates
807790
* @param {boolean} [options.autoMerge] - Merge the PR (--ship). False stops after PR creation (--pr).
808791
* @returns {Object} Agent configuration object

src/legacy-lib/detached-startup.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ interface RunOptions extends Record<string, unknown> {
2626
mergeQueue?: unknown;
2727
pr?: unknown;
2828
prBase?: unknown;
29+
prBody?: unknown;
2930
ship?: unknown;
3031
worktree?: unknown;
3132
}
@@ -236,9 +237,10 @@ async function registerDetachedSetupCluster({
236237
setupStartedAt: Date.now(),
237238
setupStage: 'starting',
238239
autoPr: plan.delivery !== 'none',
239-
prOptions: runOptions.prBase
240+
prOptions: plan.delivery !== 'none'
240241
? {
241242
prBase: runOptions.prBase,
243+
prBody: typeof runOptions.prBody === 'string' ? runOptions.prBody : null,
242244
mergeQueue: runOptions.mergeQueue || false,
243245
closeIssue: runOptions.closeIssue || null,
244246
autoMerge: plan.autoMerge,

src/legacy-lib/start-cluster-environment.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ interface RunOptions extends Record<string, unknown> {
1010
mount?: readonly string[] | null;
1111
noIsolation?: unknown;
1212
prBase?: unknown;
13+
prBody?: unknown;
1314
}
1415

1516
interface MountSpec {

src/legacy-lib/start-cluster-run-options.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface RunOptions extends Record<string, unknown> {
2828
noMounts?: unknown;
2929
pr?: unknown;
3030
prBase?: unknown;
31+
prBody?: unknown;
3132
preparedWorktree?: unknown;
3233
requiredQualityGates?: unknown;
3334
ship?: unknown;
@@ -201,6 +202,7 @@ function buildStartOptionsFromPlan({
201202
containerHome: optionalValue(options.containerHome),
202203
forceProvider: optionalValue(forceProvider),
203204
prBase: environment ? resolvePrBase(options) : optionalValue(options.prBase),
205+
prBody: typeof options.prBody === 'string' ? options.prBody : undefined,
204206
mergeQueue: environment ? resolveMergeQueue(options) : optionalValue(options.mergeQueue),
205207
closeIssue: environment ? resolveCloseIssue(options) : optionalValue(options.closeIssue),
206208
ship: plan.delivery === 'ship',

src/orchestrator.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ function buildPrOptions(options, requiredQualityGates) {
262262
prBase: options.prBase || null,
263263
mergeQueue: options.mergeQueue || false,
264264
closeIssue: options.closeIssue || null,
265+
prBody: typeof options.prBody === 'string' ? options.prBody : null,
265266
gitRemote: options.gitRemote || null,
266267
autoMerge,
267268
...(requiredQualityGates.length > 0 ? { requiredQualityGates } : {}),
@@ -2038,6 +2039,7 @@ class Orchestrator {
20382039
prBase: options.prBase,
20392040
mergeQueue: options.mergeQueue,
20402041
closeIssue: options.closeIssue,
2042+
prBody: options.prBody,
20412043
requiredQualityGates: options.requiredQualityGates,
20422044
autoMerge: resolveRunPlan(options).autoMerge,
20432045
gitRemote: gitContext?.remote || options.gitRemote,
@@ -4225,7 +4227,7 @@ Continue from where you left off. Review your previous output to understand what
42254227

42264228
// Get issue context from ledger
42274229
const issueMsg = cluster.messageBus.ledger.findLast({ topic: 'ISSUE_OPENED' });
4228-
const issueNumber = issueMsg?.content?.data?.number || 'unknown';
4230+
const issueNumber = issueMsg?.content?.data?.issue_number || 'unknown';
42294231
const issueTitle = issueMsg?.content?.data?.title || 'Implementation';
42304232

42314233
// Generate the final prompt in one typed assembly pass. Issue values are

0 commit comments

Comments
 (0)