edsger 0.43.0 → 0.44.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.
Files changed (49) hide show
  1. package/.claude/settings.local.json +23 -3
  2. package/.env.local +12 -0
  3. package/dist/api/release-test-cases.d.ts +7 -0
  4. package/dist/api/release-test-cases.js +21 -0
  5. package/dist/api/releases.d.ts +41 -0
  6. package/dist/api/releases.js +31 -0
  7. package/dist/commands/release-sync/index.d.ts +5 -0
  8. package/dist/commands/release-sync/index.js +38 -0
  9. package/dist/commands/smoke-test/index.d.ts +5 -0
  10. package/dist/commands/smoke-test/index.js +40 -0
  11. package/dist/index.js +40 -0
  12. package/dist/phases/release-sync/__tests__/github.test.d.ts +9 -0
  13. package/dist/phases/release-sync/__tests__/github.test.js +123 -0
  14. package/dist/phases/release-sync/__tests__/snapshot.test.d.ts +8 -0
  15. package/dist/phases/release-sync/__tests__/snapshot.test.js +93 -0
  16. package/dist/phases/release-sync/github.d.ts +54 -0
  17. package/dist/phases/release-sync/github.js +101 -0
  18. package/dist/phases/release-sync/index.d.ts +24 -0
  19. package/dist/phases/release-sync/index.js +147 -0
  20. package/dist/phases/release-sync/snapshot.d.ts +27 -0
  21. package/dist/phases/release-sync/snapshot.js +159 -0
  22. package/dist/phases/smoke-test/__tests__/agent.test.d.ts +4 -0
  23. package/dist/phases/smoke-test/__tests__/agent.test.js +85 -0
  24. package/dist/phases/smoke-test/agent.d.ts +12 -0
  25. package/dist/phases/smoke-test/agent.js +94 -0
  26. package/dist/phases/smoke-test/index.d.ts +22 -0
  27. package/dist/phases/smoke-test/index.js +233 -0
  28. package/dist/phases/smoke-test/prompts.d.ts +15 -0
  29. package/dist/phases/smoke-test/prompts.js +35 -0
  30. package/dist/skills/phase/smoke-test/SKILL.md +80 -0
  31. package/dist/utils/json-extract.d.ts +6 -0
  32. package/dist/utils/json-extract.js +44 -0
  33. package/dist/workspace/__tests__/workspace-manager.test.d.ts +7 -0
  34. package/dist/workspace/__tests__/workspace-manager.test.js +52 -0
  35. package/dist/workspace/workspace-manager.d.ts +31 -0
  36. package/dist/workspace/workspace-manager.js +96 -10
  37. package/package.json +1 -1
  38. package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.d.ts +0 -4
  39. package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.js +0 -133
  40. package/dist/services/lifecycle-agent/__tests__/transition-rules.test.d.ts +0 -4
  41. package/dist/services/lifecycle-agent/__tests__/transition-rules.test.js +0 -336
  42. package/dist/services/lifecycle-agent/index.d.ts +0 -24
  43. package/dist/services/lifecycle-agent/index.js +0 -25
  44. package/dist/services/lifecycle-agent/phase-criteria.d.ts +0 -57
  45. package/dist/services/lifecycle-agent/phase-criteria.js +0 -335
  46. package/dist/services/lifecycle-agent/transition-rules.d.ts +0 -60
  47. package/dist/services/lifecycle-agent/transition-rules.js +0 -184
  48. package/dist/services/lifecycle-agent/types.d.ts +0 -190
  49. package/dist/services/lifecycle-agent/types.js +0 -12
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Smoke-test generation for a single release row. Given a release that has
3
+ * already been synced into the `releases` table (see `phases/release-sync`),
4
+ * this phase clones the repo, diffs against the baseline, and asks Claude
5
+ * to emit a JSON test plan, then persists the test cases and marks the
6
+ * release `ready`.
7
+ */
8
+ import { getGitHubConfigByProduct } from '../../api/github.js';
9
+ import { getProduct } from '../../api/products.js';
10
+ import { clearReleaseTestCases, createReleaseTestCases, } from '../../api/release-test-cases.js';
11
+ import { getRelease, updateRelease } from '../../api/releases.js';
12
+ import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
13
+ import { cloneFeatureRepo, ensureWorkspaceDir, syncRepoToRef, } from '../../workspace/workspace-manager.js';
14
+ import { buildDiffDigest, fetchCompare, getDefaultBranchHead, summariseStats, } from '../release-sync/github.js';
15
+ import { executeSmokeTestQuery } from './agent.js';
16
+ import { buildSmokeTestUserPrompt, createSmokeTestSystemPrompt, } from './prompts.js';
17
+ function isSnapshotRelease(release) {
18
+ return !release.published_at && !release.url;
19
+ }
20
+ // eslint-disable-next-line complexity -- orchestration spans GitHub + DB + AI
21
+ export async function runSmokeTest(options, config) {
22
+ const { releaseId, verbose } = options;
23
+ if (verbose) {
24
+ logInfo(`Starting smoke-test generation for release: ${releaseId}`);
25
+ }
26
+ let release;
27
+ try {
28
+ release = await getRelease(releaseId, verbose);
29
+ }
30
+ catch (err) {
31
+ const message = err instanceof Error ? err.message : String(err);
32
+ return {
33
+ status: 'error',
34
+ releaseId,
35
+ summary: `Failed to load release: ${message}`,
36
+ };
37
+ }
38
+ const gh = await getGitHubConfigByProduct(release.product_id, verbose);
39
+ if (!gh.configured || !gh.token || !gh.owner || !gh.repo) {
40
+ return {
41
+ status: 'error',
42
+ releaseId,
43
+ summary: gh.message ||
44
+ 'Product is not connected to a GitHub repository. Connect it in Product Settings.',
45
+ };
46
+ }
47
+ const ghResolved = { owner: gh.owner, repo: gh.repo, token: gh.token };
48
+ const isSnapshot = isSnapshotRelease(release);
49
+ // Mark release as generating up front so the UI reflects progress.
50
+ let markedGenerating = false;
51
+ try {
52
+ await updateRelease({ release_id: release.id, status: 'generating', generation_error: null }, verbose);
53
+ markedGenerating = true;
54
+ }
55
+ catch (err) {
56
+ logWarning(`Could not mark release as generating: ${err instanceof Error ? err.message : String(err)}`);
57
+ }
58
+ try {
59
+ return await runSmokeTestInner({
60
+ release,
61
+ gh: ghResolved,
62
+ isSnapshot,
63
+ config,
64
+ verbose,
65
+ });
66
+ }
67
+ catch (err) {
68
+ // Safety net: an uncaught throw past the `generating` mark would leave the
69
+ // release row stuck. Flip it to `failed` so the UI isn't permanently stale.
70
+ const message = err instanceof Error ? err.message : String(err);
71
+ if (markedGenerating) {
72
+ try {
73
+ await updateRelease({
74
+ release_id: release.id,
75
+ status: 'failed',
76
+ generation_error: `Unexpected error: ${message}`,
77
+ }, verbose);
78
+ }
79
+ catch {
80
+ // best effort
81
+ }
82
+ }
83
+ logError(`Smoke-test generation failed: ${message}`);
84
+ return {
85
+ status: 'error',
86
+ releaseId: release.id,
87
+ releaseTag: release.tag,
88
+ summary: message,
89
+ };
90
+ }
91
+ }
92
+ // eslint-disable-next-line complexity -- orchestration spans GitHub + DB + AI
93
+ async function runSmokeTestInner(ctx) {
94
+ const { release, gh: ghResolved, isSnapshot, config, verbose } = ctx;
95
+ // Clone + checkout the ref Claude should read.
96
+ let cwd;
97
+ try {
98
+ const workspaceRoot = ensureWorkspaceDir();
99
+ const { repoPath } = cloneFeatureRepo(workspaceRoot, `release-${release.product_id}`, ghResolved.owner, ghResolved.repo, ghResolved.token);
100
+ cwd = repoPath;
101
+ }
102
+ catch (err) {
103
+ logWarning(`Could not clone repo (continuing without code access): ${err instanceof Error ? err.message : String(err)}`);
104
+ }
105
+ // Decide the diff head. For shipped releases this is the release tag; for
106
+ // snapshots it's the default branch HEAD SHA.
107
+ let diffHead = release.tag;
108
+ if (cwd && isSnapshot) {
109
+ try {
110
+ const head = await getDefaultBranchHead(ghResolved.owner, ghResolved.repo, ghResolved.token);
111
+ syncRepoToRef(cwd, { branch: head.branch }, ghResolved.token);
112
+ diffHead = head.sha;
113
+ }
114
+ catch (err) {
115
+ logWarning(`Could not sync to default branch head: ${err instanceof Error ? err.message : String(err)}`);
116
+ }
117
+ }
118
+ else if (cwd && !isSnapshot) {
119
+ try {
120
+ syncRepoToRef(cwd, { tag: release.tag }, ghResolved.token);
121
+ }
122
+ catch (err) {
123
+ logWarning(`Could not checkout release tag ${release.tag}, continuing on default ref: ${err instanceof Error ? err.message : String(err)}`);
124
+ }
125
+ }
126
+ // Reset non-approved cases so the run is idempotent.
127
+ try {
128
+ await clearReleaseTestCases(release.id, verbose);
129
+ }
130
+ catch (err) {
131
+ logWarning(`Could not clear existing draft cases: ${err instanceof Error ? err.message : String(err)}`);
132
+ }
133
+ // Fetch diff (skip if there is no baseline).
134
+ let diffDigest = '(No baseline to diff against — generate smoke tests covering the entire release notes.)';
135
+ let diffStats = {
136
+ files_changed: 0,
137
+ additions: 0,
138
+ deletions: 0,
139
+ total_commits: 0,
140
+ };
141
+ if (release.previous_tag) {
142
+ try {
143
+ const compare = await fetchCompare(ghResolved.owner, ghResolved.repo, release.previous_tag, diffHead, ghResolved.token);
144
+ diffDigest = buildDiffDigest(compare);
145
+ diffStats = summariseStats(compare);
146
+ }
147
+ catch (err) {
148
+ const message = err instanceof Error ? err.message : String(err);
149
+ await updateRelease({
150
+ release_id: release.id,
151
+ status: 'failed',
152
+ generation_error: `Failed to fetch diff: ${message}`,
153
+ }, verbose);
154
+ return {
155
+ status: 'error',
156
+ releaseId: release.id,
157
+ releaseTag: release.tag,
158
+ summary: `Failed to fetch diff: ${message}`,
159
+ };
160
+ }
161
+ }
162
+ // Pull product metadata for the prompt.
163
+ const product = await getProduct(release.product_id, verbose).catch(() => null);
164
+ const productName = product?.name ?? 'Unknown product';
165
+ const productDescription = product?.description ?? null;
166
+ // Run Claude to produce the test plan.
167
+ let plan;
168
+ try {
169
+ const systemPrompt = await createSmokeTestSystemPrompt(Boolean(cwd), cwd);
170
+ plan = await executeSmokeTestQuery(systemPrompt, buildSmokeTestUserPrompt({
171
+ productName,
172
+ productDescription,
173
+ latestTag: release.tag,
174
+ previousTag: release.previous_tag,
175
+ releaseNotes: release.body ?? null,
176
+ diffDigest,
177
+ }), config, verbose, cwd);
178
+ }
179
+ catch (err) {
180
+ const message = err instanceof Error ? err.message : String(err);
181
+ await updateRelease({ release_id: release.id, status: 'failed', generation_error: message }, verbose);
182
+ logError(`Smoke-test generation failed: ${message}`);
183
+ return {
184
+ status: 'error',
185
+ releaseId: release.id,
186
+ releaseTag: release.tag,
187
+ summary: message,
188
+ };
189
+ }
190
+ let casesCount = 0;
191
+ try {
192
+ casesCount = await createReleaseTestCases(release.id, plan.test_cases.map((c) => ({
193
+ name: c.name,
194
+ description: c.description,
195
+ is_critical: c.is_critical ?? false,
196
+ })), verbose);
197
+ }
198
+ catch (err) {
199
+ const message = err instanceof Error ? err.message : String(err);
200
+ await updateRelease({
201
+ release_id: release.id,
202
+ status: 'failed',
203
+ generation_error: `Failed to insert cases: ${message}`,
204
+ }, verbose);
205
+ return {
206
+ status: 'error',
207
+ releaseId: release.id,
208
+ releaseTag: release.tag,
209
+ summary: `Failed to insert cases: ${message}`,
210
+ };
211
+ }
212
+ await updateRelease({
213
+ release_id: release.id,
214
+ status: 'ready',
215
+ diff_summary: plan.summary,
216
+ diff_stats: diffStats,
217
+ generation_error: null,
218
+ }, verbose);
219
+ logSuccess(`Generated ${casesCount} smoke-test cases for ${release.tag}${isSnapshot
220
+ ? ` (snapshot ahead of ${release.previous_tag ?? 'first release'})`
221
+ : release.previous_tag
222
+ ? ` (vs ${release.previous_tag})`
223
+ : ' (first release)'}`);
224
+ return {
225
+ status: 'success',
226
+ releaseId: release.id,
227
+ releaseTag: release.tag,
228
+ previousReleaseTag: release.previous_tag,
229
+ casesCount,
230
+ summary: plan.summary,
231
+ isSnapshot,
232
+ };
233
+ }
@@ -0,0 +1,15 @@
1
+ export interface SmokeTestPromptInput {
2
+ productName: string;
3
+ productDescription: string | null;
4
+ latestTag: string;
5
+ previousTag: string | null;
6
+ releaseNotes: string | null;
7
+ diffDigest: string;
8
+ }
9
+ /**
10
+ * Load the smoke-test system prompt from the shared SKILL.md library.
11
+ * Keeping the prompt in a skill file means it can also be consumed by
12
+ * Claude Code, other agents, or edited by users in the desktop app.
13
+ */
14
+ export declare function createSmokeTestSystemPrompt(hasCodebase?: boolean, projectDir?: string): Promise<string>;
15
+ export declare function buildSmokeTestUserPrompt(input: SmokeTestPromptInput): string;
@@ -0,0 +1,35 @@
1
+ import { processConditionals, resolveSkill, } from '../../services/skill-resolver.js';
2
+ /**
3
+ * Load the smoke-test system prompt from the shared SKILL.md library.
4
+ * Keeping the prompt in a skill file means it can also be consumed by
5
+ * Claude Code, other agents, or edited by users in the desktop app.
6
+ */
7
+ export async function createSmokeTestSystemPrompt(hasCodebase = false, projectDir) {
8
+ const skill = await resolveSkill('phase/smoke-test', { projectDir });
9
+ if (!skill) {
10
+ throw new Error('Failed to load skill: phase/smoke-test');
11
+ }
12
+ return processConditionals(skill.prompt, { hasCodebase });
13
+ }
14
+ export function buildSmokeTestUserPrompt(input) {
15
+ const productBlock = input.productDescription
16
+ ? `Product: ${input.productName}\nDescription: ${input.productDescription}`
17
+ : `Product: ${input.productName}`;
18
+ const baseBlock = input.previousTag
19
+ ? `Previous release: ${input.previousTag}`
20
+ : 'Previous release: (none — this is the first release)';
21
+ const notesBlock = input.releaseNotes
22
+ ? `Release notes for ${input.latestTag}:\n${input.releaseNotes.slice(0, 4000)}`
23
+ : `Release notes for ${input.latestTag}: (none)`;
24
+ return `${productBlock}
25
+
26
+ Release to ship: ${input.latestTag}
27
+ ${baseBlock}
28
+
29
+ ${notesBlock}
30
+
31
+ Code changes between the two releases:
32
+ ${input.diffDigest}
33
+
34
+ Produce the smoke-test plan as described in the system prompt.`;
35
+ }
@@ -0,0 +1,80 @@
1
+ ---
2
+ description: Generate a focused smoke-test plan for an upcoming product release by diffing the two most recent GitHub releases
3
+ kind: phase
4
+ user-invocable: false
5
+ ---
6
+
7
+ You are a senior QA engineer generating a smoke-test plan for an upcoming release of a product.
8
+
9
+ **Your Role**: Produce a tight, high-signal list of smoke-test cases that verifies the changes between the previous release and the new release still work end-to-end. You are not writing unit tests — you are writing scripted, user-visible checks that a human runs before shipping.
10
+
11
+ **Inputs you will receive**:
12
+
13
+ - Product name + description
14
+ - The new release tag (what we are about to ship)
15
+ - The previous release tag (the comparison baseline)
16
+ - Release notes for the new tag
17
+ - A digest of the code diff: commit list, changed files, and truncated patches
18
+
19
+ <!-- if:hasCodebase -->
20
+
21
+ The product's git repository has been cloned into your working directory and checked out at the new release tag. Use your tools to read the actual source files mentioned in the diff when the patch alone is ambiguous — it's better to open `src/api/foo.ts` and confirm the behavior change than to guess from a partial patch.
22
+
23
+ <!-- endif -->
24
+
25
+ <!-- if:!hasCodebase -->
26
+
27
+ Source files are not available in your working directory for this run. Work strictly from the release notes and the diff digest. When a patch is ambiguous, prefer writing a more general test case over inventing details you cannot verify, and note the uncertainty in the case description.
28
+
29
+ <!-- endif -->
30
+
31
+ ## Method
32
+
33
+ 1. **Read the release notes and diff digest first**. The notes tell you user-facing intent; the diff shows reality. Reconcile them.
34
+ 2. **Cluster the changes** into coherent user-visible areas (e.g. "checkout flow", "admin settings page", "auth callback"). Cases should map to areas, not to individual files.
35
+ 3. **For each cluster, design a case** that:
36
+ - Has a clear starting state, 2–6 steps, and an explicit expected result.
37
+ - Is runnable in < 5 minutes by a human tester without reading the source.
38
+ - Covers the **behavior that changed**, not behavior that was already tested before.
39
+ 4. **Tag `is_critical: true` only** when a failure of the case should block the release. A typical release has 1–4 critical cases, not 10.
40
+ 5. **Skip** purely internal refactors, dependency bumps, and test-only commits unless they could have user-visible effects.
41
+
42
+ ## Output contract
43
+
44
+ Respond with **ONLY** a single JSON object — no prose, no markdown fences:
45
+
46
+ ```
47
+ {
48
+ "summary": "1-3 sentence summary of what changed in this release",
49
+ "test_cases": [
50
+ {
51
+ "name": "short imperative title (<= 120 chars)",
52
+ "description": "Markdown with explicit Steps and Expected result sections",
53
+ "is_critical": true
54
+ }
55
+ ]
56
+ }
57
+ ```
58
+
59
+ Rules:
60
+
61
+ - **4 to 12** test cases. Fewer is better than padded.
62
+ - Every case must tie back to a real change in the diff. Do not invent tests for code that did not change.
63
+ - Names must be unique within the list.
64
+ - Descriptions must use this Markdown structure:
65
+
66
+ ```
67
+ **Steps**
68
+ 1. ...
69
+ 2. ...
70
+
71
+ **Expected result**
72
+ ...
73
+ ```
74
+
75
+ ## Common pitfalls
76
+
77
+ - **Over-testing internal plumbing**: if the diff is an internal rename with no user-visible effect, skip it.
78
+ - **Duplicating existing feature coverage**: you are testing the delta, not the whole product. The regular feature-level test cases already cover baseline behavior.
79
+ - **Vague "verify X works" cases**: give explicit inputs and observable outputs. A tester should not have to think about what "works" means.
80
+ - **All-critical lists**: if everything is critical, nothing is. Reserve critical for cases whose failure justifies holding the release.
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Scan a string for the first balanced, top-level JSON object and return its
3
+ * substring. Handles strings with escaped quotes and nested braces. Returns
4
+ * null if no balanced object is present.
5
+ */
6
+ export declare function findBalancedJsonObject(text: string): string | null;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Scan a string for the first balanced, top-level JSON object and return its
3
+ * substring. Handles strings with escaped quotes and nested braces. Returns
4
+ * null if no balanced object is present.
5
+ */
6
+ export function findBalancedJsonObject(text) {
7
+ const start = text.indexOf('{');
8
+ if (start === -1) {
9
+ return null;
10
+ }
11
+ let depth = 0;
12
+ let inString = false;
13
+ let escaped = false;
14
+ for (let i = start; i < text.length; i++) {
15
+ const ch = text[i];
16
+ if (escaped) {
17
+ escaped = false;
18
+ continue;
19
+ }
20
+ if (inString) {
21
+ if (ch === '\\') {
22
+ escaped = true;
23
+ }
24
+ else if (ch === '"') {
25
+ inString = false;
26
+ }
27
+ continue;
28
+ }
29
+ if (ch === '"') {
30
+ inString = true;
31
+ continue;
32
+ }
33
+ if (ch === '{') {
34
+ depth++;
35
+ }
36
+ else if (ch === '}') {
37
+ depth--;
38
+ if (depth === 0) {
39
+ return text.slice(start, i + 1);
40
+ }
41
+ }
42
+ }
43
+ return null;
44
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Unit tests for the pure helpers inside workspace-manager.
3
+ *
4
+ * syncRepoToRef / cloneFeatureRepo shell out to git and are exercised
5
+ * end-to-end; only the validator is cheap to unit-test here.
6
+ */
7
+ export {};
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Unit tests for the pure helpers inside workspace-manager.
3
+ *
4
+ * syncRepoToRef / cloneFeatureRepo shell out to git and are exercised
5
+ * end-to-end; only the validator is cheap to unit-test here.
6
+ */
7
+ import assert from 'node:assert';
8
+ import { describe, it } from 'node:test';
9
+ import { isSafeGitRef } from '../workspace-manager.js';
10
+ void describe('isSafeGitRef', () => {
11
+ void it('accepts common release / branch names', () => {
12
+ assert.strictEqual(isSafeGitRef('main'), true);
13
+ assert.strictEqual(isSafeGitRef('develop'), true);
14
+ assert.strictEqual(isSafeGitRef('v1.2.3'), true);
15
+ assert.strictEqual(isSafeGitRef('2024.01.15'), true);
16
+ assert.strictEqual(isSafeGitRef('release/2.0'), true);
17
+ assert.strictEqual(isSafeGitRef('v1.0.0-rc.1'), true);
18
+ assert.strictEqual(isSafeGitRef('v2@stable'), true);
19
+ assert.strictEqual(isSafeGitRef('feature/user-auth_v2'), true);
20
+ });
21
+ void it('rejects empty / overlong input', () => {
22
+ assert.strictEqual(isSafeGitRef(''), false);
23
+ assert.strictEqual(isSafeGitRef('x'.repeat(101)), false);
24
+ });
25
+ void it('rejects whitespace', () => {
26
+ assert.strictEqual(isSafeGitRef('v 1.0'), false);
27
+ assert.strictEqual(isSafeGitRef('main\n'), false);
28
+ assert.strictEqual(isSafeGitRef('\tmain'), false);
29
+ });
30
+ void it('rejects shell metacharacters', () => {
31
+ assert.strictEqual(isSafeGitRef('v1;rm -rf /'), false);
32
+ assert.strictEqual(isSafeGitRef('v1$PATH'), false);
33
+ assert.strictEqual(isSafeGitRef('v1`id`'), false);
34
+ assert.strictEqual(isSafeGitRef('v1|less'), false);
35
+ assert.strictEqual(isSafeGitRef('v1>out'), false);
36
+ assert.strictEqual(isSafeGitRef('v1*'), false);
37
+ });
38
+ void it('rejects refs that would confuse git itself', () => {
39
+ assert.strictEqual(isSafeGitRef('.hidden'), false);
40
+ assert.strictEqual(isSafeGitRef('-foo'), false);
41
+ assert.strictEqual(isSafeGitRef('v2..rc'), false);
42
+ assert.strictEqual(isSafeGitRef('HEAD@{1}'), false);
43
+ });
44
+ void it('rejects non-string input defensively', () => {
45
+ // @ts-expect-error testing runtime guard
46
+ assert.strictEqual(isSafeGitRef(null), false);
47
+ // @ts-expect-error testing runtime guard
48
+ assert.strictEqual(isSafeGitRef(undefined), false);
49
+ // @ts-expect-error testing runtime guard
50
+ assert.strictEqual(isSafeGitRef(123), false);
51
+ });
52
+ });
@@ -52,6 +52,37 @@ export declare function featureRepoExists(workspaceRoot: string, featureId: stri
52
52
  * @returns FeatureRepo with the path and clone status
53
53
  */
54
54
  export declare function cloneFeatureRepo(workspaceRoot: string, featureId: string, owner: string, repo: string, token: string): FeatureRepo;
55
+ /**
56
+ * Loose validator for a git ref name (tag or branch) coming from callers
57
+ * that pass data originating from external sources (GitHub API, AI output).
58
+ *
59
+ * Rules match `git check-ref-format` plus a length cap. Rejecting bad
60
+ * input early keeps it out of execFileSync argv.
61
+ */
62
+ export declare function isSafeGitRef(ref: string): boolean;
63
+ /**
64
+ * Sync a cloned feature repo to a specific git ref — a tag or a branch.
65
+ *
66
+ * Behaviour:
67
+ * - `git fetch origin --tags --prune` brings in new tags (release tags!) and
68
+ * drops deleted remote branches, using the installation-token credential
69
+ * helper so private repos keep working.
70
+ * - Before switching refs, `git reset --hard HEAD` + `git clean -fd` drops
71
+ * tracked-file edits and untracked files left by a prior run. We skip the
72
+ * `-x` flag so `.gitignore`d content (node_modules, target, dist) survives
73
+ * between runs — matches the convention in pull-request/creator.ts and
74
+ * avoids an expensive reinstall every sync.
75
+ * - For a tag, detached-HEAD checkout of that tag.
76
+ * - For a branch, `checkout -B <branch> origin/<branch>` — force-updates
77
+ * the local branch to the remote tip in one step, which also works on
78
+ * the first sync when the local branch doesn't exist yet.
79
+ *
80
+ * This workspace is edsger-managed; callers should not put local work here.
81
+ */
82
+ export declare function syncRepoToRef(repoPath: string, ref: {
83
+ tag?: string;
84
+ branch?: string;
85
+ }, token: string): void;
55
86
  /**
56
87
  * Set up a feature's repo for work (install deps, etc.)
57
88
  * This is called after cloning or reusing a repo
@@ -10,6 +10,7 @@
10
10
  import { execFileSync, execSync } from 'child_process';
11
11
  import { existsSync, mkdirSync } from 'fs';
12
12
  import { basename, join } from 'path';
13
+ import { buildCredentialArgs } from '../utils/git-push.js';
13
14
  import { logError, logInfo, logSuccess, logWarning } from '../utils/logger.js';
14
15
  const WORKSPACE_DIR_NAME = 'edsger';
15
16
  /**
@@ -69,16 +70,10 @@ export function featureRepoExists(workspaceRoot, featureId) {
69
70
  export function cloneFeatureRepo(workspaceRoot, featureId, owner, repo, token) {
70
71
  const repoPath = getFeatureRepoPath(workspaceRoot, featureId);
71
72
  const repoUrl = `https://github.com/${owner}/${repo}.git`;
72
- // Configure git to use token via credential helper (avoids token in URL / process list)
73
- // First clear any existing credential helpers (e.g. osxkeychain) with an empty value,
74
- // then set our custom helper. This ensures the token is used instead of stale keychain creds.
75
- const credentialHelper = `!f() { echo "username=x-access-token"; echo "password=${token}"; }; f`;
76
- const gitCredentialArgs = [
77
- '-c',
78
- 'credential.helper=',
79
- '-c',
80
- `credential.helper=${credentialHelper}`,
81
- ];
73
+ // Use the shared credential helper builder. Passes the installation
74
+ // token via `git -c credential.helper=...` so it stays out of the
75
+ // remote URL and the process list.
76
+ const gitCredentialArgs = buildCredentialArgs(token);
82
77
  // Check if already cloned
83
78
  if (existsSync(join(repoPath, '.git'))) {
84
79
  logInfo(`Reusing existing repo for feature ${featureId}`);
@@ -121,6 +116,97 @@ export function cloneFeatureRepo(workspaceRoot, featureId, owner, repo, token) {
121
116
  throw error;
122
117
  }
123
118
  }
119
+ /**
120
+ * Loose validator for a git ref name (tag or branch) coming from callers
121
+ * that pass data originating from external sources (GitHub API, AI output).
122
+ *
123
+ * Rules match `git check-ref-format` plus a length cap. Rejecting bad
124
+ * input early keeps it out of execFileSync argv.
125
+ */
126
+ export function isSafeGitRef(ref) {
127
+ if (typeof ref !== 'string') {
128
+ return false;
129
+ }
130
+ if (ref.length === 0 || ref.length > 100) {
131
+ return false;
132
+ }
133
+ if (/\s/.test(ref)) {
134
+ return false;
135
+ }
136
+ if (/^[-.]/.test(ref)) {
137
+ return false;
138
+ }
139
+ if (ref.includes('..') || ref.includes('@{')) {
140
+ return false;
141
+ }
142
+ return /^[A-Za-z0-9._\-+/@]+$/.test(ref);
143
+ }
144
+ /**
145
+ * Sync a cloned feature repo to a specific git ref — a tag or a branch.
146
+ *
147
+ * Behaviour:
148
+ * - `git fetch origin --tags --prune` brings in new tags (release tags!) and
149
+ * drops deleted remote branches, using the installation-token credential
150
+ * helper so private repos keep working.
151
+ * - Before switching refs, `git reset --hard HEAD` + `git clean -fd` drops
152
+ * tracked-file edits and untracked files left by a prior run. We skip the
153
+ * `-x` flag so `.gitignore`d content (node_modules, target, dist) survives
154
+ * between runs — matches the convention in pull-request/creator.ts and
155
+ * avoids an expensive reinstall every sync.
156
+ * - For a tag, detached-HEAD checkout of that tag.
157
+ * - For a branch, `checkout -B <branch> origin/<branch>` — force-updates
158
+ * the local branch to the remote tip in one step, which also works on
159
+ * the first sync when the local branch doesn't exist yet.
160
+ *
161
+ * This workspace is edsger-managed; callers should not put local work here.
162
+ */
163
+ export function syncRepoToRef(repoPath, ref, token) {
164
+ if (!existsSync(join(repoPath, '.git'))) {
165
+ throw new Error(`Not a git repo: ${repoPath}`);
166
+ }
167
+ if (!ref.tag && !ref.branch) {
168
+ throw new Error('syncRepoToRef requires either tag or branch');
169
+ }
170
+ if (ref.tag && !isSafeGitRef(ref.tag)) {
171
+ throw new Error(`Unsafe tag ref: ${JSON.stringify(ref.tag)}`);
172
+ }
173
+ if (ref.branch && !isSafeGitRef(ref.branch)) {
174
+ throw new Error(`Unsafe branch ref: ${JSON.stringify(ref.branch)}`);
175
+ }
176
+ const creds = buildCredentialArgs(token);
177
+ try {
178
+ execFileSync('git', [...creds, 'fetch', 'origin', '--tags', '--prune'], { cwd: repoPath, stdio: 'pipe' });
179
+ }
180
+ catch {
181
+ logWarning('git fetch failed during sync; working tree may be stale');
182
+ }
183
+ // Discard any residual state from a previous run before switching refs.
184
+ // Use `-fd` (not `-fdx`) to preserve .gitignore'd build output / deps.
185
+ try {
186
+ execFileSync('git', ['reset', '--hard', 'HEAD'], {
187
+ cwd: repoPath,
188
+ stdio: 'pipe',
189
+ });
190
+ execFileSync('git', ['clean', '-fd'], { cwd: repoPath, stdio: 'pipe' });
191
+ }
192
+ catch {
193
+ // Non-fatal; subsequent checkout will surface any real issue.
194
+ }
195
+ try {
196
+ if (ref.tag) {
197
+ execFileSync('git', ['-c', 'advice.detachedHead=false', 'checkout', `refs/tags/${ref.tag}`], { cwd: repoPath, stdio: 'pipe' });
198
+ logInfo(`Checked out tag ${ref.tag}`);
199
+ }
200
+ else if (ref.branch) {
201
+ // Create-or-reset local branch to origin tip.
202
+ execFileSync('git', ['checkout', '-B', ref.branch, `origin/${ref.branch}`], { cwd: repoPath, stdio: 'pipe' });
203
+ logInfo(`Checked out branch ${ref.branch} at origin/${ref.branch}`);
204
+ }
205
+ }
206
+ catch (error) {
207
+ throw new Error(`Failed to checkout ${ref.tag ?? ref.branch}: ${error instanceof Error ? error.message : String(error)}`);
208
+ }
209
+ }
124
210
  /**
125
211
  * Set up a feature's repo for work (install deps, etc.)
126
212
  * This is called after cloning or reusing a repo
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"
@@ -1,4 +0,0 @@
1
- /**
2
- * Unit tests for phase quality criteria definitions
3
- */
4
- export {};