edsger 0.43.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/.claude/settings.local.json +23 -3
- package/.env.local +12 -0
- package/dist/api/release-test-cases.d.ts +7 -0
- package/dist/api/release-test-cases.js +21 -0
- package/dist/api/releases.d.ts +41 -0
- package/dist/api/releases.js +31 -0
- package/dist/api/run-sheets.d.ts +22 -0
- package/dist/api/run-sheets.js +13 -0
- package/dist/commands/release-sync/index.d.ts +5 -0
- package/dist/commands/release-sync/index.js +38 -0
- package/dist/commands/run-sheet/index.d.ts +6 -0
- package/dist/commands/run-sheet/index.js +48 -0
- package/dist/commands/smoke-test/index.d.ts +5 -0
- package/dist/commands/smoke-test/index.js +40 -0
- package/dist/index.js +62 -0
- package/dist/phases/release-sync/__tests__/github.test.d.ts +9 -0
- package/dist/phases/release-sync/__tests__/github.test.js +123 -0
- package/dist/phases/release-sync/__tests__/snapshot.test.d.ts +8 -0
- package/dist/phases/release-sync/__tests__/snapshot.test.js +93 -0
- package/dist/phases/release-sync/github.d.ts +54 -0
- package/dist/phases/release-sync/github.js +101 -0
- package/dist/phases/release-sync/index.d.ts +24 -0
- package/dist/phases/release-sync/index.js +147 -0
- package/dist/phases/release-sync/snapshot.d.ts +27 -0
- package/dist/phases/release-sync/snapshot.js +159 -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/dist/phases/smoke-test/__tests__/agent.test.d.ts +4 -0
- package/dist/phases/smoke-test/__tests__/agent.test.js +85 -0
- package/dist/phases/smoke-test/agent.d.ts +12 -0
- package/dist/phases/smoke-test/agent.js +94 -0
- package/dist/phases/smoke-test/index.d.ts +22 -0
- package/dist/phases/smoke-test/index.js +233 -0
- package/dist/phases/smoke-test/prompts.d.ts +15 -0
- package/dist/phases/smoke-test/prompts.js +35 -0
- package/dist/skills/phase/smoke-test/SKILL.md +80 -0
- package/dist/utils/json-extract.d.ts +6 -0
- package/dist/utils/json-extract.js +44 -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.d.ts +31 -0
- package/dist/workspace/workspace-manager.js +96 -10
- package/package.json +9 -2
- package/tsconfig.json +2 -1
- package/vitest.config.ts +12 -0
- package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.d.ts +0 -4
- package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.js +0 -133
- package/dist/services/lifecycle-agent/__tests__/transition-rules.test.d.ts +0 -4
- package/dist/services/lifecycle-agent/__tests__/transition-rules.test.js +0 -336
- package/dist/services/lifecycle-agent/index.d.ts +0 -24
- package/dist/services/lifecycle-agent/index.js +0 -25
- package/dist/services/lifecycle-agent/phase-criteria.d.ts +0 -57
- package/dist/services/lifecycle-agent/phase-criteria.js +0 -335
- package/dist/services/lifecycle-agent/transition-rules.d.ts +0 -60
- package/dist/services/lifecycle-agent/transition-rules.js +0 -184
- package/dist/services/lifecycle-agent/types.d.ts +0 -190
- package/dist/services/lifecycle-agent/types.js +0 -12
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"permissions": {
|
|
3
3
|
"allow": [
|
|
4
|
-
"
|
|
5
|
-
"Bash(npm run
|
|
6
|
-
|
|
4
|
+
"Read(//Users/steven/development/edsger/**)",
|
|
5
|
+
"Bash(npm run build)",
|
|
6
|
+
"Bash(node:*)",
|
|
7
|
+
"Bash(git add:*)",
|
|
8
|
+
"Bash(git commit:*)",
|
|
9
|
+
"Bash(ls:*)",
|
|
10
|
+
"Bash(cat:*)",
|
|
11
|
+
"Bash(npm run typecheck:*)",
|
|
12
|
+
"Bash(git diff:*)",
|
|
13
|
+
"WebSearch",
|
|
14
|
+
"WebFetch(domain:supabase.com)",
|
|
15
|
+
"Bash(npm install:*)",
|
|
16
|
+
"Bash(grep:*)",
|
|
17
|
+
"Bash(npx supabase gen types typescript --help:*)",
|
|
18
|
+
"Bash(git -C /Users/steven/development/edsger status)",
|
|
19
|
+
"Bash(git -C /Users/steven/development/edsger diff)",
|
|
20
|
+
"Bash(git -C /Users/steven/development/edsger log --oneline -5)",
|
|
21
|
+
"Bash(git -C /Users/steven/development/edsger add supabase/migrations/20251231000000_drop_unused_views.sql)",
|
|
22
|
+
"Bash(git -C /Users/steven/development/edsger commit -m \"$\\(cat <<''EOF''\nchore: drop unused database views\n\nRemove test_report_summary and user_stories_with_context views that are defined but never used in the application.\n\nš¤ Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
|
23
|
+
"Bash(git -C /Users/steven/development/edsger commit -m \"$\\(cat <<''EOF''\nchore: drop unused database views\n\nRemove test_report_summary and user_stories_with_context views\nthat are defined but never used in the application.\n\nš¤ Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")"
|
|
24
|
+
],
|
|
25
|
+
"deny": [],
|
|
26
|
+
"ask": []
|
|
7
27
|
}
|
|
8
28
|
}
|
package/.env.local
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface ReleaseTestCaseInput {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
is_critical?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function clearReleaseTestCases(releaseId: string, verbose?: boolean): Promise<void>;
|
|
7
|
+
export declare function createReleaseTestCases(releaseId: string, cases: ReleaseTestCaseInput[], verbose?: boolean): Promise<number>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { logDebug, logInfo } from '../utils/logger.js';
|
|
2
|
+
import { callMcpEndpoint } from './mcp-client.js';
|
|
3
|
+
export async function clearReleaseTestCases(releaseId, verbose) {
|
|
4
|
+
logDebug(`Clearing draft cases for release ${releaseId}`, verbose);
|
|
5
|
+
await callMcpEndpoint('releases/test_cases/clear', {
|
|
6
|
+
release_id: releaseId,
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
export async function createReleaseTestCases(releaseId, cases, verbose) {
|
|
10
|
+
if (cases.length === 0) {
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
if (verbose) {
|
|
14
|
+
logInfo(`Inserting ${cases.length} release test cases`);
|
|
15
|
+
}
|
|
16
|
+
const result = (await callMcpEndpoint('releases/test_cases/create', {
|
|
17
|
+
release_id: releaseId,
|
|
18
|
+
test_cases: cases,
|
|
19
|
+
}));
|
|
20
|
+
return result.count;
|
|
21
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface Release {
|
|
2
|
+
id: string;
|
|
3
|
+
product_id: string;
|
|
4
|
+
tag: string;
|
|
5
|
+
name: string | null;
|
|
6
|
+
body: string | null;
|
|
7
|
+
url: string | null;
|
|
8
|
+
published_at: string | null;
|
|
9
|
+
previous_tag: string | null;
|
|
10
|
+
previous_published_at: string | null;
|
|
11
|
+
status: 'pending' | 'generating' | 'ready' | 'failed';
|
|
12
|
+
generation_error: string | null;
|
|
13
|
+
diff_summary: string | null;
|
|
14
|
+
diff_stats: Record<string, unknown>;
|
|
15
|
+
created_at: string;
|
|
16
|
+
updated_at: string;
|
|
17
|
+
}
|
|
18
|
+
export interface UpsertReleaseParams {
|
|
19
|
+
product_id: string;
|
|
20
|
+
tag: string;
|
|
21
|
+
name?: string | null;
|
|
22
|
+
body?: string | null;
|
|
23
|
+
url?: string | null;
|
|
24
|
+
published_at?: string | null;
|
|
25
|
+
previous_tag?: string | null;
|
|
26
|
+
previous_published_at?: string | null;
|
|
27
|
+
status?: 'pending' | 'generating' | 'ready' | 'failed';
|
|
28
|
+
diff_stats?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
export interface UpdateReleaseParams {
|
|
31
|
+
release_id: string;
|
|
32
|
+
status?: 'pending' | 'generating' | 'ready' | 'failed';
|
|
33
|
+
generation_error?: string | null;
|
|
34
|
+
diff_summary?: string | null;
|
|
35
|
+
diff_stats?: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
export declare function upsertRelease(params: UpsertReleaseParams, verbose?: boolean): Promise<Release>;
|
|
38
|
+
export declare function updateRelease(params: UpdateReleaseParams, verbose?: boolean): Promise<Release>;
|
|
39
|
+
export declare function getRelease(releaseId: string, verbose?: boolean): Promise<Release>;
|
|
40
|
+
export declare function getLatestReleaseForProduct(productId: string, verbose?: boolean): Promise<Release | null>;
|
|
41
|
+
export declare function getReleaseByTag(productId: string, tag: string, verbose?: boolean): Promise<Release | null>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { logDebug } from '../utils/logger.js';
|
|
2
|
+
import { callMcpEndpoint } from './mcp-client.js';
|
|
3
|
+
export async function upsertRelease(params, verbose) {
|
|
4
|
+
logDebug(`Upserting release ${params.product_id}@${params.tag}`, verbose);
|
|
5
|
+
return (await callMcpEndpoint('releases/upsert', params));
|
|
6
|
+
}
|
|
7
|
+
export async function updateRelease(params, verbose) {
|
|
8
|
+
logDebug(`Updating release ${params.release_id}`, verbose);
|
|
9
|
+
return (await callMcpEndpoint('releases/update', params));
|
|
10
|
+
}
|
|
11
|
+
export async function getRelease(releaseId, verbose) {
|
|
12
|
+
logDebug(`Fetching release ${releaseId}`, verbose);
|
|
13
|
+
return (await callMcpEndpoint('releases/get', {
|
|
14
|
+
release_id: releaseId,
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
export async function getLatestReleaseForProduct(productId, verbose) {
|
|
18
|
+
logDebug(`Fetching latest release for product ${productId}`, verbose);
|
|
19
|
+
const result = (await callMcpEndpoint('releases/latest_for_product', {
|
|
20
|
+
product_id: productId,
|
|
21
|
+
}));
|
|
22
|
+
return result.release;
|
|
23
|
+
}
|
|
24
|
+
export async function getReleaseByTag(productId, tag, verbose) {
|
|
25
|
+
logDebug(`Looking up release ${productId}@${tag}`, verbose);
|
|
26
|
+
const result = (await callMcpEndpoint('releases/get_by_tag', {
|
|
27
|
+
product_id: productId,
|
|
28
|
+
tag,
|
|
29
|
+
}));
|
|
30
|
+
return result.release;
|
|
31
|
+
}
|
|
@@ -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,38 @@
|
|
|
1
|
+
import { runReleaseSync, } from '../../phases/release-sync/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 runReleaseSyncCommand = async (options) => {
|
|
6
|
+
const { productId } = options;
|
|
7
|
+
if (!productId) {
|
|
8
|
+
throw new Error('Product ID is required for release-sync');
|
|
9
|
+
}
|
|
10
|
+
const config = validateConfiguration({
|
|
11
|
+
verbose: options.verbose,
|
|
12
|
+
});
|
|
13
|
+
await registerSession({ command: 'release-sync', productId });
|
|
14
|
+
logInfo(`Starting release sync for product: ${productId}`);
|
|
15
|
+
try {
|
|
16
|
+
const result = await runReleaseSync({
|
|
17
|
+
productId,
|
|
18
|
+
verbose: options.verbose,
|
|
19
|
+
}, config);
|
|
20
|
+
if (result.status === 'success') {
|
|
21
|
+
logInfo(result.summary);
|
|
22
|
+
for (const r of result.syncedReleases ?? []) {
|
|
23
|
+
logInfo(` - ${r.tag}${r.isSnapshot ? ' (snapshot)' : ''}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
logError(`Release sync failed: ${result.summary}`);
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
logError(`Release sync failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
await deregisterSession();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { runSmokeTest, } from '../../phases/smoke-test/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 runSmokeTestCommand = async (options) => {
|
|
6
|
+
const { releaseId } = options;
|
|
7
|
+
if (!releaseId) {
|
|
8
|
+
throw new Error('Release ID is required for smoke-test');
|
|
9
|
+
}
|
|
10
|
+
const config = validateConfiguration({
|
|
11
|
+
verbose: options.verbose,
|
|
12
|
+
});
|
|
13
|
+
await registerSession({ command: 'smoke-test' });
|
|
14
|
+
logInfo(`Starting smoke-test generation for release: ${releaseId}`);
|
|
15
|
+
try {
|
|
16
|
+
const result = await runSmokeTest({
|
|
17
|
+
releaseId,
|
|
18
|
+
verbose: options.verbose,
|
|
19
|
+
}, config);
|
|
20
|
+
if (result.status === 'success') {
|
|
21
|
+
logInfo(result.isSnapshot
|
|
22
|
+
? `Release: ${result.releaseTag} (snapshot ā not yet published on GitHub)`
|
|
23
|
+
: `Release: ${result.releaseTag}`);
|
|
24
|
+
logInfo(`Previous: ${result.previousReleaseTag ?? '(none)'}`);
|
|
25
|
+
logInfo(`Cases generated: ${result.casesCount}`);
|
|
26
|
+
logInfo('View in the Release detail page of your product dashboard.');
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
logError(`Smoke-test generation failed: ${result.summary}`);
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
logError(`Smoke-test generation failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await deregisterSession();
|
|
39
|
+
}
|
|
40
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,9 @@ import { runIntelligence } from './commands/intelligence/index.js';
|
|
|
19
19
|
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
|
+
import { runReleaseSyncCommand } from './commands/release-sync/index.js';
|
|
23
|
+
import { runRunSheetCommand } from './commands/run-sheet/index.js';
|
|
24
|
+
import { runSmokeTestCommand } from './commands/smoke-test/index.js';
|
|
22
25
|
import { runTaskWorker } from './commands/task-worker/index.js';
|
|
23
26
|
import { runWorkflow } from './commands/workflow/index.js';
|
|
24
27
|
import { logError, logInfo } from './utils/logger.js';
|
|
@@ -274,6 +277,65 @@ program
|
|
|
274
277
|
}
|
|
275
278
|
});
|
|
276
279
|
// ============================================================
|
|
280
|
+
// Subcommand: edsger release-sync <productId>
|
|
281
|
+
// ============================================================
|
|
282
|
+
program
|
|
283
|
+
.command('release-sync <productId>')
|
|
284
|
+
.description('Sync GitHub releases (and any unreleased snapshot version) into the releases table for a product')
|
|
285
|
+
.option('-v, --verbose', 'Verbose output')
|
|
286
|
+
.action(async (productId, opts) => {
|
|
287
|
+
try {
|
|
288
|
+
await runReleaseSyncCommand({
|
|
289
|
+
productId,
|
|
290
|
+
verbose: opts.verbose,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
logError(error instanceof Error ? error.message : String(error));
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
// ============================================================
|
|
299
|
+
// Subcommand: edsger smoke-test <releaseId>
|
|
300
|
+
// ============================================================
|
|
301
|
+
program
|
|
302
|
+
.command('smoke-test <releaseId>')
|
|
303
|
+
.description('Generate smoke-test cases for a specific release (requires the release to be synced first ā see `edsger release-sync`)')
|
|
304
|
+
.option('-v, --verbose', 'Verbose output')
|
|
305
|
+
.action(async (releaseId, opts) => {
|
|
306
|
+
try {
|
|
307
|
+
await runSmokeTestCommand({
|
|
308
|
+
releaseId,
|
|
309
|
+
verbose: opts.verbose,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
logError(error instanceof Error ? error.message : String(error));
|
|
314
|
+
process.exit(1);
|
|
315
|
+
}
|
|
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
|
+
// ============================================================
|
|
277
339
|
// Subcommand: edsger pr-review <productId>
|
|
278
340
|
// ============================================================
|
|
279
341
|
program
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for smoke-test github helpers.
|
|
3
|
+
*
|
|
4
|
+
* Covers the pure functions that shape GitHub compare data into a
|
|
5
|
+
* prompt-ready digest. Network-facing functions (fetchLatestTwoReleases,
|
|
6
|
+
* fetchCompare) are exercised only indirectly ā their output shape is
|
|
7
|
+
* fed through buildDiffDigest / summariseStats here.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for smoke-test github helpers.
|
|
3
|
+
*
|
|
4
|
+
* Covers the pure functions that shape GitHub compare data into a
|
|
5
|
+
* prompt-ready digest. Network-facing functions (fetchLatestTwoReleases,
|
|
6
|
+
* fetchCompare) are exercised only indirectly ā their output shape is
|
|
7
|
+
* fed through buildDiffDigest / summariseStats here.
|
|
8
|
+
*/
|
|
9
|
+
import assert from 'node:assert';
|
|
10
|
+
import { describe, it } from 'node:test';
|
|
11
|
+
import { buildDiffDigest, summariseStats, } from '../github.js';
|
|
12
|
+
function makeCompare(over = {}) {
|
|
13
|
+
return {
|
|
14
|
+
total_commits: 2,
|
|
15
|
+
commits: [
|
|
16
|
+
{
|
|
17
|
+
sha: '1111111aaaaaaaa',
|
|
18
|
+
commit: {
|
|
19
|
+
message: 'feat: add checkout flow\n\nmore body',
|
|
20
|
+
author: null,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
sha: '2222222bbbbbbbb',
|
|
25
|
+
commit: { message: 'fix: null deref', author: { name: 'Ada' } },
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
files: [
|
|
29
|
+
{
|
|
30
|
+
filename: 'src/checkout.ts',
|
|
31
|
+
status: 'modified',
|
|
32
|
+
additions: 10,
|
|
33
|
+
deletions: 2,
|
|
34
|
+
changes: 12,
|
|
35
|
+
patch: '@@ -1 +1 @@\n-old\n+new',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
filename: 'src/util.ts',
|
|
39
|
+
status: 'added',
|
|
40
|
+
additions: 5,
|
|
41
|
+
deletions: 0,
|
|
42
|
+
changes: 5,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
...over,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
void describe('summariseStats', () => {
|
|
49
|
+
void it('sums additions, deletions, and file count', () => {
|
|
50
|
+
const stats = summariseStats(makeCompare());
|
|
51
|
+
assert.deepStrictEqual(stats, {
|
|
52
|
+
files_changed: 2,
|
|
53
|
+
additions: 15,
|
|
54
|
+
deletions: 2,
|
|
55
|
+
total_commits: 2,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
void it('handles an empty diff', () => {
|
|
59
|
+
const stats = summariseStats({
|
|
60
|
+
total_commits: 0,
|
|
61
|
+
commits: [],
|
|
62
|
+
files: [],
|
|
63
|
+
});
|
|
64
|
+
assert.deepStrictEqual(stats, {
|
|
65
|
+
files_changed: 0,
|
|
66
|
+
additions: 0,
|
|
67
|
+
deletions: 0,
|
|
68
|
+
total_commits: 0,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
void describe('buildDiffDigest', () => {
|
|
73
|
+
void it('includes commit subjects, file list, and patches', () => {
|
|
74
|
+
const digest = buildDiffDigest(makeCompare());
|
|
75
|
+
assert.match(digest, /Total commits: 2/);
|
|
76
|
+
assert.match(digest, /1111111 feat: add checkout flow/);
|
|
77
|
+
assert.match(digest, /2222222 fix: null deref/);
|
|
78
|
+
assert.match(digest, /modified src\/checkout\.ts \(\+10\/-2\)/);
|
|
79
|
+
assert.match(digest, /added src\/util\.ts \(\+5\/-0\)/);
|
|
80
|
+
assert.match(digest, /--- src\/checkout\.ts ---/);
|
|
81
|
+
assert.match(digest, /\+new/);
|
|
82
|
+
});
|
|
83
|
+
void it('truncates only the subject line of multi-line commit messages', () => {
|
|
84
|
+
const digest = buildDiffDigest(makeCompare());
|
|
85
|
+
// "more body" from the first commit's message body must not leak in.
|
|
86
|
+
assert.ok(!digest.includes('more body'));
|
|
87
|
+
});
|
|
88
|
+
void it('caps the patch budget and appends a truncation marker', () => {
|
|
89
|
+
const huge = 'x'.repeat(200_000);
|
|
90
|
+
const compare = makeCompare({
|
|
91
|
+
files: [
|
|
92
|
+
{
|
|
93
|
+
filename: 'src/big.ts',
|
|
94
|
+
status: 'modified',
|
|
95
|
+
additions: 1,
|
|
96
|
+
deletions: 0,
|
|
97
|
+
changes: 1,
|
|
98
|
+
patch: huge,
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
const digest = buildDiffDigest(compare);
|
|
103
|
+
assert.match(digest, /\[truncated\]/);
|
|
104
|
+
// Even with a 200k patch, digest stays bounded by the internal budget.
|
|
105
|
+
assert.ok(digest.length < 200_000);
|
|
106
|
+
});
|
|
107
|
+
void it('skips files without patches in the patches section', () => {
|
|
108
|
+
const compare = makeCompare({
|
|
109
|
+
files: [
|
|
110
|
+
{
|
|
111
|
+
filename: 'vendored.min.js',
|
|
112
|
+
status: 'added',
|
|
113
|
+
additions: 1,
|
|
114
|
+
deletions: 0,
|
|
115
|
+
changes: 1,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
const digest = buildDiffDigest(compare);
|
|
120
|
+
assert.ok(digest.includes('== Patches (truncated) =='));
|
|
121
|
+
assert.ok(!digest.includes('--- vendored.min.js ---'));
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for snapshot-detection pure helpers.
|
|
3
|
+
*
|
|
4
|
+
* The network / SDK path (`detectSnapshotVersion`) is not covered here
|
|
5
|
+
* ā it's exercised end-to-end when the CLI runs ā but the parser and
|
|
6
|
+
* the plausibility check are critical and easy to cover.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for snapshot-detection pure helpers.
|
|
3
|
+
*
|
|
4
|
+
* The network / SDK path (`detectSnapshotVersion`) is not covered here
|
|
5
|
+
* ā it's exercised end-to-end when the CLI runs ā but the parser and
|
|
6
|
+
* the plausibility check are critical and easy to cover.
|
|
7
|
+
*/
|
|
8
|
+
import assert from 'node:assert';
|
|
9
|
+
import { describe, it } from 'node:test';
|
|
10
|
+
import { buildSnapshotDetectionPrompt, isPlausibleSnapshotTag, parseSnapshotDetection, } from '../snapshot.js';
|
|
11
|
+
void describe('parseSnapshotDetection', () => {
|
|
12
|
+
void it('parses a clean JSON object', () => {
|
|
13
|
+
const raw = '{"snapshot_tag":"v2.0.0","source":"package.json","reasoning":"next ver"}';
|
|
14
|
+
const parsed = parseSnapshotDetection(raw);
|
|
15
|
+
assert.strictEqual(parsed.snapshot_tag, 'v2.0.0');
|
|
16
|
+
assert.strictEqual(parsed.source, 'package.json');
|
|
17
|
+
assert.match(parsed.reasoning, /next ver/);
|
|
18
|
+
});
|
|
19
|
+
void it('strips ```json fences', () => {
|
|
20
|
+
const raw = '```json\n{"snapshot_tag":"v1.0.1","source":"VERSION","reasoning":"x"}\n```';
|
|
21
|
+
const parsed = parseSnapshotDetection(raw);
|
|
22
|
+
assert.strictEqual(parsed.snapshot_tag, 'v1.0.1');
|
|
23
|
+
});
|
|
24
|
+
void it('tolerates leading and trailing prose', () => {
|
|
25
|
+
const raw = `I checked package.json:
|
|
26
|
+
{"snapshot_tag":"v3.0.0","source":"package.json","reasoning":"bumped"}
|
|
27
|
+
Done.`;
|
|
28
|
+
const parsed = parseSnapshotDetection(raw);
|
|
29
|
+
assert.strictEqual(parsed.snapshot_tag, 'v3.0.0');
|
|
30
|
+
});
|
|
31
|
+
void it('returns null snapshot_tag when model reports no snapshot', () => {
|
|
32
|
+
const raw = '{"snapshot_tag": null, "source": null, "reasoning": "version matches latest release"}';
|
|
33
|
+
const parsed = parseSnapshotDetection(raw);
|
|
34
|
+
assert.strictEqual(parsed.snapshot_tag, null);
|
|
35
|
+
assert.strictEqual(parsed.source, null);
|
|
36
|
+
});
|
|
37
|
+
void it('treats empty string snapshot_tag as null', () => {
|
|
38
|
+
const raw = '{"snapshot_tag":"","source":null,"reasoning":"nothing found"}';
|
|
39
|
+
const parsed = parseSnapshotDetection(raw);
|
|
40
|
+
assert.strictEqual(parsed.snapshot_tag, null);
|
|
41
|
+
});
|
|
42
|
+
void it('falls back to balanced-brace extraction for surrounding prose', () => {
|
|
43
|
+
const raw = 'Analysis: { could be v1 } ā but the real answer is ' +
|
|
44
|
+
'{"snapshot_tag":"v1.2.0","source":"CHANGELOG","reasoning":"unreleased section present"}';
|
|
45
|
+
const parsed = parseSnapshotDetection(raw);
|
|
46
|
+
assert.strictEqual(parsed.snapshot_tag, 'v1.2.0');
|
|
47
|
+
assert.strictEqual(parsed.source, 'CHANGELOG');
|
|
48
|
+
});
|
|
49
|
+
void it('throws when no JSON object is present', () => {
|
|
50
|
+
assert.throws(() => parseSnapshotDetection('no json at all'), /JSON/);
|
|
51
|
+
});
|
|
52
|
+
void it('supplies default reasoning when missing', () => {
|
|
53
|
+
const raw = '{"snapshot_tag":"v1","source":"package.json"}';
|
|
54
|
+
const parsed = parseSnapshotDetection(raw);
|
|
55
|
+
assert.strictEqual(parsed.snapshot_tag, 'v1');
|
|
56
|
+
assert.strictEqual(parsed.reasoning, '(no reasoning given)');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
void describe('isPlausibleSnapshotTag', () => {
|
|
60
|
+
void it('accepts normal version tags', () => {
|
|
61
|
+
assert.strictEqual(isPlausibleSnapshotTag('v2.0.0', 'v1.9.0'), true);
|
|
62
|
+
assert.strictEqual(isPlausibleSnapshotTag('2.0.0-rc.1', '1.9.0'), true);
|
|
63
|
+
assert.strictEqual(isPlausibleSnapshotTag('v1.2.3-SNAPSHOT', 'v1.2.2'), true);
|
|
64
|
+
assert.strictEqual(isPlausibleSnapshotTag('release/2.0', 'release/1.9'), true);
|
|
65
|
+
});
|
|
66
|
+
void it('rejects tags identical to the latest release', () => {
|
|
67
|
+
assert.strictEqual(isPlausibleSnapshotTag('v1.0.0', 'v1.0.0'), false);
|
|
68
|
+
});
|
|
69
|
+
void it('rejects empty or overlong tags', () => {
|
|
70
|
+
assert.strictEqual(isPlausibleSnapshotTag('', 'v1'), false);
|
|
71
|
+
assert.strictEqual(isPlausibleSnapshotTag('x'.repeat(101), 'v1'), false);
|
|
72
|
+
});
|
|
73
|
+
void it('rejects whitespace and shell metacharacters', () => {
|
|
74
|
+
assert.strictEqual(isPlausibleSnapshotTag('v 2', 'v1'), false);
|
|
75
|
+
assert.strictEqual(isPlausibleSnapshotTag('v2;rm -rf', 'v1'), false);
|
|
76
|
+
assert.strictEqual(isPlausibleSnapshotTag('v2$PATH', 'v1'), false);
|
|
77
|
+
});
|
|
78
|
+
void it('rejects leading dot or dash, double dot, @{ reflog', () => {
|
|
79
|
+
assert.strictEqual(isPlausibleSnapshotTag('.v2', 'v1'), false);
|
|
80
|
+
assert.strictEqual(isPlausibleSnapshotTag('-v2', 'v1'), false);
|
|
81
|
+
assert.strictEqual(isPlausibleSnapshotTag('v2..rc', 'v1'), false);
|
|
82
|
+
assert.strictEqual(isPlausibleSnapshotTag('v2@{1}', 'v1'), false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
void describe('buildSnapshotDetectionPrompt', () => {
|
|
86
|
+
void it('injects the latest release tag into the prompt', () => {
|
|
87
|
+
const prompt = buildSnapshotDetectionPrompt('v1.4.2');
|
|
88
|
+
assert.match(prompt, /v1\.4\.2/);
|
|
89
|
+
// Must ask for JSON output
|
|
90
|
+
assert.match(prompt, /JSON/);
|
|
91
|
+
assert.match(prompt, /snapshot_tag/);
|
|
92
|
+
});
|
|
93
|
+
});
|