ticketlens 0.41.0 → 0.42.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/package.json +1 -1
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +29 -1
- package/skills/jtb/scripts/lib/adapters/linear-adapter.mjs +39 -6
- package/skills/jtb/scripts/lib/jira-client.mjs +40 -0
- package/skills/jtb/scripts/lib/ticket-command.mjs +136 -61
- package/skills/jtb/scripts/lib/ticket-metadata-cache.mjs +57 -3
- package/skills/jtb/scripts/lib/ticket-update-enrichment.mjs +66 -0
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, fetchProjects, fetchIssueTypes, postComment, getTransitions, postTransition, assignIssue, fetchAssignableUsers, escapeJql, getIssueLinkTypes, postIssueLink, updateIssue, createIssue, DEFAULT_SEARCH_FIELDS } from '../jira-client.mjs';
|
|
1
|
+
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, fetchProjects, fetchIssueTypes, fetchProjectPriorities, postComment, getTransitions, postTransition, assignIssue, fetchAssignableUsers, escapeJql, getIssueLinkTypes, postIssueLink, updateIssue, createIssue, DEFAULT_SEARCH_FIELDS } from '../jira-client.mjs';
|
|
2
2
|
import { postWorklog } from '../jira-worklog-client.mjs';
|
|
3
3
|
import { uploadAttachment, resolveMediaId } from '../jira-attachment-client.mjs';
|
|
4
4
|
import { readAttachments } from '../attachment-uploader.mjs';
|
|
@@ -211,6 +211,34 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
211
211
|
/** Real, currently-configured issue types for one project. */
|
|
212
212
|
listIssueTypes: (projectKey, opts = {}) => fetchIssueTypes(projectKey, { ...base, ...opts }),
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Real, currently-configured priority options for one project — used
|
|
216
|
+
* only to enrich an `update` failure message, never to pre-validate
|
|
217
|
+
* (see fetchProjectPriorities' own doc for why). Priority has no
|
|
218
|
+
* dedicated list-per-project endpoint, so this walks the project's
|
|
219
|
+
* issue types until one returns a non-empty priority list. NOT every
|
|
220
|
+
* issue type has one: confirmed live against corenexus that a
|
|
221
|
+
* team-managed project's Epic type has no `priority` field in its
|
|
222
|
+
* field metadata at all (real fields returned: assignee, description,
|
|
223
|
+
* labels, ... — priority absent), while its Task type does. An
|
|
224
|
+
* earlier version picked types[0] unconditionally and silently
|
|
225
|
+
* returned [] whenever that happened to be an Epic — caught by a live
|
|
226
|
+
* CLI run against CNV1-34, not by any unit test (every unit test used
|
|
227
|
+
* a hand-picked type). Stops at the first success rather than trying
|
|
228
|
+
* every type, since priority is otherwise scheme-shared across a
|
|
229
|
+
* project's types by Jira's default. Returns [] only once every type
|
|
230
|
+
* has been tried — this can only ever make an error message less
|
|
231
|
+
* informative, never a new way for `update` to fail.
|
|
232
|
+
*/
|
|
233
|
+
async listPriorities(projectKey, opts = {}) {
|
|
234
|
+
const types = await fetchIssueTypes(projectKey, { ...base, ...opts });
|
|
235
|
+
for (const type of types) {
|
|
236
|
+
const priorities = await fetchProjectPriorities(projectKey, type.id, { ...base, ...opts });
|
|
237
|
+
if (priorities.length) return priorities;
|
|
238
|
+
}
|
|
239
|
+
return [];
|
|
240
|
+
},
|
|
241
|
+
|
|
214
242
|
/**
|
|
215
243
|
* Best-effort, per-file: one bad path or one failed upload never blocks
|
|
216
244
|
* the rest (same `{applied/uploaded, errors}` shape convention as
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { tokenize } from '../duplicate-scorer.mjs';
|
|
2
2
|
import { readAttachments } from '../attachment-uploader.mjs';
|
|
3
3
|
import { isSafeRedirectUrl, validateResolvedHost, defaultLookupFor } from '../jira-client.mjs';
|
|
4
|
+
import { SINGLE_PROJECT_TTL_MS, isFresh, mergeTeamLabels } from '../ticket-metadata-cache.mjs';
|
|
4
5
|
|
|
5
6
|
const LINEAR_API = 'https://api.linear.app/graphql';
|
|
6
7
|
|
|
@@ -103,6 +104,22 @@ function resolveLabelNames(names, byName) {
|
|
|
103
104
|
return { resolved, missing };
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Real, currently-configured labels for one team — extracted from
|
|
109
|
+
* updateFields' own inline query so the cache read-through wrapper around
|
|
110
|
+
* it (see updateFields below) and this raw fetch stay independently
|
|
111
|
+
* testable. Returns {id, name} to match ticket-metadata-cache.mjs's
|
|
112
|
+
* documented labelsByTeam shape exactly.
|
|
113
|
+
*/
|
|
114
|
+
async function fetchTeamLabels(teamId, { token, fetcher, signal }) {
|
|
115
|
+
const data = await gql(
|
|
116
|
+
`query ($teamId: ID!) { issueLabels(filter: { team: { id: { eq: $teamId } } }, first: 250) { nodes { id name } } }`,
|
|
117
|
+
{ teamId },
|
|
118
|
+
{ token, fetcher, signal },
|
|
119
|
+
);
|
|
120
|
+
return (data.issueLabels?.nodes ?? []).map(l => ({ id: l.id, name: l.name }));
|
|
121
|
+
}
|
|
122
|
+
|
|
106
123
|
async function fetchTeamWorkflowStates(teamId, { token, fetcher, signal }) {
|
|
107
124
|
const data = await gql(
|
|
108
125
|
`query ($teamId: ID!) {
|
|
@@ -393,12 +410,28 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
393
410
|
|
|
394
411
|
let addLabelsResolved, addLabelsMissing, removeLabelsResolved, removeLabelsMissing;
|
|
395
412
|
if (addLabels?.length || removeLabels?.length) {
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
413
|
+
const teamId = info.team.id;
|
|
414
|
+
const { readMetadataCacheFn, writeMetadataCacheFn, profileName, configDir } = opts;
|
|
415
|
+
const cached = readMetadataCacheFn ? readMetadataCacheFn(profileName, configDir) : null;
|
|
416
|
+
// Keyed on the timestamp existing, not on the cached array having
|
|
417
|
+
// entries — a team with genuinely zero labels configured caches
|
|
418
|
+
// `[]`, and gating on `.length` would re-fetch on every single
|
|
419
|
+
// subsequent call, never respecting SINGLE_PROJECT_TTL_MS. Same
|
|
420
|
+
// fix as ticket-update-enrichment.mjs's prioritiesByProject check.
|
|
421
|
+
const hasFreshLabels = cached?.labelsByTeam?.[teamId] !== undefined
|
|
422
|
+
&& isFresh(cached?.labelsFetchedAt?.[teamId], SINGLE_PROJECT_TTL_MS);
|
|
423
|
+
|
|
424
|
+
let labels;
|
|
425
|
+
if (hasFreshLabels) {
|
|
426
|
+
labels = cached.labelsByTeam[teamId];
|
|
427
|
+
} else {
|
|
428
|
+
labels = await fetchTeamLabels(teamId, { token, fetcher, signal });
|
|
429
|
+
if (writeMetadataCacheFn) {
|
|
430
|
+
const { labelsByTeam, labelsFetchedAt } = mergeTeamLabels(cached, teamId, labels);
|
|
431
|
+
writeMetadataCacheFn(profileName, { ...cached, labelsByTeam, labelsFetchedAt }, configDir);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const byName = new Map(labels.map(l => [l.name.toLowerCase(), l.id]));
|
|
402
435
|
|
|
403
436
|
if (addLabels?.length) {
|
|
404
437
|
({ resolved: addLabelsResolved, missing: addLabelsMissing } = resolveLabelNames(addLabels, byName));
|
|
@@ -738,6 +738,46 @@ export async function fetchIssueTypes(projectKey, opts = {}) {
|
|
|
738
738
|
return values.map(t => ({ id: t.id, name: t.name }));
|
|
739
739
|
}
|
|
740
740
|
|
|
741
|
+
/**
|
|
742
|
+
* Discovers the priority scheme actually configured for a project — used
|
|
743
|
+
* only to enrich an `update` failure message with real, current options,
|
|
744
|
+
* never as a client-side pre-validation step (same design choice as
|
|
745
|
+
* fetchIssueTypes' own doc comment). Confirmed live against a Cloud
|
|
746
|
+
* instance (corenexus) and a Server/DC instance (advent) that a project's
|
|
747
|
+
* priority list lives on this per-issuetype field-metadata sub-resource —
|
|
748
|
+
* there is no separate global-priority-list call this codebase already
|
|
749
|
+
* makes. Same v2/v3 envelope split as fetchIssueTypes' own top-level list
|
|
750
|
+
* call: v3 nests the field array under `fields`, v2 under `values` (a
|
|
751
|
+
* paginated-list envelope, maxResults/startAt/total/isLast). An earlier
|
|
752
|
+
* version of this function assumed the two were identical here — an
|
|
753
|
+
* artifact of an exploratory probe script that used a `??` fallback
|
|
754
|
+
* across both keys, which happened to mask the real difference. Caught
|
|
755
|
+
* live against advent (v2): a real Bug-type priority list came back
|
|
756
|
+
* empty under `fields`, present under `values`.
|
|
757
|
+
*/
|
|
758
|
+
export async function fetchProjectPriorities(projectKey, issueTypeId, opts = {}) {
|
|
759
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
760
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
761
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
762
|
+
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
763
|
+
|
|
764
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes/${encodeURIComponent(issueTypeId)}`;
|
|
765
|
+
const fetchOpts = { headers };
|
|
766
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
767
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
768
|
+
|
|
769
|
+
if (!response.ok) {
|
|
770
|
+
const err = new Error(`Jira API error ${response.status} fetching priorities for ${projectKey}`);
|
|
771
|
+
err.status = response.status;
|
|
772
|
+
throw err;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const raw = await response.json();
|
|
776
|
+
const fieldList = apiVersion >= 3 ? (raw.fields ?? []) : (raw.values ?? []);
|
|
777
|
+
const priorityField = fieldList.find(f => f.fieldId === 'priority');
|
|
778
|
+
return (priorityField?.allowedValues ?? []).map(p => ({ id: p.id, name: p.name }));
|
|
779
|
+
}
|
|
780
|
+
|
|
741
781
|
/**
|
|
742
782
|
* Creates a new issue. `project`/`type` are passed straight through as
|
|
743
783
|
* Jira's own {key}/{name} references — issue types are project-configurable,
|
|
@@ -14,10 +14,11 @@ import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
|
14
14
|
import { isLicensed, showUpgradePrompt } from './license.mjs';
|
|
15
15
|
import { resolveConnection, findProfilesByPrefix } from './profile-resolver.mjs';
|
|
16
16
|
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
17
|
-
import {
|
|
17
|
+
import { claimAction, releaseAction } from './ticket-action-cooldown.mjs';
|
|
18
18
|
import { logAction } from './ticket-action-log.mjs';
|
|
19
19
|
import { readMetadataCache, writeMetadataCache, isFresh, SINGLE_PROJECT_TTL_MS, mergeAssignableUsers, normalizeAssigneeQuery } from './ticket-metadata-cache.mjs';
|
|
20
20
|
import { detectProjectOrTypeError, enrichCreateFailure } from './ticket-create-enrichment.mjs';
|
|
21
|
+
import { enrichUpdateFailure } from './ticket-update-enrichment.mjs';
|
|
21
22
|
import { TICKET_KEY_PATTERN, normalizeTicketKey } from './cli.mjs';
|
|
22
23
|
import { scoreCandidates } from './duplicate-scorer.mjs';
|
|
23
24
|
import { MAX_ATTACHMENTS } from './attachment-uploader.mjs';
|
|
@@ -32,6 +33,45 @@ function parseAttachPaths(cmdArgs) {
|
|
|
32
33
|
return raw ? raw.split(',').map(p => p.trim()).filter(Boolean) : [];
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Best-effort release of a claimAction cooldown claim (Backlog #34/ROADMAP
|
|
38
|
+
* 57) — releaseAction can itself throw (lock contention, same as any
|
|
39
|
+
* withLock caller in ticket-action-cooldown.mjs). A release is always
|
|
40
|
+
* called while already reporting some other outcome (a write failure, a
|
|
41
|
+
* refusal, an executed:false result); letting a lock-contention error
|
|
42
|
+
* replace that outcome would be strictly worse than just leaving the claim
|
|
43
|
+
* in place a few seconds longer — it expires on its own. Same "best
|
|
44
|
+
* effort — the claim expires by itself" reasoning already applied to
|
|
45
|
+
* ticket-worklog.mjs's own releaseClaim helper, DRY'd here across every
|
|
46
|
+
* write in this family instead of duplicated per function.
|
|
47
|
+
*/
|
|
48
|
+
export function safeRelease(releaseActionFn, ticketKey, action, configDir) {
|
|
49
|
+
try { releaseActionFn(ticketKey, action, { configDir }); } catch { /* best effort — the claim expires by itself */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* claimAction can itself throw — lock contention on the same 2s deadline
|
|
54
|
+
* as any other withLock caller in ticket-action-cooldown.mjs. Left
|
|
55
|
+
* unguarded, that throw would fall through to the outer CLI/MCP error
|
|
56
|
+
* handler and be misreported as a real failure (and could trip the
|
|
57
|
+
* opt-in error-reporting pipeline) for what is actually correct, benign
|
|
58
|
+
* contention — the write is still safely refused either way, but the
|
|
59
|
+
* graceful "Skipped" UX this whole family is built around would be lost.
|
|
60
|
+
* Same guard ticket-worklog.mjs's own claimOrSkip already has; returns a
|
|
61
|
+
* `lockError` string instead of a boolean flag so each call site can
|
|
62
|
+
* fold it into its existing skip-message wording without a second
|
|
63
|
+
* lookup. Caught in code review before shipping — not exercised by the
|
|
64
|
+
* original 4-way live concurrency trial, which stayed under the 2s
|
|
65
|
+
* deadline.
|
|
66
|
+
*/
|
|
67
|
+
export function safeClaim(claimActionFn, ticketKey, action, configDir) {
|
|
68
|
+
try {
|
|
69
|
+
return claimActionFn(ticketKey, action, { configDir });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return { claimed: false, remainingMs: 0, lockError: err.message };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
35
75
|
/**
|
|
36
76
|
* GitHub has no PAT-compatible public API for uploading issue/comment
|
|
37
77
|
* assets (confirmed via research — the only upload endpoint requires a
|
|
@@ -263,8 +303,8 @@ export async function runTicketComment(cmdArgs, {
|
|
|
263
303
|
isLicensedFn = isLicensed,
|
|
264
304
|
resolveConnectionFn = resolveConnection,
|
|
265
305
|
resolveAdapterFn = resolveAdapter,
|
|
266
|
-
|
|
267
|
-
|
|
306
|
+
claimActionFn = claimAction,
|
|
307
|
+
releaseActionFn = releaseAction,
|
|
268
308
|
logActionFn = logAction,
|
|
269
309
|
actor = os.userInfo().username,
|
|
270
310
|
} = {}) {
|
|
@@ -281,32 +321,36 @@ export async function runTicketComment(cmdArgs, {
|
|
|
281
321
|
}
|
|
282
322
|
const attachPaths = parseAttachPaths(cmdArgs);
|
|
283
323
|
|
|
284
|
-
const
|
|
285
|
-
if (
|
|
286
|
-
stream.write(
|
|
324
|
+
const claim = safeClaim(claimActionFn, ticketKey, 'comment', configDir);
|
|
325
|
+
if (!claim.claimed) {
|
|
326
|
+
stream.write(claim.lockError
|
|
327
|
+
? ` ${ticketKey} not commented — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
|
|
328
|
+
: ` Skipped — a comment was already posted to ${ticketKey} ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
287
329
|
return { ok: false };
|
|
288
330
|
}
|
|
289
331
|
|
|
290
332
|
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
291
|
-
if (!resolved) return { ok: false };
|
|
333
|
+
if (!resolved) { safeRelease(releaseActionFn, ticketKey, 'comment', configDir); return { ok: false }; }
|
|
292
334
|
const { adapter } = resolved;
|
|
293
335
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
294
336
|
|
|
295
337
|
// Uploaded BEFORE the comment write so a tracker capable of inline
|
|
296
338
|
// rendering (Jira Server/DC via wiki markup, Jira Cloud via a real ADF
|
|
297
339
|
// media node, Linear via Markdown) can fold it into the same atomic
|
|
298
|
-
// comment post rather than needing a second edit call.
|
|
340
|
+
// comment post rather than needing a second edit call. Inside the same
|
|
341
|
+
// try/catch as addComment — every adapter's attachFiles is documented to
|
|
342
|
+
// never throw (per-file errors are caught internally), but that is an
|
|
343
|
+
// implicit contract, not something to leave an un-released claim on if
|
|
344
|
+
// it were ever violated (caught in code review before shipping).
|
|
299
345
|
let attachResult = null;
|
|
300
|
-
if (attachPaths.length && !refuseGithubAttachments(adapter, attachPaths, stream)) {
|
|
301
|
-
attachResult = await adapter.attachFiles(ticketKey, attachPaths);
|
|
302
|
-
}
|
|
303
|
-
const inlineSnippets = (attachResult?.uploaded ?? []).filter(a => a.inlineMarkup).map(a => a.inlineMarkup).join('\n\n');
|
|
304
|
-
const finalBody = inlineSnippets ? `${body}\n\n${inlineSnippets}` : body;
|
|
305
|
-
const extraAdfNodes = (attachResult?.uploaded ?? []).filter(a => a.adfMediaNode).map(a => a.adfMediaNode);
|
|
306
|
-
|
|
307
346
|
try {
|
|
347
|
+
if (attachPaths.length && !refuseGithubAttachments(adapter, attachPaths, stream)) {
|
|
348
|
+
attachResult = await adapter.attachFiles(ticketKey, attachPaths);
|
|
349
|
+
}
|
|
350
|
+
const inlineSnippets = (attachResult?.uploaded ?? []).filter(a => a.inlineMarkup).map(a => a.inlineMarkup).join('\n\n');
|
|
351
|
+
const finalBody = inlineSnippets ? `${body}\n\n${inlineSnippets}` : body;
|
|
352
|
+
const extraAdfNodes = (attachResult?.uploaded ?? []).filter(a => a.adfMediaNode).map(a => a.adfMediaNode);
|
|
308
353
|
const result = await adapter.addComment(ticketKey, finalBody, extraAdfNodes.length ? { extraAdfNodes } : {});
|
|
309
|
-
recordActionFn(ticketKey, 'comment', { configDir });
|
|
310
354
|
// attachPaths (every path attempted, raw) plus attachedFilenames (what
|
|
311
355
|
// actually landed) — a partial attach failure is reconstructable from
|
|
312
356
|
// the difference between the two, not just silently absent from audit.
|
|
@@ -314,6 +358,7 @@ export async function runTicketComment(cmdArgs, {
|
|
|
314
358
|
stream.write(` ${s.green('✔')} Comment posted to ${s.brand(s.bold(ticketKey))}${result.url ? ` (${result.url})` : ''}\n` + formatAttachSummary(attachResult, s));
|
|
315
359
|
return { ok: true };
|
|
316
360
|
} catch (err) {
|
|
361
|
+
safeRelease(releaseActionFn, ticketKey, 'comment', configDir);
|
|
317
362
|
// Attachments (if any) genuinely landed on the tracker before this
|
|
318
363
|
// write was attempted — formatAttachSummary is still shown here so a
|
|
319
364
|
// caller retrying the whole command doesn't blindly re-upload them.
|
|
@@ -383,8 +428,8 @@ export async function runTicketTransition(cmdArgs, {
|
|
|
383
428
|
isLicensedFn = isLicensed,
|
|
384
429
|
resolveConnectionFn = resolveConnection,
|
|
385
430
|
resolveAdapterFn = resolveAdapter,
|
|
386
|
-
|
|
387
|
-
|
|
431
|
+
claimActionFn = claimAction,
|
|
432
|
+
releaseActionFn = releaseAction,
|
|
388
433
|
logActionFn = logAction,
|
|
389
434
|
actor = os.userInfo().username,
|
|
390
435
|
cliHints = true,
|
|
@@ -407,29 +452,32 @@ export async function runTicketTransition(cmdArgs, {
|
|
|
407
452
|
return { ok: false };
|
|
408
453
|
}
|
|
409
454
|
|
|
410
|
-
const
|
|
411
|
-
if (
|
|
412
|
-
stream.write(
|
|
455
|
+
const claim = safeClaim(claimActionFn, ticketKey, 'transition', configDir);
|
|
456
|
+
if (!claim.claimed) {
|
|
457
|
+
stream.write(claim.lockError
|
|
458
|
+
? ` ${ticketKey} not transitioned — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
|
|
459
|
+
: ` Skipped — ${ticketKey} was already transitioned ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
413
460
|
return { ok: false };
|
|
414
461
|
}
|
|
415
462
|
|
|
416
463
|
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
417
|
-
if (!resolved) return { ok: false };
|
|
464
|
+
if (!resolved) { safeRelease(releaseActionFn, ticketKey, 'transition', configDir); return { ok: false }; }
|
|
418
465
|
const { adapter } = resolved;
|
|
419
466
|
|
|
420
467
|
try {
|
|
421
468
|
const result = await adapter.transition(ticketKey, target);
|
|
422
469
|
if (!result.executed) {
|
|
470
|
+
safeRelease(releaseActionFn, ticketKey, 'transition', configDir);
|
|
423
471
|
const optionsHint = result.options?.length ? ` Valid options: ${result.options.map(o => o.name).join(', ')}.` : '';
|
|
424
472
|
stream.write(` Not transitioned — ${result.reason}.${optionsHint}\n`);
|
|
425
473
|
return { ok: false, reason: result.reason };
|
|
426
474
|
}
|
|
427
|
-
recordActionFn(ticketKey, 'transition', { configDir });
|
|
428
475
|
logActionFn({ ticketKey, action: 'transition', actor, tracker: adapter.type, detail: { to: result.to } }, { configDir });
|
|
429
476
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
430
477
|
stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} transitioned to ${s.bold(`"${result.to}"`)}.\n`);
|
|
431
478
|
return { ok: true };
|
|
432
479
|
} catch (err) {
|
|
480
|
+
safeRelease(releaseActionFn, ticketKey, 'transition', configDir);
|
|
433
481
|
stream.write(formatWriteFailure(ticketKey, err));
|
|
434
482
|
return { ok: false };
|
|
435
483
|
}
|
|
@@ -507,8 +555,8 @@ export async function runTicketAssign(cmdArgs, {
|
|
|
507
555
|
isLicensedFn = isLicensed,
|
|
508
556
|
resolveConnectionFn = resolveConnection,
|
|
509
557
|
resolveAdapterFn = resolveAdapter,
|
|
510
|
-
|
|
511
|
-
|
|
558
|
+
claimActionFn = claimAction,
|
|
559
|
+
releaseActionFn = releaseAction,
|
|
512
560
|
logActionFn = logAction,
|
|
513
561
|
readMetadataCacheFn = readMetadataCache,
|
|
514
562
|
writeMetadataCacheFn = writeMetadataCache,
|
|
@@ -530,24 +578,26 @@ export async function runTicketAssign(cmdArgs, {
|
|
|
530
578
|
}
|
|
531
579
|
|
|
532
580
|
if (to === 'me') {
|
|
533
|
-
const
|
|
534
|
-
if (
|
|
535
|
-
stream.write(
|
|
581
|
+
const claim = safeClaim(claimActionFn, ticketKey, 'assign', configDir);
|
|
582
|
+
if (!claim.claimed) {
|
|
583
|
+
stream.write(claim.lockError
|
|
584
|
+
? ` ${ticketKey} not assigned — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
|
|
585
|
+
: ` Skipped — ${ticketKey} was already assigned ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
536
586
|
return { ok: false };
|
|
537
587
|
}
|
|
538
588
|
|
|
539
589
|
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
540
|
-
if (!resolved) return { ok: false };
|
|
590
|
+
if (!resolved) { safeRelease(releaseActionFn, ticketKey, 'assign', configDir); return { ok: false }; }
|
|
541
591
|
const { adapter } = resolved;
|
|
542
592
|
|
|
543
593
|
try {
|
|
544
594
|
const result = await adapter.assignToSelf(ticketKey);
|
|
545
|
-
recordActionFn(ticketKey, 'assign', { configDir });
|
|
546
595
|
logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: result.assignee } }, { configDir });
|
|
547
596
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
548
597
|
stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} assigned to ${s.bold(result.assignee)}.\n`);
|
|
549
598
|
return { ok: true };
|
|
550
599
|
} catch (err) {
|
|
600
|
+
safeRelease(releaseActionFn, ticketKey, 'assign', configDir);
|
|
551
601
|
stream.write(formatWriteFailure(ticketKey, err));
|
|
552
602
|
return { ok: false };
|
|
553
603
|
}
|
|
@@ -596,19 +646,21 @@ export async function runTicketAssign(cmdArgs, {
|
|
|
596
646
|
return { ok: false };
|
|
597
647
|
}
|
|
598
648
|
|
|
599
|
-
const
|
|
600
|
-
if (
|
|
601
|
-
stream.write(
|
|
649
|
+
const claim = safeClaim(claimActionFn, ticketKey, 'assign', configDir);
|
|
650
|
+
if (!claim.claimed) {
|
|
651
|
+
stream.write(claim.lockError
|
|
652
|
+
? ` ${ticketKey} not assigned — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
|
|
653
|
+
: ` Skipped — ${ticketKey} was already assigned ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
602
654
|
return { ok: false };
|
|
603
655
|
}
|
|
604
656
|
|
|
605
657
|
try {
|
|
606
658
|
await adapter.assignToUser(ticketKey, candidate.accountId);
|
|
607
|
-
recordActionFn(ticketKey, 'assign', { configDir });
|
|
608
659
|
logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: candidate.displayName, accountId: candidate.accountId } }, { configDir });
|
|
609
660
|
stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} assigned to ${s.bold(candidate.displayName)}.\n`);
|
|
610
661
|
return { ok: true };
|
|
611
662
|
} catch (err) {
|
|
663
|
+
safeRelease(releaseActionFn, ticketKey, 'assign', configDir);
|
|
612
664
|
stream.write(formatWriteFailure(ticketKey, err));
|
|
613
665
|
return { ok: false };
|
|
614
666
|
}
|
|
@@ -776,8 +828,8 @@ export async function runTicketLink(cmdArgs, {
|
|
|
776
828
|
isLicensedFn = isLicensed,
|
|
777
829
|
resolveConnectionFn = resolveConnection,
|
|
778
830
|
resolveAdapterFn = resolveAdapter,
|
|
779
|
-
|
|
780
|
-
|
|
831
|
+
claimActionFn = claimAction,
|
|
832
|
+
releaseActionFn = releaseAction,
|
|
781
833
|
logActionFn = logAction,
|
|
782
834
|
actor = os.userInfo().username,
|
|
783
835
|
cliHints = true,
|
|
@@ -803,17 +855,20 @@ export async function runTicketLink(cmdArgs, {
|
|
|
803
855
|
}
|
|
804
856
|
|
|
805
857
|
const cooldownKey = `${sourceKey}:${targetKey}`;
|
|
806
|
-
const
|
|
807
|
-
if (
|
|
808
|
-
stream.write(
|
|
858
|
+
const claim = safeClaim(claimActionFn, cooldownKey, 'link', configDir);
|
|
859
|
+
if (!claim.claimed) {
|
|
860
|
+
stream.write(claim.lockError
|
|
861
|
+
? ` ${sourceKey} not linked — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
|
|
862
|
+
: ` Skipped — ${sourceKey} was already linked to ${targetKey} ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
809
863
|
return { ok: false };
|
|
810
864
|
}
|
|
811
865
|
|
|
812
866
|
const resolved = resolveTicketAdapter(sourceKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
813
|
-
if (!resolved) return { ok: false };
|
|
867
|
+
if (!resolved) { safeRelease(releaseActionFn, cooldownKey, 'link', configDir); return { ok: false }; }
|
|
814
868
|
const { adapter } = resolved;
|
|
815
869
|
|
|
816
870
|
if (adapter.type === 'github' && type.toLowerCase() !== 'duplicate') {
|
|
871
|
+
safeRelease(releaseActionFn, cooldownKey, 'link', configDir);
|
|
817
872
|
stream.write(` GitHub only supports linking as a duplicate — no generic link types. Got type "${type}".\n`);
|
|
818
873
|
return { ok: false };
|
|
819
874
|
}
|
|
@@ -824,11 +879,11 @@ export async function runTicketLink(cmdArgs, {
|
|
|
824
879
|
try {
|
|
825
880
|
const result = await adapter.linkTo(sourceKey, targetKey, type);
|
|
826
881
|
if (!result.executed) {
|
|
882
|
+
safeRelease(releaseActionFn, cooldownKey, 'link', configDir);
|
|
827
883
|
const optionsHint = result.options?.length ? ` Valid options: ${result.options.join(', ')}.` : '';
|
|
828
884
|
stream.write(` Not linked — ${result.reason}.${optionsHint}\n`);
|
|
829
885
|
return { ok: false, reason: result.reason };
|
|
830
886
|
}
|
|
831
|
-
recordActionFn(cooldownKey, 'link', { configDir });
|
|
832
887
|
logActionFn({ ticketKey: sourceKey, action: 'link', actor, tracker: adapter.type, detail: { targetKey, type } }, { configDir });
|
|
833
888
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
834
889
|
stream.write(
|
|
@@ -838,6 +893,7 @@ export async function runTicketLink(cmdArgs, {
|
|
|
838
893
|
);
|
|
839
894
|
return { ok: true };
|
|
840
895
|
} catch (err) {
|
|
896
|
+
safeRelease(releaseActionFn, cooldownKey, 'link', configDir);
|
|
841
897
|
stream.write(formatWriteFailure(sourceKey, err));
|
|
842
898
|
return { ok: false };
|
|
843
899
|
}
|
|
@@ -870,9 +926,11 @@ export async function runTicketUpdate(cmdArgs, {
|
|
|
870
926
|
isLicensedFn = isLicensed,
|
|
871
927
|
resolveConnectionFn = resolveConnection,
|
|
872
928
|
resolveAdapterFn = resolveAdapter,
|
|
873
|
-
|
|
874
|
-
|
|
929
|
+
claimActionFn = claimAction,
|
|
930
|
+
releaseActionFn = releaseAction,
|
|
875
931
|
logActionFn = logAction,
|
|
932
|
+
readMetadataCacheFn = readMetadataCache,
|
|
933
|
+
writeMetadataCacheFn = writeMetadataCache,
|
|
876
934
|
actor = os.userInfo().username,
|
|
877
935
|
} = {}) {
|
|
878
936
|
const usage = 'Usage: ticketlens update TICKET-KEY [--title="..."] [--description="..."] [--add-labels=a,b] [--remove-labels=c] [--priority="High"]\n';
|
|
@@ -894,34 +952,45 @@ export async function runTicketUpdate(cmdArgs, {
|
|
|
894
952
|
return { ok: false };
|
|
895
953
|
}
|
|
896
954
|
|
|
897
|
-
const
|
|
898
|
-
if (
|
|
899
|
-
stream.write(
|
|
955
|
+
const claim = safeClaim(claimActionFn, ticketKey, 'update', configDir);
|
|
956
|
+
if (!claim.claimed) {
|
|
957
|
+
stream.write(claim.lockError
|
|
958
|
+
? ` ${ticketKey} not updated — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
|
|
959
|
+
: ` Skipped — ${ticketKey} was already updated ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
900
960
|
return { ok: false };
|
|
901
961
|
}
|
|
902
962
|
|
|
903
963
|
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
904
|
-
if (!resolved) return { ok: false };
|
|
905
|
-
const { adapter } = resolved;
|
|
964
|
+
if (!resolved) { safeRelease(releaseActionFn, ticketKey, 'update', configDir); return { ok: false }; }
|
|
965
|
+
const { adapter, conn } = resolved;
|
|
906
966
|
|
|
907
967
|
if (adapter.type === 'github' && priority !== undefined) {
|
|
968
|
+
safeRelease(releaseActionFn, ticketKey, 'update', configDir);
|
|
908
969
|
stream.write(` GitHub Issues have no native priority field — cannot update priority on ${ticketKey}. Remove --priority and retry.\n`);
|
|
909
970
|
return { ok: false };
|
|
910
971
|
}
|
|
911
972
|
|
|
912
973
|
try {
|
|
913
|
-
const result = await adapter.updateFields(ticketKey, { title, description, priority, addLabels, removeLabels });
|
|
974
|
+
const result = await adapter.updateFields(ticketKey, { title, description, priority, addLabels, removeLabels }, { readMetadataCacheFn, writeMetadataCacheFn, profileName: conn.profileName, configDir });
|
|
914
975
|
const hasApplied = Object.keys(result.applied).length > 0;
|
|
915
976
|
const hasErrors = Object.keys(result.errors).length > 0;
|
|
916
977
|
|
|
917
978
|
if (hasApplied) {
|
|
918
|
-
recordActionFn(ticketKey, 'update', { configDir });
|
|
919
979
|
logActionFn({ ticketKey, action: 'update', actor, tracker: adapter.type, detail: { ...result.applied, failed: Object.keys(result.errors) } }, { configDir });
|
|
980
|
+
} else {
|
|
981
|
+
// Nothing landed — release so a caller fixing the failed field(s) can
|
|
982
|
+
// retry immediately, same "release on definite failure" rule as every
|
|
983
|
+
// other write in this family. A partial success (hasApplied &&
|
|
984
|
+
// hasErrors) deliberately keeps the claim armed, unchanged from the
|
|
985
|
+
// old recordActionFn-only-when-hasApplied behavior.
|
|
986
|
+
safeRelease(releaseActionFn, ticketKey, 'update', configDir);
|
|
920
987
|
}
|
|
921
988
|
stream.write(formatUpdateResult(ticketKey, result, createStyler({ isTTY: stream.isTTY })));
|
|
922
989
|
return hasErrors ? { ok: false, applied: result.applied, errors: result.errors } : { ok: true, applied: result.applied };
|
|
923
990
|
} catch (err) {
|
|
924
|
-
|
|
991
|
+
safeRelease(releaseActionFn, ticketKey, 'update', configDir);
|
|
992
|
+
const enrichment = await enrichUpdateFailure(err, { adapter, projectKey: projectKeyFromTicket(ticketKey), profileName: conn.profileName, configDir, readMetadataCacheFn, writeMetadataCacheFn });
|
|
993
|
+
stream.write(formatWriteFailure(ticketKey, err) + enrichment);
|
|
925
994
|
return { ok: false };
|
|
926
995
|
}
|
|
927
996
|
}
|
|
@@ -956,8 +1025,8 @@ export async function runTicketCreate(cmdArgs, {
|
|
|
956
1025
|
isLicensedFn = isLicensed,
|
|
957
1026
|
resolveConnectionFn = resolveConnection,
|
|
958
1027
|
resolveAdapterFn = resolveAdapter,
|
|
959
|
-
|
|
960
|
-
|
|
1028
|
+
claimActionFn = claimAction,
|
|
1029
|
+
releaseActionFn = releaseAction,
|
|
961
1030
|
logActionFn = logAction,
|
|
962
1031
|
readMetadataCacheFn = readMetadataCache,
|
|
963
1032
|
writeMetadataCacheFn = writeMetadataCache,
|
|
@@ -1022,9 +1091,11 @@ export async function runTicketCreate(cmdArgs, {
|
|
|
1022
1091
|
// text that can themselves contain ":", which would let two genuinely
|
|
1023
1092
|
// different tuples collide onto the same cooldown key.
|
|
1024
1093
|
const cooldownKey = `create:${JSON.stringify([project ?? '', type ?? '', summary])}`;
|
|
1025
|
-
const
|
|
1026
|
-
if (
|
|
1027
|
-
stream.write(
|
|
1094
|
+
const claim = safeClaim(claimActionFn, cooldownKey, 'create', configDir);
|
|
1095
|
+
if (!claim.claimed) {
|
|
1096
|
+
stream.write(claim.lockError
|
|
1097
|
+
? ` Nothing was created — could not take the cooldown lock (${claim.lockError}). Safe to retry.\n`
|
|
1098
|
+
: ` Skipped — a ticket with this summary was already created ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
1028
1099
|
return { ok: false };
|
|
1029
1100
|
}
|
|
1030
1101
|
|
|
@@ -1032,6 +1103,9 @@ export async function runTicketCreate(cmdArgs, {
|
|
|
1032
1103
|
try {
|
|
1033
1104
|
result = await adapter.createTicket({ project, type, summary, description });
|
|
1034
1105
|
} catch (err) {
|
|
1106
|
+
// Definite failure — no ticket was created, so the claim is released to
|
|
1107
|
+
// let a corrected retry through immediately (Backlog #34/ROADMAP 57).
|
|
1108
|
+
safeRelease(releaseActionFn, cooldownKey, 'create', configDir);
|
|
1035
1109
|
// profileName is only resolved when this failure is actually
|
|
1036
1110
|
// project/issuetype-shaped — not on every failure, and never on the
|
|
1037
1111
|
// success path — since it exists solely to scope the enrichment cache.
|
|
@@ -1045,12 +1119,13 @@ export async function runTicketCreate(cmdArgs, {
|
|
|
1045
1119
|
}
|
|
1046
1120
|
|
|
1047
1121
|
// The write already landed — a real, external, hard-to-walk-back ticket
|
|
1048
|
-
// now exists. From here on, nothing may report this as a failed write
|
|
1049
|
-
// cooldown
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1122
|
+
// now exists. From here on, nothing may report this as a failed write.
|
|
1123
|
+
// The cooldown claim was already recorded atomically at claim time above
|
|
1124
|
+
// (no separate recordActionFn call needed here); only the audit log is
|
|
1125
|
+
// best-effort — a logging failure must never make a real success look
|
|
1126
|
+
// like a failure (which risks a caller retrying and fabricating a
|
|
1127
|
+
// genuine duplicate).
|
|
1052
1128
|
try {
|
|
1053
|
-
recordActionFn(cooldownKey, 'create', { configDir });
|
|
1054
1129
|
logActionFn({ ticketKey: result.key, action: 'create', actor, tracker: adapter.type, detail: { project, type } }, { configDir });
|
|
1055
1130
|
} catch (bookkeepingErr) {
|
|
1056
1131
|
stream.write(` Warning: ${result.key} was created but could not be logged: ${bookkeepingErr.message}\n`);
|
|
@@ -22,7 +22,11 @@
|
|
|
22
22
|
* issueTypesByProject: {KEY: [{id, name}]},
|
|
23
23
|
* issueTypesFetchedAt: {KEY: iso timestamp} // per-project, either access pattern
|
|
24
24
|
* assignableUsersByProject: {KEY: {normalizedQuery: [{accountId, displayName}]}},
|
|
25
|
-
* assignableUsersFetchedAt: {KEY: {normalizedQuery: iso timestamp}}
|
|
25
|
+
* assignableUsersFetchedAt: {KEY: {normalizedQuery: iso timestamp}},
|
|
26
|
+
* prioritiesByProject: {KEY: [{id, name}]},
|
|
27
|
+
* prioritiesFetchedAt: {KEY: iso timestamp},
|
|
28
|
+
* labelsByTeam: {TEAM_ID: [{id, name}]},
|
|
29
|
+
* labelsFetchedAt: {TEAM_ID: iso timestamp}
|
|
26
30
|
* }
|
|
27
31
|
*
|
|
28
32
|
* assignableUsersByProject/assignableUsersFetchedAt (ROADMAP 61) share this
|
|
@@ -32,6 +36,20 @@
|
|
|
32
36
|
* `user/assignable/search` has no "list everyone" mode (query is required
|
|
33
37
|
* unless accountId is given) — there is no per-project roster to cache,
|
|
34
38
|
* only per-(project, query) result pages, each with its own clock.
|
|
39
|
+
*
|
|
40
|
+
* prioritiesByProject/prioritiesFetchedAt (Backlog #34/ROADMAP 57 follow-up)
|
|
41
|
+
* are per-project like issueTypesByProject, not global — confirmed live
|
|
42
|
+
* against both a Cloud instance (corenexus) and a Server/DC instance
|
|
43
|
+
* (advent) that priority schemes are project-scoped, not instance-wide.
|
|
44
|
+
* Reactive-enrichment-only, same 3-day SINGLE_PROJECT_TTL_MS bar, same
|
|
45
|
+
* design as issue-types: never blocks a write, only enriches the error
|
|
46
|
+
* message after Jira's own 400.
|
|
47
|
+
*
|
|
48
|
+
* labelsByTeam/labelsFetchedAt is Linear-only — Linear requires labels to
|
|
49
|
+
* pre-exist as real objects (unlike Jira's freeform labels), and its own
|
|
50
|
+
* `updateFields` re-fetches the full team label list via GraphQL on every
|
|
51
|
+
* single call with no caching. Keyed by team id (Linear's label scope),
|
|
52
|
+
* same 3-day freshness bar.
|
|
35
53
|
*/
|
|
36
54
|
|
|
37
55
|
import fs from 'node:fs';
|
|
@@ -105,6 +123,34 @@ export function mergeAssignableUsers(cached, projectKey, query, candidates, fetc
|
|
|
105
123
|
return { assignableUsersByProject, assignableUsersFetchedAt };
|
|
106
124
|
}
|
|
107
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Merges one project's priority list into an existing (possibly null)
|
|
128
|
+
* cached map — same shape and Object.create(null) defense as
|
|
129
|
+
* mergeProjectIssueTypes, for the same reason (projectKey is an
|
|
130
|
+
* unvalidated CLI/tracker value reaching a key position).
|
|
131
|
+
*/
|
|
132
|
+
export function mergeProjectPriorities(cached, projectKey, priorities, fetchedAt = new Date().toISOString()) {
|
|
133
|
+
const prioritiesByProject = Object.assign(Object.create(null), cached?.prioritiesByProject ?? {});
|
|
134
|
+
prioritiesByProject[projectKey] = priorities;
|
|
135
|
+
const prioritiesFetchedAt = Object.assign(Object.create(null), cached?.prioritiesFetchedAt ?? {});
|
|
136
|
+
prioritiesFetchedAt[projectKey] = fetchedAt;
|
|
137
|
+
return { prioritiesByProject, prioritiesFetchedAt };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Merges one team's label list into an existing (possibly null) cached
|
|
142
|
+
* map — same shape and Object.create(null) defense as
|
|
143
|
+
* mergeProjectIssueTypes; teamId is Linear's own UUID, not user-supplied,
|
|
144
|
+
* but the same defensive pattern is kept for consistency with its siblings.
|
|
145
|
+
*/
|
|
146
|
+
export function mergeTeamLabels(cached, teamId, labels, fetchedAt = new Date().toISOString()) {
|
|
147
|
+
const labelsByTeam = Object.assign(Object.create(null), cached?.labelsByTeam ?? {});
|
|
148
|
+
labelsByTeam[teamId] = labels;
|
|
149
|
+
const labelsFetchedAt = Object.assign(Object.create(null), cached?.labelsFetchedAt ?? {});
|
|
150
|
+
labelsFetchedAt[teamId] = fetchedAt;
|
|
151
|
+
return { labelsByTeam, labelsFetchedAt };
|
|
152
|
+
}
|
|
153
|
+
|
|
108
154
|
/**
|
|
109
155
|
* Returns the absolute path to the ticket-metadata cache file for a profile.
|
|
110
156
|
*/
|
|
@@ -127,7 +173,7 @@ export function metadataCachePath(profileName, configDir = DEFAULT_CONFIG_DIR) {
|
|
|
127
173
|
* @param {string|null} profileName
|
|
128
174
|
* @param {string} [configDir]
|
|
129
175
|
* @param {number} [ttlMs] - override TTL in ms for this file's own GC deletion; defaults to METADATA_TTL_MS (7d)
|
|
130
|
-
* @returns {{ projects: {key:string,name:string}[], issueTypesByProject: object, issueTypesFetchedAt: object, assignableUsersByProject: object, assignableUsersFetchedAt: object, projectsFetchedAt: string|null, fetchedAt: string } | null}
|
|
176
|
+
* @returns {{ projects: {key:string,name:string}[], issueTypesByProject: object, issueTypesFetchedAt: object, assignableUsersByProject: object, assignableUsersFetchedAt: object, prioritiesByProject: object, prioritiesFetchedAt: object, labelsByTeam: object, labelsFetchedAt: object, projectsFetchedAt: string|null, fetchedAt: string } | null}
|
|
131
177
|
*/
|
|
132
178
|
export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, ttlMs = METADATA_TTL_MS) {
|
|
133
179
|
const filePath = metadataCachePath(profileName, configDir);
|
|
@@ -152,6 +198,10 @@ export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, t
|
|
|
152
198
|
issueTypesFetchedAt: data.issueTypesFetchedAt ?? {},
|
|
153
199
|
assignableUsersByProject: data.assignableUsersByProject ?? {},
|
|
154
200
|
assignableUsersFetchedAt: data.assignableUsersFetchedAt ?? {},
|
|
201
|
+
prioritiesByProject: data.prioritiesByProject ?? {},
|
|
202
|
+
prioritiesFetchedAt: data.prioritiesFetchedAt ?? {},
|
|
203
|
+
labelsByTeam: data.labelsByTeam ?? {},
|
|
204
|
+
labelsFetchedAt: data.labelsFetchedAt ?? {},
|
|
155
205
|
projectsFetchedAt: data.projectsFetchedAt ?? null,
|
|
156
206
|
fetchedAt: data.fetchedAt,
|
|
157
207
|
};
|
|
@@ -164,7 +214,7 @@ export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, t
|
|
|
164
214
|
* updates (e.g. a single-project fetch merging into an existing
|
|
165
215
|
* multi-project cache) — this function persists exactly what it's given.
|
|
166
216
|
*/
|
|
167
|
-
export function writeMetadataCache(profileName, { projects = [], issueTypesByProject = {}, issueTypesFetchedAt = {}, assignableUsersByProject = {}, assignableUsersFetchedAt = {}, projectsFetchedAt = null } = {}, configDir = DEFAULT_CONFIG_DIR) {
|
|
217
|
+
export function writeMetadataCache(profileName, { projects = [], issueTypesByProject = {}, issueTypesFetchedAt = {}, assignableUsersByProject = {}, assignableUsersFetchedAt = {}, prioritiesByProject = {}, prioritiesFetchedAt = {}, labelsByTeam = {}, labelsFetchedAt = {}, projectsFetchedAt = null } = {}, configDir = DEFAULT_CONFIG_DIR) {
|
|
168
218
|
const filePath = metadataCachePath(profileName, configDir);
|
|
169
219
|
try {
|
|
170
220
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
@@ -176,6 +226,10 @@ export function writeMetadataCache(profileName, { projects = [], issueTypesByPro
|
|
|
176
226
|
issueTypesFetchedAt,
|
|
177
227
|
assignableUsersByProject,
|
|
178
228
|
assignableUsersFetchedAt,
|
|
229
|
+
prioritiesByProject,
|
|
230
|
+
prioritiesFetchedAt,
|
|
231
|
+
labelsByTeam,
|
|
232
|
+
labelsFetchedAt,
|
|
179
233
|
}));
|
|
180
234
|
} catch {
|
|
181
235
|
// Non-fatal
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failure-message enrichment for `ticket_update`'s priority field —
|
|
3
|
+
* reactive only, never runs on the success path. Same structure as
|
|
4
|
+
* ticket-create-enrichment.mjs's project/issuetype enrichment; kept as a
|
|
5
|
+
* separate file (not merged into that one) since the two enrich different
|
|
6
|
+
* fields for a different write and share no state beyond the cache module.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SINGLE_PROJECT_TTL_MS, isFresh, mergeProjectPriorities } from './ticket-metadata-cache.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Detects whether an update failure is shaped like a bad priority name —
|
|
13
|
+
* the only case this enrichment applies to. Jira surfaces this via its
|
|
14
|
+
* own real `err.details.errors.priority` key, confirmed by direct
|
|
15
|
+
* observation against a live Cloud instance (corenexus) and a live
|
|
16
|
+
* Server/DC instance (advent) on 2026-09-24. Anything else (rate limits,
|
|
17
|
+
* network errors, generic 4xx/5xx, a title/description/label failure)
|
|
18
|
+
* returns false — enrichment never applies there.
|
|
19
|
+
*/
|
|
20
|
+
export function detectPriorityError(err) {
|
|
21
|
+
return Boolean(err?.details?.errors && 'priority' in err.details.errors);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Best-effort failure-message enrichment for ticket_update's priority
|
|
26
|
+
* field — reactive only, never runs on the success path or for a
|
|
27
|
+
* non-priority failure. Reuses a cached per-project priority listing when
|
|
28
|
+
* fresh (no extra network call); refreshes it when missing/stale. A
|
|
29
|
+
* refresh failure is swallowed entirely and nothing is written to the
|
|
30
|
+
* cache: this can only ever make an error message MORE informative, never
|
|
31
|
+
* introduce a new way for ticketlens update to fail or a new way to
|
|
32
|
+
* poison the cache. Jira-only — GitHub has no priority field (refused
|
|
33
|
+
* upstream in ticket-command.mjs) and Linear already returns a structured,
|
|
34
|
+
* local `{reason:'not-found', options}` error with no network round trip.
|
|
35
|
+
*
|
|
36
|
+
* Freshness is keyed on the timestamp existing, not on the cached array
|
|
37
|
+
* having entries — a project with no priority-capable issue type at all
|
|
38
|
+
* genuinely caches `[]`, and `[].length` is falsy, so gating on length
|
|
39
|
+
* would re-walk every issue type (adapter.listPriorities' own sequential
|
|
40
|
+
* per-type loop) on every single subsequent failed update, forever,
|
|
41
|
+
* never respecting SINGLE_PROJECT_TTL_MS. Caught in code review before
|
|
42
|
+
* shipping — the sibling ticket-create-enrichment.mjs has this same
|
|
43
|
+
* length-gated pattern for issueTypesByProject, not fixed here since its
|
|
44
|
+
* refresh is a single call, not a sequential loop.
|
|
45
|
+
*/
|
|
46
|
+
export async function enrichUpdateFailure(err, { adapter, projectKey, profileName, configDir, readMetadataCacheFn, writeMetadataCacheFn }) {
|
|
47
|
+
if (!detectPriorityError(err) || adapter.type !== 'jira' || !projectKey) return '';
|
|
48
|
+
|
|
49
|
+
let cached = readMetadataCacheFn(profileName, configDir);
|
|
50
|
+
const hasFreshPriorities = cached?.prioritiesByProject?.[projectKey] !== undefined
|
|
51
|
+
&& isFresh(cached?.prioritiesFetchedAt?.[projectKey], SINGLE_PROJECT_TTL_MS);
|
|
52
|
+
|
|
53
|
+
if (!hasFreshPriorities) {
|
|
54
|
+
try {
|
|
55
|
+
const { prioritiesByProject, prioritiesFetchedAt } = mergeProjectPriorities(cached, projectKey, await adapter.listPriorities(projectKey));
|
|
56
|
+
cached = { ...cached, prioritiesByProject, prioritiesFetchedAt };
|
|
57
|
+
writeMetadataCacheFn(profileName, cached, configDir);
|
|
58
|
+
} catch {
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const priorities = cached?.prioritiesByProject?.[projectKey];
|
|
64
|
+
if (!priorities?.length) return '';
|
|
65
|
+
return ` Known priorities for ${projectKey}: ${priorities.map(p => p.name).join(', ')}.\n`;
|
|
66
|
+
}
|