openspecpm 0.1.0-alpha.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 (51) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +21 -0
  3. package/README.md +352 -0
  4. package/cli/bin/openspecpm.js +198 -0
  5. package/cli/src/adapters/azure.js +230 -0
  6. package/cli/src/adapters/base.js +86 -0
  7. package/cli/src/adapters/github.js +224 -0
  8. package/cli/src/adapters/gitlab.js +170 -0
  9. package/cli/src/adapters/index.js +54 -0
  10. package/cli/src/adapters/jira.js +228 -0
  11. package/cli/src/adapters/linear.js +261 -0
  12. package/cli/src/audit.js +78 -0
  13. package/cli/src/bdd/linter.js +183 -0
  14. package/cli/src/bdd/templates.js +172 -0
  15. package/cli/src/commands/assign.js +78 -0
  16. package/cli/src/commands/blocked.js +17 -0
  17. package/cli/src/commands/bug-report.js +78 -0
  18. package/cli/src/commands/bulk.js +67 -0
  19. package/cli/src/commands/comment.js +83 -0
  20. package/cli/src/commands/decompose.js +106 -0
  21. package/cli/src/commands/doctor.js +61 -0
  22. package/cli/src/commands/fan-out.js +79 -0
  23. package/cli/src/commands/help.js +69 -0
  24. package/cli/src/commands/init.js +111 -0
  25. package/cli/src/commands/next.js +18 -0
  26. package/cli/src/commands/propose.js +67 -0
  27. package/cli/src/commands/reconcile.js +74 -0
  28. package/cli/src/commands/search.js +52 -0
  29. package/cli/src/commands/ship.js +79 -0
  30. package/cli/src/commands/standup.js +42 -0
  31. package/cli/src/commands/status.js +29 -0
  32. package/cli/src/commands/sync.js +128 -0
  33. package/cli/src/commands/validate.js +79 -0
  34. package/cli/src/commands/watch.js +67 -0
  35. package/cli/src/config.js +43 -0
  36. package/cli/src/frontmatter.js +22 -0
  37. package/cli/src/http.js +92 -0
  38. package/cli/src/install-hints.js +44 -0
  39. package/cli/src/notify.js +56 -0
  40. package/cli/src/openspec-bridge.js +64 -0
  41. package/cli/src/ratelimit.js +50 -0
  42. package/cli/src/telemetry.js +45 -0
  43. package/cli/src/tracking.js +197 -0
  44. package/package.json +60 -0
  45. package/skill/openspecpm/SKILL.md +74 -0
  46. package/skill/openspecpm/references/conventions.md +105 -0
  47. package/skill/openspecpm/references/execute.md +62 -0
  48. package/skill/openspecpm/references/plan.md +47 -0
  49. package/skill/openspecpm/references/structure.md +52 -0
  50. package/skill/openspecpm/references/sync.md +56 -0
  51. package/skill/openspecpm/references/track.md +55 -0
@@ -0,0 +1,230 @@
1
+ import { Adapter, AdapterError } from './base.js';
2
+ import { HttpClient, basicAuth } from '../http.js';
3
+ import { TokenBucket, PRESETS } from '../ratelimit.js';
4
+
5
+ const API_VERSION = '7.1';
6
+ const COMMENTS_API_VERSION = '7.1-preview.3';
7
+
8
+ const STATE_OPEN = 'New';
9
+ const STATE_CLOSED = 'Closed';
10
+
11
+ const STATE_TO_NORMALIZED = (s) => {
12
+ const v = (s ?? '').toLowerCase();
13
+ if (['closed', 'done', 'removed', 'resolved'].includes(v)) return 'closed';
14
+ if (['active', 'in progress', 'committed'].includes(v)) return 'in_progress';
15
+ if (['blocked'].includes(v)) return 'blocked';
16
+ return 'open';
17
+ };
18
+
19
+ export class AzureAdapter extends Adapter {
20
+ #http;
21
+ #bucket;
22
+
23
+ constructor(config = {}, { fetch: fetchImpl, http } = {}) {
24
+ super(config);
25
+ this.#bucket = new TokenBucket(PRESETS.ado);
26
+ if (http) {
27
+ this.#http = http;
28
+ } else {
29
+ const pat = process.env.AZURE_DEVOPS_EXT_PAT;
30
+ const baseUrl = config.baseUrl || (config.organization ? `https://dev.azure.com/${config.organization}` : 'https://placeholder.invalid');
31
+ this.#http = new HttpClient({
32
+ baseUrl,
33
+ auth: pat ? basicAuth('', pat) : null,
34
+ fetch: fetchImpl,
35
+ remediationHint: 'Set AZURE_DEVOPS_EXT_PAT with Work Items (Read/Write) scope, then retry.',
36
+ });
37
+ }
38
+ }
39
+
40
+ get name() {
41
+ return 'azure';
42
+ }
43
+
44
+ capabilities() {
45
+ return {
46
+ hierarchyDepth: 4,
47
+ supportsSubIssues: true,
48
+ supportsSprints: true,
49
+ supportsLabels: true,
50
+ fieldMap: { epic: 'Epic', feature: 'Feature', story: 'User Story', task: 'Task' },
51
+ };
52
+ }
53
+
54
+ #project() {
55
+ const project = this.config.project;
56
+ if (!project) {
57
+ throw new AdapterError('Azure adapter requires config.project.', {
58
+ remediation: 'Re-run `openspecpm init` and supply the project name.',
59
+ });
60
+ }
61
+ return project;
62
+ }
63
+
64
+ async #req(method, path, opts) {
65
+ await this.#bucket.take();
66
+ return this.#http.request(method, path, opts);
67
+ }
68
+
69
+ async doctor() {
70
+ const findings = [];
71
+ if (!process.env.AZURE_DEVOPS_EXT_PAT) {
72
+ findings.push({
73
+ ok: false,
74
+ msg: 'AZURE_DEVOPS_EXT_PAT not set.',
75
+ remediation: 'Create a PAT with Work Items (Read/Write) and export AZURE_DEVOPS_EXT_PAT.',
76
+ });
77
+ return findings;
78
+ }
79
+ try {
80
+ await this.#req('GET', `/_apis/connectionData`, { query: { 'api-version': API_VERSION } });
81
+ findings.push({ ok: true, msg: 'PAT authenticates against Azure DevOps' });
82
+ } catch (err) {
83
+ findings.push({
84
+ ok: false,
85
+ msg: `PAT auth failed: ${err.message}`,
86
+ remediation: err.remediation ?? 'Verify PAT scopes and organization URL.',
87
+ });
88
+ }
89
+ return findings;
90
+ }
91
+
92
+ async init() {
93
+ if (!process.env.AZURE_DEVOPS_EXT_PAT) {
94
+ throw new AdapterError('AZURE_DEVOPS_EXT_PAT is required.', {
95
+ remediation: 'Set the env var with Work Items (Read/Write) scope.',
96
+ });
97
+ }
98
+ return this.capabilities();
99
+ }
100
+
101
+ async #createWorkItem(type, fields) {
102
+ const ops = Object.entries(fields).map(([field, value]) => ({
103
+ op: 'add',
104
+ path: `/fields/${field}`,
105
+ value,
106
+ }));
107
+ const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workitems/$${type}`;
108
+ const data = await this.#req('POST', path, {
109
+ query: { 'api-version': API_VERSION },
110
+ body: ops,
111
+ contentType: 'application/json-patch+json',
112
+ });
113
+ return {
114
+ adapter: 'azure',
115
+ id: String(data.id),
116
+ url: data._links?.html?.href ?? data.url,
117
+ raw: data,
118
+ };
119
+ }
120
+
121
+ async createEpic(feature) {
122
+ return this.#createWorkItem('Epic', {
123
+ 'System.Title': feature.name,
124
+ 'System.Description': feature.summary ?? '',
125
+ 'System.Tags': `openspec; openspec:${feature.name}`,
126
+ });
127
+ }
128
+
129
+ async createWorkItem(epic, task /*, opts */) {
130
+ const fields = {
131
+ 'System.Title': task.title,
132
+ 'System.Description': task.body ?? '',
133
+ 'System.Tags': `openspec; openspec:${epic.feature ?? ''}`,
134
+ };
135
+ const itemType = task.type ?? 'Task';
136
+ const ref = await this.#createWorkItem(itemType, fields);
137
+ if (epic?.id) {
138
+ try {
139
+ await this.linkWorkItems(epic, ref, 'Parent');
140
+ } catch (err) {
141
+ // Non-fatal: caller still gets the ref.
142
+ }
143
+ }
144
+ return ref;
145
+ }
146
+
147
+ async linkWorkItems(parent, child, _type = 'Parent') {
148
+ const ops = [
149
+ {
150
+ op: 'add',
151
+ path: '/relations/-',
152
+ value: {
153
+ rel: 'System.LinkTypes.Hierarchy-Reverse',
154
+ url: `${this.config.baseUrl ?? `https://dev.azure.com/${this.config.organization}`}/_apis/wit/workItems/${parent.id}`,
155
+ },
156
+ },
157
+ ];
158
+ const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workitems/${child.id}`;
159
+ await this.#req('PATCH', path, {
160
+ query: { 'api-version': API_VERSION },
161
+ body: ops,
162
+ contentType: 'application/json-patch+json',
163
+ });
164
+ }
165
+
166
+ async addProgressComment(item, body) {
167
+ const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workItems/${item.id}/comments`;
168
+ await this.#req('POST', path, {
169
+ query: { 'api-version': COMMENTS_API_VERSION },
170
+ body: { text: body },
171
+ });
172
+ }
173
+
174
+ async updateWorkItem(item, patch) {
175
+ const ops = [];
176
+ if (patch.title) ops.push({ op: 'add', path: '/fields/System.Title', value: patch.title });
177
+ if (patch.state) ops.push({ op: 'add', path: '/fields/System.State', value: patch.state });
178
+ if (patch.assignee) ops.push({ op: 'add', path: '/fields/System.AssignedTo', value: patch.assignee });
179
+ if (patch.iterationPath) ops.push({ op: 'add', path: '/fields/System.IterationPath', value: patch.iterationPath });
180
+ if (patch.areaPath) ops.push({ op: 'add', path: '/fields/System.AreaPath', value: patch.areaPath });
181
+ if (!ops.length) return;
182
+ const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workitems/${item.id}`;
183
+ await this.#req('PATCH', path, {
184
+ query: { 'api-version': API_VERSION },
185
+ body: ops,
186
+ contentType: 'application/json-patch+json',
187
+ });
188
+ }
189
+
190
+ async closeWorkItem(item, resolution) {
191
+ await this.updateWorkItem(item, { state: STATE_CLOSED });
192
+ if (resolution) await this.addProgressComment(item, resolution);
193
+ }
194
+
195
+ async getWorkItem(item) {
196
+ const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workitems/${item.id}`;
197
+ const data = await this.#req('GET', path, { query: { 'api-version': API_VERSION } });
198
+ return {
199
+ ref: { adapter: 'azure', id: String(data.id), url: data._links?.html?.href },
200
+ title: data.fields?.['System.Title'],
201
+ status: STATE_TO_NORMALIZED(data.fields?.['System.State']),
202
+ labels: (data.fields?.['System.Tags'] ?? '').split(/;\s*/).filter(Boolean),
203
+ assignee: data.fields?.['System.AssignedTo']?.uniqueName,
204
+ };
205
+ }
206
+
207
+ async listWorkItems(query = {}) {
208
+ const tag = query.tag ?? `openspec`;
209
+ 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
+ const path = `/${encodeURIComponent(this.#project())}/_apis/wit/wiql`;
211
+ const res = await this.#req('POST', path, {
212
+ query: { 'api-version': API_VERSION },
213
+ body: { query: wiql },
214
+ });
215
+ const ids = (res.workItems ?? []).slice(0, query.limit ?? 50).map((w) => w.id);
216
+ if (!ids.length) return [];
217
+ const batch = await this.#req('GET', `/${encodeURIComponent(this.#project())}/_apis/wit/workitems`, {
218
+ query: { ids: ids.join(','), 'api-version': API_VERSION },
219
+ });
220
+ return (batch.value ?? []).map((data) => ({
221
+ ref: { adapter: 'azure', id: String(data.id), url: data._links?.html?.href },
222
+ title: data.fields?.['System.Title'],
223
+ status: STATE_TO_NORMALIZED(data.fields?.['System.State']),
224
+ labels: (data.fields?.['System.Tags'] ?? '').split(/;\s*/).filter(Boolean),
225
+ assignee: data.fields?.['System.AssignedTo']?.uniqueName,
226
+ }));
227
+ }
228
+ }
229
+
230
+ export { STATE_OPEN, STATE_CLOSED };
@@ -0,0 +1,86 @@
1
+ export class AdapterError extends Error {
2
+ constructor(message, { remediation, cause } = {}) {
3
+ super(message, { cause });
4
+ this.name = 'AdapterError';
5
+ this.remediation = remediation;
6
+ }
7
+ }
8
+
9
+ /**
10
+ * Abstract PM-tool adapter. Subclasses implement every method.
11
+ * Capabilities surface the differences between backends so the sync layer
12
+ * can degrade gracefully (e.g. collapse a 4-level task tree for GitHub).
13
+ */
14
+ export class Adapter {
15
+ constructor(config = {}) {
16
+ this.config = config;
17
+ }
18
+
19
+ get name() {
20
+ return 'base';
21
+ }
22
+
23
+ capabilities() {
24
+ return {
25
+ hierarchyDepth: 1,
26
+ supportsSubIssues: false,
27
+ supportsSprints: false,
28
+ supportsLabels: false,
29
+ fieldMap: {},
30
+ };
31
+ }
32
+
33
+ async init() {
34
+ throw new AdapterError(`${this.name} adapter does not implement init()`);
35
+ }
36
+
37
+ async createEpic(/* feature */) {
38
+ throw new AdapterError(`${this.name} adapter does not implement createEpic()`);
39
+ }
40
+
41
+ async createWorkItem(/* epic, task, opts */) {
42
+ throw new AdapterError(`${this.name} adapter does not implement createWorkItem()`);
43
+ }
44
+
45
+ async linkWorkItems(/* parent, child, type */) {
46
+ throw new AdapterError(`${this.name} adapter does not implement linkWorkItems()`);
47
+ }
48
+
49
+ async addProgressComment(/* item, body */) {
50
+ throw new AdapterError(`${this.name} adapter does not implement addProgressComment()`);
51
+ }
52
+
53
+ async updateWorkItem(/* item, patch */) {
54
+ throw new AdapterError(`${this.name} adapter does not implement updateWorkItem()`);
55
+ }
56
+
57
+ async closeWorkItem(/* item, resolution */) {
58
+ throw new AdapterError(`${this.name} adapter does not implement closeWorkItem()`);
59
+ }
60
+
61
+ async getWorkItem(/* item */) {
62
+ throw new AdapterError(`${this.name} adapter does not implement getWorkItem()`);
63
+ }
64
+
65
+ async listWorkItems(/* query */) {
66
+ throw new AdapterError(`${this.name} adapter does not implement listWorkItems()`);
67
+ }
68
+
69
+ async doctor() {
70
+ throw new AdapterError(`${this.name} adapter does not implement doctor()`);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * @typedef {Object} WorkItemRef
76
+ * @property {string} adapter
77
+ * @property {string} id External work-item identifier (issue number, ADO ID, Jira key)
78
+ * @property {string} [url]
79
+ *
80
+ * @typedef {Object} StatusView
81
+ * @property {WorkItemRef} ref
82
+ * @property {string} title
83
+ * @property {'open'|'in_progress'|'blocked'|'closed'} status
84
+ * @property {string[]} labels
85
+ * @property {string} [assignee]
86
+ */
@@ -0,0 +1,224 @@
1
+ import { execa } from 'execa';
2
+ import { Adapter, AdapterError } from './base.js';
3
+ import { TokenBucket, PRESETS } from '../ratelimit.js';
4
+
5
+ const LABEL_EPIC = 'openspec-epic';
6
+ const LABEL_TASK = 'openspec-task';
7
+
8
+ export class GitHubAdapter extends Adapter {
9
+ #runner;
10
+ #bucket;
11
+
12
+ constructor(config = {}, { runner = execa } = {}) {
13
+ super(config);
14
+ this.#runner = runner;
15
+ this.#bucket = new TokenBucket(PRESETS.github);
16
+ }
17
+
18
+ get name() {
19
+ return 'github';
20
+ }
21
+
22
+ capabilities() {
23
+ return {
24
+ hierarchyDepth: 2,
25
+ supportsSubIssues: true,
26
+ supportsSprints: false,
27
+ supportsLabels: true,
28
+ fieldMap: { epic: 'issue+label', task: 'issue+sub-issue' },
29
+ };
30
+ }
31
+
32
+ get #repo() {
33
+ const repo = this.config.repo;
34
+ if (!repo || !/^[^/]+\/[^/]+$/.test(repo)) {
35
+ throw new AdapterError('GitHub adapter requires config.repo as "owner/name".', {
36
+ remediation: 'Re-run `openspecpm init` and provide a valid owner/repo.',
37
+ });
38
+ }
39
+ return repo;
40
+ }
41
+
42
+ async #gh(args, opts = {}) {
43
+ await this.#bucket.take();
44
+ try {
45
+ const { stdout } = await this.#runner('gh', args, { ...opts });
46
+ return stdout;
47
+ } catch (err) {
48
+ const stderr = err.stderr ?? err.message ?? '';
49
+ throw new AdapterError(`gh ${args.slice(0, 2).join(' ')} failed: ${stderr.trim()}`, {
50
+ remediation: 'Run `openspecpm doctor github` to diagnose.',
51
+ cause: err,
52
+ });
53
+ }
54
+ }
55
+
56
+ async doctor() {
57
+ const findings = [];
58
+ try {
59
+ await this.#runner('gh', ['--version']);
60
+ } catch {
61
+ return [{ ok: false, msg: 'gh CLI not installed.', remediation: 'Install GitHub CLI: https://cli.github.com/' }];
62
+ }
63
+ try {
64
+ await this.#runner('gh', ['auth', 'status']);
65
+ findings.push({ ok: true, msg: 'gh auth ok' });
66
+ } catch {
67
+ findings.push({ ok: false, msg: 'gh not authenticated.', remediation: 'Run `gh auth login` and grant repo + read:org scopes.' });
68
+ }
69
+ try {
70
+ await this.#runner('gh', ['extension', 'list']).then((r) => {
71
+ if (!String(r.stdout ?? '').includes('yahsan2/gh-sub-issue')) {
72
+ findings.push({
73
+ ok: false,
74
+ msg: 'gh-sub-issue extension missing (needed for parent/child issue links).',
75
+ remediation: 'Install with `gh extension install yahsan2/gh-sub-issue`.',
76
+ });
77
+ } else {
78
+ findings.push({ ok: true, msg: 'gh-sub-issue installed' });
79
+ }
80
+ });
81
+ } catch {
82
+ // optional extension; non-fatal
83
+ }
84
+ return findings;
85
+ }
86
+
87
+ async init() {
88
+ // Ensure labels exist (idempotent).
89
+ for (const [label, color] of [
90
+ [LABEL_EPIC, '0E8A16'],
91
+ [LABEL_TASK, '1D76DB'],
92
+ ]) {
93
+ try {
94
+ await this.#gh(['label', 'create', label, '--color', color, '--repo', this.#repo]);
95
+ } catch {
96
+ /* already exists — fine */
97
+ }
98
+ }
99
+ return this.capabilities();
100
+ }
101
+
102
+ async createEpic(feature) {
103
+ const title = `Epic: ${feature.name}`;
104
+ const body = feature.summary ?? '';
105
+ const stdout = await this.#gh([
106
+ 'issue', 'create',
107
+ '--repo', this.#repo,
108
+ '--title', title,
109
+ '--body', body,
110
+ '--label', `${LABEL_EPIC},openspec:${feature.name}`,
111
+ ]);
112
+ const url = stdout.trim().split('\n').pop();
113
+ const id = url.match(/\/(\d+)$/)?.[1];
114
+ return { adapter: 'github', id, url };
115
+ }
116
+
117
+ async createWorkItem(epic, task /*, opts */) {
118
+ const stdout = await this.#gh([
119
+ 'issue', 'create',
120
+ '--repo', this.#repo,
121
+ '--title', task.title,
122
+ '--body', task.body ?? '',
123
+ '--label', `${LABEL_TASK},openspec:${epic.feature ?? ''}`,
124
+ ]);
125
+ const url = stdout.trim().split('\n').pop();
126
+ const id = url.match(/\/(\d+)$/)?.[1];
127
+ const ref = { adapter: 'github', id, url };
128
+ // Best-effort parent/child link via gh-sub-issue.
129
+ try {
130
+ await this.#gh(['sub-issue', 'add', '--repo', this.#repo, '--parent', epic.id, '--child', id]);
131
+ } catch {
132
+ // Extension not installed or call failed — caller can fall back to task lists.
133
+ }
134
+ return ref;
135
+ }
136
+
137
+ async linkWorkItems(parent, child /*, type */) {
138
+ await this.#gh(['sub-issue', 'add', '--repo', this.#repo, '--parent', parent.id, '--child', child.id]);
139
+ }
140
+
141
+ async listChildren(parent) {
142
+ try {
143
+ const raw = await this.#gh(['sub-issue', 'list', '--repo', this.#repo, '--parent', parent.id]);
144
+ // Newer versions of the extension support --json; older ones return text.
145
+ try {
146
+ const list = JSON.parse(raw);
147
+ return Array.isArray(list)
148
+ ? list.map((d) => ({ adapter: 'github', id: String(d.number ?? d.id), url: d.url }))
149
+ : [];
150
+ } catch {
151
+ // Fallback: parse `#42 - Title` lines.
152
+ return (raw ?? '').split(/\r?\n/).map((l) => l.match(/#(\d+)/)?.[1]).filter(Boolean)
153
+ .map((id) => ({ adapter: 'github', id, url: `https://github.com/${this.config.repo}/issues/${id}` }));
154
+ }
155
+ } catch {
156
+ return [];
157
+ }
158
+ }
159
+
160
+ async removeChild(parent, child) {
161
+ try {
162
+ await this.#gh(['sub-issue', 'remove', '--repo', this.#repo, '--parent', parent.id, '--child', child.id]);
163
+ } catch (err) {
164
+ throw new AdapterError(`Failed to remove sub-issue link: ${err.message}`, {
165
+ remediation: 'Ensure the gh-sub-issue extension is installed: `gh extension install yahsan2/gh-sub-issue`.',
166
+ });
167
+ }
168
+ }
169
+
170
+ async addProgressComment(item, body) {
171
+ await this.#gh([
172
+ 'issue', 'comment', item.id,
173
+ '--repo', this.#repo,
174
+ '--body', body,
175
+ ]);
176
+ }
177
+
178
+ async updateWorkItem(item, patch) {
179
+ const args = ['issue', 'edit', item.id, '--repo', this.#repo];
180
+ if (patch.addLabels) for (const l of patch.addLabels) args.push('--add-label', l);
181
+ if (patch.removeLabels) for (const l of patch.removeLabels) args.push('--remove-label', l);
182
+ if (patch.assignee) args.push('--add-assignee', patch.assignee);
183
+ await this.#gh(args);
184
+ }
185
+
186
+ async closeWorkItem(item, resolution) {
187
+ await this.#gh([
188
+ 'issue', 'close', item.id,
189
+ '--repo', this.#repo,
190
+ ...(resolution ? ['--comment', resolution] : []),
191
+ ]);
192
+ }
193
+
194
+ async getWorkItem(item) {
195
+ const raw = await this.#gh([
196
+ 'issue', 'view', item.id,
197
+ '--repo', this.#repo,
198
+ '--json', 'state,title,labels,assignees,url',
199
+ ]);
200
+ const data = JSON.parse(raw);
201
+ return {
202
+ ref: { adapter: 'github', id: item.id, url: data.url },
203
+ title: data.title,
204
+ status: data.state?.toLowerCase() === 'closed' ? 'closed' : 'open',
205
+ labels: (data.labels ?? []).map((l) => l.name),
206
+ assignee: data.assignees?.[0]?.login,
207
+ };
208
+ }
209
+
210
+ async listWorkItems(query = {}) {
211
+ const args = ['issue', 'list', '--repo', this.#repo, '--json', 'number,state,title,labels,assignees,url', '--limit', String(query.limit ?? 100)];
212
+ if (query.label) args.push('--label', query.label);
213
+ if (query.state) args.push('--state', query.state);
214
+ const raw = await this.#gh(args);
215
+ const list = JSON.parse(raw);
216
+ return list.map((d) => ({
217
+ ref: { adapter: 'github', id: String(d.number), url: d.url },
218
+ title: d.title,
219
+ status: d.state?.toLowerCase() === 'closed' ? 'closed' : 'open',
220
+ labels: (d.labels ?? []).map((l) => l.name),
221
+ assignee: d.assignees?.[0]?.login,
222
+ }));
223
+ }
224
+ }