edsger 0.45.1 → 0.47.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/.claude/settings.local.json +3 -23
- package/dist/api/__tests__/app-store.test.d.ts +7 -0
- package/dist/api/__tests__/app-store.test.js +60 -0
- package/dist/api/__tests__/intelligence.test.d.ts +11 -0
- package/dist/api/__tests__/intelligence.test.js +315 -0
- package/dist/api/features/__tests__/feature-utils.test.d.ts +4 -0
- package/dist/api/features/__tests__/feature-utils.test.js +370 -0
- package/dist/api/features/__tests__/status-updater.test.d.ts +4 -0
- package/dist/api/features/__tests__/status-updater.test.js +88 -0
- package/dist/commands/build/__tests__/build.test.d.ts +5 -0
- package/dist/commands/build/__tests__/build.test.js +206 -0
- package/dist/commands/build/__tests__/detect-project.test.d.ts +6 -0
- package/dist/commands/build/__tests__/detect-project.test.js +160 -0
- package/dist/commands/build/__tests__/run-build.test.d.ts +6 -0
- package/dist/commands/build/__tests__/run-build.test.js +433 -0
- package/dist/commands/intelligence/__tests__/command.test.d.ts +4 -0
- package/dist/commands/intelligence/__tests__/command.test.js +48 -0
- package/dist/commands/run-sheet/index.js +6 -0
- package/dist/commands/workflow/core/__tests__/feature-filter.test.d.ts +5 -0
- package/dist/commands/workflow/core/__tests__/feature-filter.test.js +316 -0
- package/dist/commands/workflow/core/__tests__/pipeline-evaluator.test.d.ts +4 -0
- package/dist/commands/workflow/core/__tests__/pipeline-evaluator.test.js +397 -0
- package/dist/commands/workflow/core/__tests__/state-manager.test.d.ts +4 -0
- package/dist/commands/workflow/core/__tests__/state-manager.test.js +384 -0
- package/dist/commands/workflow/executors/phase-executor.js +3 -1
- package/dist/commands/workflow/phase-orchestrator.js +1 -2
- package/dist/config/__tests__/config.test.d.ts +4 -0
- package/dist/config/__tests__/config.test.js +286 -0
- package/dist/config/__tests__/feature-status.test.d.ts +4 -0
- package/dist/config/__tests__/feature-status.test.js +111 -0
- package/dist/errors/__tests__/index.test.d.ts +4 -0
- package/dist/errors/__tests__/index.test.js +349 -0
- package/dist/index.js +0 -0
- package/dist/phases/app-store-generation/__tests__/agent.test.d.ts +5 -0
- package/dist/phases/app-store-generation/__tests__/agent.test.js +142 -0
- package/dist/phases/app-store-generation/__tests__/context.test.d.ts +4 -0
- package/dist/phases/app-store-generation/__tests__/context.test.js +284 -0
- package/dist/phases/app-store-generation/__tests__/prompts.test.d.ts +4 -0
- package/dist/phases/app-store-generation/__tests__/prompts.test.js +122 -0
- package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.d.ts +5 -0
- package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.js +826 -0
- package/dist/phases/app-store-generation/index.js +1 -2
- package/dist/phases/branch-planning/index.js +1 -2
- package/dist/phases/bug-fixing/analyzer.js +1 -2
- package/dist/phases/code-implementation/index.js +1 -2
- package/dist/phases/code-refine/index.js +1 -2
- package/dist/phases/code-review/__tests__/diff-utils.test.d.ts +1 -0
- package/dist/phases/code-review/__tests__/diff-utils.test.js +101 -0
- package/dist/phases/code-review/index.js +1 -2
- package/dist/phases/code-testing/analyzer.js +1 -2
- package/dist/phases/feature-analysis/index.js +1 -2
- package/dist/phases/functional-testing/analyzer.js +1 -2
- package/dist/phases/growth-analysis/index.js +1 -2
- package/dist/phases/intelligence-analysis/__tests__/context.test.d.ts +4 -0
- package/dist/phases/intelligence-analysis/__tests__/context.test.js +192 -0
- package/dist/phases/intelligence-analysis/__tests__/matching.test.d.ts +13 -0
- package/dist/phases/intelligence-analysis/__tests__/matching.test.js +154 -0
- package/dist/phases/intelligence-analysis/__tests__/orchestration.test.d.ts +5 -0
- package/dist/phases/intelligence-analysis/__tests__/orchestration.test.js +378 -0
- package/dist/phases/intelligence-analysis/__tests__/prompts.test.d.ts +4 -0
- package/dist/phases/intelligence-analysis/__tests__/prompts.test.js +33 -0
- package/dist/phases/pr-execution/__tests__/file-assigner.test.d.ts +1 -0
- package/dist/phases/pr-execution/__tests__/file-assigner.test.js +303 -0
- package/dist/phases/pr-execution/index.js +1 -0
- package/dist/phases/pr-resolve/__tests__/checklist-learner.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/checklist-learner.test.js +157 -0
- package/dist/phases/pr-resolve/__tests__/prompts.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/prompts.test.js +116 -0
- package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.js +138 -0
- package/dist/phases/pr-resolve/__tests__/types.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/types.test.js +43 -0
- package/dist/phases/pr-resolve/__tests__/workspace.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/workspace.test.js +111 -0
- package/dist/phases/pr-review/__tests__/prompts.test.d.ts +1 -0
- package/dist/phases/pr-review/__tests__/prompts.test.js +49 -0
- package/dist/phases/pr-review/__tests__/review-comments.test.d.ts +1 -0
- package/dist/phases/pr-review/__tests__/review-comments.test.js +110 -0
- package/dist/phases/pr-shared/__tests__/agent-utils.test.d.ts +1 -0
- package/dist/phases/pr-shared/__tests__/agent-utils.test.js +91 -0
- package/dist/phases/pr-shared/__tests__/context.test.d.ts +1 -0
- package/dist/phases/pr-shared/__tests__/context.test.js +94 -0
- package/dist/phases/pr-splitting/__tests__/import-dep-validator.test.d.ts +1 -0
- package/dist/phases/pr-splitting/__tests__/import-dep-validator.test.js +331 -0
- package/dist/phases/pr-splitting/index.js +1 -2
- package/dist/phases/release-sync/github.d.ts +12 -0
- package/dist/phases/release-sync/github.js +39 -0
- package/dist/phases/release-sync/snapshot.js +0 -1
- package/dist/phases/run-sheet/index.d.ts +15 -0
- package/dist/phases/run-sheet/index.js +161 -29
- package/dist/phases/run-sheet/render.d.ts +23 -5
- package/dist/phases/run-sheet/render.js +195 -31
- package/dist/phases/smoke-test/__tests__/agent.test.d.ts +4 -0
- package/dist/phases/smoke-test/__tests__/agent.test.js +84 -0
- package/dist/phases/smoke-test/__tests__/github.test.d.ts +9 -0
- package/dist/phases/smoke-test/__tests__/github.test.js +120 -0
- package/dist/phases/smoke-test/__tests__/snapshot.test.d.ts +8 -0
- package/dist/phases/smoke-test/__tests__/snapshot.test.js +93 -0
- package/dist/phases/smoke-test/agent.js +2 -4
- package/dist/phases/smoke-test/github.d.ts +54 -0
- package/dist/phases/smoke-test/github.js +101 -0
- package/dist/phases/smoke-test/index.js +11 -6
- package/dist/phases/smoke-test/snapshot.d.ts +27 -0
- package/dist/phases/smoke-test/snapshot.js +157 -0
- package/dist/phases/technical-design/index.js +1 -2
- package/dist/phases/test-cases-analysis/index.js +1 -2
- package/dist/phases/user-stories-analysis/index.js +1 -2
- package/dist/services/coaching/__tests__/coaching-agent.test.d.ts +1 -0
- package/dist/services/coaching/__tests__/coaching-agent.test.js +74 -0
- package/dist/services/coaching/__tests__/coaching-loop.test.d.ts +1 -0
- package/dist/services/coaching/__tests__/coaching-loop.test.js +59 -0
- package/dist/services/coaching/__tests__/self-rating.test.d.ts +1 -0
- package/dist/services/coaching/__tests__/self-rating.test.js +188 -0
- package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.d.ts +4 -0
- package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.js +133 -0
- package/dist/services/lifecycle-agent/__tests__/transition-rules.test.d.ts +4 -0
- package/dist/services/lifecycle-agent/__tests__/transition-rules.test.js +336 -0
- package/dist/services/lifecycle-agent/index.d.ts +24 -0
- package/dist/services/lifecycle-agent/index.js +25 -0
- package/dist/services/lifecycle-agent/phase-criteria.d.ts +57 -0
- package/dist/services/lifecycle-agent/phase-criteria.js +335 -0
- package/dist/services/lifecycle-agent/transition-rules.d.ts +60 -0
- package/dist/services/lifecycle-agent/transition-rules.js +184 -0
- package/dist/services/lifecycle-agent/types.d.ts +190 -0
- package/dist/services/lifecycle-agent/types.js +12 -0
- package/dist/services/phase-hooks/__tests__/bindings-fetcher.test.d.ts +1 -0
- package/dist/services/phase-hooks/__tests__/bindings-fetcher.test.js +122 -0
- package/dist/services/phase-hooks/__tests__/hook-executor.test.d.ts +1 -0
- package/dist/services/phase-hooks/__tests__/hook-executor.test.js +321 -0
- package/dist/services/phase-hooks/__tests__/hook-runner.test.d.ts +1 -0
- package/dist/services/phase-hooks/__tests__/hook-runner.test.js +261 -0
- package/dist/services/phase-hooks/__tests__/plugin-loader.test.d.ts +1 -0
- package/dist/services/phase-hooks/__tests__/plugin-loader.test.js +158 -0
- package/dist/services/video/__tests__/video-pipeline.test.d.ts +6 -0
- package/dist/services/video/__tests__/video-pipeline.test.js +249 -0
- package/dist/workspace/__tests__/workspace-manager.test.d.ts +7 -0
- package/dist/workspace/__tests__/workspace-manager.test.js +52 -0
- package/dist/workspace/workspace-manager.js +17 -4
- package/package.json +1 -1
- package/.env.local +0 -12
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* template_snapshot + tag and no clone error, we short-circuit without
|
|
11
11
|
* re-cloning. Pass `{ force: true }` to regenerate anyway.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { createHash } from 'crypto';
|
|
14
|
+
import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'fs';
|
|
14
15
|
import { join } from 'path';
|
|
15
16
|
import { getGitHubConfigByProduct } from '../../api/github.js';
|
|
16
17
|
import { getProduct } from '../../api/products.js';
|
|
@@ -18,29 +19,33 @@ import { getRelease } from '../../api/releases.js';
|
|
|
18
19
|
import { getRunSheetByRelease, upsertRunSheet, } from '../../api/run-sheets.js';
|
|
19
20
|
import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
|
|
20
21
|
import { cloneFeatureRepo, ensureWorkspaceDir, getFeatureRepoPath, syncRepoToRef, } from '../../workspace/workspace-manager.js';
|
|
21
|
-
import {
|
|
22
|
+
import { fetchAllCompareCommits, getDefaultBranchHead, } from '../release-sync/github.js';
|
|
22
23
|
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
24
|
async function fetchCommitsBetween(owner, repo, base, head, token) {
|
|
27
25
|
if (!base) {
|
|
28
|
-
return { text: '', truncated: false };
|
|
26
|
+
return { text: '', list: [], truncated: false };
|
|
29
27
|
}
|
|
30
28
|
try {
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 };
|
|
40
44
|
}
|
|
41
45
|
catch (err) {
|
|
42
|
-
|
|
43
|
-
|
|
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 };
|
|
44
49
|
}
|
|
45
50
|
}
|
|
46
51
|
// Stale locks (e.g. left behind by a crashed or SIGKILLed CLI) are
|
|
@@ -69,7 +74,8 @@ function isLockStale(lockPath) {
|
|
|
69
74
|
const raw = readFileSync(lockPath, 'utf-8').trim();
|
|
70
75
|
let ts;
|
|
71
76
|
try {
|
|
72
|
-
|
|
77
|
+
;
|
|
78
|
+
({ ts } = JSON.parse(raw));
|
|
73
79
|
}
|
|
74
80
|
catch {
|
|
75
81
|
ts = Number(raw);
|
|
@@ -118,6 +124,87 @@ export function releaseFileLock(lockPath) {
|
|
|
118
124
|
// Best-effort — lock will expire via LOCK_STALE_MS anyway.
|
|
119
125
|
}
|
|
120
126
|
}
|
|
127
|
+
// A `run-sheet-<release-id>` workspace whose lock is stale and whose
|
|
128
|
+
// dir hasn't been touched for this long is considered abandoned. 30 days
|
|
129
|
+
// is long enough to keep caches warm across normal release cadences,
|
|
130
|
+
// short enough that disk usage doesn't grow unboundedly.
|
|
131
|
+
const WORKSPACE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
132
|
+
/**
|
|
133
|
+
* Delete `run-sheet-*` workspaces that are both:
|
|
134
|
+
* - not currently locked (lock file missing or stale), AND
|
|
135
|
+
* - older than WORKSPACE_MAX_AGE_MS by mtime.
|
|
136
|
+
*
|
|
137
|
+
* Best-effort: any error on a single entry is logged and skipped so a
|
|
138
|
+
* corrupt dir doesn't block the current generation.
|
|
139
|
+
*
|
|
140
|
+
* Exported for unit tests.
|
|
141
|
+
*/
|
|
142
|
+
export function pruneStaleRunSheetWorkspaces(workspaceRoot, now = Date.now(), maxAgeMs = WORKSPACE_MAX_AGE_MS) {
|
|
143
|
+
const removed = [];
|
|
144
|
+
let entries;
|
|
145
|
+
try {
|
|
146
|
+
entries = readdirSync(workspaceRoot);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return { removed };
|
|
150
|
+
}
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
if (!entry.startsWith('run-sheet-')) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const entryPath = join(workspaceRoot, entry);
|
|
156
|
+
const lockPath = `${entryPath}.lock`;
|
|
157
|
+
let ageMs;
|
|
158
|
+
try {
|
|
159
|
+
const stat = statSync(entryPath);
|
|
160
|
+
if (!stat.isDirectory()) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
ageMs = now - stat.mtimeMs;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (ageMs < maxAgeMs) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
// Don't delete a dir with a live lock — another CLI is mid-run.
|
|
172
|
+
try {
|
|
173
|
+
readFileSync(lockPath, 'utf-8');
|
|
174
|
+
if (!isLockStale(lockPath)) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Lock missing — safe to delete.
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
rmSync(entryPath, { recursive: true, force: true });
|
|
183
|
+
try {
|
|
184
|
+
unlinkSync(lockPath);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// Lock file may not exist; that's fine.
|
|
188
|
+
}
|
|
189
|
+
removed.push(entry);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
logWarning(`Could not prune stale workspace ${entry}: ${err instanceof Error ? err.message : String(err)}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { removed };
|
|
196
|
+
}
|
|
197
|
+
function computeInputHash(parts) {
|
|
198
|
+
const h = createHash('sha256');
|
|
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
|
+
}
|
|
121
208
|
function runSheetIsFresh(existing, template, tag) {
|
|
122
209
|
if (!existing || !existing.content) {
|
|
123
210
|
return false;
|
|
@@ -134,6 +221,7 @@ function runSheetIsFresh(existing, template, tag) {
|
|
|
134
221
|
}
|
|
135
222
|
return true;
|
|
136
223
|
}
|
|
224
|
+
// eslint-disable-next-line complexity -- orchestration with cache, clone, lock, render
|
|
137
225
|
export async function runRunSheet(options) {
|
|
138
226
|
const { releaseId, force, verbose } = options;
|
|
139
227
|
let release;
|
|
@@ -170,7 +258,7 @@ export async function runRunSheet(options) {
|
|
|
170
258
|
// Short-circuit cache: identical template + tag, no prior clone error.
|
|
171
259
|
if (!force) {
|
|
172
260
|
const existing = await getRunSheetByRelease(releaseId, verbose).catch(() => null);
|
|
173
|
-
if (runSheetIsFresh(existing, template, release.tag)) {
|
|
261
|
+
if (existing && runSheetIsFresh(existing, template, release.tag)) {
|
|
174
262
|
logInfo(`Run sheet for ${release.tag} is already up-to-date (template unchanged).`);
|
|
175
263
|
return {
|
|
176
264
|
status: 'cached',
|
|
@@ -190,17 +278,30 @@ export async function runRunSheet(options) {
|
|
|
190
278
|
// whatever metadata we have).
|
|
191
279
|
let repoDir = null;
|
|
192
280
|
let cloneError = null;
|
|
281
|
+
let commitsError = null;
|
|
193
282
|
let commits = '';
|
|
283
|
+
let commitsList = [];
|
|
194
284
|
let commitsTruncated = false;
|
|
195
285
|
let lockPath = null;
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
286
|
+
// Draft mode: the release's tag doesn't exist on GitHub yet (e.g. a
|
|
287
|
+
// 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 for
|
|
289
|
+
// both the checkout and the commits compare.
|
|
290
|
+
let isDraft = false;
|
|
291
|
+
let draftBaseRef = null;
|
|
292
|
+
if (repoConfigured && gh.owner && gh.repo && gh.token) {
|
|
293
|
+
const { owner, repo, token } = gh;
|
|
200
294
|
// Serialise concurrent CLI invocations for the *same release* by
|
|
201
295
|
// taking a file lock next to the clone dir. Different releases get
|
|
202
296
|
// different lock files (per-release workspace).
|
|
203
297
|
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
|
+
const pruned = pruneStaleRunSheetWorkspaces(workspaceRoot);
|
|
302
|
+
if (pruned.removed.length > 0) {
|
|
303
|
+
logInfo(`Pruned ${pruned.removed.length} stale run-sheet workspace(s).`);
|
|
304
|
+
}
|
|
204
305
|
const workspaceName = `run-sheet-${release.id}`;
|
|
205
306
|
const repoPathAhead = getFeatureRepoPath(workspaceRoot, workspaceName);
|
|
206
307
|
lockPath = `${repoPathAhead}.lock`;
|
|
@@ -223,22 +324,39 @@ export async function runRunSheet(options) {
|
|
|
223
324
|
syncRepoToRef(repoPath, { tag: release.tag }, token);
|
|
224
325
|
}
|
|
225
326
|
catch (err) {
|
|
226
|
-
|
|
227
|
-
logWarning(
|
|
327
|
+
const tagErr = err instanceof Error ? err.message : String(err);
|
|
328
|
+
logWarning(`Tag ${release.tag} not found — generating draft run sheet from default branch.`);
|
|
329
|
+
try {
|
|
330
|
+
const { branch } = await getDefaultBranchHead(owner, repo, token);
|
|
331
|
+
syncRepoToRef(repoPath, { branch }, token);
|
|
332
|
+
isDraft = true;
|
|
333
|
+
draftBaseRef = branch;
|
|
334
|
+
cloneError = `Tag ${release.tag} not found; fell back to default branch ${branch}. (${tagErr})`;
|
|
335
|
+
}
|
|
336
|
+
catch (fallbackErr) {
|
|
337
|
+
cloneError = `Could not checkout tag ${release.tag} or fall back to default branch: ${fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)}`;
|
|
338
|
+
logWarning(cloneError);
|
|
339
|
+
}
|
|
228
340
|
}
|
|
229
341
|
}
|
|
230
342
|
catch (err) {
|
|
231
343
|
cloneError = err instanceof Error ? err.message : String(err);
|
|
232
344
|
logWarning(`Clone failed: ${cloneError}`);
|
|
233
345
|
}
|
|
234
|
-
const
|
|
346
|
+
const head = isDraft && draftBaseRef ? draftBaseRef : release.tag;
|
|
347
|
+
const c = await fetchCommitsBetween(owner, repo, release.previous_tag, head, token);
|
|
235
348
|
commits = c.text;
|
|
349
|
+
commitsList = c.list;
|
|
236
350
|
commitsTruncated = c.truncated;
|
|
351
|
+
commitsError = c.error ?? null;
|
|
237
352
|
}
|
|
238
353
|
else {
|
|
239
354
|
cloneError = 'Product is not linked to a GitHub repository';
|
|
240
355
|
}
|
|
241
|
-
const
|
|
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, {
|
|
242
360
|
name: product?.name ?? 'Unknown product',
|
|
243
361
|
github_repository_full_name: product?.github_repository_full_name ?? null,
|
|
244
362
|
}, {
|
|
@@ -250,8 +368,15 @@ export async function runRunSheet(options) {
|
|
|
250
368
|
previous_tag: release.previous_tag,
|
|
251
369
|
previous_published_at: release.previous_published_at,
|
|
252
370
|
diff_summary: release.diff_summary,
|
|
253
|
-
diff_stats:
|
|
254
|
-
}, repoDir, commits);
|
|
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
|
+
});
|
|
255
380
|
try {
|
|
256
381
|
const saved = await upsertRunSheet({
|
|
257
382
|
release_id: release.id,
|
|
@@ -261,9 +386,14 @@ export async function runRunSheet(options) {
|
|
|
261
386
|
metadata: {
|
|
262
387
|
missing_placeholders: missing,
|
|
263
388
|
clone_error: cloneError,
|
|
389
|
+
commits_error: commitsError,
|
|
264
390
|
repo: product?.github_repository_full_name ?? null,
|
|
265
391
|
tag: release.tag,
|
|
266
392
|
commits_truncated: commitsTruncated,
|
|
393
|
+
is_draft: isDraft,
|
|
394
|
+
draft_base_ref: draftBaseRef,
|
|
395
|
+
input_hash: inputHash,
|
|
396
|
+
commits_count: commitsList.length,
|
|
267
397
|
},
|
|
268
398
|
generated_at: new Date().toISOString(),
|
|
269
399
|
}, verbose);
|
|
@@ -276,7 +406,9 @@ export async function runRunSheet(options) {
|
|
|
276
406
|
summary: `Rendered ${rendered.length} characters`,
|
|
277
407
|
missingPlaceholders: missing,
|
|
278
408
|
cloneError,
|
|
409
|
+
commitsError,
|
|
279
410
|
commitsTruncated,
|
|
411
|
+
isDraft,
|
|
280
412
|
};
|
|
281
413
|
}
|
|
282
414
|
catch (err) {
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure template-rendering logic for run sheets.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* exact same output for the same template + release.
|
|
4
|
+
* The CLI is the sole authoritative renderer today — if the web app
|
|
5
|
+
* ever grows its own run-sheet generation path, it should call into
|
|
6
|
+
* this module (or a published copy of it) rather than forking.
|
|
8
7
|
*/
|
|
9
8
|
export declare const MAX_FILE_INCLUDE_BYTES: number;
|
|
10
9
|
export declare const MAX_TOTAL_FILE_BYTES: number;
|
|
@@ -33,10 +32,29 @@ export declare function safeReadRepoFile(repoDir: string, relPath: string, remai
|
|
|
33
32
|
export interface RenderResult {
|
|
34
33
|
rendered: string;
|
|
35
34
|
missing: string[];
|
|
35
|
+
filesRead: {
|
|
36
|
+
path: string;
|
|
37
|
+
bytes: number;
|
|
38
|
+
}[];
|
|
39
|
+
}
|
|
40
|
+
export interface TemplateCommit {
|
|
41
|
+
sha: string;
|
|
42
|
+
short_sha: string;
|
|
43
|
+
summary: string;
|
|
44
|
+
message: string;
|
|
45
|
+
author: string;
|
|
46
|
+
url: string;
|
|
36
47
|
}
|
|
37
48
|
/**
|
|
38
49
|
* Strip Markdown-style backslash escapes inside `{{ ... }}` spans so the
|
|
39
50
|
* Rich-text editor roundtrip doesn't break placeholder matching.
|
|
40
51
|
*/
|
|
41
52
|
export declare function normalizeTemplate(template: string): string;
|
|
42
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Escape CommonMark-significant characters so untrusted strings
|
|
55
|
+
* (commit messages, author names, release bodies) can be safely
|
|
56
|
+
* dropped into a markdown run sheet without injecting headings,
|
|
57
|
+
* blockquotes, links, images, or HTML.
|
|
58
|
+
*/
|
|
59
|
+
export declare function escapeMarkdown(raw: string): string;
|
|
60
|
+
export declare function renderTemplate(template: string, product: TemplateProduct, release: TemplateRelease, repoDir: string | null, commits: string, draftNotice?: string, commitsList?: TemplateCommit[]): Promise<RenderResult>;
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure template-rendering logic for run sheets.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* exact same output for the same template + release.
|
|
4
|
+
* The CLI is the sole authoritative renderer today — if the web app
|
|
5
|
+
* ever grows its own run-sheet generation path, it should call into
|
|
6
|
+
* this module (or a published copy of it) rather than forking.
|
|
8
7
|
*/
|
|
9
|
-
import { statSync } from 'fs';
|
|
8
|
+
import { lstatSync, realpathSync, statSync } from 'fs';
|
|
10
9
|
import { readFile } from 'fs/promises';
|
|
11
10
|
import { join, resolve } from 'path';
|
|
12
11
|
export const MAX_FILE_INCLUDE_BYTES = 256 * 1024;
|
|
@@ -32,13 +31,37 @@ export async function safeReadRepoFile(repoDir, relPath, remainingBudget) {
|
|
|
32
31
|
if (abs !== repoDirResolved && !abs.startsWith(`${repoDirResolved}/`)) {
|
|
33
32
|
return { content: null, bytes: 0, reason: 'path escape' };
|
|
34
33
|
}
|
|
34
|
+
// Refuse symlinks at the leaf. `resolve()` only collapses `..`; it does
|
|
35
|
+
// not follow symlinks, so a symlink *inside* the repo pointing at
|
|
36
|
+
// `/etc/passwd` would otherwise be readable.
|
|
37
|
+
try {
|
|
38
|
+
if (lstatSync(abs).isSymbolicLink()) {
|
|
39
|
+
return { content: null, bytes: 0, reason: 'symlink refused' };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return { content: null, bytes: 0, reason: 'not found' };
|
|
44
|
+
}
|
|
45
|
+
// Also verify the fully-resolved real path still lives inside the repo
|
|
46
|
+
// root — catches symlinked parent directories.
|
|
47
|
+
try {
|
|
48
|
+
const realAbs = realpathSync(abs);
|
|
49
|
+
const realRoot = realpathSync(repoDirResolved);
|
|
50
|
+
if (realAbs !== realRoot && !realAbs.startsWith(`${realRoot}/`)) {
|
|
51
|
+
return { content: null, bytes: 0, reason: 'symlink escapes repo' };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { content: null, bytes: 0, reason: 'not found' };
|
|
56
|
+
}
|
|
35
57
|
let size;
|
|
36
58
|
try {
|
|
37
59
|
const stat = statSync(abs);
|
|
38
60
|
if (!stat.isFile()) {
|
|
39
61
|
return { content: null, bytes: 0, reason: 'not a file' };
|
|
40
62
|
}
|
|
41
|
-
|
|
63
|
+
;
|
|
64
|
+
({ size } = stat);
|
|
42
65
|
}
|
|
43
66
|
catch {
|
|
44
67
|
return { content: null, bytes: 0, reason: 'not found' };
|
|
@@ -72,31 +95,134 @@ export async function safeReadRepoFile(repoDir, relPath, remainingBudget) {
|
|
|
72
95
|
export function normalizeTemplate(template) {
|
|
73
96
|
return template.replace(/\{\{([^}]*)\}\}/g, (_match, inner) => `{{${inner.replace(/\\([_.\-\\/])/g, '$1')}}}`);
|
|
74
97
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
commits,
|
|
98
|
+
function isTruthy(value) {
|
|
99
|
+
if (!value) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
const v = value.trim();
|
|
103
|
+
return v !== '' && v !== 'false' && v !== '0';
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Split a block body on a top-level `{{else}}` so `{{#if}}A{{else}}B{{/if}}`
|
|
107
|
+
* resolves. Because we don't support nested same-keyword blocks, a naive
|
|
108
|
+
* first-occurrence split is safe enough.
|
|
109
|
+
*/
|
|
110
|
+
function splitOnElse(body) {
|
|
111
|
+
const m = body.match(/\{\{\s*else\s*\}\}/);
|
|
112
|
+
if (!m || m.index === undefined) {
|
|
113
|
+
return { main: body, alt: '' };
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
main: body.slice(0, m.index),
|
|
117
|
+
alt: body.slice(m.index + m[0].length),
|
|
96
118
|
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Replace `{{#if key}}...{{else}}...{{/if}}` and
|
|
122
|
+
* `{{#unless key}}...{{else}}...{{/unless}}` blocks. Runs repeatedly
|
|
123
|
+
* until a fixed point so two sibling (non-nested) blocks both resolve.
|
|
124
|
+
* Nested blocks of the *same* keyword aren't supported; that's an
|
|
125
|
+
* intentional simplicity/safety choice.
|
|
126
|
+
*/
|
|
127
|
+
function substituteConditionals(template, ctx) {
|
|
128
|
+
const IF_RE = /\{\{\s*#if\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}([\s\S]*?)\{\{\s*\/if\s*\}\}/g;
|
|
129
|
+
const UNLESS_RE = /\{\{\s*#unless\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}([\s\S]*?)\{\{\s*\/unless\s*\}\}/g;
|
|
130
|
+
let out = template;
|
|
131
|
+
for (let i = 0; i < 5; i++) {
|
|
132
|
+
const before = out;
|
|
133
|
+
out = out.replace(IF_RE, (_, key, body) => {
|
|
134
|
+
const { main, alt } = splitOnElse(body);
|
|
135
|
+
return isTruthy(ctx[key] ?? '') ? main : alt;
|
|
136
|
+
});
|
|
137
|
+
out = out.replace(UNLESS_RE, (_, key, body) => {
|
|
138
|
+
const { main, alt } = splitOnElse(body);
|
|
139
|
+
return isTruthy(ctx[key] ?? '') ? alt : main;
|
|
140
|
+
});
|
|
141
|
+
if (out === before) {
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Expand `{{#each commits}}...{{/each}}`. Each iteration renders the body
|
|
149
|
+
* with a merged ctx of (outer vars + commit fields), running the full
|
|
150
|
+
* conditional + variable substitution on the body so `{{#if url}}` /
|
|
151
|
+
* `{{formatDate ...}}` / outer vars all work per-commit.
|
|
152
|
+
*/
|
|
153
|
+
function substituteEachCommits(template, commits, outerCtx) {
|
|
154
|
+
const EACH_RE = /\{\{\s*#each\s+commits\s*\}\}([\s\S]*?)\{\{\s*\/each\s*\}\}/g;
|
|
155
|
+
return template.replace(EACH_RE, (_match, body) => commits
|
|
156
|
+
.map((c) => {
|
|
157
|
+
const localCtx = {
|
|
158
|
+
...outerCtx,
|
|
159
|
+
sha: c.sha,
|
|
160
|
+
short_sha: c.short_sha,
|
|
161
|
+
summary: c.summary,
|
|
162
|
+
message: c.message,
|
|
163
|
+
author: c.author,
|
|
164
|
+
url: c.url,
|
|
165
|
+
};
|
|
166
|
+
let rendered = substituteConditionals(body, localCtx);
|
|
167
|
+
rendered = substituteDateHelper(rendered, localCtx);
|
|
168
|
+
rendered = substituteEscapeHelper(rendered, localCtx);
|
|
169
|
+
rendered = rendered.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (match, key) => (key in localCtx ? localCtx[key] : match));
|
|
170
|
+
return rendered;
|
|
171
|
+
})
|
|
172
|
+
.join(''));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Escape CommonMark-significant characters so untrusted strings
|
|
176
|
+
* (commit messages, author names, release bodies) can be safely
|
|
177
|
+
* dropped into a markdown run sheet without injecting headings,
|
|
178
|
+
* blockquotes, links, images, or HTML.
|
|
179
|
+
*/
|
|
180
|
+
export function escapeMarkdown(raw) {
|
|
181
|
+
if (!raw) {
|
|
182
|
+
return '';
|
|
183
|
+
}
|
|
184
|
+
// Backslash-escape every CommonMark-significant punctuation character.
|
|
185
|
+
// This also neutralises line-start markers (`#`, `>`, `-`, `+`, `*`,
|
|
186
|
+
// `1.`) because their first char always ends up escaped.
|
|
187
|
+
return raw.replace(/([\\`*_{}\[\]()#+\-.!|<>~])/g, '\\$1');
|
|
188
|
+
}
|
|
189
|
+
function substituteEscapeHelper(template, ctx) {
|
|
190
|
+
const RE = /\{\{\s*escape\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
|
|
191
|
+
return template.replace(RE, (_match, key) => escapeMarkdown(ctx[key] ?? ''));
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* `{{formatDate key 'YYYY-MM-DD HH:mm'}}` — resolves `key` from ctx (must
|
|
195
|
+
* be a parseable date), formats with a tiny token set. Invalid dates or
|
|
196
|
+
* empty values render as empty string (matches the "degrade gracefully"
|
|
197
|
+
* convention the rest of the template follows).
|
|
198
|
+
*/
|
|
199
|
+
function substituteDateHelper(template, ctx) {
|
|
200
|
+
const RE = /\{\{\s*formatDate\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+['"]([^'"]+)['"]\s*\}\}/g;
|
|
201
|
+
return template.replace(RE, (_match, key, format) => {
|
|
202
|
+
const raw = ctx[key];
|
|
203
|
+
if (!raw) {
|
|
204
|
+
return '';
|
|
205
|
+
}
|
|
206
|
+
const d = new Date(raw);
|
|
207
|
+
if (Number.isNaN(d.getTime())) {
|
|
208
|
+
return '';
|
|
209
|
+
}
|
|
210
|
+
const pad = (n) => n.toString().padStart(2, '0');
|
|
211
|
+
return format
|
|
212
|
+
.replace(/YYYY/g, String(d.getUTCFullYear()))
|
|
213
|
+
.replace(/MM/g, pad(d.getUTCMonth() + 1))
|
|
214
|
+
.replace(/DD/g, pad(d.getUTCDate()))
|
|
215
|
+
.replace(/HH/g, pad(d.getUTCHours()))
|
|
216
|
+
.replace(/mm/g, pad(d.getUTCMinutes()))
|
|
217
|
+
.replace(/ss/g, pad(d.getUTCSeconds()));
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
async function expandFileIncludes(template, repoDir) {
|
|
221
|
+
const missing = [];
|
|
222
|
+
const filesRead = [];
|
|
97
223
|
const fileRegex = /\{\{\s*file:([^}]+?)\s*\}\}/g;
|
|
98
224
|
const fileMatches = [];
|
|
99
|
-
for (const m of
|
|
225
|
+
for (const m of template.matchAll(fileRegex)) {
|
|
100
226
|
fileMatches.push({ match: m[0], path: m[1].trim() });
|
|
101
227
|
}
|
|
102
228
|
let remainingBudget = MAX_TOTAL_FILE_BYTES;
|
|
@@ -110,7 +236,6 @@ export async function renderTemplate(template, product, release, repoDir, commit
|
|
|
110
236
|
fileResults.set(match, `<!-- file ${path} unavailable: repo not cloned -->`);
|
|
111
237
|
continue;
|
|
112
238
|
}
|
|
113
|
-
// eslint-disable-next-line no-await-in-loop -- sequential so the byte budget is enforced
|
|
114
239
|
const res = await safeReadRepoFile(repoDir, path, remainingBudget);
|
|
115
240
|
if (res.content === null) {
|
|
116
241
|
missing.push(`file:${path} (${res.reason ?? 'unavailable'})`);
|
|
@@ -118,10 +243,49 @@ export async function renderTemplate(template, product, release, repoDir, commit
|
|
|
118
243
|
}
|
|
119
244
|
else {
|
|
120
245
|
remainingBudget -= res.bytes;
|
|
246
|
+
filesRead.push({ path, bytes: res.bytes });
|
|
121
247
|
fileResults.set(match, res.content);
|
|
122
248
|
}
|
|
123
249
|
}
|
|
124
|
-
|
|
250
|
+
const rendered = template.replace(fileRegex, (match) => fileResults.get(match) ?? match);
|
|
251
|
+
return { rendered, missing, filesRead };
|
|
252
|
+
}
|
|
253
|
+
function buildSimpleVars(product, release, commits, draftNotice) {
|
|
254
|
+
const stats = release.diff_stats ?? {};
|
|
255
|
+
return {
|
|
256
|
+
product_name: product.name,
|
|
257
|
+
release_tag: release.tag,
|
|
258
|
+
release_name: release.name ?? release.tag,
|
|
259
|
+
release_body: release.body ?? '',
|
|
260
|
+
release_url: release.url ?? '',
|
|
261
|
+
previous_tag: release.previous_tag ?? '',
|
|
262
|
+
published_at: release.published_at ?? '',
|
|
263
|
+
previous_published_at: release.previous_published_at ?? '',
|
|
264
|
+
diff_summary: release.diff_summary ?? '',
|
|
265
|
+
files_changed: String(stats.files_changed ?? ''),
|
|
266
|
+
additions: String(stats.additions ?? ''),
|
|
267
|
+
deletions: String(stats.deletions ?? ''),
|
|
268
|
+
commits_count: String(stats.commits_count ?? stats.total_commits ?? ''),
|
|
269
|
+
repository: product.github_repository_full_name ?? '',
|
|
270
|
+
generated_at: new Date().toISOString(),
|
|
271
|
+
commits,
|
|
272
|
+
draft_notice: draftNotice,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
export async function renderTemplate(template, product, release, repoDir, commits, draftNotice = '', commitsList = []) {
|
|
276
|
+
const missing = [];
|
|
277
|
+
const simpleVars = buildSimpleVars(product, release, commits, draftNotice);
|
|
278
|
+
// Pipeline: normalize → file includes → #each (self-contained, renders
|
|
279
|
+
// its body with per-commit ctx) → outer #if/#unless → date helper →
|
|
280
|
+
// variable substitution. Running `#each` first lets it fully resolve
|
|
281
|
+
// per-commit `{{#if url}}` without bleeding into outer-scope `{{#if}}`.
|
|
282
|
+
const normalized = normalizeTemplate(template);
|
|
283
|
+
const fileExpansion = await expandFileIncludes(normalized, repoDir);
|
|
284
|
+
missing.push(...fileExpansion.missing);
|
|
285
|
+
let rendered = substituteEachCommits(fileExpansion.rendered, commitsList, simpleVars);
|
|
286
|
+
rendered = substituteConditionals(rendered, simpleVars);
|
|
287
|
+
rendered = substituteDateHelper(rendered, simpleVars);
|
|
288
|
+
rendered = substituteEscapeHelper(rendered, simpleVars);
|
|
125
289
|
rendered = rendered.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (match, key) => {
|
|
126
290
|
if (key in simpleVars) {
|
|
127
291
|
return simpleVars[key];
|
|
@@ -129,5 +293,5 @@ export async function renderTemplate(template, product, release, repoDir, commit
|
|
|
129
293
|
missing.push(key);
|
|
130
294
|
return match;
|
|
131
295
|
});
|
|
132
|
-
return { rendered, missing };
|
|
296
|
+
return { rendered, missing, filesRead: fileExpansion.filesRead };
|
|
133
297
|
}
|