@web42/stask 0.1.5
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 +100 -0
- package/bin/stask.mjs +157 -0
- package/commands/approve.mjs +43 -0
- package/commands/assign.mjs +64 -0
- package/commands/create.mjs +88 -0
- package/commands/delete.mjs +127 -0
- package/commands/heartbeat.mjs +375 -0
- package/commands/list.mjs +60 -0
- package/commands/log.mjs +34 -0
- package/commands/pr-status.mjs +33 -0
- package/commands/qa.mjs +183 -0
- package/commands/session.mjs +76 -0
- package/commands/show.mjs +61 -0
- package/commands/spec-update.mjs +61 -0
- package/commands/subtask-create.mjs +62 -0
- package/commands/subtask-done.mjs +80 -0
- package/commands/sync-daemon.mjs +134 -0
- package/commands/sync.mjs +36 -0
- package/commands/transition.mjs +143 -0
- package/config.example.json +55 -0
- package/lib/env.mjs +118 -0
- package/lib/file-uploader.mjs +179 -0
- package/lib/guards.mjs +261 -0
- package/lib/pr-create.mjs +133 -0
- package/lib/pr-status.mjs +119 -0
- package/lib/roles.mjs +85 -0
- package/lib/session-tracker.mjs +150 -0
- package/lib/slack-api.mjs +198 -0
- package/lib/slack-row.mjs +257 -0
- package/lib/slack-sync.mjs +388 -0
- package/lib/sync-daemon.mjs +117 -0
- package/lib/tracker-db.mjs +473 -0
- package/lib/tx.mjs +84 -0
- package/lib/validate.mjs +90 -0
- package/lib/worktree-cleanup.mjs +91 -0
- package/lib/worktree-create.mjs +127 -0
- package/package.json +34 -0
- package/skills/stask-general.md +113 -0
- package/skills/stask-lead.md +72 -0
- package/skills/stask-qa.md +99 -0
- package/skills/stask-worker.md +61 -0
package/lib/guards.mjs
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* guards.mjs — Pre-transition guards.
|
|
3
|
+
*
|
|
4
|
+
* Two types of guards:
|
|
5
|
+
* - setup: Can create side effects (worktree, PR). Run first.
|
|
6
|
+
* Returns { pass, reason } — if fail, transition stops.
|
|
7
|
+
* - check: Read-only validation. Run after setup.
|
|
8
|
+
* Returns { pass, reason } — if fail, transition stops.
|
|
9
|
+
*
|
|
10
|
+
* Guards are registered per target status. A transition fails if ANY
|
|
11
|
+
* guard fails — all reasons are collected and reported.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { execFileSync } from 'child_process';
|
|
16
|
+
import { CONFIG, LIB_DIR, getWorkspaceLibs } from './env.mjs';
|
|
17
|
+
|
|
18
|
+
// ─── Setup guards (create side effects) ────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create worktree for parent task moving to In-Progress.
|
|
22
|
+
* Checks out a feature branch based on current main.
|
|
23
|
+
*/
|
|
24
|
+
function setupWorktree(task, libs) {
|
|
25
|
+
const wt = parseWorktree(task);
|
|
26
|
+
if (wt) return { pass: true }; // already has one
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const result = execFileSync(process.execPath, [path.join(LIB_DIR, 'worktree-create.mjs'), task['Task ID']], {
|
|
30
|
+
encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
31
|
+
});
|
|
32
|
+
console.error(` ${result.trim()}`);
|
|
33
|
+
|
|
34
|
+
// Verify it was set
|
|
35
|
+
const refreshed = libs.trackerDb.findTask(task['Task ID']);
|
|
36
|
+
if (!refreshed || refreshed['Worktree'] === 'None') {
|
|
37
|
+
return { pass: false, reason: 'Worktree creation ran but worktree was not set in DB.' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Update the task object so subsequent guards see the worktree
|
|
41
|
+
task['Worktree'] = refreshed['Worktree'];
|
|
42
|
+
return { pass: true };
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return { pass: false, reason: `Worktree creation failed: ${err.stderr || err.message}` };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create draft PR for parent task moving to Ready for Human Review.
|
|
50
|
+
*/
|
|
51
|
+
function setupPR(task, libs) {
|
|
52
|
+
if (task['PR'] !== 'None') return { pass: true }; // already has one
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const result = execFileSync(process.execPath, [path.join(LIB_DIR, 'pr-create.mjs'), task['Task ID']], {
|
|
56
|
+
encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
57
|
+
});
|
|
58
|
+
console.error(` ${result.trim()}`);
|
|
59
|
+
|
|
60
|
+
const refreshed = libs.trackerDb.findTask(task['Task ID']);
|
|
61
|
+
if (!refreshed || refreshed['PR'] === 'None') {
|
|
62
|
+
return { pass: false, reason: 'PR creation ran but PR URL was not set in DB.' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
task['PR'] = refreshed['PR'];
|
|
66
|
+
return { pass: true };
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return { pass: false, reason: `Draft PR creation failed: ${err.stderr || err.message}` };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Check guards (read-only validation) ───────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Task must not be assigned to human (needs approve first).
|
|
76
|
+
* Only applies when coming FROM To-Do (initial approval gate).
|
|
77
|
+
* Review cycles (RHR → In-Progress) skip this — the transition
|
|
78
|
+
* itself reassigns to the lead.
|
|
79
|
+
*/
|
|
80
|
+
function requireApproved(task) {
|
|
81
|
+
if (task['Status'] === 'To-Do' && task['Assigned To'] === CONFIG.human.name) {
|
|
82
|
+
return { pass: false, reason: `Still assigned to ${CONFIG.human.name}. Use "stask approve" first.` };
|
|
83
|
+
}
|
|
84
|
+
return { pass: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Task must have a PR before moving to Ready for Human Review.
|
|
89
|
+
* Richard creates this manually via `gh pr create`.
|
|
90
|
+
*/
|
|
91
|
+
function requirePR(task) {
|
|
92
|
+
if (task['PR'] === 'None' || !task['PR']) {
|
|
93
|
+
return { pass: false, reason: 'No PR found. Create a draft PR with `gh pr create --draft` first.' };
|
|
94
|
+
}
|
|
95
|
+
return { pass: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* All subtasks must be Done before parent can move to Testing.
|
|
100
|
+
*/
|
|
101
|
+
function allSubtasksDone(task, libs) {
|
|
102
|
+
const subtasks = libs.trackerDb.getSubtasks(task['Task ID']);
|
|
103
|
+
if (subtasks.length === 0) return { pass: true };
|
|
104
|
+
|
|
105
|
+
const notDone = subtasks.filter(s => s['Status'] !== 'Done');
|
|
106
|
+
if (notDone.length === 0) return { pass: true };
|
|
107
|
+
|
|
108
|
+
const summary = notDone.map(s => `${s['Task ID']} (${s['Status']}, ${s['Assigned To']})`).join(', ');
|
|
109
|
+
return { pass: false, reason: `${notDone.length} subtask(s) not Done: ${summary}` };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Worktree must have no uncommitted changes.
|
|
114
|
+
*/
|
|
115
|
+
function worktreeClean(task) {
|
|
116
|
+
const wt = parseWorktree(task);
|
|
117
|
+
if (!wt) return { pass: true };
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
121
|
+
cwd: wt.path, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
122
|
+
}).trim();
|
|
123
|
+
|
|
124
|
+
if (status === '') return { pass: true };
|
|
125
|
+
|
|
126
|
+
const lines = status.split('\n');
|
|
127
|
+
return {
|
|
128
|
+
pass: false,
|
|
129
|
+
reason: `Uncommitted changes in worktree (${lines.length} file(s)):\n${lines.map(l => ` ${l}`).join('\n')}`,
|
|
130
|
+
};
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return { pass: false, reason: `Could not check worktree: ${err.message}` };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Worktree must have no unpushed commits.
|
|
138
|
+
*/
|
|
139
|
+
function worktreePushed(task) {
|
|
140
|
+
const wt = parseWorktree(task);
|
|
141
|
+
if (!wt) return { pass: true };
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const upstream = execFileSync('git', ['rev-parse', '--abbrev-ref', '@{u}'], {
|
|
145
|
+
cwd: wt.path, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
146
|
+
}).trim();
|
|
147
|
+
|
|
148
|
+
const unpushed = execFileSync('git', ['log', `${upstream}..HEAD`, '--oneline'], {
|
|
149
|
+
cwd: wt.path, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
150
|
+
}).trim();
|
|
151
|
+
|
|
152
|
+
if (unpushed === '') return { pass: true };
|
|
153
|
+
|
|
154
|
+
const commits = unpushed.split('\n');
|
|
155
|
+
return {
|
|
156
|
+
pass: false,
|
|
157
|
+
reason: `${commits.length} unpushed commit(s) in worktree:\n${commits.map(c => ` ${c}`).join('\n')}`,
|
|
158
|
+
};
|
|
159
|
+
} catch (err) {
|
|
160
|
+
// No upstream = branch was never pushed
|
|
161
|
+
try {
|
|
162
|
+
const commits = execFileSync('git', ['log', '--oneline'], {
|
|
163
|
+
cwd: wt.path, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
164
|
+
}).trim();
|
|
165
|
+
if (commits) {
|
|
166
|
+
return { pass: false, reason: `Branch "${parseWorktree(task).branch}" has never been pushed to remote.` };
|
|
167
|
+
}
|
|
168
|
+
} catch {}
|
|
169
|
+
return { pass: false, reason: `Could not check push state: ${err.message}` };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Guard registry ────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Guards mapped to target status.
|
|
177
|
+
*
|
|
178
|
+
* Each entry has:
|
|
179
|
+
* - name: displayed in output
|
|
180
|
+
* - type: 'check' (read-only, runs first) or 'setup' (side effects, runs after all checks pass)
|
|
181
|
+
* - fn: (task, libs) => { pass, reason }
|
|
182
|
+
*
|
|
183
|
+
* Only parent tasks run guards.
|
|
184
|
+
*/
|
|
185
|
+
const GUARDS = {
|
|
186
|
+
'In-Progress': [
|
|
187
|
+
{ name: 'require_approved', type: 'check', fn: requireApproved },
|
|
188
|
+
{ name: 'setup_worktree', type: 'setup', fn: setupWorktree },
|
|
189
|
+
],
|
|
190
|
+
'Testing': [
|
|
191
|
+
{ name: 'all_subtasks_done', type: 'check', fn: allSubtasksDone },
|
|
192
|
+
{ name: 'worktree_clean', type: 'check', fn: worktreeClean },
|
|
193
|
+
{ name: 'worktree_pushed', type: 'check', fn: worktreePushed },
|
|
194
|
+
],
|
|
195
|
+
'Ready for Human Review': [
|
|
196
|
+
{ name: 'worktree_clean', type: 'check', fn: worktreeClean },
|
|
197
|
+
{ name: 'worktree_pushed', type: 'check', fn: worktreePushed },
|
|
198
|
+
{ name: 'setup_pr', type: 'setup', fn: setupPR },
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// ─── Public API ────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Run all guards for a transition. Returns { ok, failures[] }.
|
|
206
|
+
*
|
|
207
|
+
* Execution order:
|
|
208
|
+
* 1. All check guards run first (read-only). Collects all failures.
|
|
209
|
+
* 2. If ANY check fails → stop. No setup guards run.
|
|
210
|
+
* 3. If all checks pass → run setup guards (side effects).
|
|
211
|
+
* If a setup fails → stop immediately.
|
|
212
|
+
*/
|
|
213
|
+
export function runGuards(task, newStatus, libs) {
|
|
214
|
+
const isParent = task['Parent'] === 'None';
|
|
215
|
+
if (!isParent) return { ok: true, failures: [] };
|
|
216
|
+
|
|
217
|
+
const guards = GUARDS[newStatus];
|
|
218
|
+
if (!guards) return { ok: true, failures: [] };
|
|
219
|
+
|
|
220
|
+
const checks = guards.filter(g => g.type === 'check');
|
|
221
|
+
const setups = guards.filter(g => g.type === 'setup');
|
|
222
|
+
const failures = [];
|
|
223
|
+
|
|
224
|
+
// Phase 1: Run all checks (read-only)
|
|
225
|
+
for (const guard of checks) {
|
|
226
|
+
const result = guard.fn(task, libs);
|
|
227
|
+
if (!result.pass) {
|
|
228
|
+
failures.push({ name: guard.name, reason: result.reason });
|
|
229
|
+
console.error(` GUARD ${guard.name}: FAIL — ${result.reason}`);
|
|
230
|
+
} else {
|
|
231
|
+
console.error(` GUARD ${guard.name}: OK`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// If any check failed, don't run setups
|
|
236
|
+
if (failures.length > 0) return { ok: false, failures };
|
|
237
|
+
|
|
238
|
+
// Phase 2: Run setups (side effects) — stop on first failure
|
|
239
|
+
for (const guard of setups) {
|
|
240
|
+
const result = guard.fn(task, libs);
|
|
241
|
+
if (!result.pass) {
|
|
242
|
+
failures.push({ name: guard.name, reason: result.reason });
|
|
243
|
+
console.error(` GUARD ${guard.name}: FAIL — ${result.reason}`);
|
|
244
|
+
break;
|
|
245
|
+
} else {
|
|
246
|
+
console.error(` GUARD ${guard.name}: OK`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { ok: failures.length === 0, failures };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── Helpers ───────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
function parseWorktree(task) {
|
|
256
|
+
const wt = task['Worktree'];
|
|
257
|
+
if (!wt || wt === 'None') return null;
|
|
258
|
+
const match = wt.match(/^(.+?)\s+\((.+)\)$/);
|
|
259
|
+
if (match) return { branch: match[1].trim(), path: match[2].trim() };
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pr-create.mjs — Create a draft PR for a task in Ready for Human Review.
|
|
4
|
+
*
|
|
5
|
+
* Usage: node pr-create.mjs <task-id>
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { execSync } from 'child_process';
|
|
11
|
+
import {
|
|
12
|
+
findTask, updateTask, addLogEntry, parseWorktreeValue, WORKSPACE_DIR,
|
|
13
|
+
} from './tracker-db.mjs';
|
|
14
|
+
import { parseSpecValue } from './validate.mjs';
|
|
15
|
+
import { CONFIG } from './env.mjs';
|
|
16
|
+
|
|
17
|
+
const GH = 'gh';
|
|
18
|
+
|
|
19
|
+
const taskId = process.argv[2];
|
|
20
|
+
|
|
21
|
+
if (!taskId) {
|
|
22
|
+
console.error('Usage: node pr-create.mjs <task-id>');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const task = findTask(taskId);
|
|
27
|
+
|
|
28
|
+
if (!task) {
|
|
29
|
+
console.error(`ERROR: Task ${taskId} not found`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (task['Status'] !== 'Ready for Human Review' && task['Status'] !== 'Testing') {
|
|
34
|
+
console.error(`ERROR: Task ${taskId} is "${task['Status']}". Must be "Testing" or "Ready for Human Review" to create a PR.`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (task['PR'] !== 'None') {
|
|
39
|
+
console.log(`PR already exists for ${taskId}: ${task['PR']}. Skipping.`);
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const wt = parseWorktreeValue(task['Worktree']);
|
|
44
|
+
if (!wt) {
|
|
45
|
+
console.error(`ERROR: Task ${taskId} has no worktree. Cannot create PR without a branch.`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const { branch, path: wtPath } = wt;
|
|
50
|
+
|
|
51
|
+
// Check for existing PR on this branch
|
|
52
|
+
try {
|
|
53
|
+
const existingPr = execSync(
|
|
54
|
+
`${GH} pr list --head "${branch}" --json url --jq ".[0].url"`,
|
|
55
|
+
{ cwd: wtPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
56
|
+
).trim();
|
|
57
|
+
|
|
58
|
+
if (existingPr) {
|
|
59
|
+
updateTask(taskId, { pr: existingPr });
|
|
60
|
+
addLogEntry(taskId, `${taskId} "${task['Task Name']}": Linked existing PR ${existingPr}.`);
|
|
61
|
+
console.log(`${taskId}: PR linked | ${existingPr}`);
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
} catch {}
|
|
65
|
+
|
|
66
|
+
// Build PR body
|
|
67
|
+
let specContent = '';
|
|
68
|
+
const specInfo = parseSpecValue(task['Spec']);
|
|
69
|
+
if (specInfo?.filename) {
|
|
70
|
+
const specPath = path.join(WORKSPACE_DIR, 'shared', specInfo.filename);
|
|
71
|
+
if (fs.existsSync(specPath)) {
|
|
72
|
+
specContent = fs.readFileSync(specPath, 'utf-8');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let acceptanceCriteria = '';
|
|
77
|
+
if (specContent) {
|
|
78
|
+
const acMatch = specContent.match(/##\s*Acceptance Criteria[\s\S]*?(?=\n##|\n---|\Z)/i);
|
|
79
|
+
if (acMatch) acceptanceCriteria = acMatch[0].trim();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const qaReport = task['QA Report 1'] || 'None';
|
|
83
|
+
const prTitle = `[${taskId}] ${task['Task Name']}`;
|
|
84
|
+
const prBody = `## Task: ${taskId} — ${task['Task Name']}
|
|
85
|
+
|
|
86
|
+
**Type:** ${task['Type']}
|
|
87
|
+
**QA Report:** ${qaReport !== 'None' ? 'Passed' : 'N/A'}
|
|
88
|
+
|
|
89
|
+
${acceptanceCriteria ? `## Acceptance Criteria\n\n${acceptanceCriteria}\n` : ''}
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
*Created by the task framework. Review comments will be routed to the team automatically.*`;
|
|
93
|
+
|
|
94
|
+
// Push branch
|
|
95
|
+
try {
|
|
96
|
+
execSync(`git push -u origin "${branch}"`, { cwd: wtPath, stdio: 'pipe' });
|
|
97
|
+
console.log(`Pushed branch: ${branch}`);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const errMsg = err.stderr?.toString() || err.message;
|
|
100
|
+
if (!errMsg.includes('Everything up-to-date')) {
|
|
101
|
+
console.error(`WARNING: Push issue: ${errMsg}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Detect base branch
|
|
106
|
+
let baseBranch = 'dev';
|
|
107
|
+
try {
|
|
108
|
+
execSync('git rev-parse --verify dev', { cwd: wtPath, stdio: 'pipe' });
|
|
109
|
+
} catch {
|
|
110
|
+
baseBranch = 'main';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Create draft PR
|
|
114
|
+
let prUrl;
|
|
115
|
+
try {
|
|
116
|
+
prUrl = execSync(
|
|
117
|
+
`${GH} pr create --draft --base ${baseBranch} --head "${branch}" --title "${prTitle.replace(/"/g, '\\"')}" --body "${prBody.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`,
|
|
118
|
+
{ cwd: wtPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
119
|
+
).trim();
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const urlMatch = (err.stdout?.toString() || '').match(/(https:\/\/github\.com\/[^\s]+)/);
|
|
122
|
+
if (urlMatch) {
|
|
123
|
+
prUrl = urlMatch[1];
|
|
124
|
+
} else {
|
|
125
|
+
console.error(`ERROR: Failed to create PR: ${err.stderr?.toString() || err.message}`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
updateTask(taskId, { pr: prUrl });
|
|
131
|
+
addLogEntry(taskId, `${taskId} "${task['Task Name']}": Draft PR created at ${prUrl}.`);
|
|
132
|
+
|
|
133
|
+
console.log(`${taskId}: Draft PR created | ${prUrl}`);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pr-status.mjs — Stateless PR query: fetch comments and merge status from GitHub.
|
|
4
|
+
*
|
|
5
|
+
* Usage: node pr-status.mjs <task-id>
|
|
6
|
+
*
|
|
7
|
+
* No local state files. GitHub is the single source of truth.
|
|
8
|
+
* Returns yanComments + otherComments for the heartbeat to act on.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execSync } from 'child_process';
|
|
12
|
+
import { findTask } from './tracker-db.mjs';
|
|
13
|
+
import { CONFIG } from './env.mjs';
|
|
14
|
+
|
|
15
|
+
const GH = 'gh';
|
|
16
|
+
const YAN_GITHUB = CONFIG.human.githubUsername;
|
|
17
|
+
|
|
18
|
+
const taskId = process.argv[2];
|
|
19
|
+
|
|
20
|
+
if (!taskId) {
|
|
21
|
+
console.error('Usage: node pr-status.mjs <task-id>');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const task = findTask(taskId);
|
|
26
|
+
if (!task) {
|
|
27
|
+
console.error(`ERROR: Task ${taskId} not found`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const prUrl = task['PR'];
|
|
32
|
+
if (!prUrl || prUrl === 'None') {
|
|
33
|
+
console.error(`ERROR: Task ${taskId} has no PR URL`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const prMatch = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
|
|
38
|
+
if (!prMatch) {
|
|
39
|
+
console.error(`ERROR: Could not parse PR URL: ${prUrl}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const [, owner, repo, prNumber] = prMatch;
|
|
44
|
+
|
|
45
|
+
// ─── Fetch PR info ────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
let prInfo;
|
|
48
|
+
try {
|
|
49
|
+
prInfo = JSON.parse(execSync(
|
|
50
|
+
`${GH} api repos/${owner}/${repo}/pulls/${prNumber}`,
|
|
51
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
52
|
+
));
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error(`ERROR: Could not fetch PR info: ${err.message}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const isDraft = prInfo.draft === true;
|
|
59
|
+
const isMerged = prInfo.merged === true;
|
|
60
|
+
const prState = prInfo.state;
|
|
61
|
+
|
|
62
|
+
// ─── Fetch all comments ───────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
let issueComments = [];
|
|
65
|
+
try {
|
|
66
|
+
issueComments = JSON.parse(execSync(
|
|
67
|
+
`${GH} api repos/${owner}/${repo}/issues/${prNumber}/comments --paginate`,
|
|
68
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
69
|
+
));
|
|
70
|
+
} catch {}
|
|
71
|
+
|
|
72
|
+
let reviewComments = [];
|
|
73
|
+
try {
|
|
74
|
+
reviewComments = JSON.parse(execSync(
|
|
75
|
+
`${GH} api repos/${owner}/${repo}/pulls/${prNumber}/comments --paginate`,
|
|
76
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
77
|
+
));
|
|
78
|
+
} catch {}
|
|
79
|
+
|
|
80
|
+
let reviews = [];
|
|
81
|
+
try {
|
|
82
|
+
reviews = JSON.parse(execSync(
|
|
83
|
+
`${GH} api repos/${owner}/${repo}/pulls/${prNumber}/reviews --paginate`,
|
|
84
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
85
|
+
));
|
|
86
|
+
} catch {}
|
|
87
|
+
|
|
88
|
+
// ─── Normalize and split by author ────────────────────────────────
|
|
89
|
+
|
|
90
|
+
const allComments = [
|
|
91
|
+
...issueComments.map(c => ({
|
|
92
|
+
id: c.id, type: 'issue', author: c.user?.login || 'unknown',
|
|
93
|
+
body: c.body, path: null, line: null, createdAt: c.created_at,
|
|
94
|
+
updatedAt: c.updated_at, htmlUrl: c.html_url,
|
|
95
|
+
})),
|
|
96
|
+
...reviewComments.map(c => ({
|
|
97
|
+
id: c.id, type: 'review', author: c.user?.login || 'unknown',
|
|
98
|
+
body: c.body, path: c.path, line: c.line || c.original_line,
|
|
99
|
+
createdAt: c.created_at, updatedAt: c.updated_at, htmlUrl: c.html_url,
|
|
100
|
+
})),
|
|
101
|
+
...reviews.filter(r => r.body && r.body.trim()).map(r => ({
|
|
102
|
+
id: r.id, type: 'pr-review', author: r.user?.login || 'unknown',
|
|
103
|
+
body: r.body, path: null, line: null, createdAt: r.submitted_at,
|
|
104
|
+
updatedAt: r.submitted_at, htmlUrl: r.html_url,
|
|
105
|
+
reviewState: r.state,
|
|
106
|
+
})),
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
const yanComments = allComments.filter(c => c.author === YAN_GITHUB);
|
|
110
|
+
const otherComments = allComments.filter(c => c.author !== YAN_GITHUB);
|
|
111
|
+
|
|
112
|
+
const output = {
|
|
113
|
+
taskId, prUrl, isDraft, isMerged, state: prState,
|
|
114
|
+
totalComments: allComments.length,
|
|
115
|
+
yanComments,
|
|
116
|
+
otherComments,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
console.log(JSON.stringify(output, null, 2));
|
package/lib/roles.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* roles.mjs — Role-based auto-assign resolution from config.
|
|
3
|
+
*
|
|
4
|
+
* Auto-assign rules are derived from roles, not hardcoded names:
|
|
5
|
+
* - To-Do → human
|
|
6
|
+
* - In-Progress → lead (parent task ownership; subtasks keep builder assignments via cascade logic)
|
|
7
|
+
* - Testing → qa agent
|
|
8
|
+
* - Ready for Human Review → human
|
|
9
|
+
* - Blocked → human
|
|
10
|
+
* - Done → keep current
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { CONFIG } from './env.mjs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Find the agent name for a given role.
|
|
17
|
+
* Returns the display name (capitalized) or null if not found.
|
|
18
|
+
*/
|
|
19
|
+
export function getAgentByRole(role) {
|
|
20
|
+
for (const [name, agent] of Object.entries(CONFIG.agents)) {
|
|
21
|
+
if (agent.role === role) {
|
|
22
|
+
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the auto-assign target for a status transition.
|
|
30
|
+
* Returns agent display name or null (keep current).
|
|
31
|
+
*/
|
|
32
|
+
export function getAutoAssign(status) {
|
|
33
|
+
switch (status) {
|
|
34
|
+
case 'To-Do':
|
|
35
|
+
case 'Blocked':
|
|
36
|
+
return CONFIG.human.name;
|
|
37
|
+
case 'Ready for Human Review':
|
|
38
|
+
return null; // keep current assignee — preserves builder ownership
|
|
39
|
+
case 'Testing':
|
|
40
|
+
return getAgentByRole('qa');
|
|
41
|
+
case 'In-Progress':
|
|
42
|
+
return getAgentByRole('lead');
|
|
43
|
+
case 'Done':
|
|
44
|
+
return null; // keep current
|
|
45
|
+
default:
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get the Slack user ID for an agent or human name.
|
|
52
|
+
*/
|
|
53
|
+
export function getSlackUserId(name) {
|
|
54
|
+
if (!name) return null;
|
|
55
|
+
const lower = name.toLowerCase();
|
|
56
|
+
if (lower === CONFIG.human.name.toLowerCase()) return CONFIG.human.slackUserId;
|
|
57
|
+
const agent = CONFIG.agents[lower];
|
|
58
|
+
return agent?.slackUserId || null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get the lead agent name (for delegation after approval).
|
|
63
|
+
*/
|
|
64
|
+
export function getLeadAgent() {
|
|
65
|
+
return getAgentByRole('lead');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reverse lookup: Slack user ID → display name.
|
|
70
|
+
*/
|
|
71
|
+
export function getNameBySlackUserId(userId) {
|
|
72
|
+
if (!userId) return null;
|
|
73
|
+
if (userId === CONFIG.human.slackUserId) return CONFIG.human.name;
|
|
74
|
+
for (const [name, agent] of Object.entries(CONFIG.agents)) {
|
|
75
|
+
if (agent.slackUserId === userId) return name.charAt(0).toUpperCase() + name.slice(1);
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if a status requires human assignment override.
|
|
82
|
+
*/
|
|
83
|
+
export function isHumanReviewStatus(status) {
|
|
84
|
+
return status === 'Ready for Human Review' || status === 'Blocked';
|
|
85
|
+
}
|