edsger 0.48.0 → 0.49.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.
|
@@ -33,14 +33,8 @@ export const runRunSheetCommand = async (options) => {
|
|
|
33
33
|
if (result.cloneError) {
|
|
34
34
|
logInfo(`Clone warning: ${result.cloneError}`);
|
|
35
35
|
}
|
|
36
|
-
if (result.
|
|
37
|
-
logInfo(`
|
|
38
|
-
}
|
|
39
|
-
if (result.missingPlaceholders && result.missingPlaceholders.length > 0) {
|
|
40
|
-
logInfo(`Unresolved placeholders: ${result.missingPlaceholders.join(', ')}`);
|
|
41
|
-
}
|
|
42
|
-
if (result.commitsTruncated) {
|
|
43
|
-
logInfo('Commit list truncated at GitHub 250-commit cap.');
|
|
36
|
+
if (typeof result.agentTurns === 'number') {
|
|
37
|
+
logInfo(`Agent used ${result.agentTurns} turn(s)${result.model ? ` on model ${result.model}` : ''}.`);
|
|
44
38
|
}
|
|
45
39
|
logInfo('View it in the Release detail page of your product dashboard.');
|
|
46
40
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-sheet generation via the Claude Agent SDK.
|
|
3
|
+
*
|
|
4
|
+
* The agent is handed the product's cloned repo (checked out at the
|
|
5
|
+
* release ref) plus release metadata and a free-form template, and is
|
|
6
|
+
* asked to produce the final run sheet Markdown. Unlike the previous
|
|
7
|
+
* placeholder-based renderer, the template here is a structural guide
|
|
8
|
+
* — it may contain no placeholders at all. The agent reads the diff
|
|
9
|
+
* and source to decide what each section should say.
|
|
10
|
+
*/
|
|
11
|
+
export interface AgentReleaseContext {
|
|
12
|
+
tag: string;
|
|
13
|
+
name: string | null;
|
|
14
|
+
body: string | null;
|
|
15
|
+
url: string | null;
|
|
16
|
+
published_at: string | null;
|
|
17
|
+
previous_tag: string | null;
|
|
18
|
+
previous_published_at: string | null;
|
|
19
|
+
}
|
|
20
|
+
export interface AgentProductContext {
|
|
21
|
+
name: string;
|
|
22
|
+
github_repository_full_name: string | null;
|
|
23
|
+
}
|
|
24
|
+
export interface RunSheetAgentInput {
|
|
25
|
+
product: AgentProductContext;
|
|
26
|
+
release: AgentReleaseContext;
|
|
27
|
+
template: string;
|
|
28
|
+
repoDir: string | null;
|
|
29
|
+
isDraft: boolean;
|
|
30
|
+
draftBaseRef: string | null;
|
|
31
|
+
verbose?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface RunSheetAgentResult {
|
|
34
|
+
content: string;
|
|
35
|
+
turns: number;
|
|
36
|
+
model: string;
|
|
37
|
+
subtype: string;
|
|
38
|
+
error: string | null;
|
|
39
|
+
}
|
|
40
|
+
/** Exported for unit tests; not part of the public CLI surface. */
|
|
41
|
+
export declare function buildUserPrompt(input: RunSheetAgentInput): string;
|
|
42
|
+
/**
|
|
43
|
+
* Strip a single Markdown code fence wrapping the entire response.
|
|
44
|
+
* Defensive: the prompt forbids it, but models still occasionally do it.
|
|
45
|
+
*
|
|
46
|
+
* Exported for unit tests.
|
|
47
|
+
*/
|
|
48
|
+
export declare function stripWrappingFence(text: string): string;
|
|
49
|
+
export declare function generateRunSheetWithAgent(input: RunSheetAgentInput): Promise<RunSheetAgentResult>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-sheet generation via the Claude Agent SDK.
|
|
3
|
+
*
|
|
4
|
+
* The agent is handed the product's cloned repo (checked out at the
|
|
5
|
+
* release ref) plus release metadata and a free-form template, and is
|
|
6
|
+
* asked to produce the final run sheet Markdown. Unlike the previous
|
|
7
|
+
* placeholder-based renderer, the template here is a structural guide
|
|
8
|
+
* — it may contain no placeholders at all. The agent reads the diff
|
|
9
|
+
* and source to decide what each section should say.
|
|
10
|
+
*/
|
|
11
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
12
|
+
import { DEFAULT_MODEL } from '../../constants.js';
|
|
13
|
+
import { logError, logInfo } from '../../utils/logger.js';
|
|
14
|
+
// Dropped from the agent's tool surface entirely so it cannot mutate the
|
|
15
|
+
// cloned repo or anything else on disk. Bash stays available — we further
|
|
16
|
+
// constrain it to read-only commands via the system prompt.
|
|
17
|
+
const DISALLOWED_TOOLS = ['Edit', 'Write', 'NotebookEdit'];
|
|
18
|
+
const SYSTEM_PROMPT = `You are generating a release run sheet for a software product.
|
|
19
|
+
|
|
20
|
+
A run sheet is a release-readiness document: it summarises what is in the release, what operators need to do to ship it safely, and what to verify after rollout. You will receive product/release metadata and a TEMPLATE that describes the desired structure. The template is authoritative guidance — match its headings, section order, tone, and level of detail. It may be free-form prose with no placeholders or variable references at all.
|
|
21
|
+
|
|
22
|
+
You have READ-ONLY access to the product's git repository, already cloned and checked out at the release ref (or the default branch for drafts), via the Bash, Read, Grep, and Glob tools. Use them to ground your output in real code:
|
|
23
|
+
- \`git --no-pager log --no-decorate --pretty=format:"%h %s (%an)" <previous_ref>..HEAD\` to enumerate commits in the release
|
|
24
|
+
- \`git --no-pager diff --stat <previous_ref>..HEAD\` for the files-changed summary
|
|
25
|
+
- \`git --no-pager diff <previous_ref>..HEAD -- <path>\` to inspect specific changes when writing a section
|
|
26
|
+
- \`git --no-pager show <sha>\` to inspect an individual commit
|
|
27
|
+
- README / CHANGELOG / package manifests for product-level context
|
|
28
|
+
|
|
29
|
+
Bash safety rules (strict):
|
|
30
|
+
- Use \`git --no-pager ...\` for every git invocation so output never paginates.
|
|
31
|
+
- ONLY run read-only commands. Allowed: \`git log\`, \`git diff\`, \`git show\`, \`git status\`, \`git rev-parse\`, \`git ls-files\`, \`git config --get\`, \`cat\`, \`head\`, \`tail\`, \`wc\`, \`ls\`. Forbidden: anything that mutates state — \`git push\`, \`git commit\`, \`git reset\`, \`git checkout\`, \`git fetch\`, \`git pull\`, \`git clean\`, \`git config <set>\`, \`rm\`, \`mv\`, \`cp\`, \`chmod\`, \`curl\`, \`wget\`, package managers, build commands.
|
|
32
|
+
- Prefer Read/Grep/Glob over Bash for plain file inspection.
|
|
33
|
+
|
|
34
|
+
Output rules:
|
|
35
|
+
1. Output ONLY the final run-sheet Markdown. No preamble ("Here is..."), no trailing commentary, no wrapping code fence around the whole document.
|
|
36
|
+
2. Follow the structure of the TEMPLATE exactly — same section headings in the same order. If the template is free-form prose, infer sensible sections from it.
|
|
37
|
+
3. Ground every claim in the repo. Do not invent features, APIs, or risks that are not evidenced by the diff or code.
|
|
38
|
+
4. If the release is marked DRAFT, include a short "Draft" note near the top explaining the tag is not yet cut.
|
|
39
|
+
5. Prefer concise bullet points over long paragraphs for actionable items. Aim for under 600 lines of Markdown total — be specific, not exhaustive.
|
|
40
|
+
6. If the diff is empty or there is no previous ref to compare against, state that plainly rather than fabricating content.`;
|
|
41
|
+
function userMessage(content) {
|
|
42
|
+
return { type: 'user', message: { role: 'user', content } };
|
|
43
|
+
}
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/require-await -- async generator required by SDK interface
|
|
45
|
+
async function* makePrompt(text) {
|
|
46
|
+
yield userMessage(text);
|
|
47
|
+
}
|
|
48
|
+
/** Exported for unit tests; not part of the public CLI surface. */
|
|
49
|
+
export function buildUserPrompt(input) {
|
|
50
|
+
const { product, release, template, isDraft, draftBaseRef } = input;
|
|
51
|
+
const baseRef = release.previous_tag ?? '(none — no previous release)';
|
|
52
|
+
const headRef = isDraft && draftBaseRef ? draftBaseRef : release.tag;
|
|
53
|
+
const draftLine = isDraft
|
|
54
|
+
? `DRAFT: true — the tag \`${release.tag}\` has not been cut on GitHub yet; the working tree is the default branch \`${draftBaseRef ?? 'HEAD'}\`.`
|
|
55
|
+
: 'DRAFT: false';
|
|
56
|
+
const metadata = [
|
|
57
|
+
`Product: ${product.name}`,
|
|
58
|
+
`Repository: ${product.github_repository_full_name ?? '(unknown)'}`,
|
|
59
|
+
`Release tag: ${release.tag}`,
|
|
60
|
+
`Release name: ${release.name ?? release.tag}`,
|
|
61
|
+
`Previous tag: ${release.previous_tag ?? '(none)'}`,
|
|
62
|
+
`Published at: ${release.published_at ?? '(unpublished)'}`,
|
|
63
|
+
`Previous published at: ${release.previous_published_at ?? '(n/a)'}`,
|
|
64
|
+
`Release URL: ${release.url ?? '(n/a)'}`,
|
|
65
|
+
draftLine,
|
|
66
|
+
`Compare range: ${baseRef}..${headRef}`,
|
|
67
|
+
].join('\n');
|
|
68
|
+
const body = release.body?.trim()
|
|
69
|
+
? `\n\n--- RELEASE NOTES (from GitHub release body) ---\n${release.body.trim()}`
|
|
70
|
+
: '';
|
|
71
|
+
return `Here is the context for the release you are writing a run sheet for.
|
|
72
|
+
|
|
73
|
+
--- METADATA ---
|
|
74
|
+
${metadata}${body}
|
|
75
|
+
|
|
76
|
+
--- TEMPLATE (follow its structure; it may contain no placeholders) ---
|
|
77
|
+
${template}
|
|
78
|
+
|
|
79
|
+
--- INSTRUCTIONS ---
|
|
80
|
+
Inspect the repo (it is your current working directory) and produce the final run sheet in Markdown. Output only the Markdown — no preamble, no wrapping fence.`;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Strip a single Markdown code fence wrapping the entire response.
|
|
84
|
+
* Defensive: the prompt forbids it, but models still occasionally do it.
|
|
85
|
+
*
|
|
86
|
+
* Exported for unit tests.
|
|
87
|
+
*/
|
|
88
|
+
export function stripWrappingFence(text) {
|
|
89
|
+
const trimmed = text.trim();
|
|
90
|
+
const fence = trimmed.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```\s*$/);
|
|
91
|
+
return fence ? fence[1].trim() : trimmed;
|
|
92
|
+
}
|
|
93
|
+
function summariseToolUse(name, input, turn) {
|
|
94
|
+
const i = (input ?? {});
|
|
95
|
+
const descRaw = i.description ?? i.command ?? 'Running...';
|
|
96
|
+
const desc = typeof descRaw === 'string' ? descRaw : 'Running...';
|
|
97
|
+
return `[Turn ${turn}] ${name}: ${desc.slice(0, 160)}`;
|
|
98
|
+
}
|
|
99
|
+
// eslint-disable-next-line complexity -- agent loop with message type handling
|
|
100
|
+
export async function generateRunSheetWithAgent(input) {
|
|
101
|
+
const { repoDir } = input;
|
|
102
|
+
let assistantText = '';
|
|
103
|
+
let resultText = '';
|
|
104
|
+
let turns = 0;
|
|
105
|
+
let subtype = 'unknown';
|
|
106
|
+
let error = null;
|
|
107
|
+
logInfo('Connecting to Claude Agent SDK for run-sheet generation...');
|
|
108
|
+
const userPrompt = buildUserPrompt(input);
|
|
109
|
+
for await (const message of query({
|
|
110
|
+
prompt: makePrompt(userPrompt),
|
|
111
|
+
options: {
|
|
112
|
+
systemPrompt: {
|
|
113
|
+
type: 'preset',
|
|
114
|
+
preset: 'claude_code',
|
|
115
|
+
append: SYSTEM_PROMPT,
|
|
116
|
+
},
|
|
117
|
+
model: DEFAULT_MODEL,
|
|
118
|
+
permissionMode: 'bypassPermissions',
|
|
119
|
+
disallowedTools: DISALLOWED_TOOLS,
|
|
120
|
+
...(repoDir ? { cwd: repoDir } : {}),
|
|
121
|
+
},
|
|
122
|
+
})) {
|
|
123
|
+
if (message.type === 'assistant' && message.message?.content) {
|
|
124
|
+
turns++;
|
|
125
|
+
for (const content of message.message.content) {
|
|
126
|
+
if (content.type === 'text') {
|
|
127
|
+
assistantText += `${content.text}\n`;
|
|
128
|
+
}
|
|
129
|
+
else if (content.type === 'tool_use') {
|
|
130
|
+
// Always emit tool-use lines (not gated on verbose) so the
|
|
131
|
+
// desktop CliTerminal shows live progress instead of going
|
|
132
|
+
// silent for the duration of the agent run.
|
|
133
|
+
logInfo(summariseToolUse(content.name, content.input, turns));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (message.type === 'result') {
|
|
138
|
+
subtype = message.subtype;
|
|
139
|
+
resultText = ('result' in message ? (message.result ?? '') : '') || '';
|
|
140
|
+
if (message.subtype !== 'success') {
|
|
141
|
+
error = `Agent finished with subtype=${message.subtype}`;
|
|
142
|
+
logError(`Run-sheet agent incomplete: ${message.subtype}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const raw = resultText.trim() || assistantText.trim();
|
|
147
|
+
if (!raw) {
|
|
148
|
+
throw new Error(`Run-sheet agent produced no output${error ? ` (${error})` : ''}`);
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
content: stripWrappingFence(raw),
|
|
152
|
+
turns,
|
|
153
|
+
model: DEFAULT_MODEL,
|
|
154
|
+
subtype,
|
|
155
|
+
error,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Given a release, fetches the product's `run_sheet_template`, clones the
|
|
5
5
|
* repo at the release tag (reusing the workspace layout used by
|
|
6
|
-
* smoke-test),
|
|
7
|
-
*
|
|
6
|
+
* smoke-test), and hands the template + repo to the Claude Agent SDK
|
|
7
|
+
* which reads the diff and produces the run sheet Markdown. The result
|
|
8
|
+
* is upserted to the `run_sheets` table.
|
|
8
9
|
*
|
|
9
10
|
* Release tags are immutable, so if an existing run sheet has the same
|
|
10
11
|
* template_snapshot + tag and no clone error, we short-circuit without
|
|
11
12
|
* re-cloning. Pass `{ force: true }` to regenerate anyway.
|
|
12
13
|
*/
|
|
14
|
+
import { type RunSheet } from '../../api/run-sheets.js';
|
|
13
15
|
export interface RunSheetOptions {
|
|
14
16
|
releaseId: string;
|
|
15
17
|
force?: boolean;
|
|
@@ -21,11 +23,10 @@ export interface RunSheetResult {
|
|
|
21
23
|
releaseTag?: string;
|
|
22
24
|
summary: string;
|
|
23
25
|
runSheetId?: string;
|
|
24
|
-
missingPlaceholders?: string[];
|
|
25
26
|
cloneError?: string | null;
|
|
26
|
-
commitsError?: string | null;
|
|
27
|
-
commitsTruncated?: boolean;
|
|
28
27
|
isDraft?: boolean;
|
|
28
|
+
agentTurns?: number;
|
|
29
|
+
model?: string;
|
|
29
30
|
}
|
|
30
31
|
/**
|
|
31
32
|
* Atomic-create a `.lock` file alongside the workspace dir. Returns
|
|
@@ -51,4 +52,6 @@ export declare function releaseFileLock(lockPath: string): void;
|
|
|
51
52
|
export declare function pruneStaleRunSheetWorkspaces(workspaceRoot: string, now?: number, maxAgeMs?: number): {
|
|
52
53
|
removed: string[];
|
|
53
54
|
};
|
|
55
|
+
/** Exported for unit tests; not part of the public CLI surface. */
|
|
56
|
+
export declare function runSheetIsFresh(existing: RunSheet | null, template: string, tag: string): boolean;
|
|
54
57
|
export declare function runRunSheet(options: RunSheetOptions): Promise<RunSheetResult>;
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Given a release, fetches the product's `run_sheet_template`, clones the
|
|
5
5
|
* repo at the release tag (reusing the workspace layout used by
|
|
6
|
-
* smoke-test),
|
|
7
|
-
*
|
|
6
|
+
* smoke-test), and hands the template + repo to the Claude Agent SDK
|
|
7
|
+
* which reads the diff and produces the run sheet Markdown. The result
|
|
8
|
+
* is upserted to the `run_sheets` table.
|
|
8
9
|
*
|
|
9
10
|
* Release tags are immutable, so if an existing run sheet has the same
|
|
10
11
|
* template_snapshot + tag and no clone error, we short-circuit without
|
|
11
12
|
* re-cloning. Pass `{ force: true }` to regenerate anyway.
|
|
12
13
|
*/
|
|
13
|
-
import { createHash } from 'crypto';
|
|
14
14
|
import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'fs';
|
|
15
15
|
import { join } from 'path';
|
|
16
16
|
import { getGitHubConfigByProduct } from '../../api/github.js';
|
|
@@ -18,36 +18,9 @@ import { getProduct } from '../../api/products.js';
|
|
|
18
18
|
import { getRelease } from '../../api/releases.js';
|
|
19
19
|
import { getRunSheetByRelease, upsertRunSheet, } from '../../api/run-sheets.js';
|
|
20
20
|
import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
|
|
21
|
-
import { cloneFeatureRepo, ensureWorkspaceDir, getFeatureRepoPath, syncRepoToRef, } from '../../workspace/workspace-manager.js';
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
async function fetchCommitsBetween(owner, repo, base, head, token) {
|
|
25
|
-
if (!base) {
|
|
26
|
-
return { text: '', list: [], truncated: false };
|
|
27
|
-
}
|
|
28
|
-
try {
|
|
29
|
-
const { commits, truncated } = await fetchAllCompareCommits(owner, repo, base, head, token);
|
|
30
|
-
const list = commits.map((c) => {
|
|
31
|
-
const message = c.commit.message ?? '';
|
|
32
|
-
const summary = message.split('\n')[0];
|
|
33
|
-
return {
|
|
34
|
-
sha: c.sha,
|
|
35
|
-
short_sha: c.sha.slice(0, 7),
|
|
36
|
-
summary,
|
|
37
|
-
message,
|
|
38
|
-
author: c.commit.author?.name ?? '',
|
|
39
|
-
url: c.html_url ?? '',
|
|
40
|
-
};
|
|
41
|
-
});
|
|
42
|
-
const text = list.map((c) => `- ${c.short_sha} ${c.summary}`).join('\n');
|
|
43
|
-
return { text, list, truncated };
|
|
44
|
-
}
|
|
45
|
-
catch (err) {
|
|
46
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
47
|
-
logWarning(`Could not fetch commits for run sheet: ${message}`);
|
|
48
|
-
return { text: '', list: [], truncated: false, error: message };
|
|
49
|
-
}
|
|
50
|
-
}
|
|
21
|
+
import { cloneFeatureRepo, ensureWorkspaceDir, getFeatureRepoPath, isSafeGitRef, syncRepoToRef, } from '../../workspace/workspace-manager.js';
|
|
22
|
+
import { getDefaultBranchHead } from '../release-sync/github.js';
|
|
23
|
+
import { generateRunSheetWithAgent } from './agent.js';
|
|
51
24
|
// Stale locks (e.g. left behind by a crashed or SIGKILLed CLI) are
|
|
52
25
|
// considered abandoned after this many ms.
|
|
53
26
|
const LOCK_STALE_MS = 15 * 60 * 1000;
|
|
@@ -194,18 +167,8 @@ export function pruneStaleRunSheetWorkspaces(workspaceRoot, now = Date.now(), ma
|
|
|
194
167
|
}
|
|
195
168
|
return { removed };
|
|
196
169
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
h.update(`template:${parts.template}\n`);
|
|
200
|
-
h.update(`tag:${parts.tag}\n`);
|
|
201
|
-
h.update(`prev:${parts.previousTag ?? ''}\n`);
|
|
202
|
-
for (const f of [...parts.filesRead].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
203
|
-
h.update(`file:${f.path}:${f.bytes}\n`);
|
|
204
|
-
}
|
|
205
|
-
h.update(`commits:${parts.commitShas.join(',')}\n`);
|
|
206
|
-
return h.digest('hex');
|
|
207
|
-
}
|
|
208
|
-
function runSheetIsFresh(existing, template, tag) {
|
|
170
|
+
/** Exported for unit tests; not part of the public CLI surface. */
|
|
171
|
+
export function runSheetIsFresh(existing, template, tag) {
|
|
209
172
|
if (!existing || !existing.content) {
|
|
210
173
|
return false;
|
|
211
174
|
}
|
|
@@ -219,9 +182,17 @@ function runSheetIsFresh(existing, template, tag) {
|
|
|
219
182
|
if (meta.clone_error) {
|
|
220
183
|
return false;
|
|
221
184
|
}
|
|
185
|
+
if (meta.agent_error) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
// Drafts are rendered from the default branch HEAD, which moves. Always
|
|
189
|
+
// regenerate so the agent sees current code.
|
|
190
|
+
if (meta.is_draft) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
222
193
|
return true;
|
|
223
194
|
}
|
|
224
|
-
// eslint-disable-next-line complexity -- orchestration with cache, clone, lock,
|
|
195
|
+
// eslint-disable-next-line complexity -- orchestration with cache, clone, lock, agent
|
|
225
196
|
export async function runRunSheet(options) {
|
|
226
197
|
const { releaseId, force, verbose } = options;
|
|
227
198
|
let release;
|
|
@@ -255,7 +226,7 @@ export async function runRunSheet(options) {
|
|
|
255
226
|
summary: 'Product has no run_sheet_template configured. Set one in product settings.',
|
|
256
227
|
};
|
|
257
228
|
}
|
|
258
|
-
// Short-circuit cache: identical template + tag, no prior clone error.
|
|
229
|
+
// Short-circuit cache: identical template + tag, no prior clone / agent error.
|
|
259
230
|
if (!force) {
|
|
260
231
|
const existing = await getRunSheetByRelease(releaseId, verbose).catch(() => null);
|
|
261
232
|
if (existing && runSheetIsFresh(existing, template, release.tag)) {
|
|
@@ -266,38 +237,25 @@ export async function runRunSheet(options) {
|
|
|
266
237
|
releaseTag: release.tag,
|
|
267
238
|
runSheetId: existing.id,
|
|
268
239
|
summary: 'Cached (no change since last render)',
|
|
269
|
-
missingPlaceholders: existing.metadata?.missing_placeholders ?? [],
|
|
270
240
|
cloneError: null,
|
|
271
|
-
commitsTruncated: Boolean(existing.metadata?.commits_truncated),
|
|
272
241
|
};
|
|
273
242
|
}
|
|
274
243
|
}
|
|
275
244
|
const gh = await getGitHubConfigByProduct(release.product_id, verbose);
|
|
276
245
|
const repoConfigured = gh.configured && gh.token && gh.owner && gh.repo;
|
|
277
|
-
// Clone at tag + fetch commits (both best-effort — we still render with
|
|
278
|
-
// whatever metadata we have).
|
|
279
246
|
let repoDir = null;
|
|
280
247
|
let cloneError = null;
|
|
281
|
-
let commitsError = null;
|
|
282
|
-
let commits = '';
|
|
283
|
-
let commitsList = [];
|
|
284
|
-
let commitsTruncated = false;
|
|
285
248
|
let lockPath = null;
|
|
286
249
|
// Draft mode: the release's tag doesn't exist on GitHub yet (e.g. a
|
|
287
250
|
// Maven SNAPSHOT being planned pre-cut). We still want to produce a
|
|
288
|
-
// useful run sheet, so we fall back to the default branch HEAD
|
|
289
|
-
// both the checkout and the commits compare.
|
|
251
|
+
// useful run sheet, so we fall back to the default branch HEAD.
|
|
290
252
|
let isDraft = false;
|
|
291
253
|
let draftBaseRef = null;
|
|
292
254
|
if (repoConfigured && gh.owner && gh.repo && gh.token) {
|
|
293
255
|
const { owner, repo, token } = gh;
|
|
294
256
|
// Serialise concurrent CLI invocations for the *same release* by
|
|
295
|
-
// taking a file lock next to the clone dir.
|
|
296
|
-
// different lock files (per-release workspace).
|
|
257
|
+
// taking a file lock next to the clone dir.
|
|
297
258
|
const workspaceRoot = ensureWorkspaceDir();
|
|
298
|
-
// Sweep old per-release workspaces before cloning. Cheap (one
|
|
299
|
-
// readdir + stat per entry); silent unless something is actually
|
|
300
|
-
// removed.
|
|
301
259
|
const pruned = pruneStaleRunSheetWorkspaces(workspaceRoot);
|
|
302
260
|
if (pruned.removed.length > 0) {
|
|
303
261
|
logInfo(`Pruned ${pruned.removed.length} stale run-sheet workspace(s).`);
|
|
@@ -314,86 +272,90 @@ export async function runRunSheet(options) {
|
|
|
314
272
|
};
|
|
315
273
|
}
|
|
316
274
|
try {
|
|
317
|
-
// Clone into a per-release directory so two releases of the same
|
|
318
|
-
// product don't stomp each other's checkout during concurrent
|
|
319
|
-
// generation. (smoke-test uses `release-${product_id}` — that races;
|
|
320
|
-
// we don't want to inherit that bug here.)
|
|
321
275
|
const { repoPath } = cloneFeatureRepo(workspaceRoot, workspaceName, owner, repo, token);
|
|
322
276
|
repoDir = repoPath;
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
277
|
+
// SNAPSHOT releases have no GitHub release object backing them, so
|
|
278
|
+
// `published_at` is null. Use that as a free local signal to skip
|
|
279
|
+
// the tag-checkout entirely.
|
|
280
|
+
const expectDraft = release.published_at === null;
|
|
281
|
+
if (expectDraft) {
|
|
282
|
+
logInfo(`Tag ${release.tag} not yet cut (no published_at) — generating draft run sheet from default branch.`);
|
|
329
283
|
try {
|
|
330
284
|
const { branch } = await getDefaultBranchHead(owner, repo, token);
|
|
331
285
|
syncRepoToRef(repoPath, { branch }, token);
|
|
332
286
|
isDraft = true;
|
|
333
287
|
draftBaseRef = branch;
|
|
334
|
-
cloneError = `Tag ${release.tag} not found; fell back to default branch ${branch}. (${tagErr})`;
|
|
335
288
|
}
|
|
336
289
|
catch (fallbackErr) {
|
|
337
|
-
cloneError = `Could not
|
|
290
|
+
cloneError = `Could not check out default branch for draft run sheet: ${fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)}`;
|
|
338
291
|
logWarning(cloneError);
|
|
339
292
|
}
|
|
340
293
|
}
|
|
294
|
+
else {
|
|
295
|
+
try {
|
|
296
|
+
syncRepoToRef(repoPath, { tag: release.tag }, token);
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
const tagErr = err instanceof Error ? err.message : String(err);
|
|
300
|
+
logWarning(`Tag ${release.tag} checkout failed — falling back to default branch.`);
|
|
301
|
+
try {
|
|
302
|
+
const { branch } = await getDefaultBranchHead(owner, repo, token);
|
|
303
|
+
syncRepoToRef(repoPath, { branch }, token);
|
|
304
|
+
isDraft = true;
|
|
305
|
+
draftBaseRef = branch;
|
|
306
|
+
cloneError = `Tag ${release.tag} could not be checked out; fell back to default branch ${branch}. (${tagErr})`;
|
|
307
|
+
}
|
|
308
|
+
catch (fallbackErr) {
|
|
309
|
+
cloneError = `Could not checkout tag ${release.tag} or fall back to default branch: ${fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)}`;
|
|
310
|
+
logWarning(cloneError);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
341
314
|
}
|
|
342
315
|
catch (err) {
|
|
343
316
|
cloneError = err instanceof Error ? err.message : String(err);
|
|
344
317
|
logWarning(`Clone failed: ${cloneError}`);
|
|
345
318
|
}
|
|
346
|
-
const head = isDraft && draftBaseRef ? draftBaseRef : release.tag;
|
|
347
|
-
const c = await fetchCommitsBetween(owner, repo, release.previous_tag, head, token);
|
|
348
|
-
commits = c.text;
|
|
349
|
-
commitsList = c.list;
|
|
350
|
-
commitsTruncated = c.truncated;
|
|
351
|
-
commitsError = c.error ?? null;
|
|
352
319
|
}
|
|
353
320
|
else {
|
|
354
321
|
cloneError = 'Product is not linked to a GitHub repository';
|
|
355
322
|
}
|
|
356
|
-
const draftNotice = isDraft
|
|
357
|
-
? `> ⚠️ **Draft run sheet** — tag \`${release.tag}\` does not exist on GitHub yet. Rendered from default branch${draftBaseRef ? ` \`${draftBaseRef}\`` : ''}; regenerate after the tag is cut for final content.`
|
|
358
|
-
: '';
|
|
359
|
-
const { rendered, missing, filesRead } = await renderTemplate(template, {
|
|
360
|
-
name: product?.name ?? 'Unknown product',
|
|
361
|
-
github_repository_full_name: product?.github_repository_full_name ?? null,
|
|
362
|
-
}, {
|
|
363
|
-
tag: release.tag,
|
|
364
|
-
name: release.name,
|
|
365
|
-
body: release.body,
|
|
366
|
-
url: release.url,
|
|
367
|
-
published_at: release.published_at,
|
|
368
|
-
previous_tag: release.previous_tag,
|
|
369
|
-
previous_published_at: release.previous_published_at,
|
|
370
|
-
diff_summary: release.diff_summary,
|
|
371
|
-
diff_stats: release.diff_stats ?? {},
|
|
372
|
-
}, repoDir, commits, draftNotice, commitsList);
|
|
373
|
-
const inputHash = computeInputHash({
|
|
374
|
-
template,
|
|
375
|
-
tag: release.tag,
|
|
376
|
-
previousTag: release.previous_tag,
|
|
377
|
-
filesRead,
|
|
378
|
-
commitShas: commitsList.map((c) => c.sha),
|
|
379
|
-
});
|
|
380
323
|
try {
|
|
324
|
+
const agentResult = await generateRunSheetWithAgent({
|
|
325
|
+
product: {
|
|
326
|
+
name: product?.name ?? 'Unknown product',
|
|
327
|
+
github_repository_full_name: product?.github_repository_full_name ?? null,
|
|
328
|
+
},
|
|
329
|
+
release: {
|
|
330
|
+
tag: release.tag,
|
|
331
|
+
name: release.name,
|
|
332
|
+
body: release.body,
|
|
333
|
+
url: release.url,
|
|
334
|
+
published_at: release.published_at,
|
|
335
|
+
previous_tag: release.previous_tag,
|
|
336
|
+
previous_published_at: release.previous_published_at,
|
|
337
|
+
},
|
|
338
|
+
template,
|
|
339
|
+
repoDir,
|
|
340
|
+
isDraft,
|
|
341
|
+
draftBaseRef,
|
|
342
|
+
verbose,
|
|
343
|
+
});
|
|
381
344
|
const saved = await upsertRunSheet({
|
|
382
345
|
release_id: release.id,
|
|
383
|
-
content:
|
|
346
|
+
content: agentResult.content,
|
|
384
347
|
title: `Run Sheet — ${product?.name ?? ''} ${release.tag}`.trim(),
|
|
385
348
|
template_snapshot: template,
|
|
386
349
|
metadata: {
|
|
387
|
-
missing_placeholders: missing,
|
|
388
350
|
clone_error: cloneError,
|
|
389
|
-
commits_error: commitsError,
|
|
390
351
|
repo: product?.github_repository_full_name ?? null,
|
|
391
352
|
tag: release.tag,
|
|
392
|
-
commits_truncated: commitsTruncated,
|
|
393
353
|
is_draft: isDraft,
|
|
394
354
|
draft_base_ref: draftBaseRef,
|
|
395
|
-
|
|
396
|
-
|
|
355
|
+
model: agentResult.model,
|
|
356
|
+
agent_turns: agentResult.turns,
|
|
357
|
+
agent_subtype: agentResult.subtype,
|
|
358
|
+
agent_error: agentResult.error,
|
|
397
359
|
},
|
|
398
360
|
generated_at: new Date().toISOString(),
|
|
399
361
|
}, verbose);
|
|
@@ -403,22 +365,21 @@ export async function runRunSheet(options) {
|
|
|
403
365
|
releaseId: release.id,
|
|
404
366
|
releaseTag: release.tag,
|
|
405
367
|
runSheetId: saved.id,
|
|
406
|
-
summary: `Rendered ${
|
|
407
|
-
missingPlaceholders: missing,
|
|
368
|
+
summary: `Rendered ${agentResult.content.length} characters (${agentResult.turns} agent turn(s))`,
|
|
408
369
|
cloneError,
|
|
409
|
-
commitsError,
|
|
410
|
-
commitsTruncated,
|
|
411
370
|
isDraft,
|
|
371
|
+
agentTurns: agentResult.turns,
|
|
372
|
+
model: agentResult.model,
|
|
412
373
|
};
|
|
413
374
|
}
|
|
414
375
|
catch (err) {
|
|
415
376
|
const message = err instanceof Error ? err.message : String(err);
|
|
416
|
-
logError(`
|
|
377
|
+
logError(`Run-sheet generation failed: ${message}`);
|
|
417
378
|
return {
|
|
418
379
|
status: 'error',
|
|
419
380
|
releaseId: release.id,
|
|
420
381
|
releaseTag: release.tag,
|
|
421
|
-
summary: `
|
|
382
|
+
summary: `Run-sheet generation failed: ${message}`,
|
|
422
383
|
};
|
|
423
384
|
}
|
|
424
385
|
finally {
|