edsger 0.44.0 → 0.45.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.
- package/dist/api/run-sheets.d.ts +22 -0
- package/dist/api/run-sheets.js +13 -0
- package/dist/commands/run-sheet/index.d.ts +6 -0
- package/dist/commands/run-sheet/index.js +48 -0
- package/dist/index.js +22 -0
- package/dist/phases/run-sheet/index.d.ts +39 -0
- package/dist/phases/run-sheet/index.js +297 -0
- package/dist/phases/run-sheet/render.d.ts +42 -0
- package/dist/phases/run-sheet/render.js +133 -0
- package/package.json +9 -2
- package/tsconfig.json +2 -1
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface RunSheet {
|
|
2
|
+
id: string;
|
|
3
|
+
release_id: string;
|
|
4
|
+
title: string | null;
|
|
5
|
+
content: string;
|
|
6
|
+
template_snapshot: string | null;
|
|
7
|
+
metadata: Record<string, unknown>;
|
|
8
|
+
generated_at: string | null;
|
|
9
|
+
created_by: string;
|
|
10
|
+
created_at: string;
|
|
11
|
+
updated_at: string;
|
|
12
|
+
}
|
|
13
|
+
export interface UpsertRunSheetParams {
|
|
14
|
+
release_id: string;
|
|
15
|
+
content: string;
|
|
16
|
+
title?: string | null;
|
|
17
|
+
template_snapshot?: string | null;
|
|
18
|
+
metadata?: Record<string, unknown>;
|
|
19
|
+
generated_at?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function getRunSheetByRelease(releaseId: string, verbose?: boolean): Promise<RunSheet | null>;
|
|
22
|
+
export declare function upsertRunSheet(params: UpsertRunSheetParams, verbose?: boolean): Promise<RunSheet>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { logDebug } from '../utils/logger.js';
|
|
2
|
+
import { callMcpEndpoint } from './mcp-client.js';
|
|
3
|
+
export async function getRunSheetByRelease(releaseId, verbose) {
|
|
4
|
+
logDebug(`Fetching run sheet for release ${releaseId}`, verbose);
|
|
5
|
+
const result = (await callMcpEndpoint('run_sheets/get', {
|
|
6
|
+
release_id: releaseId,
|
|
7
|
+
}));
|
|
8
|
+
return result.run_sheet;
|
|
9
|
+
}
|
|
10
|
+
export async function upsertRunSheet(params, verbose) {
|
|
11
|
+
logDebug(`Upserting run sheet for release ${params.release_id}`, verbose);
|
|
12
|
+
return (await callMcpEndpoint('run_sheets/upsert', params));
|
|
13
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { runRunSheet, } from '../../phases/run-sheet/index.js';
|
|
2
|
+
import { deregisterSession, registerSession, } from '../../system/session-manager.js';
|
|
3
|
+
import { logError, logInfo } from '../../utils/logger.js';
|
|
4
|
+
import { validateConfiguration } from '../../utils/validation.js';
|
|
5
|
+
export const runRunSheetCommand = async (options) => {
|
|
6
|
+
const { releaseId } = options;
|
|
7
|
+
if (!releaseId) {
|
|
8
|
+
throw new Error('Release ID is required for run-sheet');
|
|
9
|
+
}
|
|
10
|
+
validateConfiguration({ verbose: options.verbose });
|
|
11
|
+
await registerSession({ command: 'run-sheet' });
|
|
12
|
+
logInfo(`Starting run sheet generation for release: ${releaseId}`);
|
|
13
|
+
try {
|
|
14
|
+
const result = await runRunSheet({
|
|
15
|
+
releaseId,
|
|
16
|
+
force: options.force,
|
|
17
|
+
verbose: options.verbose,
|
|
18
|
+
});
|
|
19
|
+
if (result.status === 'error') {
|
|
20
|
+
logError(`Run sheet generation failed: ${result.summary}`);
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (result.status === 'cached') {
|
|
25
|
+
logInfo(`Run sheet unchanged for ${result.releaseTag} — skipped clone.`);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
logInfo(`Generated run sheet for ${result.releaseTag}`);
|
|
29
|
+
}
|
|
30
|
+
if (result.cloneError) {
|
|
31
|
+
logInfo(`Clone warning: ${result.cloneError}`);
|
|
32
|
+
}
|
|
33
|
+
if (result.missingPlaceholders && result.missingPlaceholders.length > 0) {
|
|
34
|
+
logInfo(`Unresolved placeholders: ${result.missingPlaceholders.join(', ')}`);
|
|
35
|
+
}
|
|
36
|
+
if (result.commitsTruncated) {
|
|
37
|
+
logInfo('Commit list truncated at GitHub 250-commit cap.');
|
|
38
|
+
}
|
|
39
|
+
logInfo('View it in the Release detail page of your product dashboard.');
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
logError(`Run sheet generation failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
await deregisterSession();
|
|
47
|
+
}
|
|
48
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { runPRResolve } from './commands/pr-resolve/index.js';
|
|
|
20
20
|
import { runPRReview } from './commands/pr-review/index.js';
|
|
21
21
|
import { runRefactor } from './commands/refactor/refactor.js';
|
|
22
22
|
import { runReleaseSyncCommand } from './commands/release-sync/index.js';
|
|
23
|
+
import { runRunSheetCommand } from './commands/run-sheet/index.js';
|
|
23
24
|
import { runSmokeTestCommand } from './commands/smoke-test/index.js';
|
|
24
25
|
import { runTaskWorker } from './commands/task-worker/index.js';
|
|
25
26
|
import { runWorkflow } from './commands/workflow/index.js';
|
|
@@ -314,6 +315,27 @@ program
|
|
|
314
315
|
}
|
|
315
316
|
});
|
|
316
317
|
// ============================================================
|
|
318
|
+
// Subcommand: edsger run-sheet <releaseId>
|
|
319
|
+
// ============================================================
|
|
320
|
+
program
|
|
321
|
+
.command('run-sheet <releaseId>')
|
|
322
|
+
.description('Render the product run sheet template against a release (clones the repo at the release tag to resolve {{file:...}} placeholders)')
|
|
323
|
+
.option('-f, --force', 'Regenerate even if the cached run sheet is fresh')
|
|
324
|
+
.option('-v, --verbose', 'Verbose output')
|
|
325
|
+
.action(async (releaseId, opts) => {
|
|
326
|
+
try {
|
|
327
|
+
await runRunSheetCommand({
|
|
328
|
+
releaseId,
|
|
329
|
+
force: opts.force,
|
|
330
|
+
verbose: opts.verbose,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
logError(error instanceof Error ? error.message : String(error));
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
// ============================================================
|
|
317
339
|
// Subcommand: edsger pr-review <productId>
|
|
318
340
|
// ============================================================
|
|
319
341
|
program
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-sheet generation for a release.
|
|
3
|
+
*
|
|
4
|
+
* Given a release, fetches the product's `run_sheet_template`, clones the
|
|
5
|
+
* repo at the release tag (reusing the workspace layout used by
|
|
6
|
+
* smoke-test), renders placeholders, and upserts the result to the
|
|
7
|
+
* `run_sheets` table.
|
|
8
|
+
*
|
|
9
|
+
* Release tags are immutable, so if an existing run sheet has the same
|
|
10
|
+
* template_snapshot + tag and no clone error, we short-circuit without
|
|
11
|
+
* re-cloning. Pass `{ force: true }` to regenerate anyway.
|
|
12
|
+
*/
|
|
13
|
+
export interface RunSheetOptions {
|
|
14
|
+
releaseId: string;
|
|
15
|
+
force?: boolean;
|
|
16
|
+
verbose?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface RunSheetResult {
|
|
19
|
+
status: 'success' | 'error' | 'cached';
|
|
20
|
+
releaseId: string;
|
|
21
|
+
releaseTag?: string;
|
|
22
|
+
summary: string;
|
|
23
|
+
runSheetId?: string;
|
|
24
|
+
missingPlaceholders?: string[];
|
|
25
|
+
cloneError?: string | null;
|
|
26
|
+
commitsTruncated?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Atomic-create a `.lock` file alongside the workspace dir. Returns
|
|
30
|
+
* true if we acquired the lock, false if another CLI instance holds a
|
|
31
|
+
* fresh one. Stale locks (> LOCK_STALE_MS old) are stolen via a single
|
|
32
|
+
* unlink + create pair; concurrent stealers race fairly via O_EXCL on
|
|
33
|
+
* the second create.
|
|
34
|
+
*/
|
|
35
|
+
/** Exported for unit tests; not part of the public CLI surface. */
|
|
36
|
+
export declare function tryAcquireFileLock(lockPath: string): boolean;
|
|
37
|
+
/** Exported for unit tests; not part of the public CLI surface. */
|
|
38
|
+
export declare function releaseFileLock(lockPath: string): void;
|
|
39
|
+
export declare function runRunSheet(options: RunSheetOptions): Promise<RunSheetResult>;
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-sheet generation for a release.
|
|
3
|
+
*
|
|
4
|
+
* Given a release, fetches the product's `run_sheet_template`, clones the
|
|
5
|
+
* repo at the release tag (reusing the workspace layout used by
|
|
6
|
+
* smoke-test), renders placeholders, and upserts the result to the
|
|
7
|
+
* `run_sheets` table.
|
|
8
|
+
*
|
|
9
|
+
* Release tags are immutable, so if an existing run sheet has the same
|
|
10
|
+
* template_snapshot + tag and no clone error, we short-circuit without
|
|
11
|
+
* re-cloning. Pass `{ force: true }` to regenerate anyway.
|
|
12
|
+
*/
|
|
13
|
+
import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, } from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import { getGitHubConfigByProduct } from '../../api/github.js';
|
|
16
|
+
import { getProduct } from '../../api/products.js';
|
|
17
|
+
import { getRelease } from '../../api/releases.js';
|
|
18
|
+
import { getRunSheetByRelease, upsertRunSheet, } from '../../api/run-sheets.js';
|
|
19
|
+
import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
|
|
20
|
+
import { cloneFeatureRepo, ensureWorkspaceDir, getFeatureRepoPath, syncRepoToRef, } from '../../workspace/workspace-manager.js';
|
|
21
|
+
import { fetchCompare } from '../release-sync/github.js';
|
|
22
|
+
import { isSafeGitRef, renderTemplate } from './render.js';
|
|
23
|
+
// GitHub's compare endpoint is paginated; we don't page, so we warn when
|
|
24
|
+
// we hit the first-page cap.
|
|
25
|
+
const GITHUB_COMPARE_MAX_COMMITS = 250;
|
|
26
|
+
async function fetchCommitsBetween(owner, repo, base, head, token) {
|
|
27
|
+
if (!base) {
|
|
28
|
+
return { text: '', truncated: false };
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const compare = await fetchCompare(owner, repo, base, head, token);
|
|
32
|
+
const commits = compare.commits ?? [];
|
|
33
|
+
const text = commits
|
|
34
|
+
.map((c) => `- ${c.sha.slice(0, 7)} ${(c.commit.message || '').split('\n')[0]}`)
|
|
35
|
+
.join('\n');
|
|
36
|
+
return {
|
|
37
|
+
text,
|
|
38
|
+
truncated: commits.length >= GITHUB_COMPARE_MAX_COMMITS,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
logWarning(`Could not fetch commits for run sheet: ${err instanceof Error ? err.message : String(err)}`);
|
|
43
|
+
return { text: '', truncated: false };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Stale locks (e.g. left behind by a crashed or SIGKILLed CLI) are
|
|
47
|
+
// considered abandoned after this many ms.
|
|
48
|
+
const LOCK_STALE_MS = 15 * 60 * 1000;
|
|
49
|
+
function writeLockFile(fd) {
|
|
50
|
+
const payload = { pid: process.pid, ts: Date.now() };
|
|
51
|
+
writeFileSync(fd, JSON.stringify(payload));
|
|
52
|
+
closeSync(fd);
|
|
53
|
+
}
|
|
54
|
+
function tryCreateLock(lockPath) {
|
|
55
|
+
try {
|
|
56
|
+
const fd = openSync(lockPath, 'wx');
|
|
57
|
+
writeLockFile(fd);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (err.code === 'EEXIST') {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function isLockStale(lockPath) {
|
|
68
|
+
try {
|
|
69
|
+
const raw = readFileSync(lockPath, 'utf-8').trim();
|
|
70
|
+
let ts;
|
|
71
|
+
try {
|
|
72
|
+
ts = JSON.parse(raw).ts;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
ts = Number(raw);
|
|
76
|
+
}
|
|
77
|
+
return !Number.isFinite(ts) || Date.now() - ts > LOCK_STALE_MS;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Lock vanished between exists-check and read — treat as not held.
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Atomic-create a `.lock` file alongside the workspace dir. Returns
|
|
86
|
+
* true if we acquired the lock, false if another CLI instance holds a
|
|
87
|
+
* fresh one. Stale locks (> LOCK_STALE_MS old) are stolen via a single
|
|
88
|
+
* unlink + create pair; concurrent stealers race fairly via O_EXCL on
|
|
89
|
+
* the second create.
|
|
90
|
+
*/
|
|
91
|
+
/** Exported for unit tests; not part of the public CLI surface. */
|
|
92
|
+
export function tryAcquireFileLock(lockPath) {
|
|
93
|
+
mkdirSync(join(lockPath, '..'), { recursive: true });
|
|
94
|
+
// Pass 1: clean acquisition.
|
|
95
|
+
if (tryCreateLock(lockPath)) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
// Pass 2: someone holds it — only steal if their lock is stale.
|
|
99
|
+
if (!isLockStale(lockPath)) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
// Try to steal. Best-effort unlink (another stealer may have beaten
|
|
103
|
+
// us); the O_EXCL create then arbitrates fairly.
|
|
104
|
+
try {
|
|
105
|
+
unlinkSync(lockPath);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Already gone — fine, race the create below.
|
|
109
|
+
}
|
|
110
|
+
return tryCreateLock(lockPath);
|
|
111
|
+
}
|
|
112
|
+
/** Exported for unit tests; not part of the public CLI surface. */
|
|
113
|
+
export function releaseFileLock(lockPath) {
|
|
114
|
+
try {
|
|
115
|
+
unlinkSync(lockPath);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Best-effort — lock will expire via LOCK_STALE_MS anyway.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function runSheetIsFresh(existing, template, tag) {
|
|
122
|
+
if (!existing || !existing.content) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
if (existing.template_snapshot !== template) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
const meta = existing.metadata ?? {};
|
|
129
|
+
if (meta.tag !== tag) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
if (meta.clone_error) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
export async function runRunSheet(options) {
|
|
138
|
+
const { releaseId, force, verbose } = options;
|
|
139
|
+
let release;
|
|
140
|
+
try {
|
|
141
|
+
release = await getRelease(releaseId, verbose);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
145
|
+
return {
|
|
146
|
+
status: 'error',
|
|
147
|
+
releaseId,
|
|
148
|
+
summary: `Failed to load release: ${message}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (!isSafeGitRef(release.tag)) {
|
|
152
|
+
return {
|
|
153
|
+
status: 'error',
|
|
154
|
+
releaseId,
|
|
155
|
+
releaseTag: release.tag,
|
|
156
|
+
summary: `Unsafe release tag: ${release.tag}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mcp product shape is open
|
|
160
|
+
const product = (await getProduct(release.product_id, verbose));
|
|
161
|
+
const template = product?.run_sheet_template;
|
|
162
|
+
if (!template || !template.trim()) {
|
|
163
|
+
return {
|
|
164
|
+
status: 'error',
|
|
165
|
+
releaseId,
|
|
166
|
+
releaseTag: release.tag,
|
|
167
|
+
summary: 'Product has no run_sheet_template configured. Set one in product settings.',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
// Short-circuit cache: identical template + tag, no prior clone error.
|
|
171
|
+
if (!force) {
|
|
172
|
+
const existing = await getRunSheetByRelease(releaseId, verbose).catch(() => null);
|
|
173
|
+
if (runSheetIsFresh(existing, template, release.tag)) {
|
|
174
|
+
logInfo(`Run sheet for ${release.tag} is already up-to-date (template unchanged).`);
|
|
175
|
+
return {
|
|
176
|
+
status: 'cached',
|
|
177
|
+
releaseId,
|
|
178
|
+
releaseTag: release.tag,
|
|
179
|
+
runSheetId: existing.id,
|
|
180
|
+
summary: 'Cached (no change since last render)',
|
|
181
|
+
missingPlaceholders: existing.metadata?.missing_placeholders ?? [],
|
|
182
|
+
cloneError: null,
|
|
183
|
+
commitsTruncated: Boolean(existing.metadata?.commits_truncated),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const gh = await getGitHubConfigByProduct(release.product_id, verbose);
|
|
188
|
+
const repoConfigured = gh.configured && gh.token && gh.owner && gh.repo;
|
|
189
|
+
// Clone at tag + fetch commits (both best-effort — we still render with
|
|
190
|
+
// whatever metadata we have).
|
|
191
|
+
let repoDir = null;
|
|
192
|
+
let cloneError = null;
|
|
193
|
+
let commits = '';
|
|
194
|
+
let commitsTruncated = false;
|
|
195
|
+
let lockPath = null;
|
|
196
|
+
if (repoConfigured) {
|
|
197
|
+
const owner = gh.owner;
|
|
198
|
+
const repo = gh.repo;
|
|
199
|
+
const token = gh.token;
|
|
200
|
+
// Serialise concurrent CLI invocations for the *same release* by
|
|
201
|
+
// taking a file lock next to the clone dir. Different releases get
|
|
202
|
+
// different lock files (per-release workspace).
|
|
203
|
+
const workspaceRoot = ensureWorkspaceDir();
|
|
204
|
+
const workspaceName = `run-sheet-${release.id}`;
|
|
205
|
+
const repoPathAhead = getFeatureRepoPath(workspaceRoot, workspaceName);
|
|
206
|
+
lockPath = `${repoPathAhead}.lock`;
|
|
207
|
+
if (!tryAcquireFileLock(lockPath)) {
|
|
208
|
+
return {
|
|
209
|
+
status: 'error',
|
|
210
|
+
releaseId: release.id,
|
|
211
|
+
releaseTag: release.tag,
|
|
212
|
+
summary: 'Another run-sheet generation is in progress for this release. Wait for it to finish or retry.',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
// Clone into a per-release directory so two releases of the same
|
|
217
|
+
// product don't stomp each other's checkout during concurrent
|
|
218
|
+
// generation. (smoke-test uses `release-${product_id}` — that races;
|
|
219
|
+
// we don't want to inherit that bug here.)
|
|
220
|
+
const { repoPath } = cloneFeatureRepo(workspaceRoot, workspaceName, owner, repo, token);
|
|
221
|
+
repoDir = repoPath;
|
|
222
|
+
try {
|
|
223
|
+
syncRepoToRef(repoPath, { tag: release.tag }, token);
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
cloneError = `Could not checkout tag ${release.tag}: ${err instanceof Error ? err.message : String(err)}`;
|
|
227
|
+
logWarning(cloneError);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
cloneError = err instanceof Error ? err.message : String(err);
|
|
232
|
+
logWarning(`Clone failed: ${cloneError}`);
|
|
233
|
+
}
|
|
234
|
+
const c = await fetchCommitsBetween(owner, repo, release.previous_tag, release.tag, token);
|
|
235
|
+
commits = c.text;
|
|
236
|
+
commitsTruncated = c.truncated;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
cloneError = 'Product is not linked to a GitHub repository';
|
|
240
|
+
}
|
|
241
|
+
const { rendered, missing } = await renderTemplate(template, {
|
|
242
|
+
name: product?.name ?? 'Unknown product',
|
|
243
|
+
github_repository_full_name: product?.github_repository_full_name ?? null,
|
|
244
|
+
}, {
|
|
245
|
+
tag: release.tag,
|
|
246
|
+
name: release.name,
|
|
247
|
+
body: release.body,
|
|
248
|
+
url: release.url,
|
|
249
|
+
published_at: release.published_at,
|
|
250
|
+
previous_tag: release.previous_tag,
|
|
251
|
+
previous_published_at: release.previous_published_at,
|
|
252
|
+
diff_summary: release.diff_summary,
|
|
253
|
+
diff_stats: (release.diff_stats ?? {}),
|
|
254
|
+
}, repoDir, commits);
|
|
255
|
+
try {
|
|
256
|
+
const saved = await upsertRunSheet({
|
|
257
|
+
release_id: release.id,
|
|
258
|
+
content: rendered,
|
|
259
|
+
title: `Run Sheet — ${product?.name ?? ''} ${release.tag}`.trim(),
|
|
260
|
+
template_snapshot: template,
|
|
261
|
+
metadata: {
|
|
262
|
+
missing_placeholders: missing,
|
|
263
|
+
clone_error: cloneError,
|
|
264
|
+
repo: product?.github_repository_full_name ?? null,
|
|
265
|
+
tag: release.tag,
|
|
266
|
+
commits_truncated: commitsTruncated,
|
|
267
|
+
},
|
|
268
|
+
generated_at: new Date().toISOString(),
|
|
269
|
+
}, verbose);
|
|
270
|
+
logSuccess(`Generated run sheet for ${release.tag}`);
|
|
271
|
+
return {
|
|
272
|
+
status: 'success',
|
|
273
|
+
releaseId: release.id,
|
|
274
|
+
releaseTag: release.tag,
|
|
275
|
+
runSheetId: saved.id,
|
|
276
|
+
summary: `Rendered ${rendered.length} characters`,
|
|
277
|
+
missingPlaceholders: missing,
|
|
278
|
+
cloneError,
|
|
279
|
+
commitsTruncated,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
284
|
+
logError(`Failed to upsert run sheet: ${message}`);
|
|
285
|
+
return {
|
|
286
|
+
status: 'error',
|
|
287
|
+
releaseId: release.id,
|
|
288
|
+
releaseTag: release.tag,
|
|
289
|
+
summary: `Failed to upsert run sheet: ${message}`,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
if (lockPath) {
|
|
294
|
+
releaseFileLock(lockPath);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure template-rendering logic for run sheets.
|
|
3
|
+
*
|
|
4
|
+
* Intentionally identical to `web/src/services/run-sheets/render.ts` — any
|
|
5
|
+
* change here should mirror the web side (and its unit tests) so the CLI
|
|
6
|
+
* and the web `/api/releases/[id]/generate-run-sheet` route produce the
|
|
7
|
+
* exact same output for the same template + release.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MAX_FILE_INCLUDE_BYTES: number;
|
|
10
|
+
export declare const MAX_TOTAL_FILE_BYTES: number;
|
|
11
|
+
export interface TemplateProduct {
|
|
12
|
+
name: string;
|
|
13
|
+
github_repository_full_name: string | null;
|
|
14
|
+
}
|
|
15
|
+
export interface TemplateRelease {
|
|
16
|
+
tag: string;
|
|
17
|
+
name: string | null;
|
|
18
|
+
body: string | null;
|
|
19
|
+
url: string | null;
|
|
20
|
+
published_at: string | null;
|
|
21
|
+
previous_tag: string | null;
|
|
22
|
+
previous_published_at: string | null;
|
|
23
|
+
diff_summary: string | null;
|
|
24
|
+
diff_stats: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
export declare function isSafeGitRef(ref: string): boolean;
|
|
27
|
+
export interface FileReadResult {
|
|
28
|
+
content: string | null;
|
|
29
|
+
bytes: number;
|
|
30
|
+
reason?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function safeReadRepoFile(repoDir: string, relPath: string, remainingBudget: number): Promise<FileReadResult>;
|
|
33
|
+
export interface RenderResult {
|
|
34
|
+
rendered: string;
|
|
35
|
+
missing: string[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Strip Markdown-style backslash escapes inside `{{ ... }}` spans so the
|
|
39
|
+
* Rich-text editor roundtrip doesn't break placeholder matching.
|
|
40
|
+
*/
|
|
41
|
+
export declare function normalizeTemplate(template: string): string;
|
|
42
|
+
export declare function renderTemplate(template: string, product: TemplateProduct, release: TemplateRelease, repoDir: string | null, commits: string): Promise<RenderResult>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure template-rendering logic for run sheets.
|
|
3
|
+
*
|
|
4
|
+
* Intentionally identical to `web/src/services/run-sheets/render.ts` — any
|
|
5
|
+
* change here should mirror the web side (and its unit tests) so the CLI
|
|
6
|
+
* and the web `/api/releases/[id]/generate-run-sheet` route produce the
|
|
7
|
+
* exact same output for the same template + release.
|
|
8
|
+
*/
|
|
9
|
+
import { statSync } from 'fs';
|
|
10
|
+
import { readFile } from 'fs/promises';
|
|
11
|
+
import { join, resolve } from 'path';
|
|
12
|
+
export const MAX_FILE_INCLUDE_BYTES = 256 * 1024;
|
|
13
|
+
export const MAX_TOTAL_FILE_BYTES = 1024 * 1024;
|
|
14
|
+
export function isSafeGitRef(ref) {
|
|
15
|
+
if (typeof ref !== 'string' || ref.length === 0 || ref.length > 200) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
if (/\s/.test(ref) || /^[-.]/.test(ref)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
if (ref.includes('..') || ref.includes('@{')) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
return /^[A-Za-z0-9._\-+/@]+$/.test(ref);
|
|
25
|
+
}
|
|
26
|
+
export async function safeReadRepoFile(repoDir, relPath, remainingBudget) {
|
|
27
|
+
if (!relPath || relPath.startsWith('/') || relPath.includes('\0')) {
|
|
28
|
+
return { content: null, bytes: 0, reason: 'invalid path' };
|
|
29
|
+
}
|
|
30
|
+
const repoDirResolved = resolve(repoDir);
|
|
31
|
+
const abs = resolve(join(repoDirResolved, relPath));
|
|
32
|
+
if (abs !== repoDirResolved && !abs.startsWith(`${repoDirResolved}/`)) {
|
|
33
|
+
return { content: null, bytes: 0, reason: 'path escape' };
|
|
34
|
+
}
|
|
35
|
+
let size;
|
|
36
|
+
try {
|
|
37
|
+
const stat = statSync(abs);
|
|
38
|
+
if (!stat.isFile()) {
|
|
39
|
+
return { content: null, bytes: 0, reason: 'not a file' };
|
|
40
|
+
}
|
|
41
|
+
size = stat.size;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { content: null, bytes: 0, reason: 'not found' };
|
|
45
|
+
}
|
|
46
|
+
if (size > MAX_FILE_INCLUDE_BYTES) {
|
|
47
|
+
return {
|
|
48
|
+
content: null,
|
|
49
|
+
bytes: 0,
|
|
50
|
+
reason: `too large (${size} > ${MAX_FILE_INCLUDE_BYTES} bytes)`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (size > remainingBudget) {
|
|
54
|
+
return {
|
|
55
|
+
content: null,
|
|
56
|
+
bytes: 0,
|
|
57
|
+
reason: 'total file-inclusion budget exhausted',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const content = await readFile(abs, 'utf-8');
|
|
62
|
+
return { content, bytes: size };
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return { content: null, bytes: 0, reason: 'read failed' };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Strip Markdown-style backslash escapes inside `{{ ... }}` spans so the
|
|
70
|
+
* Rich-text editor roundtrip doesn't break placeholder matching.
|
|
71
|
+
*/
|
|
72
|
+
export function normalizeTemplate(template) {
|
|
73
|
+
return template.replace(/\{\{([^}]*)\}\}/g, (_match, inner) => `{{${inner.replace(/\\([_.\-\\/])/g, '$1')}}}`);
|
|
74
|
+
}
|
|
75
|
+
export async function renderTemplate(template, product, release, repoDir, commits) {
|
|
76
|
+
const missing = [];
|
|
77
|
+
const stats = release.diff_stats ?? {};
|
|
78
|
+
const normalized = normalizeTemplate(template);
|
|
79
|
+
const simpleVars = {
|
|
80
|
+
product_name: product.name,
|
|
81
|
+
release_tag: release.tag,
|
|
82
|
+
release_name: release.name ?? release.tag,
|
|
83
|
+
release_body: release.body ?? '',
|
|
84
|
+
release_url: release.url ?? '',
|
|
85
|
+
previous_tag: release.previous_tag ?? '',
|
|
86
|
+
published_at: release.published_at ?? '',
|
|
87
|
+
previous_published_at: release.previous_published_at ?? '',
|
|
88
|
+
diff_summary: release.diff_summary ?? '',
|
|
89
|
+
files_changed: String(stats.files_changed ?? ''),
|
|
90
|
+
additions: String(stats.additions ?? ''),
|
|
91
|
+
deletions: String(stats.deletions ?? ''),
|
|
92
|
+
commits_count: String(stats.commits_count ?? stats.total_commits ?? ''),
|
|
93
|
+
repository: product.github_repository_full_name ?? '',
|
|
94
|
+
generated_at: new Date().toISOString(),
|
|
95
|
+
commits,
|
|
96
|
+
};
|
|
97
|
+
const fileRegex = /\{\{\s*file:([^}]+?)\s*\}\}/g;
|
|
98
|
+
const fileMatches = [];
|
|
99
|
+
for (const m of normalized.matchAll(fileRegex)) {
|
|
100
|
+
fileMatches.push({ match: m[0], path: m[1].trim() });
|
|
101
|
+
}
|
|
102
|
+
let remainingBudget = MAX_TOTAL_FILE_BYTES;
|
|
103
|
+
const fileResults = new Map();
|
|
104
|
+
for (const { match, path } of fileMatches) {
|
|
105
|
+
if (fileResults.has(match)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!repoDir) {
|
|
109
|
+
missing.push(`file:${path} (no repo)`);
|
|
110
|
+
fileResults.set(match, `<!-- file ${path} unavailable: repo not cloned -->`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// eslint-disable-next-line no-await-in-loop -- sequential so the byte budget is enforced
|
|
114
|
+
const res = await safeReadRepoFile(repoDir, path, remainingBudget);
|
|
115
|
+
if (res.content === null) {
|
|
116
|
+
missing.push(`file:${path} (${res.reason ?? 'unavailable'})`);
|
|
117
|
+
fileResults.set(match, `<!-- file ${path} unavailable: ${res.reason ?? 'unknown'} -->`);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
remainingBudget -= res.bytes;
|
|
121
|
+
fileResults.set(match, res.content);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
let rendered = normalized.replace(fileRegex, (match) => fileResults.get(match) ?? match);
|
|
125
|
+
rendered = rendered.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (match, key) => {
|
|
126
|
+
if (key in simpleVars) {
|
|
127
|
+
return simpleVars[key];
|
|
128
|
+
}
|
|
129
|
+
missing.push(key);
|
|
130
|
+
return match;
|
|
131
|
+
});
|
|
132
|
+
return { rendered, missing };
|
|
133
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "edsger",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"edsger": "dist/index.js"
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
"dev": "tsc --watch",
|
|
11
11
|
"lint": "eslint .",
|
|
12
12
|
"lint:fix": "eslint . --fix",
|
|
13
|
+
"test": "npm run test:unit",
|
|
14
|
+
"test:unit": "vitest run",
|
|
15
|
+
"test:watch": "vitest",
|
|
13
16
|
"prepublishOnly": "npm run build"
|
|
14
17
|
},
|
|
15
18
|
"keywords": [
|
|
@@ -49,7 +52,11 @@
|
|
|
49
52
|
},
|
|
50
53
|
"devDependencies": {
|
|
51
54
|
"@types/node": "^20.0.0",
|
|
52
|
-
"
|
|
55
|
+
"@types/turndown": "^5.0.6",
|
|
56
|
+
"marked": "^15.0.12",
|
|
57
|
+
"turndown": "^7.2.2",
|
|
58
|
+
"typescript": "^5.0.0",
|
|
59
|
+
"vitest": "^4.1.0"
|
|
53
60
|
},
|
|
54
61
|
"peerDependenciesMeta": {
|
|
55
62
|
"@anthropic-ai/claude-agent-sdk": {
|
package/tsconfig.json
CHANGED
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config'
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
// The other __tests__ folders in this package use the node:test API
|
|
6
|
+
// (assert + node --test), not vitest. We scope vitest to just the
|
|
7
|
+
// run-sheet tests for now; migrating the rest can happen separately.
|
|
8
|
+
include: ['src/phases/run-sheet/__tests__/**/*.test.ts'],
|
|
9
|
+
exclude: ['dist/**', 'node_modules/**'],
|
|
10
|
+
environment: 'node',
|
|
11
|
+
},
|
|
12
|
+
})
|