openspecpm 0.1.0-alpha.0 → 1.0.1
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/CHANGELOG.md +148 -86
- package/README.md +388 -352
- package/cli/bin/openspecpm.js +218 -198
- package/cli/src/adapters/azure.js +21 -5
- package/cli/src/adapters/gitlab.js +10 -5
- package/cli/src/audit.js +39 -7
- package/cli/src/bdd/judge.js +216 -0
- package/cli/src/commands/bulk.js +10 -0
- package/cli/src/commands/doctor.js +11 -0
- package/cli/src/commands/propose.js +41 -6
- package/cli/src/commands/reconcile.js +17 -4
- package/cli/src/commands/sync.js +70 -5
- package/cli/src/commands/validate.js +32 -1
- package/cli/src/http.js +14 -2
- package/cli/src/notify.js +25 -2
- package/cli/src/openspec-bridge.js +31 -0
- package/cli/src/tracking.js +30 -5
- package/package.json +2 -1
- package/skill/openspecpm/SKILL.md +74 -74
- package/skill/openspecpm/references/conventions.md +106 -105
- package/skill/openspecpm/references/execute.md +4 -4
- package/skill/openspecpm/references/plan.md +2 -2
- package/skill/openspecpm/references/structure.md +52 -52
- package/skill/openspecpm/references/sync.md +56 -56
package/cli/bin/openspecpm.js
CHANGED
|
@@ -1,198 +1,218 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
.
|
|
35
|
-
.
|
|
36
|
-
.
|
|
37
|
-
|
|
38
|
-
program
|
|
39
|
-
.command('
|
|
40
|
-
.description('
|
|
41
|
-
.option('--
|
|
42
|
-
.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
.
|
|
47
|
-
.
|
|
48
|
-
.option('-
|
|
49
|
-
.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
.
|
|
55
|
-
.
|
|
56
|
-
.option('--
|
|
57
|
-
.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
.
|
|
62
|
-
.
|
|
63
|
-
.option('--
|
|
64
|
-
.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
.
|
|
89
|
-
.
|
|
90
|
-
.option('--dry-run', '
|
|
91
|
-
.action((feature, opts) =>
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
.
|
|
103
|
-
.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
.
|
|
108
|
-
.
|
|
109
|
-
.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
.
|
|
126
|
-
.
|
|
127
|
-
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
program
|
|
131
|
-
.command('
|
|
132
|
-
.description('
|
|
133
|
-
.option('-l, --limit <n>', 'Max
|
|
134
|
-
.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
.
|
|
141
|
-
.
|
|
142
|
-
|
|
143
|
-
);
|
|
144
|
-
|
|
145
|
-
program
|
|
146
|
-
.command('
|
|
147
|
-
.description('
|
|
148
|
-
.option('--
|
|
149
|
-
.option('--
|
|
150
|
-
.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
audited('
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
program
|
|
188
|
-
.command('
|
|
189
|
-
.description('
|
|
190
|
-
.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { audited } from '../src/audit.js';
|
|
5
|
+
|
|
6
|
+
// Read version from package.json so `npm version` bumps and the CLI's
|
|
7
|
+
// `--version` output stay in sync. package.json always ships in the
|
|
8
|
+
// npm tarball, so this resolves correctly under `npx openspecpm`.
|
|
9
|
+
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
10
|
+
import { runInit } from '../src/commands/init.js';
|
|
11
|
+
import { runDoctor } from '../src/commands/doctor.js';
|
|
12
|
+
import { runPropose } from '../src/commands/propose.js';
|
|
13
|
+
import { runDecompose } from '../src/commands/decompose.js';
|
|
14
|
+
import { runSync } from '../src/commands/sync.js';
|
|
15
|
+
import { runComment } from '../src/commands/comment.js';
|
|
16
|
+
import { runReconcile } from '../src/commands/reconcile.js';
|
|
17
|
+
import { runStatus } from '../src/commands/status.js';
|
|
18
|
+
import { runStandup } from '../src/commands/standup.js';
|
|
19
|
+
import { runNext } from '../src/commands/next.js';
|
|
20
|
+
import { runBlocked } from '../src/commands/blocked.js';
|
|
21
|
+
import { runValidate } from '../src/commands/validate.js';
|
|
22
|
+
import { runSearch } from '../src/commands/search.js';
|
|
23
|
+
import { runFanOut } from '../src/commands/fan-out.js';
|
|
24
|
+
import { runBugReport } from '../src/commands/bug-report.js';
|
|
25
|
+
import { runShip } from '../src/commands/ship.js';
|
|
26
|
+
import { runHelp } from '../src/commands/help.js';
|
|
27
|
+
import { runAssign } from '../src/commands/assign.js';
|
|
28
|
+
import { runSyncAll, runShipAllReady } from '../src/commands/bulk.js';
|
|
29
|
+
import { runWatch } from '../src/commands/watch.js';
|
|
30
|
+
|
|
31
|
+
const program = new Command();
|
|
32
|
+
|
|
33
|
+
program
|
|
34
|
+
.name('openspecpm')
|
|
35
|
+
.description('Spec-driven, BDD-shaped project management for AI agents.')
|
|
36
|
+
.version(pkg.version);
|
|
37
|
+
|
|
38
|
+
program
|
|
39
|
+
.command('init')
|
|
40
|
+
.description('Interactive wizard: pick a PM tool and write .openspecpm/config.json')
|
|
41
|
+
.option('--non-interactive', 'Fail instead of prompting when input is required')
|
|
42
|
+
.action((opts) => audited('init', runInit)(opts).catch(fatal));
|
|
43
|
+
|
|
44
|
+
program
|
|
45
|
+
.command('doctor [adapter]')
|
|
46
|
+
.description('Check auth + tooling health for one or all adapters')
|
|
47
|
+
.option('--install', 'Print OS-specific install commands for missing CLIs')
|
|
48
|
+
.option('--setup-auth', 'Print PAT/token URLs and required scopes')
|
|
49
|
+
.action((adapter, opts) => audited('doctor', runDoctor)({ adapter, install: opts.install, setupAuth: opts.setupAuth }).catch(fatal));
|
|
50
|
+
|
|
51
|
+
program
|
|
52
|
+
.command('propose <feature>')
|
|
53
|
+
.description('Create an OpenSpec proposal (proposal.md, design.md, tasks.md, specs/) for <feature>')
|
|
54
|
+
.option('-p, --prompt <text>', 'One-line description for the AI to seed the proposal')
|
|
55
|
+
.option('-t, --type <type>', 'Change type: feature | bug | refactor | incident', 'feature')
|
|
56
|
+
.option('--offline', 'Scaffold from templates without calling the openspec CLI')
|
|
57
|
+
.option('--llm', 'Augment BDD soft-lint with the LLM judge (requires ANTHROPIC_API_KEY)')
|
|
58
|
+
.action((feature, opts) => audited('propose', runPropose)({ feature, prompt: opts.prompt, type: opts.type, offline: opts.offline, llm: opts.llm }).catch(fatal));
|
|
59
|
+
|
|
60
|
+
program
|
|
61
|
+
.command('decompose <feature>')
|
|
62
|
+
.description('Extract tasks from proposal + BDD scenarios into tasks.md')
|
|
63
|
+
.option('--force', 'Merge into an existing tasks.md instead of refusing')
|
|
64
|
+
.action((feature, opts) => audited('decompose', runDecompose)({ feature, force: opts.force }).catch(fatal));
|
|
65
|
+
|
|
66
|
+
program
|
|
67
|
+
.command('sync [feature]')
|
|
68
|
+
.description('Push an OpenSpec change to the configured PM tool (idempotent; BDD-linted)')
|
|
69
|
+
.option('--all', 'Sync every change under openspec/changes/')
|
|
70
|
+
.option('--dry-run', 'Print the call plan without making remote changes')
|
|
71
|
+
.option('--force', 'Bypass BDD lint errors')
|
|
72
|
+
.option('--diff', 'Show the call plan in detail')
|
|
73
|
+
.option('--llm', 'Augment BDD hard-lint with the LLM judge (requires ANTHROPIC_API_KEY)')
|
|
74
|
+
.option('-y, --yes', 'Skip the confirmation prompt for --all')
|
|
75
|
+
.action((feature, opts) => {
|
|
76
|
+
if (opts.all) {
|
|
77
|
+
return audited('sync-all', runSyncAll)({ dryRun: opts.dryRun, force: opts.force, yes: opts.yes }).catch(fatal);
|
|
78
|
+
}
|
|
79
|
+
if (!feature) {
|
|
80
|
+
process.stderr.write('error: provide a <feature> or pass --all\n');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
return audited('sync', runSync)({ feature, dryRun: opts.dryRun, force: opts.force, diff: opts.diff, llm: opts.llm }).catch(fatal);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
program
|
|
87
|
+
.command('comment <feature> <task>')
|
|
88
|
+
.description('Post a progress comment to the PM tool work item')
|
|
89
|
+
.option('-m, --message <text>', 'Inline message (otherwise reads from local progress.md)')
|
|
90
|
+
.option('--dry-run', 'Print the comment instead of posting')
|
|
91
|
+
.action((feature, task, opts) =>
|
|
92
|
+
audited('comment', runComment)({ feature, task, message: opts.message, dryRun: opts.dryRun }).catch(fatal),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
program
|
|
96
|
+
.command('reconcile <feature>')
|
|
97
|
+
.description('Pull remote state back into local task frontmatter')
|
|
98
|
+
.option('--dry-run', 'Show drift without writing local changes')
|
|
99
|
+
.action((feature, opts) => audited('reconcile', runReconcile)({ feature, dryRun: opts.dryRun }).catch(fatal));
|
|
100
|
+
|
|
101
|
+
program
|
|
102
|
+
.command('status')
|
|
103
|
+
.description('Local snapshot: configured adapter + per-change task counts')
|
|
104
|
+
.action(() => audited('status', runStatus)().catch(fatal));
|
|
105
|
+
|
|
106
|
+
program
|
|
107
|
+
.command('standup')
|
|
108
|
+
.description('Show progress updates within a recent window')
|
|
109
|
+
.option('--since <window>', 'Time window: e.g. 12h, 2d, 1w', '24h')
|
|
110
|
+
.option('--broadcast', 'Also POST to configured Slack/Teams/generic webhooks')
|
|
111
|
+
.action((opts) => audited('standup', runStandup)({ since: opts.since, broadcast: opts.broadcast }).catch(fatal));
|
|
112
|
+
|
|
113
|
+
program
|
|
114
|
+
.command('next')
|
|
115
|
+
.description('List tasks ready to start (no unmet dependencies)')
|
|
116
|
+
.option('-l, --limit <n>', 'Max items to show', (v) => parseInt(v, 10), 5)
|
|
117
|
+
.action((opts) => audited('next', runNext)({ limit: opts.limit }).catch(fatal));
|
|
118
|
+
|
|
119
|
+
program
|
|
120
|
+
.command('blocked')
|
|
121
|
+
.description('List tasks waiting on unmet dependencies')
|
|
122
|
+
.action(() => audited('blocked', runBlocked)().catch(fatal));
|
|
123
|
+
|
|
124
|
+
program
|
|
125
|
+
.command('validate')
|
|
126
|
+
.description('Schema + dependency + BDD-lint sweep across every change')
|
|
127
|
+
.option('--llm', 'Augment BDD lint with the LLM judge (requires ANTHROPIC_API_KEY)')
|
|
128
|
+
.action((opts) => audited('validate', runValidate)({ llm: opts.llm }).catch(fatal));
|
|
129
|
+
|
|
130
|
+
program
|
|
131
|
+
.command('search <query>')
|
|
132
|
+
.description('Grep across proposals, specs, tasks, and progress notes')
|
|
133
|
+
.option('-l, --limit <n>', 'Max matches to show', (v) => parseInt(v, 10), 50)
|
|
134
|
+
.option('--case-sensitive', 'Match case')
|
|
135
|
+
.action((query, opts) =>
|
|
136
|
+
audited('search', runSearch)({ query, limit: opts.limit, caseSensitive: opts.caseSensitive }).catch(fatal),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
program
|
|
140
|
+
.command('fan-out <feature>')
|
|
141
|
+
.description('Emit ready-to-paste agent prompts for parallel:true tasks')
|
|
142
|
+
.option('-l, --limit <n>', 'Max prompts to emit', (v) => parseInt(v, 10), 5)
|
|
143
|
+
.action((feature, opts) => audited('fan-out', runFanOut)({ feature, limit: opts.limit }).catch(fatal));
|
|
144
|
+
|
|
145
|
+
program
|
|
146
|
+
.command('bug-report <feature> <task>')
|
|
147
|
+
.description('File a regression bug linked to a shipped task')
|
|
148
|
+
.option('-t, --title <text>', 'Bug title (required)')
|
|
149
|
+
.option('-b, --body <text>', 'Bug body')
|
|
150
|
+
.action((feature, task, opts) =>
|
|
151
|
+
audited('bug-report', runBugReport)({ feature, task, title: opts.title, body: opts.body }).catch(fatal),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
program
|
|
155
|
+
.command('assign <feature> <task>')
|
|
156
|
+
.description('Set assignee / sprint / iteration / area / story-points on a synced task')
|
|
157
|
+
.option('--assignee <id>', 'Assignee id (backend-specific)')
|
|
158
|
+
.option('--sprint <id>', 'Sprint / cycle / milestone id (whichever the backend supports)')
|
|
159
|
+
.option('--iteration <path>', 'Iteration path (Azure DevOps; alias for --sprint elsewhere)')
|
|
160
|
+
.option('--area <path>', 'Area path (Azure DevOps)')
|
|
161
|
+
.option('--story-points <n>', 'Story points / estimate / weight')
|
|
162
|
+
.action((feature, task, opts) =>
|
|
163
|
+
audited('assign', runAssign)({
|
|
164
|
+
feature, task,
|
|
165
|
+
assignee: opts.assignee, sprint: opts.sprint, iteration: opts.iteration,
|
|
166
|
+
area: opts.area, storyPoints: opts.storyPoints,
|
|
167
|
+
}).catch(fatal),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
program
|
|
171
|
+
.command('ship [feature]')
|
|
172
|
+
.description('Close all work items for <feature> and archive the OpenSpec change')
|
|
173
|
+
.option('--all-ready', 'Ship every change whose tasks are all synced (no pending/failed)')
|
|
174
|
+
.option('-y, --yes', 'Skip the confirmation prompt')
|
|
175
|
+
.option('--skip-archive', 'Close work items but leave openspec/changes/<feature>/ in place')
|
|
176
|
+
.action((feature, opts) => {
|
|
177
|
+
if (opts.allReady) {
|
|
178
|
+
return audited('ship-all-ready', runShipAllReady)({ yes: opts.yes, skipArchive: opts.skipArchive }).catch(fatal);
|
|
179
|
+
}
|
|
180
|
+
if (!feature) {
|
|
181
|
+
process.stderr.write('error: provide a <feature> or pass --all-ready\n');
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
return audited('ship', runShip)({ feature, yes: opts.yes, skipArchive: opts.skipArchive }).catch(fatal);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
program
|
|
188
|
+
.command('watch [feature]')
|
|
189
|
+
.description('Re-lint on file change. Use --all to validate the whole project on every change.')
|
|
190
|
+
.option('--all', 'Validate every change instead of linting one feature')
|
|
191
|
+
.option('--debounce <ms>', 'Debounce window in milliseconds', (v) => parseInt(v, 10), 300)
|
|
192
|
+
.action((feature, opts) =>
|
|
193
|
+
audited('watch', runWatch)({ feature, allChanges: opts.all, debounceMs: opts.debounce }).catch(fatal),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
program
|
|
197
|
+
.command('help-table [topic]')
|
|
198
|
+
.description('Context-aware help grouped by workflow phase')
|
|
199
|
+
.action((topic) => { runHelp({ topic }); });
|
|
200
|
+
|
|
201
|
+
// Sanitize uncaught errors so a future programming bug (TypeError, etc.)
|
|
202
|
+
// doesn't dump a stack trace with absolute install / tmp paths to stderr.
|
|
203
|
+
// fatal() is the normal path; these handlers are insurance for code paths
|
|
204
|
+
// that don't reach Commander's .catch(fatal).
|
|
205
|
+
process.on('uncaughtException', fatal);
|
|
206
|
+
process.on('unhandledRejection', (reason) => {
|
|
207
|
+
fatal(reason instanceof Error ? reason : new Error(String(reason ?? 'unknown')));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
program.parseAsync(process.argv);
|
|
211
|
+
|
|
212
|
+
function fatal(err) {
|
|
213
|
+
const msg = err?.message ?? String(err ?? 'unknown error');
|
|
214
|
+
process.stderr.write(`\n✖ ${msg}\n`);
|
|
215
|
+
if (err?.remediation) process.stderr.write(` → ${err.remediation}\n`);
|
|
216
|
+
process.stderr.write(` See .openspecpm/audit.log for details.\n`);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
@@ -2,6 +2,11 @@ import { Adapter, AdapterError } from './base.js';
|
|
|
2
2
|
import { HttpClient, basicAuth } from '../http.js';
|
|
3
3
|
import { TokenBucket, PRESETS } from '../ratelimit.js';
|
|
4
4
|
|
|
5
|
+
// User-controlled ids (from tasks.md frontmatter) MUST be encoded before
|
|
6
|
+
// interpolation into URL paths or body URL values, or a value like
|
|
7
|
+
// "1/../99" can reach unintended endpoints.
|
|
8
|
+
const enc = (v) => encodeURIComponent(String(v));
|
|
9
|
+
|
|
5
10
|
const API_VERSION = '7.1';
|
|
6
11
|
const COMMENTS_API_VERSION = '7.1-preview.3';
|
|
7
12
|
|
|
@@ -151,11 +156,11 @@ export class AzureAdapter extends Adapter {
|
|
|
151
156
|
path: '/relations/-',
|
|
152
157
|
value: {
|
|
153
158
|
rel: 'System.LinkTypes.Hierarchy-Reverse',
|
|
154
|
-
url: `${this.config.baseUrl ?? `https://dev.azure.com/${this.config.organization}`}/_apis/wit/workItems/${parent.id}`,
|
|
159
|
+
url: `${this.config.baseUrl ?? `https://dev.azure.com/${this.config.organization}`}/_apis/wit/workItems/${enc(parent.id)}`,
|
|
155
160
|
},
|
|
156
161
|
},
|
|
157
162
|
];
|
|
158
|
-
const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workitems/${child.id}`;
|
|
163
|
+
const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workitems/${enc(child.id)}`;
|
|
159
164
|
await this.#req('PATCH', path, {
|
|
160
165
|
query: { 'api-version': API_VERSION },
|
|
161
166
|
body: ops,
|
|
@@ -164,7 +169,7 @@ export class AzureAdapter extends Adapter {
|
|
|
164
169
|
}
|
|
165
170
|
|
|
166
171
|
async addProgressComment(item, body) {
|
|
167
|
-
const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workItems/${item.id}/comments`;
|
|
172
|
+
const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workItems/${enc(item.id)}/comments`;
|
|
168
173
|
await this.#req('POST', path, {
|
|
169
174
|
query: { 'api-version': COMMENTS_API_VERSION },
|
|
170
175
|
body: { text: body },
|
|
@@ -179,7 +184,7 @@ export class AzureAdapter extends Adapter {
|
|
|
179
184
|
if (patch.iterationPath) ops.push({ op: 'add', path: '/fields/System.IterationPath', value: patch.iterationPath });
|
|
180
185
|
if (patch.areaPath) ops.push({ op: 'add', path: '/fields/System.AreaPath', value: patch.areaPath });
|
|
181
186
|
if (!ops.length) return;
|
|
182
|
-
const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workitems/${item.id}`;
|
|
187
|
+
const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workitems/${enc(item.id)}`;
|
|
183
188
|
await this.#req('PATCH', path, {
|
|
184
189
|
query: { 'api-version': API_VERSION },
|
|
185
190
|
body: ops,
|
|
@@ -193,7 +198,7 @@ export class AzureAdapter extends Adapter {
|
|
|
193
198
|
}
|
|
194
199
|
|
|
195
200
|
async getWorkItem(item) {
|
|
196
|
-
const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workitems/${item.id}`;
|
|
201
|
+
const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workitems/${enc(item.id)}`;
|
|
197
202
|
const data = await this.#req('GET', path, { query: { 'api-version': API_VERSION } });
|
|
198
203
|
return {
|
|
199
204
|
ref: { adapter: 'azure', id: String(data.id), url: data._links?.html?.href },
|
|
@@ -206,6 +211,17 @@ export class AzureAdapter extends Adapter {
|
|
|
206
211
|
|
|
207
212
|
async listWorkItems(query = {}) {
|
|
208
213
|
const tag = query.tag ?? `openspec`;
|
|
214
|
+
// Defense in depth: WIQL string-literal syntax means `'` is the only
|
|
215
|
+
// breakout char and we already double it. But there's no known WIQL
|
|
216
|
+
// feature that escapes a literal via other chars, so reject anything
|
|
217
|
+
// outside a known-safe set rather than trust escaping alone. Tags in
|
|
218
|
+
// this codebase are always `openspec` or `openspec:<feature>`; the
|
|
219
|
+
// feature side is validated by openspec-bridge.assertSafeFeatureName.
|
|
220
|
+
if (!/^[a-zA-Z0-9._:-]+$/.test(String(tag))) {
|
|
221
|
+
throw new AdapterError(`Unsafe tag for WIQL: "${tag}".`, {
|
|
222
|
+
remediation: 'Tag must match /^[a-zA-Z0-9._:-]+$/. Use a plain slug.',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
209
225
|
const wiql = `SELECT [System.Id], [System.Title], [System.State], [System.Tags], [System.AssignedTo] FROM workitems WHERE [System.TeamProject] = @project AND [System.Tags] CONTAINS '${tag.replace(/'/g, "''")}' ORDER BY [System.Id] DESC`;
|
|
210
226
|
const path = `/${encodeURIComponent(this.#project())}/_apis/wit/wiql`;
|
|
211
227
|
const res = await this.#req('POST', path, {
|
|
@@ -2,6 +2,11 @@ import { Adapter, AdapterError } from './base.js';
|
|
|
2
2
|
import { HttpClient } from '../http.js';
|
|
3
3
|
import { TokenBucket } from '../ratelimit.js';
|
|
4
4
|
|
|
5
|
+
// User-controlled ids (from tasks.md frontmatter) MUST be encoded before
|
|
6
|
+
// interpolation into URL paths, or a value like "1/../99" can reach
|
|
7
|
+
// unintended project endpoints.
|
|
8
|
+
const enc = (v) => encodeURIComponent(String(v));
|
|
9
|
+
|
|
5
10
|
const STATE_TO_NORMALIZED = (s) => {
|
|
6
11
|
const v = (s ?? '').toLowerCase();
|
|
7
12
|
if (v === 'closed') return 'closed';
|
|
@@ -109,7 +114,7 @@ export class GitLabAdapter extends Adapter {
|
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
async linkWorkItems(parent, child, type = 'relates_to') {
|
|
112
|
-
await this.#req('POST', `/projects/${this.#project()}/issues/${child.id}/links`, {
|
|
117
|
+
await this.#req('POST', `/projects/${this.#project()}/issues/${enc(child.id)}/links`, {
|
|
113
118
|
body: {
|
|
114
119
|
target_project_id: this.config.projectId,
|
|
115
120
|
target_issue_iid: parent.id,
|
|
@@ -119,7 +124,7 @@ export class GitLabAdapter extends Adapter {
|
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
async addProgressComment(item, body) {
|
|
122
|
-
await this.#req('POST', `/projects/${this.#project()}/issues/${item.id}/notes`, {
|
|
127
|
+
await this.#req('POST', `/projects/${this.#project()}/issues/${enc(item.id)}/notes`, {
|
|
123
128
|
body: { body },
|
|
124
129
|
});
|
|
125
130
|
}
|
|
@@ -134,18 +139,18 @@ export class GitLabAdapter extends Adapter {
|
|
|
134
139
|
if (patch.milestoneId) body.milestone_id = patch.milestoneId; // sprint
|
|
135
140
|
if (patch.weight !== undefined) body.weight = patch.weight; // story points
|
|
136
141
|
if (!Object.keys(body).length) return;
|
|
137
|
-
await this.#req('PUT', `/projects/${this.#project()}/issues/${item.id}`, { body });
|
|
142
|
+
await this.#req('PUT', `/projects/${this.#project()}/issues/${enc(item.id)}`, { body });
|
|
138
143
|
}
|
|
139
144
|
|
|
140
145
|
async closeWorkItem(item, resolution) {
|
|
141
|
-
await this.#req('PUT', `/projects/${this.#project()}/issues/${item.id}`, {
|
|
146
|
+
await this.#req('PUT', `/projects/${this.#project()}/issues/${enc(item.id)}`, {
|
|
142
147
|
body: { state_event: 'close' },
|
|
143
148
|
});
|
|
144
149
|
if (resolution) await this.addProgressComment(item, resolution);
|
|
145
150
|
}
|
|
146
151
|
|
|
147
152
|
async getWorkItem(item) {
|
|
148
|
-
const data = await this.#req('GET', `/projects/${this.#project()}/issues/${item.id}`);
|
|
153
|
+
const data = await this.#req('GET', `/projects/${this.#project()}/issues/${enc(item.id)}`);
|
|
149
154
|
return {
|
|
150
155
|
ref: { adapter: 'gitlab', id: String(data.iid), url: data.web_url },
|
|
151
156
|
title: data.title,
|