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.
- package/CHANGELOG.md +86 -0
- package/LICENSE +21 -0
- package/README.md +352 -0
- package/cli/bin/openspecpm.js +198 -0
- package/cli/src/adapters/azure.js +230 -0
- package/cli/src/adapters/base.js +86 -0
- package/cli/src/adapters/github.js +224 -0
- package/cli/src/adapters/gitlab.js +170 -0
- package/cli/src/adapters/index.js +54 -0
- package/cli/src/adapters/jira.js +228 -0
- package/cli/src/adapters/linear.js +261 -0
- package/cli/src/audit.js +78 -0
- package/cli/src/bdd/linter.js +183 -0
- package/cli/src/bdd/templates.js +172 -0
- package/cli/src/commands/assign.js +78 -0
- package/cli/src/commands/blocked.js +17 -0
- package/cli/src/commands/bug-report.js +78 -0
- package/cli/src/commands/bulk.js +67 -0
- package/cli/src/commands/comment.js +83 -0
- package/cli/src/commands/decompose.js +106 -0
- package/cli/src/commands/doctor.js +61 -0
- package/cli/src/commands/fan-out.js +79 -0
- package/cli/src/commands/help.js +69 -0
- package/cli/src/commands/init.js +111 -0
- package/cli/src/commands/next.js +18 -0
- package/cli/src/commands/propose.js +67 -0
- package/cli/src/commands/reconcile.js +74 -0
- package/cli/src/commands/search.js +52 -0
- package/cli/src/commands/ship.js +79 -0
- package/cli/src/commands/standup.js +42 -0
- package/cli/src/commands/status.js +29 -0
- package/cli/src/commands/sync.js +128 -0
- package/cli/src/commands/validate.js +79 -0
- package/cli/src/commands/watch.js +67 -0
- package/cli/src/config.js +43 -0
- package/cli/src/frontmatter.js +22 -0
- package/cli/src/http.js +92 -0
- package/cli/src/install-hints.js +44 -0
- package/cli/src/notify.js +56 -0
- package/cli/src/openspec-bridge.js +64 -0
- package/cli/src/ratelimit.js +50 -0
- package/cli/src/telemetry.js +45 -0
- package/cli/src/tracking.js +197 -0
- package/package.json +60 -0
- package/skill/openspecpm/SKILL.md +74 -0
- package/skill/openspecpm/references/conventions.md +105 -0
- package/skill/openspecpm/references/execute.md +62 -0
- package/skill/openspecpm/references/plan.md +47 -0
- package/skill/openspecpm/references/structure.md +52 -0
- package/skill/openspecpm/references/sync.md +56 -0
- package/skill/openspecpm/references/track.md +55 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { Adapter, AdapterError } from './base.js';
|
|
2
|
+
import { HttpClient } from '../http.js';
|
|
3
|
+
import { TokenBucket } from '../ratelimit.js';
|
|
4
|
+
|
|
5
|
+
const STATE_TO_NORMALIZED = (s) => {
|
|
6
|
+
const v = (s ?? '').toLowerCase();
|
|
7
|
+
if (v === 'closed') return 'closed';
|
|
8
|
+
return 'open';
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export class GitLabAdapter extends Adapter {
|
|
12
|
+
#http;
|
|
13
|
+
#bucket;
|
|
14
|
+
|
|
15
|
+
constructor(config = {}, { fetch: fetchImpl, http } = {}) {
|
|
16
|
+
super(config);
|
|
17
|
+
this.#bucket = new TokenBucket({ capacity: 30, refillPerSec: 1 }); // GitLab.com is generous
|
|
18
|
+
const baseUrl = (config.baseUrl || 'https://gitlab.com').replace(/\/$/, '');
|
|
19
|
+
const token = process.env.GITLAB_TOKEN;
|
|
20
|
+
if (http) {
|
|
21
|
+
this.#http = http;
|
|
22
|
+
} else {
|
|
23
|
+
this.#http = new HttpClient({
|
|
24
|
+
baseUrl: `${baseUrl}/api/v4`,
|
|
25
|
+
auth: null,
|
|
26
|
+
defaultHeaders: token ? { 'PRIVATE-TOKEN': token } : {},
|
|
27
|
+
fetch: fetchImpl,
|
|
28
|
+
remediationHint: 'Set GITLAB_TOKEN with api scope; verify projectId.',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get name() {
|
|
34
|
+
return 'gitlab';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
capabilities() {
|
|
38
|
+
return {
|
|
39
|
+
hierarchyDepth: 2, // 3 with Premium epics; we conservatively report 2
|
|
40
|
+
supportsSubIssues: true, // via issue links
|
|
41
|
+
supportsSprints: true, // milestones
|
|
42
|
+
supportsLabels: true,
|
|
43
|
+
fieldMap: { epic: 'Issue+epic-label', task: 'Issue', sprint: 'Milestone' },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#project() {
|
|
48
|
+
const id = this.config.projectId;
|
|
49
|
+
if (id === undefined || id === null || id === '') {
|
|
50
|
+
throw new AdapterError('GitLab adapter requires config.projectId.', {
|
|
51
|
+
remediation: 'Re-run `openspecpm init`; projectId is the numeric ID or "owner/repo".',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return encodeURIComponent(String(id));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async #req(method, path, opts) {
|
|
58
|
+
await this.#bucket.take();
|
|
59
|
+
return this.#http.request(method, path, opts);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async doctor() {
|
|
63
|
+
if (!process.env.GITLAB_TOKEN) {
|
|
64
|
+
return [{ ok: false, msg: 'GITLAB_TOKEN not set.', remediation: 'Create a PAT with `api` scope at gitlab.com/-/user_settings/personal_access_tokens.' }];
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const me = await this.#req('GET', '/user');
|
|
68
|
+
return [{ ok: true, msg: `Authenticated as ${me.username ?? me.email}` }];
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return [{ ok: false, msg: `Auth failed: ${err.message}`, remediation: err.remediation }];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async init() {
|
|
75
|
+
if (!process.env.GITLAB_TOKEN) {
|
|
76
|
+
throw new AdapterError('GITLAB_TOKEN is required.', { remediation: 'Export a PAT with api scope.' });
|
|
77
|
+
}
|
|
78
|
+
return this.capabilities();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async createEpic(feature) {
|
|
82
|
+
// Treat as a labeled issue if Premium Epics not available.
|
|
83
|
+
return this.createWorkItem(null, { title: `Epic: ${feature.name}`, body: feature.summary ?? '', type: 'epic' });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async createWorkItem(epic, task /*, opts */) {
|
|
87
|
+
const labels = ['openspec'];
|
|
88
|
+
if (task.type === 'epic') labels.push('openspec-epic');
|
|
89
|
+
else labels.push('openspec-task');
|
|
90
|
+
if (epic?.feature) labels.push(`openspec:${epic.feature}`);
|
|
91
|
+
|
|
92
|
+
const data = await this.#req('POST', `/projects/${this.#project()}/issues`, {
|
|
93
|
+
body: {
|
|
94
|
+
title: task.title,
|
|
95
|
+
description: task.body ?? '',
|
|
96
|
+
labels: labels.join(','),
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const ref = { adapter: 'gitlab', id: String(data.iid), url: data.web_url };
|
|
100
|
+
|
|
101
|
+
if (epic?.id && task.type !== 'epic') {
|
|
102
|
+
try {
|
|
103
|
+
await this.linkWorkItems(epic, ref, 'relates_to');
|
|
104
|
+
} catch {
|
|
105
|
+
// Non-fatal
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return ref;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async linkWorkItems(parent, child, type = 'relates_to') {
|
|
112
|
+
await this.#req('POST', `/projects/${this.#project()}/issues/${child.id}/links`, {
|
|
113
|
+
body: {
|
|
114
|
+
target_project_id: this.config.projectId,
|
|
115
|
+
target_issue_iid: parent.id,
|
|
116
|
+
link_type: type,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async addProgressComment(item, body) {
|
|
122
|
+
await this.#req('POST', `/projects/${this.#project()}/issues/${item.id}/notes`, {
|
|
123
|
+
body: { body },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async updateWorkItem(item, patch) {
|
|
128
|
+
const body = {};
|
|
129
|
+
if (patch.title) body.title = patch.title;
|
|
130
|
+
if (patch.description) body.description = patch.description;
|
|
131
|
+
if (patch.addLabels) body.add_labels = patch.addLabels.join(',');
|
|
132
|
+
if (patch.removeLabels) body.remove_labels = patch.removeLabels.join(',');
|
|
133
|
+
if (patch.assignee) body.assignee_ids = [patch.assignee];
|
|
134
|
+
if (patch.milestoneId) body.milestone_id = patch.milestoneId; // sprint
|
|
135
|
+
if (patch.weight !== undefined) body.weight = patch.weight; // story points
|
|
136
|
+
if (!Object.keys(body).length) return;
|
|
137
|
+
await this.#req('PUT', `/projects/${this.#project()}/issues/${item.id}`, { body });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async closeWorkItem(item, resolution) {
|
|
141
|
+
await this.#req('PUT', `/projects/${this.#project()}/issues/${item.id}`, {
|
|
142
|
+
body: { state_event: 'close' },
|
|
143
|
+
});
|
|
144
|
+
if (resolution) await this.addProgressComment(item, resolution);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async getWorkItem(item) {
|
|
148
|
+
const data = await this.#req('GET', `/projects/${this.#project()}/issues/${item.id}`);
|
|
149
|
+
return {
|
|
150
|
+
ref: { adapter: 'gitlab', id: String(data.iid), url: data.web_url },
|
|
151
|
+
title: data.title,
|
|
152
|
+
status: STATE_TO_NORMALIZED(data.state),
|
|
153
|
+
labels: data.labels ?? [],
|
|
154
|
+
assignee: data.assignee?.username ?? null,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async listWorkItems(query = {}) {
|
|
159
|
+
const data = await this.#req('GET', `/projects/${this.#project()}/issues`, {
|
|
160
|
+
query: { labels: query.label ?? 'openspec', per_page: query.limit ?? 50, state: query.state },
|
|
161
|
+
});
|
|
162
|
+
return (data ?? []).map((d) => ({
|
|
163
|
+
ref: { adapter: 'gitlab', id: String(d.iid), url: d.web_url },
|
|
164
|
+
title: d.title,
|
|
165
|
+
status: STATE_TO_NORMALIZED(d.state),
|
|
166
|
+
labels: d.labels ?? [],
|
|
167
|
+
assignee: d.assignee?.username ?? null,
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { GitHubAdapter } from './github.js';
|
|
2
|
+
import { AzureAdapter } from './azure.js';
|
|
3
|
+
import { JiraAdapter } from './jira.js';
|
|
4
|
+
import { LinearAdapter } from './linear.js';
|
|
5
|
+
import { GitLabAdapter } from './gitlab.js';
|
|
6
|
+
import { AdapterError } from './base.js';
|
|
7
|
+
|
|
8
|
+
const REGISTRY = {
|
|
9
|
+
github: GitHubAdapter,
|
|
10
|
+
azure: AzureAdapter,
|
|
11
|
+
jira: JiraAdapter,
|
|
12
|
+
linear: LinearAdapter,
|
|
13
|
+
gitlab: GitLabAdapter,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const ALIASES = {
|
|
17
|
+
gh: 'github',
|
|
18
|
+
ado: 'azure',
|
|
19
|
+
'azure-devops': 'azure',
|
|
20
|
+
atlassian: 'jira',
|
|
21
|
+
gl: 'gitlab',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Plugin registration hook. Third-party adapters register via:
|
|
25
|
+
// import { registerAdapter } from 'openspecpm/cli/src/adapters/index.js';
|
|
26
|
+
// registerAdapter('myname', MyAdapterClass, { aliases: ['mn'] });
|
|
27
|
+
export function registerAdapter(name, ctor, { aliases = [] } = {}) {
|
|
28
|
+
if (REGISTRY[name]) throw new AdapterError(`Adapter "${name}" already registered.`);
|
|
29
|
+
REGISTRY[name] = ctor;
|
|
30
|
+
for (const a of aliases) ALIASES[a] = name;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function listAdapters() {
|
|
34
|
+
return Object.keys(REGISTRY);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveAdapter(name) {
|
|
38
|
+
if (!name) return name;
|
|
39
|
+
const key = name.toLowerCase();
|
|
40
|
+
return ALIASES[key] ?? key;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function loadAdapter(name, config = {}) {
|
|
44
|
+
const resolved = resolveAdapter(name);
|
|
45
|
+
const Cls = REGISTRY[resolved];
|
|
46
|
+
if (!Cls) {
|
|
47
|
+
throw new AdapterError(`Unknown adapter: ${name}`, {
|
|
48
|
+
remediation: `Pick one of: ${listAdapters().join(', ')} (aliases: ${Object.keys(ALIASES).join(', ')})`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return new Cls(config);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export { AdapterError };
|
|
@@ -0,0 +1,228 @@
|
|
|
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_PREFIX = '/rest/api/3';
|
|
6
|
+
|
|
7
|
+
const STATE_TO_NORMALIZED = (s) => {
|
|
8
|
+
const v = (s ?? '').toLowerCase();
|
|
9
|
+
if (['done', 'closed', 'resolved'].includes(v)) return 'closed';
|
|
10
|
+
if (['in progress', 'in review'].includes(v)) return 'in_progress';
|
|
11
|
+
if (['blocked'].includes(v)) return 'blocked';
|
|
12
|
+
return 'open';
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function adfFromText(text) {
|
|
16
|
+
return {
|
|
17
|
+
type: 'doc',
|
|
18
|
+
version: 1,
|
|
19
|
+
content: [
|
|
20
|
+
{
|
|
21
|
+
type: 'paragraph',
|
|
22
|
+
content: text ? [{ type: 'text', text }] : [],
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class JiraAdapter extends Adapter {
|
|
29
|
+
#http;
|
|
30
|
+
#bucket;
|
|
31
|
+
|
|
32
|
+
constructor(config = {}, { fetch: fetchImpl, http } = {}) {
|
|
33
|
+
super(config);
|
|
34
|
+
this.#bucket = new TokenBucket(PRESETS.jira);
|
|
35
|
+
if (http) {
|
|
36
|
+
this.#http = http;
|
|
37
|
+
} else {
|
|
38
|
+
const email = process.env.JIRA_EMAIL;
|
|
39
|
+
const token = process.env.JIRA_API_TOKEN;
|
|
40
|
+
this.#http = new HttpClient({
|
|
41
|
+
baseUrl: config.baseUrl || 'https://placeholder.invalid',
|
|
42
|
+
auth: email && token ? basicAuth(email, token) : null,
|
|
43
|
+
fetch: fetchImpl,
|
|
44
|
+
remediationHint: 'Set JIRA_EMAIL and JIRA_API_TOKEN; verify project key + base URL.',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get name() {
|
|
50
|
+
return 'jira';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
capabilities() {
|
|
54
|
+
return {
|
|
55
|
+
hierarchyDepth: 3,
|
|
56
|
+
supportsSubIssues: true,
|
|
57
|
+
supportsSprints: true,
|
|
58
|
+
supportsLabels: true,
|
|
59
|
+
fieldMap: { epic: 'Epic', story: 'Story', subtask: 'Sub-task' },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#projectKey() {
|
|
64
|
+
const key = this.config.projectKey;
|
|
65
|
+
if (!key) {
|
|
66
|
+
throw new AdapterError('Jira adapter requires config.projectKey.', {
|
|
67
|
+
remediation: 'Re-run `openspecpm init` and supply the project key (e.g. "PROJ").',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return key;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async #req(method, path, opts) {
|
|
74
|
+
await this.#bucket.take();
|
|
75
|
+
return this.#http.request(method, `${API_PREFIX}${path}`, opts);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async doctor() {
|
|
79
|
+
const findings = [];
|
|
80
|
+
if (!process.env.JIRA_EMAIL || !process.env.JIRA_API_TOKEN) {
|
|
81
|
+
findings.push({
|
|
82
|
+
ok: false,
|
|
83
|
+
msg: 'JIRA_EMAIL and/or JIRA_API_TOKEN not set.',
|
|
84
|
+
remediation: 'Create an API token at id.atlassian.com/manage-profile/security/api-tokens and export both vars.',
|
|
85
|
+
});
|
|
86
|
+
return findings;
|
|
87
|
+
}
|
|
88
|
+
if (!this.config.baseUrl) {
|
|
89
|
+
findings.push({ ok: false, msg: 'baseUrl missing from config.', remediation: 'Re-run `openspecpm init`.' });
|
|
90
|
+
return findings;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const me = await this.#req('GET', '/myself');
|
|
94
|
+
findings.push({ ok: true, msg: `Authenticated as ${me.emailAddress ?? me.displayName ?? me.accountId}` });
|
|
95
|
+
} catch (err) {
|
|
96
|
+
findings.push({
|
|
97
|
+
ok: false,
|
|
98
|
+
msg: `Auth failed: ${err.message}`,
|
|
99
|
+
remediation: err.remediation ?? 'Verify email + API token + base URL.',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return findings;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async init() {
|
|
106
|
+
if (!process.env.JIRA_EMAIL || !process.env.JIRA_API_TOKEN) {
|
|
107
|
+
throw new AdapterError('JIRA_EMAIL and JIRA_API_TOKEN are required.', {
|
|
108
|
+
remediation: 'Set both env vars before running sync.',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return this.capabilities();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async #createIssue({ summary, description, issuetype, parent, labels }) {
|
|
115
|
+
const body = {
|
|
116
|
+
fields: {
|
|
117
|
+
project: { key: this.#projectKey() },
|
|
118
|
+
summary,
|
|
119
|
+
description: adfFromText(description ?? ''),
|
|
120
|
+
issuetype: { name: issuetype },
|
|
121
|
+
labels: labels ?? [],
|
|
122
|
+
...(parent ? { parent: { key: parent } } : {}),
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
const data = await this.#req('POST', '/issue', { body });
|
|
126
|
+
const url = this.config.baseUrl ? `${this.config.baseUrl.replace(/\/$/, '')}/browse/${data.key}` : data.self;
|
|
127
|
+
return { adapter: 'jira', id: data.key, url, raw: data };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async createEpic(feature) {
|
|
131
|
+
return this.#createIssue({
|
|
132
|
+
summary: feature.name,
|
|
133
|
+
description: feature.summary ?? '',
|
|
134
|
+
issuetype: 'Epic',
|
|
135
|
+
labels: ['openspec', `openspec-${feature.name}`],
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async createWorkItem(epic, task /*, opts */) {
|
|
140
|
+
const issuetype = task.type ?? 'Story';
|
|
141
|
+
const ref = await this.#createIssue({
|
|
142
|
+
summary: task.title,
|
|
143
|
+
description: task.body ?? '',
|
|
144
|
+
issuetype,
|
|
145
|
+
parent: issuetype === 'Sub-task' ? epic.id : undefined,
|
|
146
|
+
labels: ['openspec', `openspec-${epic.feature ?? ''}`],
|
|
147
|
+
});
|
|
148
|
+
if (epic?.id && issuetype !== 'Sub-task') {
|
|
149
|
+
try {
|
|
150
|
+
await this.linkWorkItems(epic, ref, 'Relates');
|
|
151
|
+
} catch {
|
|
152
|
+
// Non-fatal: Epic-link customField IDs vary across instances.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return ref;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async linkWorkItems(parent, child, type = 'Relates') {
|
|
159
|
+
await this.#req('POST', '/issueLink', {
|
|
160
|
+
body: {
|
|
161
|
+
type: { name: type },
|
|
162
|
+
inwardIssue: { key: child.id },
|
|
163
|
+
outwardIssue: { key: parent.id },
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async addProgressComment(item, body) {
|
|
169
|
+
await this.#req('POST', `/issue/${encodeURIComponent(item.id)}/comment`, {
|
|
170
|
+
body: { body: adfFromText(body) },
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async updateWorkItem(item, patch) {
|
|
175
|
+
const fields = {};
|
|
176
|
+
if (patch.title) fields.summary = patch.title;
|
|
177
|
+
if (patch.description) fields.description = adfFromText(patch.description);
|
|
178
|
+
if (patch.assignee) fields.assignee = { accountId: patch.assignee };
|
|
179
|
+
if (patch.addLabels) fields.labels = patch.addLabels;
|
|
180
|
+
if (Object.keys(fields).length) {
|
|
181
|
+
await this.#req('PUT', `/issue/${encodeURIComponent(item.id)}`, { body: { fields } });
|
|
182
|
+
}
|
|
183
|
+
if (patch.transition) {
|
|
184
|
+
await this.#req('POST', `/issue/${encodeURIComponent(item.id)}/transitions`, {
|
|
185
|
+
body: { transition: { id: String(patch.transition) } },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async closeWorkItem(item, resolution) {
|
|
191
|
+
const transitions = await this.#req('GET', `/issue/${encodeURIComponent(item.id)}/transitions`);
|
|
192
|
+
const done = (transitions.transitions ?? []).find((t) => /done|closed|resolve/i.test(t.name));
|
|
193
|
+
if (!done) {
|
|
194
|
+
throw new AdapterError('No Done/Closed transition available for this issue.', {
|
|
195
|
+
remediation: 'Update the workflow or call updateWorkItem({ transition: <id> }) directly.',
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
await this.updateWorkItem(item, { transition: done.id });
|
|
199
|
+
if (resolution) await this.addProgressComment(item, resolution);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async getWorkItem(item) {
|
|
203
|
+
const data = await this.#req('GET', `/issue/${encodeURIComponent(item.id)}`, {
|
|
204
|
+
query: { fields: 'summary,status,labels,assignee' },
|
|
205
|
+
});
|
|
206
|
+
return {
|
|
207
|
+
ref: { adapter: 'jira', id: data.key, url: this.config.baseUrl ? `${this.config.baseUrl.replace(/\/$/, '')}/browse/${data.key}` : data.self },
|
|
208
|
+
title: data.fields?.summary,
|
|
209
|
+
status: STATE_TO_NORMALIZED(data.fields?.status?.name),
|
|
210
|
+
labels: data.fields?.labels ?? [],
|
|
211
|
+
assignee: data.fields?.assignee?.emailAddress ?? data.fields?.assignee?.accountId,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async listWorkItems(query = {}) {
|
|
216
|
+
const jql = query.jql ?? `project = "${this.#projectKey()}" AND labels = "openspec" ORDER BY created DESC`;
|
|
217
|
+
const data = await this.#req('POST', '/search/jql', {
|
|
218
|
+
body: { jql, fields: ['summary', 'status', 'labels', 'assignee'], maxResults: query.limit ?? 50 },
|
|
219
|
+
});
|
|
220
|
+
return (data.issues ?? []).map((d) => ({
|
|
221
|
+
ref: { adapter: 'jira', id: d.key, url: this.config.baseUrl ? `${this.config.baseUrl.replace(/\/$/, '')}/browse/${d.key}` : d.self },
|
|
222
|
+
title: d.fields?.summary,
|
|
223
|
+
status: STATE_TO_NORMALIZED(d.fields?.status?.name),
|
|
224
|
+
labels: d.fields?.labels ?? [],
|
|
225
|
+
assignee: d.fields?.assignee?.emailAddress ?? d.fields?.assignee?.accountId,
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
}
|