@ulysses-ai/create-workspace 0.16.0-beta.0 → 0.17.0-beta.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/README.md +2 -2
- package/lib/init.mjs +19 -0
- package/package.json +1 -1
- package/template/.claude/hooks/session-end.mjs +68 -2
- package/template/.claude/rules/config-review.md.skip +29 -0
- package/template/.claude/rules/forge-operations.md +107 -0
- package/template/.claude/rules/goal-driven-work.md +48 -4
- package/template/.claude/rules/workspace-structure.md +38 -0
- package/template/.claude/scripts/cleanup-work-session.mjs +164 -26
- package/template/.claude/scripts/forges/github.mjs +210 -0
- package/template/.claude/scripts/forges/gitlab.mjs +19 -0
- package/template/.claude/scripts/forges/interface.mjs +113 -0
- package/template/.claude/settings.json +5 -13
- package/template/.claude/skills/complete-work/SKILL.md +73 -42
- package/template/.claude/skills/maintenance/SKILL.md +32 -6
- package/template/.claude/skills/pause-work/SKILL.md +24 -7
- package/template/.claude/skills/release/SKILL.md +7 -5
- package/template/.claude/skills/workspace-init/SKILL.md +32 -0
- package/template/.claudeignore +3 -0
- package/template/CLAUDE.md.tmpl +1 -0
- package/template/CODEBASE.md.tmpl +13 -0
- package/template/repo-claude.md.tmpl +10 -0
- package/template/workspace.json.tmpl +2 -1
- package/template/.claude/hooks/worktree-create.mjs +0 -53
|
@@ -11,9 +11,23 @@
|
|
|
11
11
|
// Workspace-first removal silently deletes the nested project worktrees'
|
|
12
12
|
// .git files and leaves orphan worktree records in the project repos.
|
|
13
13
|
// The safe order keeps both sides of the relationship in sync.
|
|
14
|
+
//
|
|
15
|
+
// State discovery is defensive: the session tracker (session.md) may have
|
|
16
|
+
// been stripped by /complete-work Step 7 before this script runs, leaving
|
|
17
|
+
// no `repos:` or `branch:` to read. When that happens we discover repos
|
|
18
|
+
// from the work-sessions/{name}/workspace/repos/ directory listing and the
|
|
19
|
+
// branch from `git branch --show-current` on the workspace worktree —
|
|
20
|
+
// BEFORE removing anything. Without that, the per-repo loops silently
|
|
21
|
+
// no-op and the script reports success while leaving orphans behind
|
|
22
|
+
// (gh:119).
|
|
23
|
+
//
|
|
24
|
+
// `success: true` means VERIFIED: every project worktree record is gone
|
|
25
|
+
// (no prunable entries left over), every local branch is deleted, and the
|
|
26
|
+
// session folder is removed. The script post-verifies all of these and
|
|
27
|
+
// surfaces any leftover state as an error rather than swallowing it.
|
|
14
28
|
import '../lib/require-node.mjs';
|
|
15
29
|
import { execSync } from 'child_process';
|
|
16
|
-
import { existsSync } from 'fs';
|
|
30
|
+
import { existsSync, readdirSync, statSync } from 'fs';
|
|
17
31
|
import { join } from 'path';
|
|
18
32
|
import {
|
|
19
33
|
getWorkspaceRoot,
|
|
@@ -36,74 +50,198 @@ if (!sessionName) {
|
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
const root = getWorkspaceRoot(import.meta.url);
|
|
39
|
-
const tracker = readSessionTracker(root, sessionName);
|
|
40
|
-
const repos = normalizeRepos(tracker?.repos);
|
|
41
|
-
const branch = tracker?.branch;
|
|
42
53
|
const reposDir = join(root, 'repos');
|
|
43
54
|
const sessionFolder = sessionFolderPath(root, sessionName);
|
|
44
55
|
const wsWorktree = join(sessionFolder, 'workspace');
|
|
45
56
|
|
|
46
57
|
const removed = [];
|
|
58
|
+
const skipped = [];
|
|
47
59
|
const errors = [];
|
|
48
60
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
// === Discovery: where session.md is silent or missing, fall back to disk ===
|
|
62
|
+
//
|
|
63
|
+
// The tracker may have been stripped before this script runs. Read whatever
|
|
64
|
+
// is still there, then fill gaps from the live worktree state.
|
|
65
|
+
|
|
66
|
+
const tracker = readSessionTracker(root, sessionName);
|
|
67
|
+
let repos = normalizeRepos(tracker?.repos);
|
|
68
|
+
let branch = tracker?.branch || null;
|
|
69
|
+
|
|
70
|
+
// If repos is empty, discover from work-sessions/{name}/workspace/repos/.
|
|
71
|
+
// That directory is the workspace worktree's nested-project-worktrees dir;
|
|
72
|
+
// each entry is one project repo this session checked out.
|
|
73
|
+
if (repos.length === 0) {
|
|
74
|
+
const nestedReposDir = join(wsWorktree, 'repos');
|
|
75
|
+
if (existsSync(nestedReposDir)) {
|
|
54
76
|
try {
|
|
55
|
-
|
|
56
|
-
|
|
77
|
+
const discovered = readdirSync(nestedReposDir).filter((entry) => {
|
|
78
|
+
try {
|
|
79
|
+
return statSync(join(nestedReposDir, entry)).isDirectory();
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
if (discovered.length > 0) {
|
|
85
|
+
repos = discovered;
|
|
86
|
+
skipped.push({
|
|
87
|
+
step: 'discovery',
|
|
88
|
+
reason: `Tracker missing repos; discovered ${discovered.length} from ${nestedReposDir}: ${discovered.join(', ')}`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
57
91
|
} catch (err) {
|
|
58
|
-
|
|
92
|
+
skipped.push({ step: 'discovery', reason: `Failed to list ${nestedReposDir}: ${err.message}` });
|
|
59
93
|
}
|
|
60
94
|
}
|
|
61
95
|
}
|
|
62
96
|
|
|
63
|
-
//
|
|
97
|
+
// If branch is missing, ask the workspace worktree itself.
|
|
98
|
+
if (!branch && existsSync(wsWorktree)) {
|
|
99
|
+
try {
|
|
100
|
+
branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: wsWorktree, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
101
|
+
if (branch) {
|
|
102
|
+
skipped.push({ step: 'discovery', reason: `Tracker missing branch; discovered from worktree: ${branch}` });
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
skipped.push({ step: 'discovery', reason: `Failed to read branch from ${wsWorktree}: ${err.message}` });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// === Step 1: Remove each project worktree FIRST, from its project repo ===
|
|
110
|
+
for (const repo of repos) {
|
|
111
|
+
const projWorktree = join(wsWorktree, 'repos', repo);
|
|
112
|
+
const repoDir = join(reposDir, repo);
|
|
113
|
+
if (!existsSync(projWorktree)) {
|
|
114
|
+
skipped.push({ step: 'remove-project-worktree', repo, reason: `${projWorktree} does not exist` });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (!existsSync(repoDir)) {
|
|
118
|
+
errors.push(`Cannot remove ${repo} worktree: source clone missing at ${repoDir}`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
execSync(`git worktree remove "${projWorktree}" --force`, { cwd: repoDir, stdio: 'pipe' });
|
|
123
|
+
removed.push(`project worktree ${repo}`);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
errors.push(`Failed to remove ${repo} worktree: ${err.message.trim()}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// === Step 2: Remove the workspace worktree AFTER project worktrees are gone ===
|
|
64
130
|
if (existsSync(wsWorktree)) {
|
|
65
131
|
try {
|
|
66
132
|
execSync(`git worktree remove "${wsWorktree}" --force`, { cwd: root, stdio: 'pipe' });
|
|
67
133
|
removed.push('workspace worktree');
|
|
68
134
|
} catch (err) {
|
|
69
|
-
errors.push(`Failed to remove workspace worktree: ${err.message}`);
|
|
135
|
+
errors.push(`Failed to remove workspace worktree: ${err.message.trim()}`);
|
|
70
136
|
}
|
|
137
|
+
} else {
|
|
138
|
+
skipped.push({ step: 'remove-workspace-worktree', reason: `${wsWorktree} does not exist` });
|
|
71
139
|
}
|
|
72
140
|
|
|
73
|
-
// Step 3: Prune each project repo to mop up orphans
|
|
141
|
+
// === Step 3: Prune each project repo to mop up orphans ===
|
|
74
142
|
for (const repo of repos) {
|
|
75
143
|
const repoDir = join(reposDir, repo);
|
|
144
|
+
if (!existsSync(repoDir)) continue;
|
|
76
145
|
try {
|
|
77
146
|
execSync('git worktree prune', { cwd: repoDir, stdio: 'pipe' });
|
|
78
|
-
} catch {
|
|
79
|
-
//
|
|
147
|
+
} catch (err) {
|
|
148
|
+
// Prune is a safety net, but if it fails on a repo we touched, surface
|
|
149
|
+
// it — verification below will catch leftover orphans either way.
|
|
150
|
+
errors.push(`Prune failed in ${repo}: ${err.message.trim()}`);
|
|
80
151
|
}
|
|
81
152
|
}
|
|
82
153
|
|
|
83
|
-
// Step 4: Delete local branches
|
|
154
|
+
// === Step 4: Delete local branches ===
|
|
84
155
|
if (branch) {
|
|
85
156
|
for (const repo of repos) {
|
|
86
157
|
const repoDir = join(reposDir, repo);
|
|
158
|
+
if (!existsSync(repoDir)) continue;
|
|
87
159
|
try {
|
|
88
|
-
execSync(`git branch
|
|
160
|
+
execSync(`git branch --list "${branch}"`, { cwd: repoDir, stdio: 'pipe', encoding: 'utf-8' });
|
|
89
161
|
} catch {
|
|
90
|
-
//
|
|
162
|
+
continue; // Repo broken; verification will catch downstream impact.
|
|
163
|
+
}
|
|
164
|
+
const exists = execSync(`git branch --list "${branch}"`, { cwd: repoDir, encoding: 'utf-8' }).trim();
|
|
165
|
+
if (!exists) continue; // Already gone (e.g., gh pr merge --delete-branch did it).
|
|
166
|
+
try {
|
|
167
|
+
execSync(`git branch -D "${branch}"`, { cwd: repoDir, stdio: 'pipe' });
|
|
168
|
+
} catch (err) {
|
|
169
|
+
errors.push(`Failed to delete branch ${branch} in ${repo}: ${err.message.trim()}`);
|
|
91
170
|
}
|
|
92
171
|
}
|
|
172
|
+
// Same for the workspace repo (root).
|
|
93
173
|
try {
|
|
94
|
-
execSync(`git branch
|
|
174
|
+
const exists = execSync(`git branch --list "${branch}"`, { cwd: root, encoding: 'utf-8' }).trim();
|
|
175
|
+
if (exists) {
|
|
176
|
+
try {
|
|
177
|
+
execSync(`git branch -D "${branch}"`, { cwd: root, stdio: 'pipe' });
|
|
178
|
+
} catch (err) {
|
|
179
|
+
errors.push(`Failed to delete branch ${branch} in workspace repo: ${err.message.trim()}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
95
182
|
} catch {
|
|
96
|
-
//
|
|
183
|
+
// workspace root not a git repo — unusual, skip.
|
|
97
184
|
}
|
|
98
185
|
}
|
|
99
186
|
|
|
100
|
-
// Step 5: Delete the whole work-sessions/{name}/ folder
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
187
|
+
// === Step 5: Delete the whole work-sessions/{name}/ folder ===
|
|
188
|
+
try {
|
|
189
|
+
deleteSessionFolder(root, sessionName);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
errors.push(`Failed to delete session folder ${sessionFolder}: ${err.message.trim()}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// === Post-verification: success means VERIFIED, not "no try/catch threw" ===
|
|
195
|
+
//
|
|
196
|
+
// Without these checks, an empty repos list (the gh:119 root cause) lets
|
|
197
|
+
// every silent skip add up to a "success" output while leaving orphans
|
|
198
|
+
// behind. The verification turns silent skips into honest errors.
|
|
199
|
+
|
|
200
|
+
if (existsSync(sessionFolder)) {
|
|
201
|
+
errors.push(`Session folder still present after cleanup: ${sessionFolder}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const wsPath = wsWorktree; // canonical path the worktree had
|
|
205
|
+
for (const repo of repos) {
|
|
206
|
+
const repoDir = join(reposDir, repo);
|
|
207
|
+
if (!existsSync(repoDir)) continue;
|
|
208
|
+
let wtList = '';
|
|
209
|
+
try {
|
|
210
|
+
wtList = execSync('git worktree list --porcelain', { cwd: repoDir, encoding: 'utf-8' });
|
|
211
|
+
} catch (err) {
|
|
212
|
+
errors.push(`Could not list worktrees in ${repo}: ${err.message.trim()}`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (wtList.includes('prunable')) {
|
|
216
|
+
errors.push(`Prunable worktree record remains in ${repo} after cleanup (gh:119 symptom)`);
|
|
217
|
+
}
|
|
218
|
+
if (wtList.includes(wsPath)) {
|
|
219
|
+
errors.push(`${repo} still has a worktree record referencing the session path`);
|
|
220
|
+
}
|
|
221
|
+
if (branch) {
|
|
222
|
+
const branchStill = execSync(`git branch --list "${branch}"`, { cwd: repoDir, encoding: 'utf-8' }).trim();
|
|
223
|
+
if (branchStill) {
|
|
224
|
+
errors.push(`Branch ${branch} still present in ${repo} after cleanup`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (branch) {
|
|
230
|
+
try {
|
|
231
|
+
const branchStill = execSync(`git branch --list "${branch}"`, { cwd: root, encoding: 'utf-8' }).trim();
|
|
232
|
+
if (branchStill) {
|
|
233
|
+
errors.push(`Branch ${branch} still present in workspace repo after cleanup`);
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// not a git repo — already noted
|
|
237
|
+
}
|
|
238
|
+
}
|
|
104
239
|
|
|
105
240
|
console.log(JSON.stringify({
|
|
106
241
|
success: errors.length === 0,
|
|
107
242
|
removed,
|
|
243
|
+
skipped: skipped.length > 0 ? skipped : undefined,
|
|
108
244
|
errors: errors.length > 0 ? errors : undefined,
|
|
109
245
|
}));
|
|
246
|
+
|
|
247
|
+
if (errors.length > 0) process.exit(1);
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// GitHub forge adapter. Wraps the `gh` CLI via an injectable spawnFn so
|
|
2
|
+
// tests can mock without spawning a real subprocess (mirrors the pattern
|
|
3
|
+
// in trackers/github-issues.mjs).
|
|
4
|
+
//
|
|
5
|
+
// All operations target a single default `repo` resolved at construction
|
|
6
|
+
// time from `config.repo` (e.g. `"owner/name"`) or from the local git
|
|
7
|
+
// origin remote when `config.repo` is unset or `"auto"`. Per-call `repo`
|
|
8
|
+
// overrides allow targeting a different repo when needed.
|
|
9
|
+
|
|
10
|
+
import '../../lib/require-node.mjs';
|
|
11
|
+
import { spawnSync as nodeSpawnSync } from 'node:child_process';
|
|
12
|
+
import {
|
|
13
|
+
PrNotFound,
|
|
14
|
+
ReleaseNotFound,
|
|
15
|
+
WorkflowNotFound,
|
|
16
|
+
MergeRejected,
|
|
17
|
+
} from './interface.mjs';
|
|
18
|
+
|
|
19
|
+
const PR_VIEW_FIELDS = 'number,url,state,title,mergeable,mergeStateStatus,reviewDecision,headRefName,baseRefName,isDraft,mergedAt';
|
|
20
|
+
|
|
21
|
+
export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
|
|
22
|
+
const defaultRepo = resolveRepo(config, spawnFn);
|
|
23
|
+
|
|
24
|
+
function gh(args, { input } = {}) {
|
|
25
|
+
const result = spawnFn('gh', args, {
|
|
26
|
+
input,
|
|
27
|
+
encoding: 'utf-8',
|
|
28
|
+
stdio: input !== undefined ? ['pipe', 'pipe', 'pipe'] : ['inherit', 'pipe', 'pipe'],
|
|
29
|
+
});
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ghOrThrow(args, opts) {
|
|
34
|
+
const result = gh(args, opts);
|
|
35
|
+
if (result.status !== 0) {
|
|
36
|
+
throw new Error(`gh ${args.join(' ')} failed: ${(result.stderr || '').trim()}`);
|
|
37
|
+
}
|
|
38
|
+
return result.stdout || '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function repoFor(override) {
|
|
42
|
+
return override || defaultRepo;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function prCreate({ title, body = '', draft = false, base, head, repo }) {
|
|
46
|
+
if (!title) throw new Error('prCreate: title is required');
|
|
47
|
+
const args = ['pr', 'create', '--repo', repoFor(repo), '--title', title, '--body-file', '-'];
|
|
48
|
+
if (draft) args.push('--draft');
|
|
49
|
+
if (base) args.push('--base', base);
|
|
50
|
+
if (head) args.push('--head', head);
|
|
51
|
+
const stdout = ghOrThrow(args, { input: body }).trim();
|
|
52
|
+
// gh pr create prints the PR URL on success; sometimes preceded by warnings.
|
|
53
|
+
const url = stdout.split('\n').filter(Boolean).pop();
|
|
54
|
+
const m = url.match(/\/pull\/(\d+)/);
|
|
55
|
+
if (!m) throw new Error(`Could not parse PR number from gh output: ${stdout}`);
|
|
56
|
+
const number = parseInt(m[1], 10);
|
|
57
|
+
return { id: `${repoFor(repo)}#${number}`, url, number };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function prMerge({ id, strategy = 'merge', deleteBranch = false, repo }) {
|
|
61
|
+
if (!id) throw new Error('prMerge: id is required');
|
|
62
|
+
const { number, repo: parsedRepo } = parsePrId(id, repoFor(repo));
|
|
63
|
+
const args = ['pr', 'merge', String(number), '--repo', parsedRepo];
|
|
64
|
+
switch (strategy) {
|
|
65
|
+
case 'merge': args.push('--merge'); break;
|
|
66
|
+
case 'squash': args.push('--squash'); break;
|
|
67
|
+
case 'rebase': args.push('--rebase'); break;
|
|
68
|
+
default: throw new Error(`prMerge: unknown strategy: ${strategy}`);
|
|
69
|
+
}
|
|
70
|
+
if (deleteBranch) args.push('--delete-branch');
|
|
71
|
+
const result = gh(args);
|
|
72
|
+
if (result.status !== 0) {
|
|
73
|
+
const stderr = (result.stderr || '').trim();
|
|
74
|
+
// Distinguish "not found" from "rejected" so callers can react.
|
|
75
|
+
if (/not\s+found|could\s+not\s+resolve/i.test(stderr)) {
|
|
76
|
+
throw new PrNotFound(id);
|
|
77
|
+
}
|
|
78
|
+
throw new MergeRejected(id, stderr || 'gh pr merge exited non-zero');
|
|
79
|
+
}
|
|
80
|
+
return { merged: true, url: `https://github.com/${parsedRepo}/pull/${number}` };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function prView({ id, repo, json }) {
|
|
84
|
+
if (!id) throw new Error('prView: id is required');
|
|
85
|
+
const { number, repo: parsedRepo } = parsePrId(id, repoFor(repo));
|
|
86
|
+
const fields = json || PR_VIEW_FIELDS;
|
|
87
|
+
const result = gh(['pr', 'view', String(number), '--repo', parsedRepo, '--json', fields]);
|
|
88
|
+
if (result.status !== 0) {
|
|
89
|
+
const stderr = (result.stderr || '').trim();
|
|
90
|
+
if (/not\s+found|could\s+not\s+resolve/i.test(stderr)) {
|
|
91
|
+
throw new PrNotFound(id);
|
|
92
|
+
}
|
|
93
|
+
throw new Error(`gh pr view failed: ${stderr}`);
|
|
94
|
+
}
|
|
95
|
+
const raw = JSON.parse(result.stdout);
|
|
96
|
+
return {
|
|
97
|
+
id,
|
|
98
|
+
number: raw.number,
|
|
99
|
+
url: raw.url,
|
|
100
|
+
state: raw.state,
|
|
101
|
+
title: raw.title,
|
|
102
|
+
mergeable: raw.mergeable,
|
|
103
|
+
mergeStateStatus: raw.mergeStateStatus,
|
|
104
|
+
reviewDecision: raw.reviewDecision,
|
|
105
|
+
headRefName: raw.headRefName,
|
|
106
|
+
baseRefName: raw.baseRefName,
|
|
107
|
+
isDraft: raw.isDraft,
|
|
108
|
+
mergedAt: raw.mergedAt,
|
|
109
|
+
_raw: raw,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function releaseView({ tag, repo }) {
|
|
114
|
+
if (!tag) throw new Error('releaseView: tag is required');
|
|
115
|
+
const target = repoFor(repo);
|
|
116
|
+
const result = gh(['release', 'view', tag, '--repo', target, '--json', 'name,tagName,url,publishedAt,isDraft,isPrerelease']);
|
|
117
|
+
if (result.status !== 0) {
|
|
118
|
+
const stderr = (result.stderr || '').trim();
|
|
119
|
+
if (/release\s+not\s+found|not\s+found/i.test(stderr)) {
|
|
120
|
+
throw new ReleaseNotFound(tag);
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`gh release view failed: ${stderr}`);
|
|
123
|
+
}
|
|
124
|
+
const raw = JSON.parse(result.stdout);
|
|
125
|
+
return {
|
|
126
|
+
tag: raw.tagName,
|
|
127
|
+
name: raw.name,
|
|
128
|
+
url: raw.url,
|
|
129
|
+
publishedAt: raw.publishedAt,
|
|
130
|
+
isDraft: raw.isDraft,
|
|
131
|
+
isPrerelease: raw.isPrerelease,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function workflowRunFind({ workflow, branch, repo, limit = 1 }) {
|
|
136
|
+
if (!workflow) throw new Error('workflowRunFind: workflow is required');
|
|
137
|
+
const target = repoFor(repo);
|
|
138
|
+
const args = [
|
|
139
|
+
'run', 'list',
|
|
140
|
+
'--repo', target,
|
|
141
|
+
'--workflow', workflow,
|
|
142
|
+
'--limit', String(limit),
|
|
143
|
+
'--json', 'databaseId,status,conclusion,url,headBranch,createdAt',
|
|
144
|
+
];
|
|
145
|
+
if (branch) args.push('--branch', branch);
|
|
146
|
+
const result = gh(args);
|
|
147
|
+
if (result.status !== 0) {
|
|
148
|
+
throw new Error(`gh run list failed: ${(result.stderr || '').trim()}`);
|
|
149
|
+
}
|
|
150
|
+
const runs = JSON.parse(result.stdout);
|
|
151
|
+
if (runs.length === 0) return null;
|
|
152
|
+
const first = runs[0];
|
|
153
|
+
return {
|
|
154
|
+
runId: String(first.databaseId),
|
|
155
|
+
status: first.status,
|
|
156
|
+
conclusion: first.conclusion,
|
|
157
|
+
url: first.url,
|
|
158
|
+
branch: first.headBranch,
|
|
159
|
+
createdAt: first.createdAt,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function workflowRunWatch({ runId, repo, exitStatus = false }) {
|
|
164
|
+
if (!runId) throw new Error('workflowRunWatch: runId is required');
|
|
165
|
+
const target = repoFor(repo);
|
|
166
|
+
const args = ['run', 'watch', String(runId), '--repo', target];
|
|
167
|
+
if (exitStatus) args.push('--exit-status');
|
|
168
|
+
const result = gh(args);
|
|
169
|
+
if (result.status === 0) return { exitCode: 0 };
|
|
170
|
+
// Distinguish "couldn't find" from "ran but failed".
|
|
171
|
+
const stderr = (result.stderr || '').trim();
|
|
172
|
+
if (/not\s+found|could\s+not\s+find/i.test(stderr)) {
|
|
173
|
+
throw new WorkflowNotFound({ runId });
|
|
174
|
+
}
|
|
175
|
+
// `--exit-status` makes gh exit non-zero on workflow failure; surface
|
|
176
|
+
// that without throwing so callers can record the failure URL.
|
|
177
|
+
return { exitCode: result.status, stderr };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
prCreate,
|
|
182
|
+
prMerge,
|
|
183
|
+
prView,
|
|
184
|
+
releaseView,
|
|
185
|
+
workflowRunFind,
|
|
186
|
+
workflowRunWatch,
|
|
187
|
+
get identity() { return `github:${defaultRepo}`; },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// "owner/name#NUMBER" or just NUMBER (defaulting to the adapter's repo).
|
|
192
|
+
function parsePrId(id, fallbackRepo) {
|
|
193
|
+
if (typeof id === 'number') return { number: id, repo: fallbackRepo };
|
|
194
|
+
const m1 = String(id).match(/^(?<repo>[^#\s]+\/[^#\s]+)#(?<number>\d+)$/);
|
|
195
|
+
if (m1) return { number: parseInt(m1.groups.number, 10), repo: m1.groups.repo };
|
|
196
|
+
const m2 = String(id).match(/^#?(\d+)$/);
|
|
197
|
+
if (m2) return { number: parseInt(m2[1], 10), repo: fallbackRepo };
|
|
198
|
+
throw new Error(`Unparseable PR id: ${id}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function resolveRepo(config, spawnFn) {
|
|
202
|
+
if (config?.repo && config.repo !== 'auto') return config.repo;
|
|
203
|
+
const result = spawnFn('git', ['remote', 'get-url', 'origin'], { encoding: 'utf-8' });
|
|
204
|
+
if (result.status !== 0) {
|
|
205
|
+
throw new Error(`git remote get-url failed: ${(result.stderr || '').trim()}`);
|
|
206
|
+
}
|
|
207
|
+
const m = result.stdout.trim().match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
208
|
+
if (!m) throw new Error(`Cannot parse GitHub remote: ${result.stdout.trim()}`);
|
|
209
|
+
return `${m[1]}/${m[2]}`;
|
|
210
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// GitLab forge adapter — stub. Reserved as the next sibling of github.mjs
|
|
2
|
+
// so the discovery pattern stays uniform: workspaces opting into GitLab
|
|
3
|
+
// set `workspace.forge.type: gitlab` in workspace.json and get a clear
|
|
4
|
+
// "not implemented" error pointing at the gap, rather than silently
|
|
5
|
+
// routing through GitHub.
|
|
6
|
+
//
|
|
7
|
+
// When implemented, this adapter wraps the `glab` CLI the same way
|
|
8
|
+
// github.mjs wraps `gh`: same method surface (prCreate, prMerge, prView,
|
|
9
|
+
// releaseView, workflowRunFind, workflowRunWatch), same spawnFn-injectable
|
|
10
|
+
// shape for testability, same error types from interface.mjs.
|
|
11
|
+
|
|
12
|
+
import { ForgeError } from './interface.mjs';
|
|
13
|
+
|
|
14
|
+
export function createGitlabAdapter(config /* , options */) {
|
|
15
|
+
throw new ForgeError(
|
|
16
|
+
'GitLab forge adapter is not implemented yet. Set workspace.forge.type to "github", or contribute a glab-based adapter at .claude/scripts/forges/gitlab.mjs following the shape of github.mjs.',
|
|
17
|
+
'NOT_IMPLEMENTED',
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Forge adapter interface. Skills import only from this module.
|
|
2
|
+
//
|
|
3
|
+
// Where `trackers/` covers issue lifecycle (issues, comments, labels,
|
|
4
|
+
// milestones), `forges/` covers cross-cutting repo-host operations:
|
|
5
|
+
// pull requests, releases, and workflow runs. The two abstractions are
|
|
6
|
+
// intentionally separate — a workspace could in principle mix
|
|
7
|
+
// (e.g. `github-issues` tracker + `gitlab` forge), though the common
|
|
8
|
+
// case is forge = same host as tracker.
|
|
9
|
+
//
|
|
10
|
+
// Method contracts:
|
|
11
|
+
//
|
|
12
|
+
// prCreate({ title, body, draft = false, base, head, repo? })
|
|
13
|
+
// → { id, url, number }
|
|
14
|
+
// prMerge({ id, strategy = 'merge', deleteBranch = false, repo? })
|
|
15
|
+
// → { merged: true, url }
|
|
16
|
+
// strategy: 'merge' | 'squash' | 'rebase'
|
|
17
|
+
// prView({ id, repo?, json? })
|
|
18
|
+
// → { id, url, state, mergeable, mergeStateStatus, reviewDecision, title }
|
|
19
|
+
// json may name additional fields to pass through
|
|
20
|
+
// releaseView({ tag, repo? })
|
|
21
|
+
// → { tag, url, name, publishedAt }
|
|
22
|
+
// throws ReleaseNotFound if the tag has no release
|
|
23
|
+
// workflowRunFind({ workflow, branch, repo?, limit = 1 })
|
|
24
|
+
// → { runId, status, conclusion, url } | null
|
|
25
|
+
// workflowRunWatch({ runId, repo?, exitStatus = false })
|
|
26
|
+
// → { exitCode }
|
|
27
|
+
// exitStatus: when true, the underlying command exits non-zero on
|
|
28
|
+
// workflow failure; the adapter still returns the exit code rather
|
|
29
|
+
// than throwing — callers decide how to handle a failed run.
|
|
30
|
+
//
|
|
31
|
+
// `repo` defaults: each adapter resolves a default repo at construction
|
|
32
|
+
// time (e.g. from `workspace.forge.repo` or the local git origin remote);
|
|
33
|
+
// callers pass `repo` only when targeting a different one.
|
|
34
|
+
//
|
|
35
|
+
// All methods are async and may throw `ForgeError` subclasses on
|
|
36
|
+
// adapter-detectable failures. Raw spawn failures throw `Error`.
|
|
37
|
+
|
|
38
|
+
import '../../lib/require-node.mjs';
|
|
39
|
+
import { createGithubAdapter } from './github.mjs';
|
|
40
|
+
import { createGitlabAdapter } from './gitlab.mjs';
|
|
41
|
+
|
|
42
|
+
export class ForgeError extends Error {
|
|
43
|
+
constructor(message, code) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'ForgeError';
|
|
46
|
+
this.code = code;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class PrNotFound extends ForgeError {
|
|
51
|
+
constructor(id) {
|
|
52
|
+
super(`Pull request not found: ${id}`, 'PR_NOT_FOUND');
|
|
53
|
+
this.name = 'PrNotFound';
|
|
54
|
+
this.id = id;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class ReleaseNotFound extends ForgeError {
|
|
59
|
+
constructor(tag) {
|
|
60
|
+
super(`Release not found for tag: ${tag}`, 'RELEASE_NOT_FOUND');
|
|
61
|
+
this.name = 'ReleaseNotFound';
|
|
62
|
+
this.tag = tag;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class WorkflowNotFound extends ForgeError {
|
|
67
|
+
constructor(query) {
|
|
68
|
+
super(`Workflow run not found: ${JSON.stringify(query)}`, 'WORKFLOW_NOT_FOUND');
|
|
69
|
+
this.name = 'WorkflowNotFound';
|
|
70
|
+
this.query = query;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class MergeRejected extends ForgeError {
|
|
75
|
+
constructor(id, reason) {
|
|
76
|
+
super(`Merge rejected for ${id}: ${reason}`, 'MERGE_REJECTED');
|
|
77
|
+
this.name = 'MergeRejected';
|
|
78
|
+
this.id = id;
|
|
79
|
+
this.reason = reason;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// createForge takes the `workspace.forge` config block. If the block is
|
|
84
|
+
// absent (`undefined`/`null`), default to GitHub — this matches the
|
|
85
|
+
// migration story documented in `.claude/rules/forge-operations.md`:
|
|
86
|
+
// existing workspaces predate the field, so an unset value means
|
|
87
|
+
// "behave as you always have." A workspace that wants to opt out of
|
|
88
|
+
// forge operations entirely should set `workspace.forge: false`;
|
|
89
|
+
// callers passing `false` will get a no-op throw on every method.
|
|
90
|
+
export function createForge(config, options = {}) {
|
|
91
|
+
if (config === false) {
|
|
92
|
+
throw new ForgeError(
|
|
93
|
+
'Forge operations disabled — set workspace.forge in workspace.json to enable.',
|
|
94
|
+
'FORGE_DISABLED',
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const resolved = config ?? { type: 'github' };
|
|
98
|
+
if (typeof resolved !== 'object') {
|
|
99
|
+
throw new ForgeError(
|
|
100
|
+
`Invalid workspace.forge config: expected object, got ${typeof resolved}`,
|
|
101
|
+
'INVALID_CONFIG',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const type = resolved.type ?? 'github';
|
|
105
|
+
switch (type) {
|
|
106
|
+
case 'github':
|
|
107
|
+
return createGithubAdapter(resolved, options);
|
|
108
|
+
case 'gitlab':
|
|
109
|
+
return createGitlabAdapter(resolved, options);
|
|
110
|
+
default:
|
|
111
|
+
throw new ForgeError(`Unknown forge type: ${type}`, 'UNKNOWN_TYPE');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -92,25 +92,17 @@
|
|
|
92
92
|
}
|
|
93
93
|
]
|
|
94
94
|
}
|
|
95
|
-
],
|
|
96
|
-
"WorktreeCreate": [
|
|
97
|
-
{
|
|
98
|
-
"hooks": [
|
|
99
|
-
{
|
|
100
|
-
"type": "command",
|
|
101
|
-
"command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PROJECT_DIR:-$PWD}\"; else echo \"${CLAUDE_PROJECT_DIR:-$PWD}\"; fi)\"/.claude/hooks/worktree-create.mjs",
|
|
102
|
-
"timeout": 5000,
|
|
103
|
-
"statusMessage": "Checking for stale worktrees..."
|
|
104
|
-
}
|
|
105
|
-
]
|
|
106
|
-
}
|
|
107
95
|
]
|
|
108
96
|
},
|
|
97
|
+
"worktree": {
|
|
98
|
+
"baseRef": "head"
|
|
99
|
+
},
|
|
109
100
|
"permissions": {
|
|
110
101
|
"allow": [
|
|
111
102
|
"Bash(git:*)",
|
|
112
103
|
"Bash(ls:*)"
|
|
113
|
-
]
|
|
104
|
+
],
|
|
105
|
+
"deny": []
|
|
114
106
|
},
|
|
115
107
|
"enabledPlugins": {
|
|
116
108
|
"playwright@claude-plugins-official": false
|