@falldownthesystem/devtool 0.4.0 → 0.5.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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2025 FallDownTheSystem
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -5,8 +5,7 @@ A terminal-first CLI for working with Bitbucket PRs, Jira tickets, and Confluenc
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- npm install
9
- npm link
8
+ npm install -g @falldownthesystem/devtool
10
9
  ```
11
10
 
12
11
  This gives you the `dev` command globally. On first run, it'll walk you through connecting your Bitbucket, Jira, and Confluence accounts.
@@ -54,7 +53,7 @@ Stored via the `conf` package in your OS config directory. You'll need:
54
53
 
55
54
  - **Bitbucket**: workspace, default repo, email, app password
56
55
  - **Jira**: base URL, API token, default project
57
- - **Confluence**: uses the same Jira base URL and token
56
+ - **Confluence**: base URL, email, API token, default space. Cloud and Server/Data Center are detected from the base URL. For Server/Data Center, include any context path (`https://company.com/confluence`) and leave the email empty to use a personal access token, or set it to your username for Basic auth.
58
57
 
59
58
  ## License
60
59
 
package/package.json CHANGED
@@ -1,17 +1,35 @@
1
1
  {
2
2
  "name": "@falldownthesystem/devtool",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "CLI for Bitbucket PRs, Jira tickets, Confluence pages, and Service Desk tickets",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "dev": "src/index.js"
8
8
  },
9
+ "files": [
10
+ "src/"
11
+ ],
9
12
  "scripts": {
10
- "start": "node src/index.js"
13
+ "start": "node src/index.js",
14
+ "test": "node --test test/config.test.js test/confluence.test.js test/service-desk.test.js"
11
15
  },
12
- "keywords": [],
13
- "author": "",
16
+ "keywords": [
17
+ "cli",
18
+ "bitbucket",
19
+ "jira",
20
+ "confluence",
21
+ "pull-request",
22
+ "devtools"
23
+ ],
24
+ "author": "FallDownTheSystem",
14
25
  "license": "ISC",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/FallDownTheSystem/devtool.git"
29
+ },
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
15
33
  "dependencies": {
16
34
  "@clack/prompts": "^1.0.0",
17
35
  "cli-table3": "^0.6.5",
@@ -1,63 +1,171 @@
1
- import { get } from '../config/store.js';
2
-
3
- function baseUrl() {
4
- return `${get('confluence.baseUrl')}/wiki`;
5
- }
6
-
7
- function authHeader() {
8
- const email = get('confluence.email');
9
- const token = get('confluence.token');
10
- return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
11
- }
12
-
13
- async function request(path, params = {}) {
14
- const url = new URL(path.startsWith('http') ? path : `${baseUrl()}${path}`);
15
- for (const [k, v] of Object.entries(params)) {
16
- if (v !== undefined && v !== null) url.searchParams.set(k, v);
17
- }
18
-
19
- const res = await fetch(url, {
20
- headers: {
21
- 'Authorization': authHeader(),
22
- 'Accept': 'application/json',
23
- },
24
- });
25
-
26
- if (!res.ok) {
27
- const err = new Error(`Confluence API error: ${res.status} ${res.statusText}`);
28
- err.status = res.status;
29
- err.url = url.toString();
30
- throw err;
31
- }
32
-
33
- return res.json();
34
- }
35
-
36
- async function paginateV2(path, params = {}) {
37
- const results = [];
38
- let data = await request(path, params);
39
- results.push(...(data.results || []));
40
-
41
- while (data._links?.next) {
42
- data = await request(data._links.next);
43
- results.push(...(data.results || []));
44
- }
45
-
46
- return results;
47
- }
48
-
49
- export async function listSpaces() {
50
- return paginateV2('/api/v2/spaces');
51
- }
52
-
53
- export async function getPage(id) {
54
- return request(`/api/v2/pages/${id}`, { 'body-format': 'storage' });
55
- }
56
-
57
- export async function listSpacePages(spaceId) {
58
- return paginateV2(`/api/v2/spaces/${spaceId}/pages`);
59
- }
60
-
61
- export async function searchPages(cql) {
62
- return request('/rest/api/search', { cql });
63
- }
1
+ import { get } from '../config/store.js';
2
+
3
+ const deployments = new Map();
4
+
5
+ // Custom domains hide the atlassian.net hostname, so anything else is probed:
6
+ // Atlassian's edge answers tenant_info anonymously, Server/Data Center does not.
7
+ async function detectDeployment(origin) {
8
+ if (/\.atlassian\.net$/i.test(new URL(origin).hostname)) return 'cloud';
9
+ try {
10
+ const res = await fetch(new URL('/_edge/tenant_info', origin), {
11
+ headers: { 'Accept': 'application/json' },
12
+ });
13
+ if (res.ok && (await res.json()).cloudId) return 'cloud';
14
+ } catch {
15
+ // An unreachable or non-JSON probe means no Atlassian edge; the real request reports connection errors.
16
+ }
17
+ return 'server';
18
+ }
19
+
20
+ async function connect() {
21
+ const base = new URL(get('confluence.baseUrl'));
22
+ if (!deployments.has(base.origin)) deployments.set(base.origin, detectDeployment(base.origin));
23
+ const kind = await deployments.get(base.origin);
24
+ // Cloud always serves Confluence under /wiki; Server/Data Center keeps its own context path.
25
+ const path = kind === 'cloud' ? '/wiki' : base.pathname.replace(/\/+$/, '');
26
+ return { kind, origin: base.origin, path };
27
+ }
28
+
29
+ function apiUrl(conn, path) {
30
+ return new URL(`${conn.origin}${conn.path}${path}`);
31
+ }
32
+
33
+ // Cloud v2 links are origin-relative, Server/Data Center links are relative to the context path.
34
+ function nextUrl(conn, link, current) {
35
+ const contextRelative = conn.kind === 'server'
36
+ && link.startsWith('/') && !link.startsWith('//')
37
+ && !(conn.path && link.startsWith(`${conn.path}/`));
38
+ return contextRelative ? apiUrl(conn, link) : new URL(link, current);
39
+ }
40
+
41
+ // Server/Data Center personal access tokens use Bearer auth; a username switches to Basic.
42
+ function authHeader(conn) {
43
+ const email = get('confluence.email');
44
+ const token = get('confluence.token');
45
+ if (conn.kind === 'server' && !email) return `Bearer ${token}`;
46
+ return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
47
+ }
48
+
49
+ async function request(conn, url, params = {}) {
50
+ if (url.origin !== conn.origin) {
51
+ throw new Error('Confluence pagination points to a different site. Check confluence.baseUrl.');
52
+ }
53
+ for (const [k, v] of Object.entries(params)) {
54
+ if (v !== undefined && v !== null) url.searchParams.set(k, v);
55
+ }
56
+
57
+ const res = await fetch(url, {
58
+ headers: {
59
+ 'Authorization': authHeader(conn),
60
+ 'Accept': 'application/json',
61
+ },
62
+ });
63
+
64
+ if (!res.ok) {
65
+ const err = new Error(`Confluence API error: ${res.status} ${res.statusText}`);
66
+ err.status = res.status;
67
+ err.url = url.toString();
68
+ throw err;
69
+ }
70
+
71
+ // Server/Data Center answers rejected credentials with a login page instead of a 401.
72
+ if (!res.headers.get('content-type')?.includes('json')) {
73
+ const err = new Error('Confluence returned a non-JSON response. Check confluence.baseUrl and confluence.token.');
74
+ err.url = url.toString();
75
+ throw err;
76
+ }
77
+
78
+ return res.json();
79
+ }
80
+
81
+ async function paginate(conn, path, params = {}) {
82
+ const results = [];
83
+ let url = apiUrl(conn, path);
84
+ let data = await request(conn, url, params);
85
+ results.push(...(data.results || []));
86
+
87
+ while (data._links?.next) {
88
+ url = nextUrl(conn, data._links.next, url);
89
+ data = await request(conn, url);
90
+ results.push(...(data.results || []));
91
+ }
92
+
93
+ return results;
94
+ }
95
+
96
+ // Server/Data Center v1 content is reshaped to the Cloud v2 fields the formatters read.
97
+ function serverSpace(space) {
98
+ return {
99
+ id: String(space.id),
100
+ key: space.key,
101
+ name: space.name,
102
+ type: space.type,
103
+ status: space.status,
104
+ };
105
+ }
106
+
107
+ function serverPage(page) {
108
+ return {
109
+ id: page.id,
110
+ title: page.title,
111
+ status: page.status,
112
+ spaceId: page.space?.id != null ? String(page.space.id) : undefined,
113
+ createdAt: page.history?.createdDate,
114
+ version: page.version ? { number: page.version.number } : undefined,
115
+ body: page.body,
116
+ };
117
+ }
118
+
119
+ // The content listing silently returns nothing for an unknown space key, so the space is looked up first.
120
+ async function serverSpaceKey(conn, { spaceId, spaceKey }) {
121
+ if (!spaceKey) return (await listSpaces()).find((s) => s.id === String(spaceId))?.key;
122
+ try {
123
+ return (await request(conn, apiUrl(conn, `/rest/api/space/${encodeURIComponent(spaceKey)}`))).key;
124
+ } catch (err) {
125
+ if (err.status === 404) return undefined;
126
+ throw err;
127
+ }
128
+ }
129
+
130
+ export async function listSpaces() {
131
+ const conn = await connect();
132
+ if (conn.kind === 'cloud') return paginate(conn, '/api/v2/spaces');
133
+ return (await paginate(conn, '/rest/api/space', { limit: 100 })).map(serverSpace);
134
+ }
135
+
136
+ export async function getPage(id) {
137
+ const conn = await connect();
138
+ if (conn.kind === 'cloud') {
139
+ return request(conn, apiUrl(conn, `/api/v2/pages/${id}`), { 'body-format': 'storage' });
140
+ }
141
+ return serverPage(await request(conn, apiUrl(conn, `/rest/api/content/${id}`), {
142
+ expand: 'body.storage,version,space,history',
143
+ }));
144
+ }
145
+
146
+ // Cloud lists pages by space ID and Server/Data Center by space key; returns null for an unknown space.
147
+ export async function listSpacePages({ spaceId, spaceKey }) {
148
+ const conn = await connect();
149
+ if (conn.kind === 'cloud') {
150
+ const id = spaceId
151
+ ?? (await request(conn, apiUrl(conn, '/api/v2/spaces'), { keys: spaceKey })).results?.[0]?.id;
152
+ if (!id) return null;
153
+ return paginate(conn, `/api/v2/spaces/${id}/pages`);
154
+ }
155
+
156
+ const key = await serverSpaceKey(conn, { spaceId, spaceKey });
157
+ if (!key) return null;
158
+ const pages = await paginate(conn, '/rest/api/content', {
159
+ spaceKey: key,
160
+ type: 'page',
161
+ expand: 'version,history',
162
+ limit: 100,
163
+ });
164
+ return pages.map(serverPage);
165
+ }
166
+
167
+ export async function searchPages(cql) {
168
+ const conn = await connect();
169
+ const result = await request(conn, apiUrl(conn, '/rest/api/search'), { cql });
170
+ return { ...result, totalSize: result.totalSize ?? result.totalCount };
171
+ }
@@ -1,119 +1,181 @@
1
- import { get } from '../config/store.js';
2
-
3
- function baseUrl() {
4
- return get('serviceDesk.baseUrl');
5
- }
6
-
7
- function authHeader() {
8
- const email = get('serviceDesk.email');
9
- const token = get('serviceDesk.token');
10
- return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
11
- }
12
-
13
- async function request(path, params = {}, extraHeaders = {}) {
14
- const url = new URL(path.startsWith('http') ? path : `${baseUrl()}${path}`);
15
- for (const [k, v] of Object.entries(params)) {
16
- if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v);
17
- }
18
-
19
- const res = await fetch(url, {
20
- headers: {
21
- 'Authorization': authHeader(),
22
- 'Accept': 'application/json',
23
- ...extraHeaders,
24
- },
25
- });
26
-
27
- if (!res.ok) {
28
- const err = new Error(`Service Desk API error: ${res.status} ${res.statusText}`);
29
- err.status = res.status;
30
- err.url = url.toString();
31
- throw err;
32
- }
33
-
34
- return res.json();
35
- }
36
-
37
- export async function getIssue(issueKey) {
38
- return request(`/rest/api/3/issue/${encodeURIComponent(issueKey)}`, {
39
- expand: 'renderedFields',
40
- });
41
- }
42
-
43
- export async function getIssueComments(issueKey) {
44
- const data = await request(`/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`, {
45
- orderBy: 'created',
46
- maxResults: 100,
47
- });
48
- return data.comments || [];
49
- }
50
-
51
- export async function searchIssues(jql, maxResults = 50, nextPageToken = '') {
52
- const fields = 'summary,status,assignee,priority,updated,issuetype,reporter,customfield_10010';
53
- const data = await request('/rest/api/3/search/jql', {
54
- jql,
55
- fields,
56
- maxResults,
57
- nextPageToken,
58
- });
59
- return {
60
- issues: data.issues || [],
61
- nextPageToken: data.nextPageToken || null,
62
- isLast: data.isLast !== false,
63
- maxResults,
64
- };
65
- }
66
-
67
- export async function getMyIssues() {
68
- const project = get('serviceDesk.defaultProject');
69
- const jql = project
70
- ? `project = ${project} AND assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC`
71
- : `assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC`;
72
- return searchIssues(jql);
73
- }
74
-
75
- let _serviceDeskIdCache = null;
76
-
77
- async function getServiceDeskId(projectKey) {
78
- if (_serviceDeskIdCache && _serviceDeskIdCache.projectKey === projectKey) {
79
- return _serviceDeskIdCache.id;
80
- }
81
- const data = await request('/rest/servicedeskapi/servicedesk', { start: 0, limit: 100 }, { 'X-ExperimentalApi': 'opt-in' });
82
- const match = (data.values || []).find((sd) => sd.projectKey === projectKey);
83
- if (!match) {
84
- throw new Error(`No Service Desk found for project ${projectKey}`);
85
- }
86
- _serviceDeskIdCache = { projectKey, id: match.id };
87
- return match.id;
88
- }
89
-
90
- export async function listQueues() {
91
- const projectKey = get('serviceDesk.defaultProject');
92
- if (!projectKey) throw new Error('serviceDesk.defaultProject is not configured');
93
- const sdId = await getServiceDeskId(projectKey);
94
- const data = await request(`/rest/servicedeskapi/servicedesk/${sdId}/queue`, { start: 0, limit: 100 }, { 'X-ExperimentalApi': 'opt-in' });
95
- return (data.values || []).map((q) => ({ id: q.id, name: q.name, jql: q.jql }));
96
- }
97
-
98
- export async function findQueue(nameOrId) {
99
- const queues = await listQueues();
100
- const idMatch = queues.find((q) => String(q.id) === String(nameOrId));
101
- if (idMatch) return idMatch;
102
- const lower = nameOrId.toLowerCase();
103
- const exact = queues.find((q) => q.name.toLowerCase() === lower);
104
- if (exact) return exact;
105
- const partial = queues.filter((q) => q.name.toLowerCase().includes(lower));
106
- if (partial.length === 1) return partial[0];
107
- if (partial.length > 1) {
108
- const err = new Error(`Ambiguous queue "${nameOrId}" — matches: ${partial.map((q) => q.name).join(', ')}`);
109
- err.candidates = partial;
110
- throw err;
111
- }
112
- throw new Error(`No queue found matching "${nameOrId}"`);
113
- }
114
-
115
- export async function searchByQueue(nameOrId, maxResults = 50, nextPageToken = '') {
116
- const queue = await findQueue(nameOrId);
117
- const result = await searchIssues(queue.jql, maxResults, nextPageToken);
118
- return { ...result, queue };
119
- }
1
+ import { get } from '../config/store.js';
2
+
3
+ function configurationError(reason) {
4
+ return new Error(`Service Desk configuration error: ${reason}. Check serviceDesk.baseUrl; use the canonical https://<site>.atlassian.net URL.`);
5
+ }
6
+
7
+ function baseUrl() {
8
+ let url;
9
+ try {
10
+ url = new URL(get('serviceDesk.baseUrl'));
11
+ } catch {
12
+ throw configurationError('invalid base URL');
13
+ }
14
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || /[^/]/.test(url.pathname)) {
15
+ throw configurationError('the base URL must be an HTTPS site URL without a path, query, or credentials');
16
+ }
17
+ return new URL(url.origin);
18
+ }
19
+
20
+ function authHeader() {
21
+ const email = get('serviceDesk.email');
22
+ const token = get('serviceDesk.token');
23
+ return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
24
+ }
25
+
26
+ async function fetchJson(url, headers = {}) {
27
+ const res = await fetch(url, {
28
+ redirect: 'manual',
29
+ headers: { 'Accept': 'application/json', ...headers },
30
+ });
31
+
32
+ if (res.redirected || (res.status >= 300 && res.status < 400)) {
33
+ throw configurationError('the API returned a redirect; authentication may not reach the intended site');
34
+ }
35
+ if (res.url && new URL(res.url).origin !== url.origin) {
36
+ throw configurationError('the API response came from an unexpected host');
37
+ }
38
+ if (!res.ok) {
39
+ const err = new Error(`Service Desk API error: ${res.status} ${res.statusText}`);
40
+ err.status = res.status;
41
+ err.url = url.toString();
42
+ throw err;
43
+ }
44
+
45
+ const contentType = res.headers.get('content-type')?.split(';')[0].trim().toLowerCase();
46
+ if (!/^application\/(?:[\w.+-]+\+)?json$/.test(contentType || '')) {
47
+ throw configurationError('expected a JSON API response, but received a different content type');
48
+ }
49
+ let data;
50
+ try {
51
+ data = await res.json();
52
+ } catch {
53
+ throw configurationError('the API response contains invalid JSON');
54
+ }
55
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
56
+ throw configurationError('unexpected API response structure');
57
+ }
58
+ return data;
59
+ }
60
+
61
+ async function request(path, params = {}, { extraHeaders = {}, validate = () => true } = {}) {
62
+ const site = baseUrl();
63
+ if (!site.hostname.endsWith('.atlassian.net')) {
64
+ const info = await fetchJson(new URL('/rest/api/2/serverInfo', site));
65
+ let canonical;
66
+ try {
67
+ canonical = new URL(info.baseUrl);
68
+ } catch {
69
+ throw configurationError('serverInfo did not return a valid canonical base URL');
70
+ }
71
+ if (canonical.origin !== site.origin || canonical.pathname.replace(/\/+$/, '') !== '') {
72
+ throw configurationError(`serverInfo identifies a different canonical base URL: ${canonical.origin}${canonical.pathname.replace(/\/+$/, '')}`);
73
+ }
74
+ }
75
+
76
+ const url = new URL(path, site);
77
+ for (const [k, v] of Object.entries(params)) {
78
+ if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v);
79
+ }
80
+ const data = await fetchJson(url, { 'Authorization': authHeader(), ...extraHeaders });
81
+ if (!validate(data)) throw configurationError('unexpected API response structure');
82
+ return data;
83
+ }
84
+
85
+ export async function getIssue(issueKey) {
86
+ return request(`/rest/api/3/issue/${encodeURIComponent(issueKey)}`, {
87
+ expand: 'renderedFields',
88
+ }, {
89
+ validate: (data) => typeof data.key === 'string' && data.fields && typeof data.fields === 'object' && !Array.isArray(data.fields),
90
+ });
91
+ }
92
+
93
+ export async function getIssueComments(issueKey) {
94
+ const data = await request(`/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`, {
95
+ orderBy: 'created',
96
+ maxResults: 100,
97
+ }, {
98
+ validate: (data) => Array.isArray(data.comments),
99
+ });
100
+ return data.comments;
101
+ }
102
+
103
+ export async function searchIssues(jql, maxResults = 50, nextPageToken = '') {
104
+ const fields = 'summary,status,assignee,priority,updated,issuetype,reporter,customfield_10010';
105
+ const data = await request('/rest/api/3/search/jql', {
106
+ jql,
107
+ fields,
108
+ maxResults,
109
+ nextPageToken,
110
+ }, {
111
+ validate: (data) => Array.isArray(data.issues)
112
+ && (data.isLast === undefined || typeof data.isLast === 'boolean')
113
+ && (data.nextPageToken == null || typeof data.nextPageToken === 'string'),
114
+ });
115
+ return {
116
+ issues: data.issues,
117
+ nextPageToken: data.nextPageToken || null,
118
+ isLast: data.isLast !== false,
119
+ maxResults,
120
+ };
121
+ }
122
+
123
+ export async function getMyIssues() {
124
+ const project = get('serviceDesk.defaultProject');
125
+ const jql = project
126
+ ? `project = ${project} AND assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC`
127
+ : `assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC`;
128
+ return searchIssues(jql);
129
+ }
130
+
131
+ let _serviceDeskIdCache = null;
132
+
133
+ async function getServiceDeskId(projectKey) {
134
+ if (_serviceDeskIdCache && _serviceDeskIdCache.projectKey === projectKey) {
135
+ return _serviceDeskIdCache.id;
136
+ }
137
+ const data = await request('/rest/servicedeskapi/servicedesk', { start: 0, limit: 100 }, {
138
+ extraHeaders: { 'X-ExperimentalApi': 'opt-in' },
139
+ validate: (data) => Array.isArray(data.values),
140
+ });
141
+ const match = data.values.find((sd) => sd.projectKey === projectKey);
142
+ if (!match) {
143
+ throw new Error(`No Service Desk found for project ${projectKey}`);
144
+ }
145
+ _serviceDeskIdCache = { projectKey, id: match.id };
146
+ return match.id;
147
+ }
148
+
149
+ export async function listQueues() {
150
+ const projectKey = get('serviceDesk.defaultProject');
151
+ if (!projectKey) throw new Error('serviceDesk.defaultProject is not configured');
152
+ const sdId = await getServiceDeskId(projectKey);
153
+ const data = await request(`/rest/servicedeskapi/servicedesk/${sdId}/queue`, { start: 0, limit: 100 }, {
154
+ extraHeaders: { 'X-ExperimentalApi': 'opt-in' },
155
+ validate: (data) => Array.isArray(data.values),
156
+ });
157
+ return data.values.map((q) => ({ id: q.id, name: q.name, jql: q.jql }));
158
+ }
159
+
160
+ export async function findQueue(nameOrId) {
161
+ const queues = await listQueues();
162
+ const idMatch = queues.find((q) => String(q.id) === String(nameOrId));
163
+ if (idMatch) return idMatch;
164
+ const lower = nameOrId.toLowerCase();
165
+ const exact = queues.find((q) => q.name.toLowerCase() === lower);
166
+ if (exact) return exact;
167
+ const partial = queues.filter((q) => q.name.toLowerCase().includes(lower));
168
+ if (partial.length === 1) return partial[0];
169
+ if (partial.length > 1) {
170
+ const err = new Error(`Ambiguous queue "${nameOrId}" — matches: ${partial.map((q) => q.name).join(', ')}`);
171
+ err.candidates = partial;
172
+ throw err;
173
+ }
174
+ throw new Error(`No queue found matching "${nameOrId}"`);
175
+ }
176
+
177
+ export async function searchByQueue(nameOrId, maxResults = 50, nextPageToken = '') {
178
+ const queue = await findQueue(nameOrId);
179
+ const result = await searchIssues(queue.jql, maxResults, nextPageToken);
180
+ return { ...result, queue };
181
+ }