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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub release helpers for smoke-test generation, implemented on top of
|
|
3
|
+
* @octokit/rest so we share retry / rate-limit / User-Agent behavior with the
|
|
4
|
+
* rest of the packages/edsger codebase.
|
|
5
|
+
*/
|
|
6
|
+
export interface GithubRelease {
|
|
7
|
+
id: number;
|
|
8
|
+
tag_name: string;
|
|
9
|
+
name: string | null;
|
|
10
|
+
body: string | null;
|
|
11
|
+
html_url: string;
|
|
12
|
+
published_at: string | null;
|
|
13
|
+
draft: boolean;
|
|
14
|
+
prerelease: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface CompareFile {
|
|
17
|
+
filename: string;
|
|
18
|
+
status: string;
|
|
19
|
+
additions: number;
|
|
20
|
+
deletions: number;
|
|
21
|
+
changes: number;
|
|
22
|
+
patch?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface CompareCommit {
|
|
25
|
+
sha: string;
|
|
26
|
+
commit: {
|
|
27
|
+
message: string;
|
|
28
|
+
author?: {
|
|
29
|
+
name?: string;
|
|
30
|
+
} | null;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export interface CompareResponse {
|
|
34
|
+
total_commits: number;
|
|
35
|
+
files: CompareFile[];
|
|
36
|
+
commits: CompareCommit[];
|
|
37
|
+
}
|
|
38
|
+
export interface ReleasePair {
|
|
39
|
+
latest: GithubRelease;
|
|
40
|
+
previous: GithubRelease | null;
|
|
41
|
+
}
|
|
42
|
+
export declare function fetchLatestTwoReleases(owner: string, repo: string, token: string): Promise<ReleasePair>;
|
|
43
|
+
export declare function getDefaultBranchHead(owner: string, repo: string, token: string): Promise<{
|
|
44
|
+
branch: string;
|
|
45
|
+
sha: string;
|
|
46
|
+
}>;
|
|
47
|
+
export declare function fetchCompare(owner: string, repo: string, base: string, head: string, token: string): Promise<CompareResponse>;
|
|
48
|
+
export declare function buildDiffDigest(compare: CompareResponse): string;
|
|
49
|
+
export declare function summariseStats(compare: CompareResponse): {
|
|
50
|
+
files_changed: number;
|
|
51
|
+
additions: number;
|
|
52
|
+
deletions: number;
|
|
53
|
+
total_commits: number;
|
|
54
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub release helpers for smoke-test generation, implemented on top of
|
|
3
|
+
* @octokit/rest so we share retry / rate-limit / User-Agent behavior with the
|
|
4
|
+
* rest of the packages/edsger codebase.
|
|
5
|
+
*/
|
|
6
|
+
import { Octokit } from '@octokit/rest';
|
|
7
|
+
const MAX_PATCH_CHARS = 60_000;
|
|
8
|
+
const MAX_COMMITS_INCLUDED = 40;
|
|
9
|
+
function octokit(token) {
|
|
10
|
+
return new Octokit({ auth: token, userAgent: 'edsger-cli' });
|
|
11
|
+
}
|
|
12
|
+
export async function fetchLatestTwoReleases(owner, repo, token) {
|
|
13
|
+
const gh = octokit(token);
|
|
14
|
+
const { data } = await gh.repos.listReleases({ owner, repo, per_page: 10 });
|
|
15
|
+
const real = data.filter((r) => !r.draft);
|
|
16
|
+
if (real.length === 0) {
|
|
17
|
+
throw new Error(`Repository ${owner}/${repo} has no releases yet. Publish one first.`);
|
|
18
|
+
}
|
|
19
|
+
return { latest: real[0], previous: real[1] ?? null };
|
|
20
|
+
}
|
|
21
|
+
export async function getDefaultBranchHead(owner, repo, token) {
|
|
22
|
+
const gh = octokit(token);
|
|
23
|
+
const { data: repoInfo } = await gh.repos.get({ owner, repo });
|
|
24
|
+
const branch = repoInfo.default_branch;
|
|
25
|
+
const { data: branchInfo } = await gh.repos.getBranch({
|
|
26
|
+
owner,
|
|
27
|
+
repo,
|
|
28
|
+
branch,
|
|
29
|
+
});
|
|
30
|
+
return { branch, sha: branchInfo.commit.sha };
|
|
31
|
+
}
|
|
32
|
+
export async function fetchCompare(owner, repo, base, head, token) {
|
|
33
|
+
const gh = octokit(token);
|
|
34
|
+
const { data } = await gh.repos.compareCommits({
|
|
35
|
+
owner,
|
|
36
|
+
repo,
|
|
37
|
+
base,
|
|
38
|
+
head,
|
|
39
|
+
});
|
|
40
|
+
// Octokit returns a richer shape; narrow to the subset we consume.
|
|
41
|
+
return {
|
|
42
|
+
total_commits: data.total_commits ?? 0,
|
|
43
|
+
files: (data.files ?? []).map((f) => ({
|
|
44
|
+
filename: f.filename,
|
|
45
|
+
status: f.status,
|
|
46
|
+
additions: f.additions ?? 0,
|
|
47
|
+
deletions: f.deletions ?? 0,
|
|
48
|
+
changes: f.changes ?? 0,
|
|
49
|
+
patch: f.patch,
|
|
50
|
+
})),
|
|
51
|
+
commits: (data.commits ?? []).map((c) => ({
|
|
52
|
+
sha: c.sha,
|
|
53
|
+
commit: {
|
|
54
|
+
message: c.commit?.message ?? '',
|
|
55
|
+
author: c.commit?.author
|
|
56
|
+
? { name: c.commit.author.name ?? undefined }
|
|
57
|
+
: null,
|
|
58
|
+
},
|
|
59
|
+
})),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function buildDiffDigest(compare) {
|
|
63
|
+
const lines = [];
|
|
64
|
+
lines.push(`Total commits: ${compare.total_commits}`);
|
|
65
|
+
lines.push('\n== Commits ==');
|
|
66
|
+
for (const c of compare.commits.slice(0, MAX_COMMITS_INCLUDED)) {
|
|
67
|
+
const subject = (c.commit.message || '').split('\n')[0].slice(0, 200);
|
|
68
|
+
lines.push(`- ${c.sha.slice(0, 7)} ${subject}`);
|
|
69
|
+
}
|
|
70
|
+
lines.push('\n== Files ==');
|
|
71
|
+
for (const f of compare.files) {
|
|
72
|
+
lines.push(`- ${f.status} ${f.filename} (+${f.additions}/-${f.deletions})`);
|
|
73
|
+
}
|
|
74
|
+
lines.push('\n== Patches (truncated) ==');
|
|
75
|
+
let budget = MAX_PATCH_CHARS;
|
|
76
|
+
for (const f of compare.files) {
|
|
77
|
+
if (budget <= 0 || !f.patch) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const header = `\n--- ${f.filename} ---\n`;
|
|
81
|
+
const piece = header + f.patch;
|
|
82
|
+
if (piece.length > budget) {
|
|
83
|
+
lines.push(piece.slice(0, budget));
|
|
84
|
+
lines.push('\n...[truncated]');
|
|
85
|
+
budget = 0;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
lines.push(piece);
|
|
89
|
+
budget -= piece.length;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return lines.join('\n');
|
|
93
|
+
}
|
|
94
|
+
export function summariseStats(compare) {
|
|
95
|
+
return {
|
|
96
|
+
files_changed: compare.files.length,
|
|
97
|
+
additions: compare.files.reduce((s, f) => s + f.additions, 0),
|
|
98
|
+
deletions: compare.files.reduce((s, f) => s + f.deletions, 0),
|
|
99
|
+
total_commits: compare.total_commits,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release sync: reconcile the `releases` table with GitHub releases for a
|
|
3
|
+
* product. Writes a `pending` row for each release we discover (latest +
|
|
4
|
+
* previous + any unreleased snapshot version). Does NOT generate smoke-test
|
|
5
|
+
* cases — that is handled per-release by `phases/smoke-test` once the user
|
|
6
|
+
* opts in from the release detail page.
|
|
7
|
+
*/
|
|
8
|
+
import { type EdsgerConfig } from '../../types/index.js';
|
|
9
|
+
export interface ReleaseSyncOptions {
|
|
10
|
+
productId: string;
|
|
11
|
+
verbose?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface ReleaseSyncResult {
|
|
14
|
+
status: 'success' | 'error';
|
|
15
|
+
productId: string;
|
|
16
|
+
summary: string;
|
|
17
|
+
/** Releases that were created or updated during this sync. */
|
|
18
|
+
syncedReleases?: {
|
|
19
|
+
id: string;
|
|
20
|
+
tag: string;
|
|
21
|
+
isSnapshot: boolean;
|
|
22
|
+
}[];
|
|
23
|
+
}
|
|
24
|
+
export declare function runReleaseSync(options: ReleaseSyncOptions, _config: EdsgerConfig): Promise<ReleaseSyncResult>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release sync: reconcile the `releases` table with GitHub releases for a
|
|
3
|
+
* product. Writes a `pending` row for each release we discover (latest +
|
|
4
|
+
* previous + any unreleased snapshot version). Does NOT generate smoke-test
|
|
5
|
+
* cases — that is handled per-release by `phases/smoke-test` once the user
|
|
6
|
+
* opts in from the release detail page.
|
|
7
|
+
*/
|
|
8
|
+
import { getGitHubConfigByProduct } from '../../api/github.js';
|
|
9
|
+
import { getReleaseByTag, upsertRelease, } from '../../api/releases.js';
|
|
10
|
+
import { logInfo, logSuccess, logWarning } from '../../utils/logger.js';
|
|
11
|
+
import { cloneFeatureRepo, ensureWorkspaceDir, syncRepoToRef, } from '../../workspace/workspace-manager.js';
|
|
12
|
+
import { fetchLatestTwoReleases, getDefaultBranchHead, } from './github.js';
|
|
13
|
+
import { detectSnapshotVersion, isPlausibleSnapshotTag } from './snapshot.js';
|
|
14
|
+
/**
|
|
15
|
+
* Upsert a release row as `pending` only when it doesn't already exist.
|
|
16
|
+
* Preserves `ready` / `failed` / `generating` status on existing rows so
|
|
17
|
+
* repeated syncs don't clobber smoke-test progress.
|
|
18
|
+
*/
|
|
19
|
+
async function ensureReleaseRow(args, verbose) {
|
|
20
|
+
const existing = await getReleaseByTag(args.productId, args.tag, verbose);
|
|
21
|
+
if (existing) {
|
|
22
|
+
return { release: existing, created: false };
|
|
23
|
+
}
|
|
24
|
+
const release = await upsertRelease({
|
|
25
|
+
product_id: args.productId,
|
|
26
|
+
tag: args.tag,
|
|
27
|
+
name: args.githubRelease?.name ?? null,
|
|
28
|
+
body: args.githubRelease?.body ?? null,
|
|
29
|
+
url: args.githubRelease?.html_url ?? null,
|
|
30
|
+
published_at: args.githubRelease?.published_at ?? null,
|
|
31
|
+
previous_tag: args.previousTag,
|
|
32
|
+
previous_published_at: args.previousPublishedAt,
|
|
33
|
+
status: 'pending',
|
|
34
|
+
}, verbose);
|
|
35
|
+
return { release, created: true };
|
|
36
|
+
}
|
|
37
|
+
// eslint-disable-next-line complexity -- orchestration across GitHub + DB + snapshot
|
|
38
|
+
export async function runReleaseSync(options, _config) {
|
|
39
|
+
const { productId, verbose } = options;
|
|
40
|
+
if (verbose) {
|
|
41
|
+
logInfo(`Starting release sync for product: ${productId}`);
|
|
42
|
+
}
|
|
43
|
+
const gh = await getGitHubConfigByProduct(productId, verbose);
|
|
44
|
+
if (!gh.configured || !gh.token || !gh.owner || !gh.repo) {
|
|
45
|
+
return {
|
|
46
|
+
status: 'error',
|
|
47
|
+
productId,
|
|
48
|
+
summary: gh.message ||
|
|
49
|
+
'Product is not connected to a GitHub repository. Connect it in Product Settings.',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const releases = await fetchLatestTwoReleases(gh.owner, gh.repo, gh.token);
|
|
53
|
+
const { latest, previous } = releases;
|
|
54
|
+
if (verbose) {
|
|
55
|
+
logInfo(`Latest GitHub release: ${latest.tag_name}, previous: ${previous?.tag_name ?? '(none)'}`);
|
|
56
|
+
}
|
|
57
|
+
const synced = [];
|
|
58
|
+
// Always reflect the latest GitHub release.
|
|
59
|
+
const latestRow = await ensureReleaseRow({
|
|
60
|
+
productId,
|
|
61
|
+
tag: latest.tag_name,
|
|
62
|
+
previousTag: previous?.tag_name ?? null,
|
|
63
|
+
previousPublishedAt: previous?.published_at ?? null,
|
|
64
|
+
githubRelease: latest,
|
|
65
|
+
}, verbose);
|
|
66
|
+
synced.push({
|
|
67
|
+
id: latestRow.release.id,
|
|
68
|
+
tag: latestRow.release.tag,
|
|
69
|
+
isSnapshot: false,
|
|
70
|
+
});
|
|
71
|
+
if (latestRow.created) {
|
|
72
|
+
logInfo(`Synced release ${latest.tag_name}`);
|
|
73
|
+
}
|
|
74
|
+
// Reflect the previous GitHub release so the UI can show history, even
|
|
75
|
+
// if smoke-test was never run against it.
|
|
76
|
+
if (previous) {
|
|
77
|
+
const previousRow = await ensureReleaseRow({
|
|
78
|
+
productId,
|
|
79
|
+
tag: previous.tag_name,
|
|
80
|
+
previousTag: null,
|
|
81
|
+
previousPublishedAt: null,
|
|
82
|
+
githubRelease: previous,
|
|
83
|
+
}, verbose);
|
|
84
|
+
synced.push({
|
|
85
|
+
id: previousRow.release.id,
|
|
86
|
+
tag: previousRow.release.tag,
|
|
87
|
+
isSnapshot: false,
|
|
88
|
+
});
|
|
89
|
+
if (previousRow.created) {
|
|
90
|
+
logInfo(`Synced release ${previous.tag_name}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Snapshot detection: clone the repo at the default branch tip and ask
|
|
94
|
+
// the agent whether there's an unreleased version ahead of `latest`.
|
|
95
|
+
let cwd;
|
|
96
|
+
try {
|
|
97
|
+
const workspaceRoot = ensureWorkspaceDir();
|
|
98
|
+
const { repoPath } = cloneFeatureRepo(workspaceRoot, `release-sync-${productId}`, gh.owner, gh.repo, gh.token);
|
|
99
|
+
cwd = repoPath;
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
logWarning(`Could not clone repo for snapshot detection: ${err instanceof Error ? err.message : String(err)}`);
|
|
103
|
+
}
|
|
104
|
+
if (cwd) {
|
|
105
|
+
try {
|
|
106
|
+
const { branch } = await getDefaultBranchHead(gh.owner, gh.repo, gh.token);
|
|
107
|
+
syncRepoToRef(cwd, { branch }, gh.token);
|
|
108
|
+
const detection = await detectSnapshotVersion({
|
|
109
|
+
cwd,
|
|
110
|
+
latestReleaseTag: latest.tag_name,
|
|
111
|
+
config: _config,
|
|
112
|
+
verbose,
|
|
113
|
+
});
|
|
114
|
+
if (detection.snapshot_tag &&
|
|
115
|
+
isPlausibleSnapshotTag(detection.snapshot_tag, latest.tag_name)) {
|
|
116
|
+
const snapshotRow = await ensureReleaseRow({
|
|
117
|
+
productId,
|
|
118
|
+
tag: detection.snapshot_tag,
|
|
119
|
+
previousTag: latest.tag_name,
|
|
120
|
+
previousPublishedAt: latest.published_at ?? null,
|
|
121
|
+
githubRelease: null,
|
|
122
|
+
}, verbose);
|
|
123
|
+
synced.push({
|
|
124
|
+
id: snapshotRow.release.id,
|
|
125
|
+
tag: snapshotRow.release.tag,
|
|
126
|
+
isSnapshot: true,
|
|
127
|
+
});
|
|
128
|
+
if (snapshotRow.created) {
|
|
129
|
+
logInfo(`Detected unreleased snapshot ${detection.snapshot_tag} (${detection.source ?? 'unknown source'})`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else if (verbose) {
|
|
133
|
+
logInfo(`No snapshot version ahead of ${latest.tag_name}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
logWarning(`Snapshot detection skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
logSuccess(`Synced ${synced.length} release${synced.length === 1 ? '' : 's'}`);
|
|
141
|
+
return {
|
|
142
|
+
status: 'success',
|
|
143
|
+
productId,
|
|
144
|
+
summary: `Synced ${synced.length} release${synced.length === 1 ? '' : 's'} from GitHub`,
|
|
145
|
+
syncedReleases: synced,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot detection: after the latest GitHub release already has a
|
|
3
|
+
* smoke-test plan, inspect the cloned repo for a yet-to-be-released
|
|
4
|
+
* "snapshot" version (next package.json version, an [Unreleased] entry
|
|
5
|
+
* in CHANGELOG, an unreleased git tag, etc.) so we can prepare the
|
|
6
|
+
* next smoke test before anyone cuts the tag on GitHub.
|
|
7
|
+
*/
|
|
8
|
+
import { type EdsgerConfig } from '../../types/index.js';
|
|
9
|
+
export interface SnapshotDetection {
|
|
10
|
+
snapshot_tag: string | null;
|
|
11
|
+
source: string | null;
|
|
12
|
+
reasoning: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function buildSnapshotDetectionPrompt(latestReleaseTag: string): string;
|
|
15
|
+
export declare function parseSnapshotDetection(raw: string): SnapshotDetection;
|
|
16
|
+
/**
|
|
17
|
+
* Very loose sanity check on a proposed snapshot tag. We reject tags
|
|
18
|
+
* that are obviously the same as the latest release or that contain
|
|
19
|
+
* characters that would break downstream git / GitHub API calls.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isPlausibleSnapshotTag(candidate: string, latestReleaseTag: string): boolean;
|
|
22
|
+
export declare function detectSnapshotVersion(options: {
|
|
23
|
+
cwd: string;
|
|
24
|
+
latestReleaseTag: string;
|
|
25
|
+
config: EdsgerConfig;
|
|
26
|
+
verbose?: boolean;
|
|
27
|
+
}): Promise<SnapshotDetection>;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot detection: after the latest GitHub release already has a
|
|
3
|
+
* smoke-test plan, inspect the cloned repo for a yet-to-be-released
|
|
4
|
+
* "snapshot" version (next package.json version, an [Unreleased] entry
|
|
5
|
+
* in CHANGELOG, an unreleased git tag, etc.) so we can prepare the
|
|
6
|
+
* next smoke test before anyone cuts the tag on GitHub.
|
|
7
|
+
*/
|
|
8
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
9
|
+
import { DEFAULT_MODEL } from '../../constants.js';
|
|
10
|
+
import { findBalancedJsonObject } from '../../utils/json-extract.js';
|
|
11
|
+
import { logDebug, logInfo } from '../../utils/logger.js';
|
|
12
|
+
export function buildSnapshotDetectionPrompt(latestReleaseTag) {
|
|
13
|
+
return `You are inspecting a code repository to decide whether an unreleased "snapshot" version is being prepared.
|
|
14
|
+
|
|
15
|
+
The most recent shipped release on GitHub is tagged: **${latestReleaseTag}**
|
|
16
|
+
|
|
17
|
+
Your job:
|
|
18
|
+
|
|
19
|
+
1. Read the repository's primary version source. In priority order:
|
|
20
|
+
- package.json "version" field (JS/TS projects)
|
|
21
|
+
- Cargo.toml [package] version (Rust)
|
|
22
|
+
- pyproject.toml [project] / [tool.poetry] version (Python)
|
|
23
|
+
- pom.xml <version> (Java / Maven)
|
|
24
|
+
- VERSION file (plain text)
|
|
25
|
+
2. Read CHANGELOG.md / HISTORY.md / RELEASES.md for an [Unreleased] or similarly labelled section.
|
|
26
|
+
3. Run \`git tag --list\` and \`git tag --sort=-creatordate | head -5\` to look for tags newer than ${latestReleaseTag} that have not yet been cut as GitHub releases.
|
|
27
|
+
|
|
28
|
+
Then decide:
|
|
29
|
+
- If the primary version source is strictly greater than ${latestReleaseTag} (accounting for "v" prefixes and semver prerelease suffixes like \`-SNAPSHOT\`, \`-rc.1\`, \`-next.0\`), OR there is a newer unreleased git tag, report that string as \`snapshot_tag\`.
|
|
30
|
+
- Otherwise report \`snapshot_tag: null\`.
|
|
31
|
+
- Do NOT invent a version. Use the exact string from the source file. Preserve the repo's existing tag convention (e.g. if releases are \`v1.2.3\` but package.json says \`1.2.3\`, return \`v1.2.3\`).
|
|
32
|
+
|
|
33
|
+
Respond with ONLY a JSON object — no prose, no markdown fences:
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
"snapshot_tag": "v2.0.0" | null,
|
|
37
|
+
"source": "package.json" | "Cargo.toml" | "pyproject.toml" | "pom.xml" | "VERSION" | "CHANGELOG" | "git_tag" | null,
|
|
38
|
+
"reasoning": "<one sentence>"
|
|
39
|
+
}`;
|
|
40
|
+
}
|
|
41
|
+
export function parseSnapshotDetection(raw) {
|
|
42
|
+
let body = raw.trim();
|
|
43
|
+
const fence = body.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
44
|
+
if (fence) {
|
|
45
|
+
body = fence[1].trim();
|
|
46
|
+
}
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(body);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
const object = findBalancedJsonObject(body);
|
|
53
|
+
if (!object) {
|
|
54
|
+
throw new Error('No JSON object found in snapshot-detection output');
|
|
55
|
+
}
|
|
56
|
+
parsed = JSON.parse(object);
|
|
57
|
+
}
|
|
58
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
59
|
+
throw new Error('Snapshot detection returned non-object response');
|
|
60
|
+
}
|
|
61
|
+
const obj = parsed;
|
|
62
|
+
const rawTag = obj.snapshot_tag;
|
|
63
|
+
const snapshotTag = typeof rawTag === 'string' && rawTag.trim().length > 0
|
|
64
|
+
? rawTag.trim()
|
|
65
|
+
: null;
|
|
66
|
+
const source = typeof obj.source === 'string' ? obj.source : null;
|
|
67
|
+
const reasoning = typeof obj.reasoning === 'string' ? obj.reasoning : '(no reasoning given)';
|
|
68
|
+
return { snapshot_tag: snapshotTag, source, reasoning };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Very loose sanity check on a proposed snapshot tag. We reject tags
|
|
72
|
+
* that are obviously the same as the latest release or that contain
|
|
73
|
+
* characters that would break downstream git / GitHub API calls.
|
|
74
|
+
*/
|
|
75
|
+
export function isPlausibleSnapshotTag(candidate, latestReleaseTag) {
|
|
76
|
+
if (candidate === latestReleaseTag) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
if (candidate.length === 0 || candidate.length > 100) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
if (/\s/.test(candidate)) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
if (/^[-.]/.test(candidate) ||
|
|
86
|
+
candidate.includes('..') ||
|
|
87
|
+
candidate.includes('@{')) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return /^[A-Za-z0-9._\-+/@]+$/.test(candidate);
|
|
91
|
+
}
|
|
92
|
+
function userMessage(content) {
|
|
93
|
+
return { type: 'user', message: { role: 'user', content } };
|
|
94
|
+
}
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/require-await -- async generator required by SDK interface
|
|
96
|
+
async function* makePrompt(text) {
|
|
97
|
+
yield userMessage(text);
|
|
98
|
+
}
|
|
99
|
+
// eslint-disable-next-line complexity -- agent loop with message-type handling
|
|
100
|
+
export async function detectSnapshotVersion(options) {
|
|
101
|
+
const { cwd, latestReleaseTag, verbose } = options;
|
|
102
|
+
if (verbose) {
|
|
103
|
+
logInfo(`Detecting snapshot version ahead of ${latestReleaseTag}...`);
|
|
104
|
+
}
|
|
105
|
+
let lastAssistant = '';
|
|
106
|
+
let detection = null;
|
|
107
|
+
for await (const message of query({
|
|
108
|
+
prompt: makePrompt(buildSnapshotDetectionPrompt(latestReleaseTag)),
|
|
109
|
+
options: {
|
|
110
|
+
systemPrompt: {
|
|
111
|
+
type: 'preset',
|
|
112
|
+
preset: 'claude_code',
|
|
113
|
+
},
|
|
114
|
+
model: DEFAULT_MODEL,
|
|
115
|
+
maxTurns: 10,
|
|
116
|
+
permissionMode: 'bypassPermissions',
|
|
117
|
+
cwd,
|
|
118
|
+
},
|
|
119
|
+
})) {
|
|
120
|
+
if (message.type === 'assistant' && message.message?.content) {
|
|
121
|
+
for (const content of message.message.content) {
|
|
122
|
+
if (content.type === 'text') {
|
|
123
|
+
lastAssistant += `${content.text}\n`;
|
|
124
|
+
logDebug(content.text, verbose);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (message.type === 'result') {
|
|
129
|
+
const text = ('result' in message ? message.result : '') || lastAssistant;
|
|
130
|
+
try {
|
|
131
|
+
detection = parseSnapshotDetection(text);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
if (verbose) {
|
|
135
|
+
logDebug(`Snapshot detection parse error: ${err instanceof Error ? err.message : String(err)}`, verbose);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!detection) {
|
|
141
|
+
return {
|
|
142
|
+
snapshot_tag: null,
|
|
143
|
+
source: null,
|
|
144
|
+
reasoning: 'Could not parse detection response',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// Validate the proposed tag before returning.
|
|
148
|
+
if (detection.snapshot_tag !== null &&
|
|
149
|
+
!isPlausibleSnapshotTag(detection.snapshot_tag, latestReleaseTag)) {
|
|
150
|
+
if (verbose) {
|
|
151
|
+
logInfo(`Rejecting implausible snapshot tag: ${JSON.stringify(detection.snapshot_tag)}`);
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
...detection,
|
|
155
|
+
snapshot_tag: null,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return detection;
|
|
159
|
+
}
|
|
@@ -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>;
|