ccdeck 3.43.4 → 3.45.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/README.md +79 -18
- package/THIRD_PARTY_NOTICES.md +70 -7
- package/bin/cli/commands.js +613 -0
- package/bin/cli/help.js +26 -3
- package/bin/cli/one-shot.js +2 -2
- package/bin/cli/picker.js +86 -0
- package/bin/cli/startup.js +13 -0
- package/bin/deck.js +16 -1
- package/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +1 -0
- package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
- package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
- package/dist/web/assets/TrafficRadar-D1gZCIwX.js +8 -0
- package/dist/web/assets/UsageHistoryModal-DAhplVju.js +1 -0
- package/dist/web/assets/app-BW3NYUYB.js +318 -0
- package/dist/web/assets/app-C5RY_wZ4.css +1 -0
- package/dist/web/assets/{index-Ch4ks76J.js → index-r8Ymr47Q.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +2 -1
- package/release-notes.json +52 -0
- package/src/server/account-notify.mjs +268 -9
- package/src/server/account-pick.mjs +160 -0
- package/src/server/account-projects.mjs +55 -15
- package/src/server/account-routes.mjs +47 -4
- package/src/server/account-watch.mjs +107 -4
- package/src/server/activity.mjs +3 -2
- package/src/server/args.mjs +112 -2
- package/src/server/autoswitch-failover.mjs +138 -0
- package/src/server/claude-accounts.mjs +127 -16
- package/src/server/claude-dir.mjs +142 -5
- package/src/server/claude-identity.mjs +23 -0
- package/src/server/cli-lines.mjs +224 -0
- package/src/server/codex-cost-usage.mjs +67 -0
- package/src/server/codex-enrichment.mjs +53 -9
- package/src/server/codex-review-policy.d.mts +4 -0
- package/src/server/codex-review-policy.mjs +11 -0
- package/src/server/codex-skills.mjs +146 -0
- package/src/server/codex-watch.mjs +5 -2
- package/src/server/cswap-auto-choice.mjs +53 -0
- package/src/server/cswap-auto-loop.mjs +100 -9
- package/src/server/deck-client.mjs +112 -0
- package/src/server/deck-prefs.mjs +26 -5
- package/src/server/event-pipeline.mjs +19 -3
- package/src/server/event-routes.mjs +15 -0
- package/src/server/exec.mjs +6 -2
- package/src/server/feature-use.mjs +11 -1
- package/src/server/forge-http.mjs +514 -0
- package/src/server/forge-public-fetch.mjs +228 -0
- package/src/server/forge-routes.mjs +154 -0
- package/src/server/github-api.mjs +1369 -0
- package/src/server/gitlab-api.mjs +1271 -0
- package/src/server/index.mjs +23 -3
- package/src/server/installer.mjs +149 -8
- package/src/server/integrations/cache.mjs +42 -0
- package/src/server/integrations/errors.mjs +167 -0
- package/src/server/integrations/forge.mjs +76 -0
- package/src/server/integrations/jira.mjs +1026 -0
- package/src/server/integrations/providers.mjs +11 -0
- package/src/server/integrations/proxy.mjs +314 -0
- package/src/server/integrations/registry.mjs +142 -0
- package/src/server/integrations/routes.mjs +498 -0
- package/src/server/integrations/store.mjs +226 -0
- package/src/server/lan-deck.mjs +7 -0
- package/src/server/lan-elsewhere.d.mts +30 -0
- package/src/server/lan-elsewhere.mjs +137 -0
- package/src/server/lan-engine.mjs +2 -2
- package/src/server/lan-sync.mjs +3 -3
- package/src/server/lan-wire.mjs +3 -3
- package/src/server/login-guard.mjs +162 -0
- package/src/server/login-service.mjs +3 -0
- package/src/server/org-telemetry.mjs +173 -0
- package/src/server/pinned-build.mjs +8 -0
- package/src/server/prefs-routes.mjs +4 -0
- package/src/server/request-gates.mjs +26 -0
- package/src/server/session-read-gate.mjs +5 -3
- package/src/server/session-summary.mjs +281 -0
- package/src/server/skill-hook.mjs +162 -0
- package/src/server/stop-continue-gate.d.mts +8 -0
- package/src/server/stop-continue-gate.mjs +77 -0
- package/src/server/stop-continue.mjs +544 -0
- package/src/server/stop-failure.d.mts +10 -0
- package/src/server/stop-failure.mjs +97 -0
- package/src/server/swap-log.mjs +19 -10
- package/src/server/tool-audit-report.mjs +5 -3
- package/src/server/tool-audit-routes.mjs +5 -3
- package/src/server/tool-audit-skills.mjs +82 -0
- package/src/server/tool-audit.mjs +53 -11
- package/src/server/traffic-radar-capture.mjs +10 -1
- package/src/server/traffic-radar-monitor.mjs +4 -2
- package/src/server/traffic-radar-routes.mjs +13 -0
- package/src/server/traffic-radar-service.mjs +133 -0
- package/src/server/usage-day.mjs +3 -1
- package/dist/web/assets/BrowserWatchModal-nOxhGOO4.js +0 -1
- package/dist/web/assets/RadioPanel-BLfpQ1Iw.js +0 -1
- package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
- package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
- package/dist/web/assets/app-BiJ8TfUl.css +0 -1
- package/dist/web/assets/app-DBsNB-2y.js +0 -312
|
@@ -0,0 +1,1026 @@
|
|
|
1
|
+
// Jira, as an integration provider (#2178): Jira Cloud with an API token, and
|
|
2
|
+
// Jira Server / Data Center 8.14 or newer with a personal access token.
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT READS, AND FROM WHERE.
|
|
5
|
+
// Cloud REST v3 (`/rest/api/3/...`) — search only through the enhanced
|
|
6
|
+
// `/rest/api/3/search/jql` with `nextPageToken`, since the old
|
|
7
|
+
// `/rest/api/3/search` was removed — and Agile 1.0 for boards and
|
|
8
|
+
// sprints. A board's issue lists come from `/rest/software/1.0`
|
|
9
|
+
// (token paged), which replaces Agile 1.0's deprecated lists there,
|
|
10
|
+
// falling back to Agile 1.0 where it is not deployed. Basic auth with
|
|
11
|
+
// the account's email and an API token. A token created "with
|
|
12
|
+
// scopes" is refused on the site itself and answers on
|
|
13
|
+
// `https://api.atlassian.com/ex/jira/{cloudId}`, so validate tries
|
|
14
|
+
// the site first and, when the refusal looks like that, the
|
|
15
|
+
// api.atlassian.com base (found from the site's `/_edge/tenant_info`).
|
|
16
|
+
// Server REST v2 (`/rest/api/2/...`, startAt/maxResults/total paging) and
|
|
17
|
+
// Agile 1.0. `Authorization: Bearer <PAT>`. Personal access tokens
|
|
18
|
+
// arrived in Jira 8.14, so an older server is `incompatible`.
|
|
19
|
+
//
|
|
20
|
+
// Every request goes through the core's proxy (proxy.mjs), which
|
|
21
|
+
// signs with `sign` below and only for the connection's allowed origins. The
|
|
22
|
+
// routes are GET-only and answer normalised shapes (see `normaliseIssue`), so
|
|
23
|
+
// the page draws Cloud and Server with one renderer: descriptions and comments
|
|
24
|
+
// are Jira's own `renderedFields` HTML on both, sanitised in the browser
|
|
25
|
+
// (src/web/jira-html.tsx).
|
|
26
|
+
import { createCache } from "./cache.mjs";
|
|
27
|
+
import { errorResponse, integrationError, IntegrationError } from "./errors.mjs";
|
|
28
|
+
import { createProxy, fetchImage, isLocalHost, isSafeTarget, normaliseBaseUrl, originOf } from "./proxy.mjs";
|
|
29
|
+
import { createRouter } from "./registry.mjs";
|
|
30
|
+
|
|
31
|
+
export const MIN_SERVER_VERSION = [8, 14, 0];
|
|
32
|
+
const ATLASSIAN_API = "https://api.atlassian.com";
|
|
33
|
+
const PAGE = 100;
|
|
34
|
+
/** How many issues a sprint or a kanban column is read to, page by page. A
|
|
35
|
+
* backlog is paged by the reader instead. */
|
|
36
|
+
const SPRINT_CAP = 600;
|
|
37
|
+
|
|
38
|
+
const HOUR = 3600_000;
|
|
39
|
+
const MINUTE = 60_000;
|
|
40
|
+
|
|
41
|
+
// ── form, auth ──────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
function text(value, max = 500) {
|
|
44
|
+
return typeof value === "string" ? value.trim().slice(0, max) : "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The form's values as a candidate connection. `existing` is set on an edit:
|
|
48
|
+
* a token left blank keeps the saved one. */
|
|
49
|
+
export function parseJiraInput(input = {}, existing = null) {
|
|
50
|
+
const deployment = input.deployment === "cloud" ? "cloud" : input.deployment === "server" ? "server" : "";
|
|
51
|
+
if (!deployment) throw integrationError("invalid", "Choose Jira Cloud or Jira Server / Data Center.", { field: "deployment" });
|
|
52
|
+
let typed = text(input.baseUrl, 500);
|
|
53
|
+
// "acme" for a Cloud site is acme.atlassian.net: the one shorthand anybody types.
|
|
54
|
+
if (deployment === "cloud" && /^[a-z0-9][a-z0-9-]*$/i.test(typed)) typed = `${typed}.atlassian.net`;
|
|
55
|
+
const baseUrl = normaliseBaseUrl(typed, { field: "baseUrl", label: deployment === "cloud" ? "your Jira site URL" : "your Jira URL" });
|
|
56
|
+
const kept = existing?.secret && typeof existing.secret === "object" ? existing.secret : null;
|
|
57
|
+
const sameKind = kept && (deployment === "cloud" ? kept.auth === "basic" : kept.auth === "bearer");
|
|
58
|
+
const token = text(input.token, 4096) || (sameKind ? kept.token : "");
|
|
59
|
+
if (!token) throw integrationError("invalid", deployment === "cloud" ? "Enter an API token." : "Enter a personal access token.", { field: "token" });
|
|
60
|
+
if (/\s/.test(token)) throw integrationError("invalid", "The token has a space in it. Paste it again.", { field: "token" });
|
|
61
|
+
if (deployment === "cloud") {
|
|
62
|
+
const email = text(input.email, 320) || (sameKind ? kept.email : "");
|
|
63
|
+
if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw integrationError("invalid", "Enter the email address of your Atlassian account.", { field: "email" });
|
|
64
|
+
return { baseUrl, secret: { auth: "basic", email, token }, meta: { deployment } };
|
|
65
|
+
}
|
|
66
|
+
return { baseUrl, secret: { auth: "bearer", token }, meta: { deployment } };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function signJira(secret) {
|
|
70
|
+
if (secret?.auth === "basic") {
|
|
71
|
+
return { Authorization: `Basic ${Buffer.from(`${secret.email}:${secret.token}`, "utf8").toString("base64")}` };
|
|
72
|
+
}
|
|
73
|
+
if (secret?.auth === "bearer") return { Authorization: `Bearer ${secret.token}` };
|
|
74
|
+
return {};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A refusal Jira answers with a 200. A bad credential on an endpoint that
|
|
79
|
+
* anonymous visitors may read is served as anonymous rather than refused: Cloud
|
|
80
|
+
* and Server say so in `X-Seraph-LoginReason`, and Server, for a bearer token
|
|
81
|
+
* it did not accept, only in `X-AUSERNAME: anonymous` (JRASERVER-78126).
|
|
82
|
+
*/
|
|
83
|
+
export function jiraAuthCheck(headers, secret) {
|
|
84
|
+
const reason = String(headers?.get?.("x-seraph-loginreason") ?? "").toUpperCase();
|
|
85
|
+
if (reason.includes("AUTHENTICATION_DENIED")) {
|
|
86
|
+
return "Jira wants a CAPTCHA after too many failed sign-ins. Sign in to Jira once in your browser, then test the connection again.";
|
|
87
|
+
}
|
|
88
|
+
if (reason.includes("AUTHENTICATED_FAILED")) return "Jira did not accept these credentials.";
|
|
89
|
+
if (secret?.auth === "bearer" && String(headers?.get?.("x-ausername") ?? "").toLowerCase() === "anonymous") {
|
|
90
|
+
return "Jira did not accept this personal access token: it answered as an anonymous visitor. Check that the token is right and has not expired or been revoked.";
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Jira's own reason, short, out of an error body: `errorMessages` and `errors`. */
|
|
96
|
+
export function jiraErrorDetail(json) {
|
|
97
|
+
const parts = [
|
|
98
|
+
...(Array.isArray(json?.errorMessages) ? json.errorMessages : []),
|
|
99
|
+
...(json?.errors && typeof json.errors === "object" ? Object.values(json.errors) : []),
|
|
100
|
+
].filter(v => typeof v === "string" && v.trim());
|
|
101
|
+
return parts.join(" ").slice(0, 300);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── validation ──────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/** "9.0.0" or [9, 0, 0] against the minimum. */
|
|
107
|
+
export function versionAtLeast(version, min = MIN_SERVER_VERSION) {
|
|
108
|
+
const parts = Array.isArray(version) ? version : String(version ?? "").split(".");
|
|
109
|
+
const nums = [0, 1, 2].map(i => Number.parseInt(parts[i], 10) || 0);
|
|
110
|
+
for (let i = 0; i < 3; i++) {
|
|
111
|
+
if (nums[i] > min[i]) return true;
|
|
112
|
+
if (nums[i] < min[i]) return false;
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function personFrom(user, conn) {
|
|
118
|
+
if (!user || typeof user !== "object") return null;
|
|
119
|
+
const id = text(user.accountId ?? user.key ?? user.name, 200);
|
|
120
|
+
if (!id) return null;
|
|
121
|
+
return {
|
|
122
|
+
id,
|
|
123
|
+
name: text(user.displayName ?? user.name, 200) || id,
|
|
124
|
+
avatar: imageUrl(conn, user.avatarUrls?.["48x48"] ?? user.avatarUrls?.["32x32"] ?? user.avatarUrls?.["24x24"] ?? null),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Where the deck sends a scoped Cloud token: api.atlassian.com, or — for the
|
|
129
|
+
* suite and the browser mock, and only when the Jira "site" is itself on this
|
|
130
|
+
* machine — the origin CCDECK_JIRA_API_ORIGIN names. */
|
|
131
|
+
function atlassianApiOrigin(siteUrl, override) {
|
|
132
|
+
const forced = override ?? process.env.CCDECK_JIRA_API_ORIGIN;
|
|
133
|
+
if (forced) {
|
|
134
|
+
try {
|
|
135
|
+
if (isLocalHost(new URL(siteUrl).hostname) && isLocalHost(new URL(forced).hostname)) return originOf(forced);
|
|
136
|
+
} catch { /* fall through to the real one */ }
|
|
137
|
+
}
|
|
138
|
+
return ATLASSIAN_API;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function validateServer(conn, ctx) {
|
|
142
|
+
const client = ctx.client([originOf(conn.baseUrl)]);
|
|
143
|
+
let info;
|
|
144
|
+
try { info = await client.json(`${conn.baseUrl}/rest/api/2/serverInfo`); }
|
|
145
|
+
catch (err) {
|
|
146
|
+
if (err instanceof IntegrationError && (err.kind === "not-found" || err.kind === "incompatible")) {
|
|
147
|
+
throw integrationError("incompatible", "There is no Jira REST API at this address. Check the URL — some servers live under a path such as /jira.", { field: "baseUrl" });
|
|
148
|
+
}
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
if (String(info?.deploymentType ?? "").toLowerCase() === "cloud") {
|
|
152
|
+
throw integrationError("incompatible", "This is a Jira Cloud site. Choose Jira Cloud and connect with your email and an API token.", { field: "deployment" });
|
|
153
|
+
}
|
|
154
|
+
if (!versionAtLeast(info?.versionNumbers ?? info?.version)) {
|
|
155
|
+
throw integrationError("incompatible",
|
|
156
|
+
`This server runs Jira ${text(info?.version, 40) || "of an unknown version"}. Personal access tokens need Jira Server or Data Center 8.14 or newer, so the deck cannot connect to it.`);
|
|
157
|
+
}
|
|
158
|
+
const me = await client.json(`${conn.baseUrl}/rest/api/2/myself`);
|
|
159
|
+
const warnings = [];
|
|
160
|
+
const reported = originOf(info?.baseUrl);
|
|
161
|
+
if (reported && reported !== originOf(conn.baseUrl)) {
|
|
162
|
+
warnings.push(`Jira says its own address is ${reported}. The deck uses the one you entered.`);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
account: {
|
|
166
|
+
id: text(me?.key ?? me?.name, 200),
|
|
167
|
+
login: text(me?.name ?? me?.emailAddress, 200),
|
|
168
|
+
name: text(me?.displayName ?? me?.name, 200),
|
|
169
|
+
avatarUrl: text(me?.avatarUrls?.["48x48"], 1000),
|
|
170
|
+
},
|
|
171
|
+
meta: {
|
|
172
|
+
deployment: "server",
|
|
173
|
+
deploymentType: info?.deploymentType === "DataCenter" ? "DataCenter" : "Server",
|
|
174
|
+
serverVersion: text(info?.version, 40),
|
|
175
|
+
serverTitle: text(info?.serverTitle, 120),
|
|
176
|
+
apiVersion: "2",
|
|
177
|
+
warnings,
|
|
178
|
+
},
|
|
179
|
+
allowedOrigins: [originOf(conn.baseUrl)],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function validateCloud(conn, ctx, apiOriginOverride) {
|
|
184
|
+
const site = originOf(conn.baseUrl);
|
|
185
|
+
const siteClient = ctx.client([site]);
|
|
186
|
+
let me = null;
|
|
187
|
+
let via = "site";
|
|
188
|
+
let apiBase = "";
|
|
189
|
+
let cloudId = "";
|
|
190
|
+
let apiOrigin = "";
|
|
191
|
+
let siteError = null;
|
|
192
|
+
try { me = await siteClient.json(`${conn.baseUrl}/rest/api/3/myself`); }
|
|
193
|
+
catch (err) {
|
|
194
|
+
if (!(err instanceof IntegrationError) || !["auth", "expired", "permission", "not-found"].includes(err.kind)) throw err;
|
|
195
|
+
siteError = err;
|
|
196
|
+
}
|
|
197
|
+
if (!me) {
|
|
198
|
+
// A refusal on the site is what a token created with scopes gets: it is
|
|
199
|
+
// valid only on api.atlassian.com, under the site's cloud id.
|
|
200
|
+
let tenant;
|
|
201
|
+
try { tenant = await siteClient.json(`${site}/_edge/tenant_info`); }
|
|
202
|
+
catch { throw siteError; }
|
|
203
|
+
cloudId = text(tenant?.cloudId, 100);
|
|
204
|
+
if (!/^[A-Za-z0-9-]{8,100}$/.test(cloudId)) throw siteError;
|
|
205
|
+
apiOrigin = atlassianApiOrigin(conn.baseUrl, apiOriginOverride);
|
|
206
|
+
apiBase = `${apiOrigin}/ex/jira/${cloudId}`;
|
|
207
|
+
const apiClient = ctx.client([site, apiOrigin]);
|
|
208
|
+
try { me = await apiClient.json(`${apiBase}/rest/api/3/myself`); }
|
|
209
|
+
catch (err) {
|
|
210
|
+
if (err instanceof IntegrationError && (err.kind === "auth" || err.kind === "expired")) {
|
|
211
|
+
throw integrationError(ctx.phase === "use" ? "expired" : "auth",
|
|
212
|
+
"Jira did not accept this email and API token, on the site or through api.atlassian.com. Check both, and that the token has not expired.");
|
|
213
|
+
}
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
via = "api";
|
|
217
|
+
}
|
|
218
|
+
const api = apiBase || conn.baseUrl;
|
|
219
|
+
const client = apiBase ? ctx.client([site, apiOrigin]) : siteClient;
|
|
220
|
+
let info = null;
|
|
221
|
+
try { info = await client.json(`${api}/rest/api/3/serverInfo`); } catch { /* the account answered; serverInfo is a nicety */ }
|
|
222
|
+
if (info && info.deploymentType && String(info.deploymentType).toLowerCase() !== "cloud") {
|
|
223
|
+
throw integrationError("incompatible", "This is Jira Server or Data Center, not Cloud. Choose Jira Server / Data Center and use a personal access token.", { field: "deployment" });
|
|
224
|
+
}
|
|
225
|
+
const warnings = [];
|
|
226
|
+
if (via === "api") warnings.push("This is a scoped API token: the deck reads Jira through api.atlassian.com. Make sure it has the read scopes listed in the form.");
|
|
227
|
+
return {
|
|
228
|
+
account: {
|
|
229
|
+
id: text(me?.accountId, 200),
|
|
230
|
+
login: text(me?.emailAddress, 200),
|
|
231
|
+
name: text(me?.displayName, 200),
|
|
232
|
+
avatarUrl: text(me?.avatarUrls?.["48x48"], 1000),
|
|
233
|
+
},
|
|
234
|
+
meta: {
|
|
235
|
+
deployment: "cloud",
|
|
236
|
+
deploymentType: "Cloud",
|
|
237
|
+
apiVersion: "3",
|
|
238
|
+
via,
|
|
239
|
+
...(apiBase ? { apiBase, cloudId } : {}),
|
|
240
|
+
serverTitle: text(info?.serverTitle, 120),
|
|
241
|
+
warnings,
|
|
242
|
+
},
|
|
243
|
+
allowedOrigins: apiBase ? [site, apiOrigin] : [site],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function validateJira(conn, secret, ctx, { apiOrigin } = {}) {
|
|
248
|
+
if (conn?.meta?.deployment === "cloud") return validateCloud(conn, ctx, apiOrigin);
|
|
249
|
+
return validateServer(conn, ctx);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── shapes ──────────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
const isCloud = conn => conn?.meta?.deployment === "cloud";
|
|
255
|
+
const apiRoot = conn => (isCloud(conn) && conn.meta.apiBase ? conn.meta.apiBase : conn.baseUrl);
|
|
256
|
+
const restBase = conn => `${apiRoot(conn)}/rest/api/${isCloud(conn) ? "3" : "2"}`;
|
|
257
|
+
const agileBase = conn => `${apiRoot(conn)}/rest/agile/1.0`;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The hosts Jira serves PUBLIC images from, which the deck may read an image
|
|
261
|
+
* off without the token — never with it. Taken from what Jira's own answers
|
|
262
|
+
* point at: a Cloud user's `avatarUrls` are on Atlassian's avatar CDN
|
|
263
|
+
* (avatar-management--avatars.<region>.prod.public.atl-paas.net), or on
|
|
264
|
+
* Gravatar with the CDN as its fallback (`?d=…`, a redirect there); an older
|
|
265
|
+
* Cloud site still names avatar-cdn.atlassian.com; a picture attached to a
|
|
266
|
+
* Cloud issue is answered with a 303 to api.media.atlassian.com, whose URL
|
|
267
|
+
* carries its own short-lived token. Jira Server and Data Center keep every
|
|
268
|
+
* avatar and icon on the instance, except Gravatar when an admin turned it on.
|
|
269
|
+
*/
|
|
270
|
+
export function jiraImageHost(conn, url) {
|
|
271
|
+
let host;
|
|
272
|
+
try { host = new URL(String(url)).hostname.toLowerCase(); } catch { return false; }
|
|
273
|
+
if (/^(?:[a-z0-9-]+\.)?gravatar\.com$/.test(host)) return true;
|
|
274
|
+
if (!isCloud(conn)) return false;
|
|
275
|
+
return /^avatar-management--avatars\.[a-z0-9-]+\.prod\.public\.atl-paas\.net$/.test(host)
|
|
276
|
+
|| host === "avatar-cdn.atlassian.com"
|
|
277
|
+
|| host === "api.media.atlassian.com";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* An image Jira named, as the page should load it: always through the
|
|
282
|
+
* connection's asset route, which reads it with the token on the
|
|
283
|
+
* connection's own origins and without it on Jira's public image hosts
|
|
284
|
+
* (jiraImageHost). Anything else, and an SVG — Jira's priority icons, which
|
|
285
|
+
* the route does not serve — is null, and the page draws its own.
|
|
286
|
+
*/
|
|
287
|
+
export function imageUrl(conn, raw) {
|
|
288
|
+
if (!raw || typeof raw !== "string") return null;
|
|
289
|
+
let u;
|
|
290
|
+
try { u = new URL(raw, `${conn.baseUrl}/`); } catch { return null; }
|
|
291
|
+
// A scoped token is refused on the site: the images the gateway serves too
|
|
292
|
+
// (universal avatars, icons) are asked for through the api base instead.
|
|
293
|
+
if (conn.meta?.apiBase && u.origin === originOf(conn.baseUrl) && /^\/(rest|images)\//.test(u.pathname)) {
|
|
294
|
+
try { u = new URL(`${conn.meta.apiBase}${u.pathname}${u.search}`); } catch { return null; }
|
|
295
|
+
}
|
|
296
|
+
if (/\.svg$/i.test(u.pathname)) return null;
|
|
297
|
+
const own = (conn.allowedOrigins ?? []).includes(u.origin) && isSafeTarget(u.href);
|
|
298
|
+
if (!own && !(u.protocol === "https:" && jiraImageHost(conn, u.href))) return null;
|
|
299
|
+
return `/api/integrations/jira/${encodeURIComponent(conn.id)}/asset?url=${encodeURIComponent(u.href)}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** A status category as one of Jira's three keys. */
|
|
303
|
+
function category(status) {
|
|
304
|
+
const key = status?.statusCategory?.key;
|
|
305
|
+
return key === "done" || key === "indeterminate" ? key : "new";
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Jira's epic colour, as its number 1-14 ("ghx-label-4", "color_4"). */
|
|
309
|
+
export function epicColour(value) {
|
|
310
|
+
const raw = typeof value === "string" ? value : value?.key;
|
|
311
|
+
const m = /(?:ghx-label-|color_)(\d{1,2})$/.exec(String(raw ?? ""));
|
|
312
|
+
const n = m ? Number(m[1]) : 0;
|
|
313
|
+
return n >= 1 && n <= 14 ? n : null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** A sprint field value, in either shape: Cloud's objects, or the
|
|
317
|
+
* `com.atlassian.greenhopper.service.sprint.Sprint@…[id=1,state=ACTIVE,name=…]`
|
|
318
|
+
* strings Server's REST v2 still answers with. */
|
|
319
|
+
export function sprintsFrom(value) {
|
|
320
|
+
const list = Array.isArray(value) ? value : value ? [value] : [];
|
|
321
|
+
return list.map(item => {
|
|
322
|
+
if (item && typeof item === "object") {
|
|
323
|
+
return { id: Number(item.id) || 0, name: text(item.name, 200), state: String(item.state ?? "").toLowerCase() };
|
|
324
|
+
}
|
|
325
|
+
if (typeof item === "string") {
|
|
326
|
+
const get = key => new RegExp(`[\\[,]${key}=([^,\\]]*)`).exec(item)?.[1] ?? "";
|
|
327
|
+
return { id: Number(get("id")) || 0, name: get("name"), state: get("state").toLowerCase() };
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}).filter(s => s && s.name);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function flaggedFrom(fields, ids) {
|
|
334
|
+
if (fields?.flagged === true) return true;
|
|
335
|
+
const value = ids.flagged ? fields?.[ids.flagged] : null;
|
|
336
|
+
return Array.isArray(value) ? value.length > 0 : Boolean(value);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function summaryOf(issue) {
|
|
340
|
+
return text(issue?.fields?.summary, 500);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function typeOf(conn, issuetype) {
|
|
344
|
+
return {
|
|
345
|
+
name: text(issuetype?.name, 80) || "Issue",
|
|
346
|
+
icon: imageUrl(conn, issuetype?.iconUrl),
|
|
347
|
+
subtask: Boolean(issuetype?.subtask),
|
|
348
|
+
epic: issuetype?.hierarchyLevel === 1 || /^epic$/i.test(String(issuetype?.name ?? "")),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function statusOf(status) {
|
|
353
|
+
return { name: text(status?.name, 80) || "Unknown", category: category(status) };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function priorityOf(conn, priority) {
|
|
357
|
+
return priority ? { name: text(priority.name, 80), icon: imageUrl(conn, priority.iconUrl) } : null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const seconds = v => (Number.isFinite(v) && v >= 0 ? v : null);
|
|
361
|
+
|
|
362
|
+
/** One issue, as a row of the backlog draws it. `ids` is the field map
|
|
363
|
+
* (fieldIds), `epics` what is known of the epics by key. */
|
|
364
|
+
export function normaliseIssue(conn, issue, ids = {}, epics = new Map(), estimationField = null) {
|
|
365
|
+
const f = issue?.fields ?? {};
|
|
366
|
+
const key = text(issue?.key, 64);
|
|
367
|
+
const parentRaw = f.parent ?? null;
|
|
368
|
+
const parentType = parentRaw ? typeOf(conn, parentRaw.fields?.issuetype) : null;
|
|
369
|
+
// The epic: Server's Epic Link field, the Agile API's `epic`, or on Cloud an
|
|
370
|
+
// epic-level parent.
|
|
371
|
+
let epicKey = (ids.epicLink && typeof f[ids.epicLink] === "string" ? f[ids.epicLink] : "")
|
|
372
|
+
|| (f.epic && typeof f.epic === "object" ? text(f.epic.key, 64) : "")
|
|
373
|
+
|| (parentRaw && parentType?.epic ? text(parentRaw.key, 64) : "");
|
|
374
|
+
epicKey = text(epicKey, 64);
|
|
375
|
+
const known = epicKey ? epics.get(epicKey) : null;
|
|
376
|
+
const epic = epicKey ? {
|
|
377
|
+
key: epicKey,
|
|
378
|
+
name: known?.name || (f.epic && typeof f.epic === "object" ? text(f.epic.name ?? f.epic.summary, 200) : "")
|
|
379
|
+
|| (parentType?.epic ? summaryOf(parentRaw) : "") || epicKey,
|
|
380
|
+
color: known?.color ?? epicColour(f.epic?.color) ?? null,
|
|
381
|
+
} : null;
|
|
382
|
+
const assignee = personFrom(f.assignee, conn);
|
|
383
|
+
const estimateValue = estimationField && estimationField !== "timeoriginalestimate" ? f[estimationField] : null;
|
|
384
|
+
return {
|
|
385
|
+
id: text(String(issue?.id ?? ""), 40),
|
|
386
|
+
key,
|
|
387
|
+
summary: text(f.summary, 500),
|
|
388
|
+
url: `${conn.baseUrl}/browse/${encodeURIComponent(key)}`,
|
|
389
|
+
type: typeOf(conn, f.issuetype),
|
|
390
|
+
status: statusOf(f.status),
|
|
391
|
+
priority: priorityOf(conn, f.priority),
|
|
392
|
+
assignee,
|
|
393
|
+
mine: Boolean(assignee && assignee.id === conn.account?.id),
|
|
394
|
+
// A subtask's parent always; an epic is not a parent for the backlog's
|
|
395
|
+
// purposes — it is the epic chip.
|
|
396
|
+
parent: parentRaw && !parentType?.epic ? {
|
|
397
|
+
key: text(parentRaw.key, 64),
|
|
398
|
+
summary: summaryOf(parentRaw),
|
|
399
|
+
type: parentType,
|
|
400
|
+
status: statusOf(parentRaw.fields?.status),
|
|
401
|
+
priority: priorityOf(conn, parentRaw.fields?.priority),
|
|
402
|
+
} : null,
|
|
403
|
+
subtaskKeys: Array.isArray(f.subtasks) ? f.subtasks.map(s => text(s?.key, 64)).filter(Boolean) : [],
|
|
404
|
+
epic,
|
|
405
|
+
fixVersions: Array.isArray(f.fixVersions) ? f.fixVersions.map(v => text(v?.name, 80)).filter(Boolean) : [],
|
|
406
|
+
estimate: seconds(f.timeoriginalestimate) ?? seconds(f.aggregatetimeoriginalestimate),
|
|
407
|
+
remaining: seconds(f.timeestimate),
|
|
408
|
+
points: Number.isFinite(estimateValue) ? estimateValue : null,
|
|
409
|
+
flagged: flaggedFrom(f, ids),
|
|
410
|
+
updated: text(f.updated, 40),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── reads ───────────────────────────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
/** The custom fields the backlog needs, found by their type, once an hour. */
|
|
417
|
+
async function fieldIds(ctx) {
|
|
418
|
+
return ctx.cache.remember("fields", HOUR, async () => {
|
|
419
|
+
const list = await ctx.client.json(`${restBase(ctx.conn)}/field`);
|
|
420
|
+
const ids = {};
|
|
421
|
+
for (const field of Array.isArray(list) ? list : []) {
|
|
422
|
+
const custom = String(field?.schema?.custom ?? "");
|
|
423
|
+
if (custom === "com.pyxis.greenhopper.jira:gh-sprint") ids.sprint ??= field.id;
|
|
424
|
+
else if (custom === "com.pyxis.greenhopper.jira:gh-epic-link") ids.epicLink ??= field.id;
|
|
425
|
+
else if (custom === "com.pyxis.greenhopper.jira:gh-epic-color") ids.epicColor ??= field.id;
|
|
426
|
+
else if (custom === "com.pyxis.greenhopper.jira:gh-epic-label") ids.epicName ??= field.id;
|
|
427
|
+
else if (custom === "com.pyxis.greenhopper.jira:jsw-issue-color") ids.issueColor ??= field.id;
|
|
428
|
+
else if (/^flagged$/i.test(String(field?.name ?? "")) && field.custom) ids.flagged ??= field.id;
|
|
429
|
+
}
|
|
430
|
+
return ids;
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Hours in a working day and days in a week, as the instance counts them —
|
|
435
|
+
* what "2d 2h" means there. 8 and 5 when it will not say. */
|
|
436
|
+
async function timeTracking(ctx) {
|
|
437
|
+
return ctx.cache.remember("timetracking", HOUR, async () => {
|
|
438
|
+
const fallback = { hoursPerDay: 8, daysPerWeek: 5 };
|
|
439
|
+
const pick = cfg => {
|
|
440
|
+
const h = Number(cfg?.workingHoursPerDay);
|
|
441
|
+
const d = Number(cfg?.workingDaysPerWeek);
|
|
442
|
+
return { hoursPerDay: h > 0 && h <= 24 ? h : 8, daysPerWeek: d > 0 && d <= 7 ? d : 5 };
|
|
443
|
+
};
|
|
444
|
+
try {
|
|
445
|
+
// /configuration answers any signed-in user on Cloud and on Server; the
|
|
446
|
+
// time-tracking options endpoint is Cloud-only and wants an admin.
|
|
447
|
+
const cfg = await ctx.client.json(`${restBase(ctx.conn)}/configuration`);
|
|
448
|
+
return cfg?.timeTrackingConfiguration ? pick(cfg.timeTrackingConfiguration) : fallback;
|
|
449
|
+
} catch (err) {
|
|
450
|
+
if (err instanceof IntegrationError && (err.kind === "expired" || err.kind === "rate-limit")) throw err;
|
|
451
|
+
return fallback;
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function boardConfig(ctx, boardId) {
|
|
457
|
+
return ctx.cache.remember(`board-config:${boardId}`, 10 * MINUTE, async () => {
|
|
458
|
+
const cfg = await ctx.client.json(`${agileBase(ctx.conn)}/board/${boardId}/configuration`);
|
|
459
|
+
const field = cfg?.estimation?.field ?? null;
|
|
460
|
+
const fieldId = text(field?.fieldId, 80);
|
|
461
|
+
const kind = !fieldId ? "count" : fieldId === "timeoriginalestimate" ? "time" : "points";
|
|
462
|
+
const columns = (Array.isArray(cfg?.columnConfig?.columns) ? cfg.columnConfig.columns : []).map(c => ({
|
|
463
|
+
name: text(c?.name, 80),
|
|
464
|
+
statusIds: (Array.isArray(c?.statuses) ? c.statuses : []).map(st => String(st?.id ?? "")).filter(id => /^\d{1,12}$/.test(id)),
|
|
465
|
+
}));
|
|
466
|
+
return {
|
|
467
|
+
estimation: { kind, fieldId: fieldId || null, label: text(field?.displayName, 80) || (kind === "time" ? "Original Time Estimate" : kind === "points" ? "Story points" : "Issue count") },
|
|
468
|
+
columns,
|
|
469
|
+
};
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function quickFilters(ctx, boardId) {
|
|
474
|
+
return ctx.cache.remember(`quickfilters:${boardId}`, 10 * MINUTE, async () => {
|
|
475
|
+
// Agile 1.0 lists a board's quick filters on Cloud only. Server and Data
|
|
476
|
+
// Center have them in the board's configuration model, which the board's
|
|
477
|
+
// own settings page reads.
|
|
478
|
+
if (!isCloud(ctx.conn)) {
|
|
479
|
+
const model = await ctx.client.json(`${ctx.conn.baseUrl}/rest/greenhopper/1.0/rapidviewconfig/editmodel.json?rapidViewId=${boardId}`);
|
|
480
|
+
const list = Array.isArray(model?.quickFilterConfig?.quickFilters) ? model.quickFilterConfig.quickFilters : [];
|
|
481
|
+
return list
|
|
482
|
+
.filter(q => q?.id != null && typeof q.query === "string")
|
|
483
|
+
.map(q => ({ id: String(q.id), name: text(q.name, 120), jql: q.query, description: text(q.description, 300) }));
|
|
484
|
+
}
|
|
485
|
+
const out = [];
|
|
486
|
+
for (let startAt = 0; startAt < 200;) {
|
|
487
|
+
const page = await ctx.client.json(`${agileBase(ctx.conn)}/board/${boardId}/quickfilter?startAt=${startAt}&maxResults=50`);
|
|
488
|
+
const values = Array.isArray(page?.values) ? page.values : [];
|
|
489
|
+
for (const q of values) {
|
|
490
|
+
if (q?.id != null && typeof q.jql === "string") out.push({ id: String(q.id), name: text(q.name, 120), jql: q.jql, description: text(q.description, 300) });
|
|
491
|
+
}
|
|
492
|
+
if (page?.isLast !== false || values.length === 0) break;
|
|
493
|
+
startAt += values.length;
|
|
494
|
+
}
|
|
495
|
+
return out;
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function sprintOf(raw) {
|
|
500
|
+
return {
|
|
501
|
+
id: Number(raw?.id) || 0,
|
|
502
|
+
name: text(raw?.name, 200),
|
|
503
|
+
state: String(raw?.state ?? "").toLowerCase(),
|
|
504
|
+
startDate: text(raw?.startDate, 40) || null,
|
|
505
|
+
endDate: text(raw?.endDate, 40) || null,
|
|
506
|
+
completeDate: text(raw?.completeDate, 40) || null,
|
|
507
|
+
goal: text(raw?.goal, 500),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function sprintsIn(ctx, boardId, state, cap = 300) {
|
|
512
|
+
const out = [];
|
|
513
|
+
for (let startAt = 0; startAt < cap;) {
|
|
514
|
+
const page = await ctx.client.json(`${agileBase(ctx.conn)}/board/${boardId}/sprint?state=${state}&startAt=${startAt}&maxResults=50`);
|
|
515
|
+
const values = Array.isArray(page?.values) ? page.values : [];
|
|
516
|
+
out.push(...values.map(sprintOf));
|
|
517
|
+
if (page?.isLast !== false || values.length === 0) break;
|
|
518
|
+
startAt += values.length;
|
|
519
|
+
}
|
|
520
|
+
return out;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Whether a kanban board shows a backlog. Agile's feature list where the
|
|
524
|
+
* instance has it; otherwise, whether the backlog answers at all. */
|
|
525
|
+
async function kanbanBacklog(ctx, boardId) {
|
|
526
|
+
return ctx.cache.remember(`backlog-on:${boardId}`, 10 * MINUTE, async () => {
|
|
527
|
+
try {
|
|
528
|
+
const features = await ctx.client.json(`${agileBase(ctx.conn)}/board/${boardId}/features`);
|
|
529
|
+
const backlog = (features?.features ?? []).find(f => /backlog/i.test(String(f?.boardFeature ?? f?.feature ?? "")));
|
|
530
|
+
if (backlog) return String(backlog.state ?? "").toUpperCase() === "ENABLED";
|
|
531
|
+
} catch (err) {
|
|
532
|
+
if (err instanceof IntegrationError && (err.kind === "expired" || err.kind === "rate-limit")) throw err;
|
|
533
|
+
}
|
|
534
|
+
try {
|
|
535
|
+
await listPage(ctx, `board/${boardId}/backlog`, { fields: "summary", max: 1 });
|
|
536
|
+
return true;
|
|
537
|
+
} catch (err) {
|
|
538
|
+
if (err instanceof IntegrationError && (err.kind === "expired" || err.kind === "rate-limit")) throw err;
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const softwareBase = conn => `${apiRoot(conn)}/rest/software/1.0`;
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* One page of a board's issue list (`suffix`: "board/1/backlog",
|
|
548
|
+
* "board/1/sprint/5/issue", "board/1/issue"), in Rank order. On Cloud it is
|
|
549
|
+
* /rest/software/1.0, paged by a next-page token, and Agile 1.0 where that is
|
|
550
|
+
* not deployed; on Server, Agile 1.0 paged by startAt. `cursor` is the `next`
|
|
551
|
+
* the last page answered — "" for the first — and `next` is "" on the last.
|
|
552
|
+
*/
|
|
553
|
+
async function listPage(ctx, suffix, { jql = "", fields, cursor = "", max = PAGE }) {
|
|
554
|
+
if (isCloud(ctx.conn) && !cursor.startsWith("s.")) {
|
|
555
|
+
const params = new URLSearchParams({ maxResults: String(max), fields });
|
|
556
|
+
if (jql) params.set("jql", jql);
|
|
557
|
+
if (cursor.startsWith("t.")) params.set("nextPageToken", cursor.slice(2));
|
|
558
|
+
try {
|
|
559
|
+
const page = await ctx.client.json(`${softwareBase(ctx.conn)}/${suffix}?${params}`);
|
|
560
|
+
const issues = Array.isArray(page?.issues) ? page.issues : [];
|
|
561
|
+
const token = typeof page?.nextPageToken === "string" ? page.nextPageToken : "";
|
|
562
|
+
return { issues, next: token && page?.isLast !== true && issues.length ? `t.${token}` : "", total: null };
|
|
563
|
+
} catch (err) {
|
|
564
|
+
if (!(err instanceof IntegrationError) || err.kind !== "not-found" || cursor) throw err;
|
|
565
|
+
// Not on this site yet: Agile 1.0's own list.
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const startAt = cursor.startsWith("s.") ? Math.max(0, Number.parseInt(cursor.slice(2), 10) || 0) : 0;
|
|
569
|
+
const params = new URLSearchParams({ startAt: String(startAt), maxResults: String(max), fields });
|
|
570
|
+
if (jql) params.set("jql", jql);
|
|
571
|
+
const page = await ctx.client.json(`${agileBase(ctx.conn)}/${suffix}?${params}`);
|
|
572
|
+
const issues = Array.isArray(page?.issues) ? page.issues : [];
|
|
573
|
+
const total = Number.isFinite(page?.total) ? page.total : null;
|
|
574
|
+
const end = startAt + issues.length;
|
|
575
|
+
const more = issues.length > 0 && (total == null ? issues.length >= max : end < total);
|
|
576
|
+
return { issues, next: more ? `s.${end}` : "", total };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Cloud's count of a backlog, which its token-paged list does not carry. */
|
|
580
|
+
async function approximateCount(ctx, suffix, jql) {
|
|
581
|
+
if (!isCloud(ctx.conn)) return null;
|
|
582
|
+
try {
|
|
583
|
+
const params = new URLSearchParams(jql ? { jql } : {});
|
|
584
|
+
const answer = await ctx.client.json(`${softwareBase(ctx.conn)}/${suffix}/approximate-count?${params}`);
|
|
585
|
+
return Number.isFinite(answer?.count) ? answer.count : null;
|
|
586
|
+
} catch (err) {
|
|
587
|
+
if (err instanceof IntegrationError && (err.kind === "expired" || err.kind === "rate-limit")) throw err;
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** The issue fields a backlog row needs. */
|
|
593
|
+
function rowFields(ids, estimationField) {
|
|
594
|
+
return [
|
|
595
|
+
"summary", "status", "issuetype", "priority", "assignee", "parent", "subtasks", "fixVersions",
|
|
596
|
+
"timeoriginalestimate", "timeestimate", "aggregatetimeoriginalestimate", "updated", "flagged", "epic",
|
|
597
|
+
ids.epicLink, ids.flagged, estimationField && estimationField !== "timeoriginalestimate" ? estimationField : null,
|
|
598
|
+
].filter(Boolean).join(",");
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Cloud's "Issue color" names, as Jira's epic colour numbers. Without a
|
|
602
|
+
* prototype, so a name every object inherits (`constructor`, `toString`…)
|
|
603
|
+
* is not one of them. */
|
|
604
|
+
const ISSUE_COLOURS = {
|
|
605
|
+
__proto__: null,
|
|
606
|
+
dark_grey: 1, dark_yellow: 2, yellow: 3, dark_blue: 4, dark_teal: 5, green: 6, purple: 7,
|
|
607
|
+
dark_purple: 8, orange: 9, blue: 10, teal: 11, grey: 12, dark_green: 13, dark_orange: 14,
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
function epicFrom(fields, ids) {
|
|
611
|
+
const f = fields ?? {};
|
|
612
|
+
const named = ids.issueColor ? ISSUE_COLOURS[String(f[ids.issueColor] ?? "").toLowerCase()] ?? null : null;
|
|
613
|
+
return {
|
|
614
|
+
name: text((ids.epicName && f[ids.epicName]) || f.summary, 200),
|
|
615
|
+
color: (ids.epicColor ? epicColour(f[ids.epicColor]) : null) ?? named,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Read the names and colours of the epics a page of issues points at: one
|
|
620
|
+
* search for all of them, or one read each when the search is refused (a key
|
|
621
|
+
* the account cannot see fails the whole JQL). */
|
|
622
|
+
async function resolveEpics(ctx, issues, ids) {
|
|
623
|
+
const keys = new Set();
|
|
624
|
+
for (const issue of issues) {
|
|
625
|
+
const f = issue?.fields ?? {};
|
|
626
|
+
const k = (ids.epicLink && typeof f[ids.epicLink] === "string" && f[ids.epicLink])
|
|
627
|
+
|| (f.epic && typeof f.epic === "object" && f.epic.key)
|
|
628
|
+
|| (f.parent && (f.parent.fields?.issuetype?.hierarchyLevel === 1 || /^epic$/i.test(String(f.parent.fields?.issuetype?.name ?? ""))) && f.parent.key);
|
|
629
|
+
if (k && /^[A-Z][A-Z0-9_]{0,40}-\d{1,12}$/i.test(String(k))) keys.add(String(k).toUpperCase());
|
|
630
|
+
}
|
|
631
|
+
const epics = new Map();
|
|
632
|
+
const wanted = [];
|
|
633
|
+
for (const key of [...keys].slice(0, 100)) {
|
|
634
|
+
const hit = ctx.cache.peek(`epic:${key}`, 10 * MINUTE);
|
|
635
|
+
if (hit) epics.set(key, hit); else wanted.push(key);
|
|
636
|
+
}
|
|
637
|
+
if (!wanted.length) return epics;
|
|
638
|
+
const fields = ["summary", ids.epicName, ids.epicColor, ids.issueColor].filter(Boolean).join(",");
|
|
639
|
+
const keep = (key, epic) => { epics.set(key, epic); ctx.cache.set(`epic:${key}`, epic); };
|
|
640
|
+
try {
|
|
641
|
+
const found = await searchAll(ctx, `issuekey in (${wanted.join(",")})`, fields, wanted.length);
|
|
642
|
+
for (const issue of found) {
|
|
643
|
+
const key = text(issue?.key, 64).toUpperCase();
|
|
644
|
+
if (key) keep(key, epicFrom(issue.fields, ids));
|
|
645
|
+
}
|
|
646
|
+
return epics;
|
|
647
|
+
} catch (err) {
|
|
648
|
+
if (err instanceof IntegrationError && (err.kind === "expired" || err.kind === "rate-limit")) throw err;
|
|
649
|
+
}
|
|
650
|
+
await Promise.all(wanted.slice(0, 30).map(async key => {
|
|
651
|
+
try {
|
|
652
|
+
const issue = await ctx.client.json(`${restBase(ctx.conn)}/issue/${encodeURIComponent(key)}?fields=${fields}`);
|
|
653
|
+
keep(key, epicFrom(issue?.fields, ids));
|
|
654
|
+
} catch (err) {
|
|
655
|
+
if (err instanceof IntegrationError && (err.kind === "expired" || err.kind === "rate-limit")) throw err;
|
|
656
|
+
// An epic the account cannot read is a neutral chip with its key.
|
|
657
|
+
}
|
|
658
|
+
}));
|
|
659
|
+
return epics;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** The JQL the reader's filters add: their own issues, and the board's quick
|
|
663
|
+
* filters by id — the page never sends JQL of its own. */
|
|
664
|
+
async function filterJql(ctx, boardId, query) {
|
|
665
|
+
const clauses = [];
|
|
666
|
+
if (query.get("mine") === "1") clauses.push("assignee = currentUser()");
|
|
667
|
+
const wanted = String(query.get("quick") ?? "").split(",").map(s => s.trim()).filter(Boolean);
|
|
668
|
+
if (wanted.length) {
|
|
669
|
+
const filters = await quickFilters(ctx, boardId);
|
|
670
|
+
for (const id of wanted) {
|
|
671
|
+
const q = filters.find(f => f.id === id);
|
|
672
|
+
if (!q) throw integrationError("not-found", "That quick filter is not on this board any more. Refresh the board.");
|
|
673
|
+
if (q.jql.trim()) clauses.push(`(${q.jql})`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return clauses;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// The kanban columns the page may ask for, by the name it sends. Without a
|
|
680
|
+
// prototype, so `constructor` and the like are not columns.
|
|
681
|
+
const KANBAN_COLUMNS = {
|
|
682
|
+
__proto__: null,
|
|
683
|
+
todo: 'statusCategory = "To Do"',
|
|
684
|
+
progress: 'statusCategory = "In Progress"',
|
|
685
|
+
done: "statusCategory = Done AND updated >= -14d",
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
function boardId(params) {
|
|
689
|
+
const id = String(params.boardId ?? "");
|
|
690
|
+
if (!/^\d{1,12}$/.test(id)) throw integrationError("invalid", "Not a board id.");
|
|
691
|
+
return id;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function issueKey(params) {
|
|
695
|
+
const key = String(params.key ?? "");
|
|
696
|
+
if (!/^[A-Z][A-Z0-9_]{0,40}-\d{1,12}$/i.test(key)) throw integrationError("invalid", "Not an issue key.");
|
|
697
|
+
return key.toUpperCase();
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function startAtOf(query) {
|
|
701
|
+
const n = Number.parseInt(String(query.get("startAt") ?? "0"), 10);
|
|
702
|
+
return Number.isFinite(n) && n >= 0 && n < 100_000 ? n : 0;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Search issues by JQL: Cloud's enhanced search with next-page tokens, or
|
|
706
|
+
* Server's v2 search with startAt. Up to `cap` issues. */
|
|
707
|
+
async function searchAll(ctx, jql, fields, cap = 300) {
|
|
708
|
+
const out = [];
|
|
709
|
+
if (isCloud(ctx.conn)) {
|
|
710
|
+
let token = "";
|
|
711
|
+
for (let guard = 0; guard < 20 && out.length < cap; guard++) {
|
|
712
|
+
const params = new URLSearchParams({ jql, fields, maxResults: String(Math.min(PAGE, cap - out.length)) });
|
|
713
|
+
if (token) params.set("nextPageToken", token);
|
|
714
|
+
const page = await ctx.client.json(`${restBase(ctx.conn)}/search/jql?${params}`);
|
|
715
|
+
const issues = Array.isArray(page?.issues) ? page.issues : [];
|
|
716
|
+
out.push(...issues);
|
|
717
|
+
token = typeof page?.nextPageToken === "string" ? page.nextPageToken : "";
|
|
718
|
+
if (!token || page?.isLast === true || issues.length === 0) break;
|
|
719
|
+
}
|
|
720
|
+
return out;
|
|
721
|
+
}
|
|
722
|
+
for (let startAt = 0; out.length < cap;) {
|
|
723
|
+
const params = new URLSearchParams({ jql, fields, startAt: String(startAt), maxResults: String(Math.min(PAGE, cap - out.length)) });
|
|
724
|
+
const page = await ctx.client.json(`${restBase(ctx.conn)}/search?${params}`);
|
|
725
|
+
const issues = Array.isArray(page?.issues) ? page.issues : [];
|
|
726
|
+
out.push(...issues);
|
|
727
|
+
startAt += issues.length;
|
|
728
|
+
if (issues.length === 0 || startAt >= Number(page?.total ?? 0)) break;
|
|
729
|
+
}
|
|
730
|
+
return out;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ── routes ──────────────────────────────────────────────────────────────────
|
|
734
|
+
|
|
735
|
+
export function registerJiraRoutes(router) {
|
|
736
|
+
/** Boards, a page at a time, optionally by name. */
|
|
737
|
+
router.get("boards", async ctx => {
|
|
738
|
+
const startAt = startAtOf(ctx.query);
|
|
739
|
+
const name = text(ctx.query.get("name") ?? "", 100);
|
|
740
|
+
const params = new URLSearchParams({ startAt: String(startAt), maxResults: "50" });
|
|
741
|
+
if (name) params.set("name", name);
|
|
742
|
+
return ctx.cache.remember(`boards:${params}`, 5 * MINUTE, async () => {
|
|
743
|
+
const page = await ctx.client.json(`${agileBase(ctx.conn)}/board?${params}`);
|
|
744
|
+
const values = (Array.isArray(page?.values) ? page.values : []).map(b => ({
|
|
745
|
+
id: Number(b?.id) || 0,
|
|
746
|
+
name: text(b?.name, 200),
|
|
747
|
+
type: String(b?.type ?? "").toLowerCase(),
|
|
748
|
+
projectKey: text(b?.location?.projectKey, 40),
|
|
749
|
+
projectName: text(b?.location?.projectName ?? b?.location?.displayName ?? b?.location?.name, 200),
|
|
750
|
+
avatar: imageUrl(ctx.conn, b?.location?.avatarURI),
|
|
751
|
+
})).filter(b => b.id);
|
|
752
|
+
const total = Number.isFinite(page?.total) ? page.total : null;
|
|
753
|
+
return { boards: values, startAt, total, isLast: page?.isLast !== false || values.length === 0 };
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
/** One board: what it is, how it estimates, its quick filters and its open
|
|
758
|
+
* sprints — everything the panel draws before any issue is read. */
|
|
759
|
+
router.get("board/:boardId", async ctx => {
|
|
760
|
+
const id = boardId(ctx.params);
|
|
761
|
+
const board = await ctx.cache.remember(`board:${id}`, 10 * MINUTE, () => ctx.client.json(`${agileBase(ctx.conn)}/board/${id}`));
|
|
762
|
+
const type = String(board?.type ?? "").toLowerCase();
|
|
763
|
+
const scrum = type === "scrum";
|
|
764
|
+
const [config, filters, time, sprints, backlog] = await Promise.all([
|
|
765
|
+
boardConfig(ctx, id),
|
|
766
|
+
quickFilters(ctx, id).catch(err => {
|
|
767
|
+
if (err instanceof IntegrationError && (err.kind === "expired" || err.kind === "rate-limit")) throw err;
|
|
768
|
+
return [];
|
|
769
|
+
}),
|
|
770
|
+
timeTracking(ctx),
|
|
771
|
+
scrum ? sprintsIn(ctx, id, "active,future") : Promise.resolve([]),
|
|
772
|
+
scrum ? Promise.resolve(true) : kanbanBacklog(ctx, id),
|
|
773
|
+
]);
|
|
774
|
+
return {
|
|
775
|
+
board: {
|
|
776
|
+
id: Number(board?.id) || Number(id),
|
|
777
|
+
name: text(board?.name, 200),
|
|
778
|
+
type: type || "simple",
|
|
779
|
+
projectKey: text(board?.location?.projectKey, 40),
|
|
780
|
+
projectName: text(board?.location?.projectName ?? board?.location?.displayName, 200),
|
|
781
|
+
},
|
|
782
|
+
estimation: config.estimation,
|
|
783
|
+
quickFilters: filters.map(({ id: qid, name, description }) => ({ id: qid, name, description })),
|
|
784
|
+
sprints: {
|
|
785
|
+
active: sprints.filter(s => s.state === "active"),
|
|
786
|
+
future: sprints.filter(s => s.state === "future"),
|
|
787
|
+
},
|
|
788
|
+
hasSprints: scrum,
|
|
789
|
+
hasBacklog: backlog,
|
|
790
|
+
timeTracking: time,
|
|
791
|
+
};
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
/** The board's closed sprints, most recent first, a page at a time. */
|
|
795
|
+
router.get("board/:boardId/closed-sprints", async ctx => {
|
|
796
|
+
const id = boardId(ctx.params);
|
|
797
|
+
const startAt = startAtOf(ctx.query);
|
|
798
|
+
const all = await ctx.cache.remember(`closed:${id}`, 10 * MINUTE, () => sprintsIn(ctx, id, "closed", 1000));
|
|
799
|
+
const ordered = all.slice().sort((a, b) => String(b.completeDate ?? b.endDate ?? "").localeCompare(String(a.completeDate ?? a.endDate ?? "")) || b.id - a.id);
|
|
800
|
+
const size = 10;
|
|
801
|
+
return { sprints: ordered.slice(startAt, startAt + size), startAt, total: ordered.length, isLast: startAt + size >= ordered.length };
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
/** A group's issues, in rank order: one sprint, the backlog (a page at a
|
|
805
|
+
* time, from `cursor`), or one kanban column. */
|
|
806
|
+
router.get("board/:boardId/issues", async ctx => {
|
|
807
|
+
const id = boardId(ctx.params);
|
|
808
|
+
const source = String(ctx.query.get("source") ?? "");
|
|
809
|
+
const [ids, config] = await Promise.all([fieldIds(ctx), boardConfig(ctx, id)]);
|
|
810
|
+
const clauses = await filterJql(ctx, id, ctx.query);
|
|
811
|
+
let suffix;
|
|
812
|
+
let paged = false;
|
|
813
|
+
if (source === "sprint") {
|
|
814
|
+
const sprint = String(ctx.query.get("sprint") ?? "");
|
|
815
|
+
if (!/^\d{1,12}$/.test(sprint)) throw integrationError("invalid", "Not a sprint id.");
|
|
816
|
+
suffix = `board/${id}/sprint/${sprint}/issue`;
|
|
817
|
+
} else if (source === "backlog") {
|
|
818
|
+
suffix = `board/${id}/backlog`;
|
|
819
|
+
paged = true;
|
|
820
|
+
} else if (source === "kanban") {
|
|
821
|
+
const name = String(ctx.query.get("column") ?? "");
|
|
822
|
+
const column = KANBAN_COLUMNS[name];
|
|
823
|
+
if (!column) throw integrationError("invalid", "Not a board column.");
|
|
824
|
+
// With the kanban backlog on, its column's issues are the Backlog
|
|
825
|
+
// group's, not To Do's.
|
|
826
|
+
const backlogColumn = config.columns[0];
|
|
827
|
+
if (name === "todo" && ctx.query.get("backlog") === "1" && backlogColumn && /backlog/i.test(backlogColumn.name) && backlogColumn.statusIds.length) {
|
|
828
|
+
clauses.unshift(`status not in (${backlogColumn.statusIds.join(",")})`);
|
|
829
|
+
}
|
|
830
|
+
clauses.unshift(column);
|
|
831
|
+
suffix = `board/${id}/issue`;
|
|
832
|
+
} else {
|
|
833
|
+
throw integrationError("invalid", "Not an issue group.");
|
|
834
|
+
}
|
|
835
|
+
const estimationField = config.estimation.fieldId;
|
|
836
|
+
const fields = rowFields(ids, estimationField);
|
|
837
|
+
const jql = clauses.join(" AND ");
|
|
838
|
+
const issues = [];
|
|
839
|
+
const cursor = paged ? String(ctx.query.get("cursor") ?? "").slice(0, 2000) : "";
|
|
840
|
+
const cap = paged ? PAGE : SPRINT_CAP;
|
|
841
|
+
let next = cursor;
|
|
842
|
+
let total = null;
|
|
843
|
+
const counting = paged && !cursor ? approximateCount(ctx, suffix, jql) : Promise.resolve(null);
|
|
844
|
+
// The count is awaited below only when the list has no total of its own,
|
|
845
|
+
// and not at all when the list fails first: its failure must not be left
|
|
846
|
+
// unhandled meanwhile, which would end the deck's process. Where it
|
|
847
|
+
// is awaited, it still fails the read as before.
|
|
848
|
+
counting.catch(() => {});
|
|
849
|
+
for (let guard = 0; guard < 12 && issues.length < cap; guard++) {
|
|
850
|
+
const page = await listPage(ctx, suffix, { jql, fields, cursor: next, max: Math.min(PAGE, cap - issues.length) });
|
|
851
|
+
issues.push(...page.issues);
|
|
852
|
+
if (page.total != null) total = page.total;
|
|
853
|
+
next = page.next;
|
|
854
|
+
if (!next || page.issues.length === 0) break;
|
|
855
|
+
}
|
|
856
|
+
total ??= await counting;
|
|
857
|
+
const epics = await resolveEpics(ctx, issues, ids);
|
|
858
|
+
return {
|
|
859
|
+
issues: issues.map(issue => normaliseIssue(ctx.conn, issue, ids, epics, estimationField)),
|
|
860
|
+
// Cloud's token-paged backlog has no total but its approximate count.
|
|
861
|
+
total: total ?? (!paged && !next ? issues.length : null),
|
|
862
|
+
next: paged ? next : "",
|
|
863
|
+
capped: !paged && Boolean(next),
|
|
864
|
+
};
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
/** Every subtask of one issue, whoever has it. */
|
|
868
|
+
router.get("issue/:key/subtasks", async ctx => {
|
|
869
|
+
const key = issueKey(ctx.params);
|
|
870
|
+
const ids = await fieldIds(ctx);
|
|
871
|
+
const issues = await searchAll(ctx, `parent = ${key}`, rowFields(ids, null), 200);
|
|
872
|
+
const epics = new Map();
|
|
873
|
+
return { issues: issues.map(issue => normaliseIssue(ctx.conn, issue, ids, epics, null)) };
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
/** One issue in full: the detail pane. */
|
|
877
|
+
router.get("issue/:key", async ctx => {
|
|
878
|
+
const key = issueKey(ctx.params);
|
|
879
|
+
const ids = await fieldIds(ctx);
|
|
880
|
+
const fields = [
|
|
881
|
+
"summary", "status", "issuetype", "priority", "assignee", "reporter", "project", "labels", "components",
|
|
882
|
+
"fixVersions", "duedate", "created", "updated", "timeoriginalestimate", "timeestimate", "timespent",
|
|
883
|
+
"aggregatetimeoriginalestimate", "description", "subtasks", "parent", "attachment", "resolution",
|
|
884
|
+
ids.sprint, ids.epicLink, ids.flagged,
|
|
885
|
+
].filter(Boolean).join(",");
|
|
886
|
+
const [issue, comments] = await Promise.all([
|
|
887
|
+
ctx.client.json(`${restBase(ctx.conn)}/issue/${encodeURIComponent(key)}?fields=${fields}&expand=renderedFields`),
|
|
888
|
+
ctx.client.json(`${restBase(ctx.conn)}/issue/${encodeURIComponent(key)}/comment?orderBy=-created&maxResults=10&expand=renderedBody`).catch(err => {
|
|
889
|
+
if (err instanceof IntegrationError && (err.kind === "expired" || err.kind === "rate-limit")) throw err;
|
|
890
|
+
return null;
|
|
891
|
+
}),
|
|
892
|
+
]);
|
|
893
|
+
const f = issue?.fields ?? {};
|
|
894
|
+
const epics = await resolveEpics(ctx, [issue], ids);
|
|
895
|
+
const row = normaliseIssue(ctx.conn, issue, ids, epics, null);
|
|
896
|
+
const subtasks = row.subtaskKeys.length
|
|
897
|
+
? await searchAll(ctx, `parent = ${key}`, rowFields(ids, null), 200).catch(() => [])
|
|
898
|
+
: [];
|
|
899
|
+
const order = new Map(row.subtaskKeys.map((k, i) => [k, i]));
|
|
900
|
+
const sprints = sprintsFrom(ids.sprint ? f[ids.sprint] : null);
|
|
901
|
+
const sprint = sprints.find(s => s.state === "active") ?? sprints.find(s => s.state === "future") ?? sprints.at(-1) ?? null;
|
|
902
|
+
const items = Array.isArray(comments?.comments) ? comments.comments : [];
|
|
903
|
+
return {
|
|
904
|
+
issue: {
|
|
905
|
+
...row,
|
|
906
|
+
project: { key: text(f.project?.key, 40), name: text(f.project?.name, 200), avatar: imageUrl(ctx.conn, f.project?.avatarUrls?.["48x48"]) },
|
|
907
|
+
reporter: personFrom(f.reporter, ctx.conn),
|
|
908
|
+
labels: Array.isArray(f.labels) ? f.labels.map(l => text(l, 80)).filter(Boolean) : [],
|
|
909
|
+
components: Array.isArray(f.components) ? f.components.map(c => text(c?.name, 80)).filter(Boolean) : [],
|
|
910
|
+
dueDate: text(f.duedate, 20) || null,
|
|
911
|
+
created: text(f.created, 40),
|
|
912
|
+
resolution: text(f.resolution?.name, 80) || null,
|
|
913
|
+
timeSpent: seconds(f.timespent),
|
|
914
|
+
sprint: sprint ? { name: sprint.name, state: sprint.state } : null,
|
|
915
|
+
descriptionHtml: typeof issue?.renderedFields?.description === "string" ? issue.renderedFields.description : "",
|
|
916
|
+
},
|
|
917
|
+
comments: {
|
|
918
|
+
total: Number.isFinite(comments?.total) ? comments.total : items.length,
|
|
919
|
+
items: items.map(c => ({
|
|
920
|
+
id: text(String(c?.id ?? ""), 40),
|
|
921
|
+
author: personFrom(c?.author, ctx.conn),
|
|
922
|
+
created: text(c?.created, 40),
|
|
923
|
+
updated: text(c?.updated, 40),
|
|
924
|
+
html: typeof c?.renderedBody === "string" ? c.renderedBody : "",
|
|
925
|
+
})),
|
|
926
|
+
},
|
|
927
|
+
attachments: (Array.isArray(f.attachment) ? f.attachment : []).map(a => ({
|
|
928
|
+
id: text(String(a?.id ?? ""), 40),
|
|
929
|
+
filename: text(a?.filename, 300),
|
|
930
|
+
size: Number.isFinite(a?.size) ? a.size : null,
|
|
931
|
+
mimeType: text(a?.mimeType, 100),
|
|
932
|
+
created: text(a?.created, 40),
|
|
933
|
+
// Opened in the browser, where Jira's own sign-in applies; never
|
|
934
|
+
// fetched by the deck.
|
|
935
|
+
url: typeof a?.content === "string" && /^https?:\/\//.test(a.content) ? a.content : null,
|
|
936
|
+
})),
|
|
937
|
+
subtasks: subtasks
|
|
938
|
+
.map(s => normaliseIssue(ctx.conn, s, ids, new Map(), null))
|
|
939
|
+
.sort((a, b) => (order.get(a.key) ?? 1e9) - (order.get(b.key) ?? 1e9)),
|
|
940
|
+
};
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const jiraRouter = createRouter();
|
|
945
|
+
registerJiraRoutes(jiraRouter);
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* A client for one Jira connection (registry.mjs's contract). It holds the
|
|
949
|
+
* secret in its closure — never as a property, so nothing that serialises a
|
|
950
|
+
* client can carry it — a transport that signs only for the connection's
|
|
951
|
+
* origins, and the connection's short caches.
|
|
952
|
+
*
|
|
953
|
+
* `apiOrigin` replaces api.atlassian.com for a suite whose "site" is on this
|
|
954
|
+
* machine (atlassianApiOrigin keeps it to local hosts).
|
|
955
|
+
*/
|
|
956
|
+
export function createJiraClient(conn, secret, { fetchImpl, apiOrigin } = {}) {
|
|
957
|
+
let origins = Array.isArray(conn.allowedOrigins) && conn.allowedOrigins.length ? conn.allowedOrigins.slice() : [originOf(conn.baseUrl)].filter(Boolean);
|
|
958
|
+
const cache = createCache();
|
|
959
|
+
const proxy = (list, phase, signal) => createProxy({
|
|
960
|
+
conn: { ...conn, allowedOrigins: list }, secret, sign: signJira, phase, signal, fetchImpl,
|
|
961
|
+
label: "Jira", errorDetail: jiraErrorDetail, authCheck: jiraAuthCheck,
|
|
962
|
+
});
|
|
963
|
+
let useProxy = null;
|
|
964
|
+
const reader = () => (useProxy ??= proxy(origins, "use"));
|
|
965
|
+
return {
|
|
966
|
+
kind: "jira",
|
|
967
|
+
conn,
|
|
968
|
+
cache,
|
|
969
|
+
get allowedOrigins() { return origins.slice(); },
|
|
970
|
+
async validate({ phase = "validate", signal } = {}) {
|
|
971
|
+
const result = await validateJira(conn, secret, { phase, signal, client: list => proxy(list, phase, signal) }, { apiOrigin });
|
|
972
|
+
origins = result.allowedOrigins.slice();
|
|
973
|
+
useProxy = null;
|
|
974
|
+
return { account: result.account, meta: result.meta };
|
|
975
|
+
},
|
|
976
|
+
/** An image for the asset route (registry.mjs): with the token on the
|
|
977
|
+
* connection's own origins only, without it on Jira's public image
|
|
978
|
+
* hosts, nowhere else — the address and every redirect checked. */
|
|
979
|
+
fetchAsset(url, { maxBytes, signal } = {}) {
|
|
980
|
+
return fetchImage(url, {
|
|
981
|
+
tokenOrigins: origins, imageHost: u => jiraImageHost(conn, u.href), sign: signJira, secret, fetchImpl, maxBytes, signal,
|
|
982
|
+
});
|
|
983
|
+
},
|
|
984
|
+
/** A read through this connection, bound to one request's abort. */
|
|
985
|
+
reader(signal) {
|
|
986
|
+
const p = reader();
|
|
987
|
+
return {
|
|
988
|
+
allows: p.allows,
|
|
989
|
+
origins: p.origins,
|
|
990
|
+
json: (url, opts = {}) => p.json(url, { ...opts, signal }),
|
|
991
|
+
bytes: (url, opts = {}) => p.bytes(url, { ...opts, signal }),
|
|
992
|
+
};
|
|
993
|
+
},
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** The panel's reads (registerJiraRoutes), as registry.mjs's `routes`. */
|
|
998
|
+
export async function jiraRoutes(client, method, subpath, searchParams, _req, { signal } = {}) {
|
|
999
|
+
if (method !== "GET" && method !== "HEAD") return errorResponse(integrationError("invalid", "The Jira panel only reads."));
|
|
1000
|
+
const route = jiraRouter.find(subpath);
|
|
1001
|
+
if (!route) return errorResponse(integrationError("not-found", "No such Jira read."));
|
|
1002
|
+
try {
|
|
1003
|
+
const data = await route.handler({
|
|
1004
|
+
conn: client.conn, client: client.reader(signal), params: route.params, query: searchParams, cache: client.cache, signal,
|
|
1005
|
+
});
|
|
1006
|
+
return { status: 200, body: { ok: true, ...(data ?? {}) } };
|
|
1007
|
+
} catch (err) {
|
|
1008
|
+
return errorResponse(err);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** The provider, for providers.mjs. */
|
|
1013
|
+
export function createJiraProvider({ apiOrigin } = {}) {
|
|
1014
|
+
return {
|
|
1015
|
+
kind: "jira",
|
|
1016
|
+
label: "Jira",
|
|
1017
|
+
createClient: (conn, secret, opts = {}) => createJiraClient(conn, secret, { ...opts, apiOrigin }),
|
|
1018
|
+
routes: jiraRoutes,
|
|
1019
|
+
parseInput: parseJiraInput,
|
|
1020
|
+
defaultName: checked => {
|
|
1021
|
+
try { return new URL(checked.baseUrl).host; } catch { return "Jira"; }
|
|
1022
|
+
},
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
export const jiraProvider = createJiraProvider();
|