edsger 0.48.1 → 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,8 +3,9 @@
|
|
|
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
|
|
@@ -22,24 +23,11 @@ export interface RunSheetResult {
|
|
|
22
23
|
releaseTag?: string;
|
|
23
24
|
summary: string;
|
|
24
25
|
runSheetId?: string;
|
|
25
|
-
missingPlaceholders?: string[];
|
|
26
26
|
cloneError?: string | null;
|
|
27
|
-
commitsError?: string | null;
|
|
28
|
-
commitsTruncated?: boolean;
|
|
29
27
|
isDraft?: boolean;
|
|
28
|
+
agentTurns?: number;
|
|
29
|
+
model?: string;
|
|
30
30
|
}
|
|
31
|
-
/**
|
|
32
|
-
* Draft releases have no stored `diff_summary` / `diff_stats` (those are
|
|
33
|
-
* only written by smoke-test against a cut tag). Compute a lightweight
|
|
34
|
-
* substitute on the fly so draft templates that reference `{{diff_summary}}`
|
|
35
|
-
* or `{{diff_stats.*}}` still render something useful.
|
|
36
|
-
*
|
|
37
|
-
* Exported for unit tests.
|
|
38
|
-
*/
|
|
39
|
-
export declare function fetchDraftDiff(owner: string, repo: string, base: string | null, head: string, token: string): Promise<{
|
|
40
|
-
summary: string | null;
|
|
41
|
-
stats: Record<string, unknown> | null;
|
|
42
|
-
}>;
|
|
43
31
|
/**
|
|
44
32
|
* Atomic-create a `.lock` file alongside the workspace dir. Returns
|
|
45
33
|
* true if we acquired the lock, false if another CLI instance holds a
|
|
@@ -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,60 +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
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Draft releases have no stored `diff_summary` / `diff_stats` (those are
|
|
53
|
-
* only written by smoke-test against a cut tag). Compute a lightweight
|
|
54
|
-
* substitute on the fly so draft templates that reference `{{diff_summary}}`
|
|
55
|
-
* or `{{diff_stats.*}}` still render something useful.
|
|
56
|
-
*
|
|
57
|
-
* Exported for unit tests.
|
|
58
|
-
*/
|
|
59
|
-
export async function fetchDraftDiff(owner, repo, base, head, token) {
|
|
60
|
-
if (!base) {
|
|
61
|
-
return { summary: null, stats: null };
|
|
62
|
-
}
|
|
63
|
-
try {
|
|
64
|
-
const compare = await fetchCompare(owner, repo, base, head, token);
|
|
65
|
-
const stats = summariseStats(compare);
|
|
66
|
-
const summary = `Draft diff from ${base} to ${head}: ${stats.total_commits} commit(s), ${stats.files_changed} file(s) changed (+${stats.additions}/-${stats.deletions}).`;
|
|
67
|
-
return { summary, stats: stats };
|
|
68
|
-
}
|
|
69
|
-
catch (err) {
|
|
70
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
71
|
-
logWarning(`Could not compute draft diff summary: ${message}`);
|
|
72
|
-
return { summary: null, stats: null };
|
|
73
|
-
}
|
|
74
|
-
}
|
|
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';
|
|
75
24
|
// Stale locks (e.g. left behind by a crashed or SIGKILLed CLI) are
|
|
76
25
|
// considered abandoned after this many ms.
|
|
77
26
|
const LOCK_STALE_MS = 15 * 60 * 1000;
|
|
@@ -218,17 +167,6 @@ export function pruneStaleRunSheetWorkspaces(workspaceRoot, now = Date.now(), ma
|
|
|
218
167
|
}
|
|
219
168
|
return { removed };
|
|
220
169
|
}
|
|
221
|
-
function computeInputHash(parts) {
|
|
222
|
-
const h = createHash('sha256');
|
|
223
|
-
h.update(`template:${parts.template}\n`);
|
|
224
|
-
h.update(`tag:${parts.tag}\n`);
|
|
225
|
-
h.update(`prev:${parts.previousTag ?? ''}\n`);
|
|
226
|
-
for (const f of [...parts.filesRead].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
227
|
-
h.update(`file:${f.path}:${f.bytes}\n`);
|
|
228
|
-
}
|
|
229
|
-
h.update(`commits:${parts.commitShas.join(',')}\n`);
|
|
230
|
-
return h.digest('hex');
|
|
231
|
-
}
|
|
232
170
|
/** Exported for unit tests; not part of the public CLI surface. */
|
|
233
171
|
export function runSheetIsFresh(existing, template, tag) {
|
|
234
172
|
if (!existing || !existing.content) {
|
|
@@ -244,15 +182,17 @@ export function runSheetIsFresh(existing, template, tag) {
|
|
|
244
182
|
if (meta.clone_error) {
|
|
245
183
|
return false;
|
|
246
184
|
}
|
|
185
|
+
if (meta.agent_error) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
247
188
|
// Drafts are rendered from the default branch HEAD, which moves. Always
|
|
248
|
-
// regenerate so
|
|
249
|
-
// branch tip rather than a stale snapshot.
|
|
189
|
+
// regenerate so the agent sees current code.
|
|
250
190
|
if (meta.is_draft) {
|
|
251
191
|
return false;
|
|
252
192
|
}
|
|
253
193
|
return true;
|
|
254
194
|
}
|
|
255
|
-
// eslint-disable-next-line complexity -- orchestration with cache, clone, lock,
|
|
195
|
+
// eslint-disable-next-line complexity -- orchestration with cache, clone, lock, agent
|
|
256
196
|
export async function runRunSheet(options) {
|
|
257
197
|
const { releaseId, force, verbose } = options;
|
|
258
198
|
let release;
|
|
@@ -286,7 +226,7 @@ export async function runRunSheet(options) {
|
|
|
286
226
|
summary: 'Product has no run_sheet_template configured. Set one in product settings.',
|
|
287
227
|
};
|
|
288
228
|
}
|
|
289
|
-
// Short-circuit cache: identical template + tag, no prior clone error.
|
|
229
|
+
// Short-circuit cache: identical template + tag, no prior clone / agent error.
|
|
290
230
|
if (!force) {
|
|
291
231
|
const existing = await getRunSheetByRelease(releaseId, verbose).catch(() => null);
|
|
292
232
|
if (existing && runSheetIsFresh(existing, template, release.tag)) {
|
|
@@ -297,42 +237,25 @@ export async function runRunSheet(options) {
|
|
|
297
237
|
releaseTag: release.tag,
|
|
298
238
|
runSheetId: existing.id,
|
|
299
239
|
summary: 'Cached (no change since last render)',
|
|
300
|
-
missingPlaceholders: existing.metadata?.missing_placeholders ?? [],
|
|
301
240
|
cloneError: null,
|
|
302
|
-
commitsTruncated: Boolean(existing.metadata?.commits_truncated),
|
|
303
241
|
};
|
|
304
242
|
}
|
|
305
243
|
}
|
|
306
244
|
const gh = await getGitHubConfigByProduct(release.product_id, verbose);
|
|
307
245
|
const repoConfigured = gh.configured && gh.token && gh.owner && gh.repo;
|
|
308
|
-
// Clone at tag + fetch commits (both best-effort — we still render with
|
|
309
|
-
// whatever metadata we have).
|
|
310
246
|
let repoDir = null;
|
|
311
247
|
let cloneError = null;
|
|
312
|
-
let commitsError = null;
|
|
313
|
-
let commits = '';
|
|
314
|
-
let commitsList = [];
|
|
315
|
-
let commitsTruncated = false;
|
|
316
248
|
let lockPath = null;
|
|
317
249
|
// Draft mode: the release's tag doesn't exist on GitHub yet (e.g. a
|
|
318
250
|
// Maven SNAPSHOT being planned pre-cut). We still want to produce a
|
|
319
|
-
// useful run sheet, so we fall back to the default branch HEAD
|
|
320
|
-
// both the checkout and the commits compare.
|
|
251
|
+
// useful run sheet, so we fall back to the default branch HEAD.
|
|
321
252
|
let isDraft = false;
|
|
322
253
|
let draftBaseRef = null;
|
|
323
|
-
// Draft-only: live-computed diff stats/summary (DB columns are empty
|
|
324
|
-
// until smoke-test runs against a real tag).
|
|
325
|
-
let draftDiffSummary = null;
|
|
326
|
-
let draftDiffStats = null;
|
|
327
254
|
if (repoConfigured && gh.owner && gh.repo && gh.token) {
|
|
328
255
|
const { owner, repo, token } = gh;
|
|
329
256
|
// Serialise concurrent CLI invocations for the *same release* by
|
|
330
|
-
// taking a file lock next to the clone dir.
|
|
331
|
-
// different lock files (per-release workspace).
|
|
257
|
+
// taking a file lock next to the clone dir.
|
|
332
258
|
const workspaceRoot = ensureWorkspaceDir();
|
|
333
|
-
// Sweep old per-release workspaces before cloning. Cheap (one
|
|
334
|
-
// readdir + stat per entry); silent unless something is actually
|
|
335
|
-
// removed.
|
|
336
259
|
const pruned = pruneStaleRunSheetWorkspaces(workspaceRoot);
|
|
337
260
|
if (pruned.removed.length > 0) {
|
|
338
261
|
logInfo(`Pruned ${pruned.removed.length} stale run-sheet workspace(s).`);
|
|
@@ -349,20 +272,12 @@ export async function runRunSheet(options) {
|
|
|
349
272
|
};
|
|
350
273
|
}
|
|
351
274
|
try {
|
|
352
|
-
// Clone into a per-release directory so two releases of the same
|
|
353
|
-
// product don't stomp each other's checkout during concurrent
|
|
354
|
-
// generation. (smoke-test uses `release-${product_id}` — that races;
|
|
355
|
-
// we don't want to inherit that bug here.)
|
|
356
275
|
const { repoPath } = cloneFeatureRepo(workspaceRoot, workspaceName, owner, repo, token);
|
|
357
276
|
repoDir = repoPath;
|
|
358
|
-
// SNAPSHOT releases
|
|
359
|
-
// detection) have no GitHub release object backing them, so
|
|
277
|
+
// SNAPSHOT releases have no GitHub release object backing them, so
|
|
360
278
|
// `published_at` is null. Use that as a free local signal to skip
|
|
361
|
-
// the tag-checkout entirely
|
|
362
|
-
|
|
363
|
-
// real releases (`published_at` set) we still attempt the tag and
|
|
364
|
-
// fall back if the checkout itself fails (deleted tag, network).
|
|
365
|
-
const expectDraft = release.published_at == null;
|
|
279
|
+
// the tag-checkout entirely.
|
|
280
|
+
const expectDraft = release.published_at === null;
|
|
366
281
|
if (expectDraft) {
|
|
367
282
|
logInfo(`Tag ${release.tag} not yet cut (no published_at) — generating draft run sheet from default branch.`);
|
|
368
283
|
try {
|
|
@@ -401,66 +316,46 @@ export async function runRunSheet(options) {
|
|
|
401
316
|
cloneError = err instanceof Error ? err.message : String(err);
|
|
402
317
|
logWarning(`Clone failed: ${cloneError}`);
|
|
403
318
|
}
|
|
404
|
-
const head = isDraft && draftBaseRef ? draftBaseRef : release.tag;
|
|
405
|
-
const c = await fetchCommitsBetween(owner, repo, release.previous_tag, head, token);
|
|
406
|
-
commits = c.text;
|
|
407
|
-
commitsList = c.list;
|
|
408
|
-
commitsTruncated = c.truncated;
|
|
409
|
-
commitsError = c.error ?? null;
|
|
410
|
-
if (isDraft) {
|
|
411
|
-
const d = await fetchDraftDiff(owner, repo, release.previous_tag, head, token);
|
|
412
|
-
draftDiffSummary = d.summary;
|
|
413
|
-
draftDiffStats = d.stats;
|
|
414
|
-
}
|
|
415
319
|
}
|
|
416
320
|
else {
|
|
417
321
|
cloneError = 'Product is not linked to a GitHub repository';
|
|
418
322
|
}
|
|
419
|
-
const draftNotice = isDraft
|
|
420
|
-
? `> ⚠️ **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.`
|
|
421
|
-
: '';
|
|
422
|
-
const { rendered, missing, filesRead } = await renderTemplate(template, {
|
|
423
|
-
name: product?.name ?? 'Unknown product',
|
|
424
|
-
github_repository_full_name: product?.github_repository_full_name ?? null,
|
|
425
|
-
}, {
|
|
426
|
-
tag: release.tag,
|
|
427
|
-
name: release.name,
|
|
428
|
-
body: release.body,
|
|
429
|
-
url: release.url,
|
|
430
|
-
published_at: release.published_at,
|
|
431
|
-
previous_tag: release.previous_tag,
|
|
432
|
-
previous_published_at: release.previous_published_at,
|
|
433
|
-
diff_summary: isDraft
|
|
434
|
-
? (draftDiffSummary ?? release.diff_summary)
|
|
435
|
-
: release.diff_summary,
|
|
436
|
-
diff_stats: isDraft
|
|
437
|
-
? (draftDiffStats ?? release.diff_stats ?? {})
|
|
438
|
-
: (release.diff_stats ?? {}),
|
|
439
|
-
}, repoDir, commits, draftNotice, commitsList);
|
|
440
|
-
const inputHash = computeInputHash({
|
|
441
|
-
template,
|
|
442
|
-
tag: release.tag,
|
|
443
|
-
previousTag: release.previous_tag,
|
|
444
|
-
filesRead,
|
|
445
|
-
commitShas: commitsList.map((c) => c.sha),
|
|
446
|
-
});
|
|
447
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
|
+
});
|
|
448
344
|
const saved = await upsertRunSheet({
|
|
449
345
|
release_id: release.id,
|
|
450
|
-
content:
|
|
346
|
+
content: agentResult.content,
|
|
451
347
|
title: `Run Sheet — ${product?.name ?? ''} ${release.tag}`.trim(),
|
|
452
348
|
template_snapshot: template,
|
|
453
349
|
metadata: {
|
|
454
|
-
missing_placeholders: missing,
|
|
455
350
|
clone_error: cloneError,
|
|
456
|
-
commits_error: commitsError,
|
|
457
351
|
repo: product?.github_repository_full_name ?? null,
|
|
458
352
|
tag: release.tag,
|
|
459
|
-
commits_truncated: commitsTruncated,
|
|
460
353
|
is_draft: isDraft,
|
|
461
354
|
draft_base_ref: draftBaseRef,
|
|
462
|
-
|
|
463
|
-
|
|
355
|
+
model: agentResult.model,
|
|
356
|
+
agent_turns: agentResult.turns,
|
|
357
|
+
agent_subtype: agentResult.subtype,
|
|
358
|
+
agent_error: agentResult.error,
|
|
464
359
|
},
|
|
465
360
|
generated_at: new Date().toISOString(),
|
|
466
361
|
}, verbose);
|
|
@@ -470,22 +365,21 @@ export async function runRunSheet(options) {
|
|
|
470
365
|
releaseId: release.id,
|
|
471
366
|
releaseTag: release.tag,
|
|
472
367
|
runSheetId: saved.id,
|
|
473
|
-
summary: `Rendered ${
|
|
474
|
-
missingPlaceholders: missing,
|
|
368
|
+
summary: `Rendered ${agentResult.content.length} characters (${agentResult.turns} agent turn(s))`,
|
|
475
369
|
cloneError,
|
|
476
|
-
commitsError,
|
|
477
|
-
commitsTruncated,
|
|
478
370
|
isDraft,
|
|
371
|
+
agentTurns: agentResult.turns,
|
|
372
|
+
model: agentResult.model,
|
|
479
373
|
};
|
|
480
374
|
}
|
|
481
375
|
catch (err) {
|
|
482
376
|
const message = err instanceof Error ? err.message : String(err);
|
|
483
|
-
logError(`
|
|
377
|
+
logError(`Run-sheet generation failed: ${message}`);
|
|
484
378
|
return {
|
|
485
379
|
status: 'error',
|
|
486
380
|
releaseId: release.id,
|
|
487
381
|
releaseTag: release.tag,
|
|
488
|
-
summary: `
|
|
382
|
+
summary: `Run-sheet generation failed: ${message}`,
|
|
489
383
|
};
|
|
490
384
|
}
|
|
491
385
|
finally {
|