ticketlens 0.38.54 → 0.38.56
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 +7 -5
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +6 -5
- package/skills/jtb/hooks/recall-auto-capture.mjs +6 -2
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/help.mjs +7 -4
- package/skills/jtb/scripts/lib/mcp-server.mjs +2 -1
- package/skills/jtb/scripts/lib/mcp-tool-schemas.mjs +3 -2
- package/skills/jtb/scripts/lib/run-issue-types.mjs +90 -8
- package/skills/jtb/scripts/lib/ticket-create-enrichment.mjs +23 -9
- package/skills/jtb/scripts/lib/ticket-metadata-cache.mjs +69 -17
package/README.md
CHANGED
|
@@ -462,7 +462,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
462
462
|
|
|
463
463
|
**Removing a note:** `ticketlens note delete --id="..." [--ticket=KEY]` removes a note from your local vault. Local only — if it was already pushed to a team, teammates who pulled it keep their copy; deleting it there too is a manager action from the Console (Admin > Recall).
|
|
464
464
|
|
|
465
|
-
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `review`, `standup`, `pr`, `stats`, `issue_types`, `history`, `collisions`, `ledger`, `doctor`, `recall_add`, `recall_update`, `recall_delete`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — every CLI action now has an MCP tool, so any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same license gate per tool, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. `fetch`, `doctor`, and `standup` are Free; `triage`'s base scan is Free with some options gated Pro/Team, same as the CLI (`ticketlens triage --help`); `compliance` and `pr` are Free, sharing a 3-checks/month cap on their requirements-coverage section, Pro unlimited; `compliance` additionally accepts `consensus: true` (Pro) to replace the local deterministic matcher with a multi-agent AI review, run server-side against a "consensus" role configured at Console > Admin > AI Roles (2+ providers from the team's shared pool), and always implies `--yes` under MCP since there's no TTY for the cost-confirmation prompt; `review` is Free for branch/files/ticket context, with its coverage/focus section requiring Pro as a plain license check — it does not draw from that same monthly counter; `stats` is Free with a 7-day lookback cap, Pro extends it to 30 days, same split as the CLI (`ticketlens stats --help`); `issue_types` is Free and Jira-only — pre-fetches and caches a profile's real creatable projects and issue types ahead of a `ticket_create` attempt, sharing its cache with that tool's own reactive enrichment; Linear/GitHub profiles get a clear "not available" instead of an empty result; `history` reads local triage history only (zero network) and requires Pro; `collisions` requires `ticketlens login` (Console access) plus a Team license; `ledger` exports the local, signed compliance audit trail (zero network) and requires Pro; every other tool needs Pro. `recall_update` overwrites an existing Recall note's body — internal plumbing for the note quality loop, not typically called directly; its `attachments` array appends new files to whatever the note already has, same as `recall_add`'s, never replacing existing ones. `recall_delete` is destructive and local-vault-only — requires `confirm: true` alongside `id` to actually execute; there is no interactive y/N prompt under MCP (no real terminal to prompt against), so omitting it always fails rather than silently blocking. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
|
|
465
|
+
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `review`, `standup`, `pr`, `stats`, `issue_types`, `history`, `collisions`, `ledger`, `doctor`, `recall_add`, `recall_update`, `recall_delete`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — every CLI action now has an MCP tool, so any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same license gate per tool, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. `fetch`, `doctor`, and `standup` are Free; `triage`'s base scan is Free with some options gated Pro/Team, same as the CLI (`ticketlens triage --help`); `compliance` and `pr` are Free, sharing a 3-checks/month cap on their requirements-coverage section, Pro unlimited; `compliance` additionally accepts `consensus: true` (Pro) to replace the local deterministic matcher with a multi-agent AI review, run server-side against a "consensus" role configured at Console > Admin > AI Roles (2+ providers from the team's shared pool), and always implies `--yes` under MCP since there's no TTY for the cost-confirmation prompt; `review` is Free for branch/files/ticket context, with its coverage/focus section requiring Pro as a plain license check — it does not draw from that same monthly counter; `stats` is Free with a 7-day lookback cap, Pro extends it to 30 days, same split as the CLI (`ticketlens stats --help`); `issue_types` is Free and Jira-only — pre-fetches and caches a profile's real creatable projects and issue types ahead of a `ticket_create` attempt, sharing its cache with that tool's own reactive enrichment; accepts an optional `project` to skip the full scan and return just that one project's types (3-day cache, vs. 7-day for the full scan); Linear/GitHub profiles get a clear "not available" instead of an empty result; `history` reads local triage history only (zero network) and requires Pro; `collisions` requires `ticketlens login` (Console access) plus a Team license; `ledger` exports the local, signed compliance audit trail (zero network) and requires Pro; every other tool needs Pro. `recall_update` overwrites an existing Recall note's body — internal plumbing for the note quality loop, not typically called directly; its `attachments` array appends new files to whatever the note already has, same as `recall_add`'s, never replacing existing ones. `recall_delete` is destructive and local-vault-only — requires `confirm: true` alongside `id` to actually execute; there is no interactive y/N prompt under MCP (no real terminal to prompt against), so omitting it always fails rather than silently blocking. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
|
|
466
466
|
|
|
467
467
|
`note add`'s save confirmation and `recall`'s search results are styled by default in a terminal; add `--plain` to either for bare, pipe-safe output. `recall` always shows each note's file ID (e.g. `[1784135399545-fe01c4.md]`) so you can open it directly (`cat ~/.ticketlens/recall/<PREFIX>/<id>`), or pass `--full` to print the full body content inline instead. Each result shows a relative time (`2h ago`, `3d ago`) rather than a bare date — the full-precision timestamp is always in the note file's own frontmatter.
|
|
468
468
|
|
|
@@ -508,7 +508,7 @@ Write directly to the ticket in its real tracker — Jira, GitHub, or Linear —
|
|
|
508
508
|
|
|
509
509
|
`--attach=path1,path2` (comma-separated local file paths) is available on `comment` and `create` only. Images render as an inline thumbnail on Jira and Linear; GitHub has no attachment upload API, so `--attach` is unsupported there.
|
|
510
510
|
|
|
511
|
-
**A bad `--project`/`--type` gets a better error, automatically.** If create fails because the project or issue type doesn't exist, TicketLens fetches your tracker's real, current project list (and, for Jira, the real issue types for that project) and shows them alongside the failure — e.g. `Known creatable projects: CNV1, CNV2.` — rather than a bare tracker error. This is reactive only: it never runs on a successful create, never auto-retries the write, and is cached locally per profile for
|
|
511
|
+
**A bad `--project`/`--type` gets a better error, automatically.** If create fails because the project or issue type doesn't exist, TicketLens fetches your tracker's real, current project list (and, for Jira, the real issue types for that project) and shows them alongside the failure — e.g. `Known creatable projects: CNV1, CNV2.` — rather than a bare tracker error. This is reactive only: it never runs on a successful create, never auto-retries the write, and is cached locally per profile for 3 days (a targeted, single-project lookup — same bar `issue-types --project=KEY` uses) so a burst of failed attempts doesn't re-fetch every time. `ticketlens issue-types` (below) is the proactive counterpart — pre-fetches and caches the same data ahead of time, so a `create` right after it is a pure cache hit.
|
|
512
512
|
|
|
513
513
|
All six write actions (comment/transition/assign/link/update/create) have a short local debounce (10s) against an accidental double-fire (a flaky retry, hitting enter twice), and every successful write is appended to a local, append-only audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — unlike Recall notes, ticket writes aren't naturally idempotent, so a timed-out attempt is surfaced to you instead of silently repeated. `duplicates` has neither, since nothing is written.
|
|
514
514
|
|
|
@@ -540,13 +540,14 @@ A one-line summary footer is also appended automatically to `ticketlens triage`
|
|
|
540
540
|
### Pre-fetch Issue Types
|
|
541
541
|
|
|
542
542
|
```bash
|
|
543
|
-
ticketlens issue-types # Pre-fetch/cache
|
|
543
|
+
ticketlens issue-types # Pre-fetch/cache every project this connection sees + Jira issue types
|
|
544
544
|
ticketlens issue-types --profile=acme # Target a specific profile
|
|
545
|
+
ticketlens issue-types --project=PROJ # Only this project — skips the full scan, 3-day cache
|
|
545
546
|
ticketlens issue-types --refresh # Force a live fetch, bypassing the cache
|
|
546
547
|
ticketlens issue-types --format=json # JSON output for scripting
|
|
547
548
|
```
|
|
548
549
|
|
|
549
|
-
Fetches a profile's real creatable projects and, for Jira, each project's valid issue types — the proactive counterpart to `create`'s reactive failure-message enrichment (above): a ready lookup instead of only learning them from a failed create's error. Jira only — Linear has no per-project issue-type concept and GitHub has neither, so both report a clear "not available" instead of an empty result. Free, no license gate. Shares its cache with `create`'s enrichment (`~/.ticketlens/cache/PROFILE/ticket-metadata.json
|
|
550
|
+
Fetches a profile's real creatable projects and, for Jira, each project's valid issue types — the proactive counterpart to `create`'s reactive failure-message enrichment (above): a ready lookup instead of only learning them from a failed create's error. Jira only — Linear has no per-project issue-type concept and GitHub has neither, so both report a clear "not available" instead of an empty result. Free, no license gate. `--project=KEY` skips the full project-list scan and returns just that one project's types — a targeted, "on purpose" lookup, so it trusts its cache entry for 3 days instead of the full scan's 7. Shares its cache with `create`'s enrichment (`~/.ticketlens/cache/PROFILE/ticket-metadata.json`), so a `create` failure right after this command is a pure cache hit, no extra network round-trip.
|
|
550
551
|
|
|
551
552
|
---
|
|
552
553
|
|
|
@@ -844,8 +845,9 @@ ticketlens stats --days=14 # Extend lookback window (Pro, max
|
|
|
844
845
|
ticketlens stats --format=json # JSON output for scripting
|
|
845
846
|
|
|
846
847
|
# ── Issue Types ───────────────────────────────────────────────────────────────
|
|
847
|
-
ticketlens issue-types # Pre-fetch/cache
|
|
848
|
+
ticketlens issue-types # Pre-fetch/cache every project this connection sees + Jira issue types
|
|
848
849
|
ticketlens issue-types --profile=acme # Target a specific profile
|
|
850
|
+
ticketlens issue-types --project=PROJ # Only this project — skips the full scan, 3-day cache
|
|
849
851
|
ticketlens issue-types --refresh # Force a live fetch, bypassing the cache
|
|
850
852
|
ticketlens issue-types --format=json # JSON output for scripting
|
|
851
853
|
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.43.
|
|
1
|
+
<!-- jtb-skill-version: 0.43.3 -->
|
|
2
2
|
---
|
|
3
3
|
name: jtb
|
|
4
4
|
description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
|
|
@@ -42,7 +42,8 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
42
42
|
/jtb stats # personal response-time metrics from local history
|
|
43
43
|
/jtb stats --days=14 # extend lookback window (Pro, max 30)
|
|
44
44
|
/jtb stats --format=json # JSON output for scripting
|
|
45
|
-
/jtb issue-types # pre-fetch/cache
|
|
45
|
+
/jtb issue-types # pre-fetch/cache every project's valid Jira issue types
|
|
46
|
+
/jtb issue-types --project=PROJ # only this project — skips the full scan, 3-day cache
|
|
46
47
|
/jtb issue-types --refresh # force a live fetch, bypassing the cache
|
|
47
48
|
/jtb collisions # show branch collisions with teammates (Team)
|
|
48
49
|
/jtb collisions --json # machine-readable output
|
|
@@ -160,13 +161,13 @@ Run:
|
|
|
160
161
|
ticketlens issue-types $EXTRA_ARGS
|
|
161
162
|
```
|
|
162
163
|
|
|
163
|
-
Where `$EXTRA_ARGS` are any flags passed (e.g. `--profile=acme --refresh --format=json`).
|
|
164
|
+
Where `$EXTRA_ARGS` are any flags passed (e.g. `--profile=acme --project=PROJ --refresh --format=json`).
|
|
164
165
|
|
|
165
|
-
Pre-fetches and caches a profile's real creatable projects and their valid Jira issue types, ahead of a `ticketlens create` attempt — a ready lookup instead of only learning them from a failed create's error message. Jira only; Linear (no per-project issue-type concept) and GitHub (neither) report a clear "not available" instead of an empty result. Free, no license gate. `--profile=NAME` (target a specific tracker profile); `--refresh` (force a live fetch even if a
|
|
166
|
+
Pre-fetches and caches a profile's real creatable projects and their valid Jira issue types, ahead of a `ticketlens create` attempt — a ready lookup instead of only learning them from a failed create's error message. Jira only; Linear (no per-project issue-type concept) and GitHub (neither) report a clear "not available" instead of an empty result. Free, no license gate. `--profile=NAME` (target a specific tracker profile); `--project=KEY` (only this project — skips the full project scan, 3-day cache TTL since a targeted lookup is "on purpose"; without it, every project this connection can see, 7-day TTL); `--refresh` (force a live fetch even if a fresh cache entry already exists); `--format=plain` (default, human-readable table) or `--format=json` (scripting). Shares its cache with `ticketlens create`'s own failure-message enrichment (`~/.ticketlens/cache/PROFILE/ticket-metadata.json`) — a create failure right after this command is a pure cache hit.
|
|
166
167
|
|
|
167
168
|
Display the script's stdout directly. No plan mode. Stop here.
|
|
168
169
|
|
|
169
|
-
If this harness has TicketLens's MCP server configured (a tool named `issue_types` — often shown as `mcp__ticketlens__issue_types` — visible in your tool list), prefer it over the bash form: same Jira-only scope, same shared cache, no shell command to construct. It accepts `profile`/`refresh`/`format`.
|
|
170
|
+
If this harness has TicketLens's MCP server configured (a tool named `issue_types` — often shown as `mcp__ticketlens__issue_types` — visible in your tool list), prefer it over the bash form: same Jira-only scope, same shared cache, no shell command to construct. It accepts `profile`/`project`/`refresh`/`format`.
|
|
170
171
|
|
|
171
172
|
---
|
|
172
173
|
|
|
@@ -33,6 +33,7 @@ import { buildCaptureExcerpt, privateTmpDir } from './recall-nudge-lib.mjs';
|
|
|
33
33
|
import { autoCapture } from '../scripts/lib/summarizer.mjs';
|
|
34
34
|
import { runNoteAdd } from '../scripts/lib/note-command.mjs';
|
|
35
35
|
import { DEFAULT_CONFIG_DIR } from '../scripts/lib/config.mjs';
|
|
36
|
+
import { apiBase } from '../scripts/lib/api-utils.mjs';
|
|
36
37
|
|
|
37
38
|
/** Buffers stream.write() calls instead of touching a real stream — same shape mcp-server.mjs's own capturingStream() uses, this process has no real stdout/stderr worth writing to. */
|
|
38
39
|
function capturingStream() {
|
|
@@ -40,9 +41,12 @@ function capturingStream() {
|
|
|
40
41
|
return { write(s) { parts.push(s); return true; }, get text() { return parts.join(''); } };
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
// backlog #33: every outcome logs its resolved backend URL — "Unauthorized"/
|
|
45
|
+
// "not logged in" reports had no way to attribute which backend was hit
|
|
46
|
+
// (ngrok tunnel rotation and local-dev fallback both silently swap it).
|
|
43
47
|
function logLine(message) {
|
|
44
48
|
try {
|
|
45
|
-
const line = `${new Date().toISOString()} ${message}\n`;
|
|
49
|
+
const line = `${new Date().toISOString()} ${message} [api=${apiBase()}]\n`;
|
|
46
50
|
appendFileSync(join(privateTmpDir(), 'auto-capture.log'), line);
|
|
47
51
|
} catch { /* best-effort — losing a debug log line is not worth failing over */ }
|
|
48
52
|
}
|
|
@@ -85,7 +89,7 @@ export async function runAutoCapture({
|
|
|
85
89
|
try {
|
|
86
90
|
result = await autoCaptureFn({ excerpt, ticketKey, cliToken });
|
|
87
91
|
} catch (err) {
|
|
88
|
-
logLine(`error: ${err.message}`);
|
|
92
|
+
logLine(`error: ${err.message}${err.status ? ` (status=${err.status})` : ''}`);
|
|
89
93
|
return { outcome: 'error', error: err.message };
|
|
90
94
|
}
|
|
91
95
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Centralised here to avoid triplicating the regex and warning logic.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export const DEFAULT_API_BASE = '
|
|
6
|
+
export const DEFAULT_API_BASE = 'http://api.ticketlens.test';
|
|
7
7
|
export const DEFAULT_SITE_BASE = 'https://ticketlens.app';
|
|
8
8
|
|
|
9
9
|
// Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
|
|
@@ -62,7 +62,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
62
62
|
` ${s.brand('ticketlens')} link ${s.dim('<SOURCE> <TARGET> [--type=... --confirm]')} Link two tickets ${s.dim('[Pro]')}`,
|
|
63
63
|
` ${s.brand('ticketlens')} update ${s.dim('<TICKET-KEY> [--title=... --description=... --priority=... --add-labels=... --remove-labels=...]')} Update fields ${s.dim('[Pro]')}`,
|
|
64
64
|
` ${s.brand('ticketlens')} create ${s.dim('--project=... [--type=...] --summary=... [--description=...] [--attach=...]')} Create a new ticket ${s.dim('[Pro]')}`,
|
|
65
|
-
` ${s.brand('ticketlens')} issue-types ${s.dim('[--profile=NAME] [--refresh]')} Pre-fetch valid Jira issue types ${s.dim('[Jira only]')}`,
|
|
65
|
+
` ${s.brand('ticketlens')} issue-types ${s.dim('[--profile=NAME] [--project=KEY] [--refresh]')} Pre-fetch valid Jira issue types ${s.dim('[Jira only]')}`,
|
|
66
66
|
'',
|
|
67
67
|
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
|
|
68
68
|
` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
|
|
@@ -1335,7 +1335,7 @@ export function printIssueTypesHelp({ stream = process.stdout } = {}) {
|
|
|
1335
1335
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
1336
1336
|
const lines = [
|
|
1337
1337
|
'',
|
|
1338
|
-
` ${s.bold(s.brand('ticketlens'))} ${s.bold('issue-types')} ${s.dim('[--profile=NAME] [--refresh] [--format=plain|json]')}`,
|
|
1338
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('issue-types')} ${s.dim('[--profile=NAME] [--project=KEY] [--refresh] [--format=plain|json]')}`,
|
|
1339
1339
|
'',
|
|
1340
1340
|
` Pre-fetch and cache a profile's real creatable projects and their valid`,
|
|
1341
1341
|
` Jira issue types, ahead of a ${s.brand('ticketlens create')} attempt — a ready lookup`,
|
|
@@ -1343,12 +1343,14 @@ export function printIssueTypesHelp({ stream = process.stdout } = {}) {
|
|
|
1343
1343
|
` Jira only — Linear has no per-project issue-type concept and GitHub has`,
|
|
1344
1344
|
` neither, so both report a clear "not available" instead of an empty result.`,
|
|
1345
1345
|
` Shares its cache with ${s.brand('ticketlens create')}'s own failure-message enrichment`,
|
|
1346
|
-
` (${s.dim('~/.ticketlens/cache/PROFILE/ticket-metadata.json')}
|
|
1346
|
+
` (${s.dim('~/.ticketlens/cache/PROFILE/ticket-metadata.json')}).`,
|
|
1347
1347
|
'',
|
|
1348
1348
|
` ${s.bold('OPTIONS')}`,
|
|
1349
1349
|
'',
|
|
1350
1350
|
` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific tracker profile`,
|
|
1351
|
-
` ${s.brand('--
|
|
1351
|
+
` ${s.brand('--project')}=${s.dim('KEY')} Only this project — skips the full scan, 3-day cache TTL`,
|
|
1352
|
+
` ${s.dim('(no --project: every project this connection sees, 7-day TTL)')}`,
|
|
1353
|
+
` ${s.brand('--refresh')} Force a live fetch even if a fresh cache entry exists`,
|
|
1352
1354
|
` ${s.brand('--format')}=${s.dim('plain')} Human-readable table ${s.dim('(default)')}`,
|
|
1353
1355
|
` ${s.brand('--format')}=${s.dim('json')} JSON output for scripting/piping`,
|
|
1354
1356
|
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
@@ -1357,6 +1359,7 @@ export function printIssueTypesHelp({ stream = process.stdout } = {}) {
|
|
|
1357
1359
|
'',
|
|
1358
1360
|
` ${s.dim('$')} ticketlens issue-types`,
|
|
1359
1361
|
` ${s.dim('$')} ticketlens issue-types --profile=myteam --refresh`,
|
|
1362
|
+
` ${s.dim('$')} ticketlens issue-types --project=PROJ`,
|
|
1360
1363
|
` ${s.dim('$')} ticketlens issue-types --format=json | jq .`,
|
|
1361
1364
|
'',
|
|
1362
1365
|
];
|
|
@@ -312,9 +312,10 @@ async function callStats(args, { configDir, runStatsFn }) {
|
|
|
312
312
|
return callPrintWarnRun(buildStatsArgs, args, { configDir, runFn: runStatsFn }, 'stats failed');
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
-
function buildIssueTypesArgs({ profile, refresh, format }) {
|
|
315
|
+
function buildIssueTypesArgs({ profile, project, refresh, format }) {
|
|
316
316
|
const args = [];
|
|
317
317
|
if (profile) args.push(`--profile=${profile}`);
|
|
318
|
+
if (project) args.push(`--project=${project}`);
|
|
318
319
|
if (refresh === true) args.push('--refresh');
|
|
319
320
|
if (format) args.push(`--format=${format}`);
|
|
320
321
|
return args;
|
|
@@ -113,12 +113,13 @@ export const TOOLS = [
|
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
115
|
name: 'issue_types',
|
|
116
|
-
description: 'Pre-fetch and cache a profile\'s real creatable projects and their valid Jira issue types, ahead of a `ticket_create` attempt — a ready lookup instead of only learning them from a failed create\'s error message. Jira only; Linear/GitHub report "not available" (Linear has no per-project issue-type concept, GitHub has neither). Shares its cache with ticket_create\'s own failure-message enrichment
|
|
116
|
+
description: 'Pre-fetch and cache a profile\'s real creatable projects and their valid Jira issue types, ahead of a `ticket_create` attempt — a ready lookup instead of only learning them from a failed create\'s error message. Jira only; Linear/GitHub report "not available" (Linear has no per-project issue-type concept, GitHub has neither). Shares its cache with ticket_create\'s own failure-message enrichment — a create failure right after this call is a pure cache hit, no extra network round-trip. Without `project`, lists every project this connection can see (7-day cache); with `project`, skips the full scan and returns just that one project\'s issue types (3-day cache, since a targeted lookup is "on purpose" and expects fresher data).',
|
|
117
117
|
inputSchema: {
|
|
118
118
|
type: 'object',
|
|
119
119
|
properties: {
|
|
120
120
|
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
121
|
-
|
|
121
|
+
project: { type: 'string', description: 'Only fetch this project\'s issue types instead of scanning every project the connection can see. Skips the full project-list call; uses a shorter 3-day cache TTL.' },
|
|
122
|
+
refresh: { type: 'boolean', description: 'Force a live fetch even if a fresh cache entry already exists.' },
|
|
122
123
|
format: { type: 'string', enum: ['plain', 'json'], description: 'Output shape: "plain" (default) is a human-readable table; "json" is structured for scripting.' },
|
|
123
124
|
},
|
|
124
125
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_CONFIG_DIR, timeAgo } from './config.mjs';
|
|
2
2
|
import { resolveConnection } from './profile-resolver.mjs';
|
|
3
3
|
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
4
|
-
import { readMetadataCache, writeMetadataCache } from './ticket-metadata-cache.mjs';
|
|
4
|
+
import { readMetadataCache, writeMetadataCache, METADATA_TTL_MS, SINGLE_PROJECT_TTL_MS, isFresh, mergeProjectIssueTypes } from './ticket-metadata-cache.mjs';
|
|
5
5
|
import { createStyler } from './ansi.mjs';
|
|
6
6
|
import { handleUnknownFlags } from './arg-validator.mjs';
|
|
7
7
|
import { printIssueTypesHelp } from './help.mjs';
|
|
@@ -11,12 +11,23 @@ import { formatTable } from './table-formatter.mjs';
|
|
|
11
11
|
// ticket-create-enrichment.mjs's reactive path (one project at a time, on a
|
|
12
12
|
// create failure) can have projects with no recorded issue types yet. Showing
|
|
13
13
|
// that as "the" answer would silently omit the rest of the profile's projects.
|
|
14
|
+
// Recency is checked per-project (not just the whole file's own GC marker) —
|
|
15
|
+
// a project's entry can be older than the file itself if only OTHER projects
|
|
16
|
+
// were refreshed since (e.g. by an intervening --project=KEY fetch). A cache
|
|
17
|
+
// written before issueTypesFetchedAt/projectsFetchedAt existed has neither
|
|
18
|
+
// field, so isFresh(undefined, ...) correctly treats it as stale — one live
|
|
19
|
+
// full scan repopulates both, then reuse resumes as normal.
|
|
14
20
|
function isCacheComplete(cached) {
|
|
15
21
|
if (!cached || cached.projects.length === 0) return false;
|
|
16
|
-
|
|
22
|
+
if (!isFresh(cached.projectsFetchedAt, METADATA_TTL_MS)) return false;
|
|
23
|
+
return cached.projects.every(p => {
|
|
24
|
+
const types = cached.issueTypesByProject[p.key];
|
|
25
|
+
if (!types || types.length === 0) return false;
|
|
26
|
+
return isFresh(cached.issueTypesFetchedAt?.[p.key], METADATA_TTL_MS);
|
|
27
|
+
});
|
|
17
28
|
}
|
|
18
29
|
|
|
19
|
-
function render({ print, format, projects, issueTypesByProject, fetchedAt, cached }) {
|
|
30
|
+
function render({ print, format, projects, issueTypesByProject, fetchedAt, cached, ttlLabel }) {
|
|
20
31
|
if (format === 'json') {
|
|
21
32
|
print(JSON.stringify({ projects, issueTypesByProject, fetchedAt, cached }, null, 2) + '\n');
|
|
22
33
|
return;
|
|
@@ -38,7 +49,61 @@ function render({ print, format, projects, issueTypesByProject, fetchedAt, cache
|
|
|
38
49
|
print(formatTable(['Project', 'Issue Types'], rows) + '\n');
|
|
39
50
|
print(`\n ${s.dim(cached
|
|
40
51
|
? `Cached ${timeAgo(fetchedAt)} — pass --refresh to force a live fetch.`
|
|
41
|
-
:
|
|
52
|
+
: `Fetched live and cached for ${ttlLabel}.`)}\n\n`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A targeted `--project=KEY` lookup — "on purpose," so it trusts a shorter
|
|
57
|
+
* SINGLE_PROJECT_TTL_MS (3d) than a full scan's 7d, and merge-writes just
|
|
58
|
+
* this one project into the shared cache instead of replacing it (same
|
|
59
|
+
* read-merge-write pattern ticket-create-enrichment.mjs already uses for
|
|
60
|
+
* its own single-project reactive path). No display name is available
|
|
61
|
+
* without the full project-list scan, so `name` is honestly null rather
|
|
62
|
+
* than guessed.
|
|
63
|
+
*/
|
|
64
|
+
async function runSingleProject({ print, warn, format, projectKey, forceRefresh, adapter, profileName, configDir, readMetadataCacheFn, writeMetadataCacheFn }) {
|
|
65
|
+
const cached = !forceRefresh ? readMetadataCacheFn(profileName, configDir) : null;
|
|
66
|
+
const cachedTypes = cached?.issueTypesByProject?.[projectKey];
|
|
67
|
+
|
|
68
|
+
if (cachedTypes?.length && isFresh(cached.issueTypesFetchedAt?.[projectKey], SINGLE_PROJECT_TTL_MS)) {
|
|
69
|
+
render({
|
|
70
|
+
print, format,
|
|
71
|
+
projects: [{ key: projectKey, name: null }],
|
|
72
|
+
issueTypesByProject: { [projectKey]: cachedTypes },
|
|
73
|
+
fetchedAt: cached.issueTypesFetchedAt[projectKey],
|
|
74
|
+
cached: true,
|
|
75
|
+
});
|
|
76
|
+
return { ok: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let types;
|
|
80
|
+
try {
|
|
81
|
+
types = await adapter.listIssueTypes(projectKey);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
warn(` Could not fetch issue types: ${err.message}\n`);
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
return { ok: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const now = new Date().toISOString();
|
|
89
|
+
const { issueTypesByProject, issueTypesFetchedAt } = mergeProjectIssueTypes(cached, projectKey, types, now);
|
|
90
|
+
|
|
91
|
+
writeMetadataCacheFn(profileName, {
|
|
92
|
+
projects: cached?.projects ?? [],
|
|
93
|
+
issueTypesByProject,
|
|
94
|
+
issueTypesFetchedAt,
|
|
95
|
+
projectsFetchedAt: cached?.projectsFetchedAt ?? null,
|
|
96
|
+
}, configDir);
|
|
97
|
+
|
|
98
|
+
render({
|
|
99
|
+
print, format,
|
|
100
|
+
projects: [{ key: projectKey, name: null }],
|
|
101
|
+
issueTypesByProject: { [projectKey]: types },
|
|
102
|
+
fetchedAt: now,
|
|
103
|
+
cached: false,
|
|
104
|
+
ttlLabel: '3 days',
|
|
105
|
+
});
|
|
106
|
+
return { ok: true };
|
|
42
107
|
}
|
|
43
108
|
|
|
44
109
|
/**
|
|
@@ -48,6 +113,7 @@ function render({ print, format, projects, issueTypesByProject, fetchedAt, cache
|
|
|
48
113
|
* attempt instead of only learning them from a failed create's error.
|
|
49
114
|
* Writes through the same ticket-metadata-cache.mjs file/TTL that enrichment
|
|
50
115
|
* reads, so a create failure right after this command is a pure cache hit.
|
|
116
|
+
* `--project=KEY` narrows to a single project — see runSingleProject above.
|
|
51
117
|
*
|
|
52
118
|
* @param {string[]} args
|
|
53
119
|
* @returns {Promise<{ ok: boolean }>}
|
|
@@ -68,13 +134,14 @@ export async function runIssueTypes(args = [], opts = {}) {
|
|
|
68
134
|
|
|
69
135
|
const validated = await handleUnknownFlags(
|
|
70
136
|
args,
|
|
71
|
-
['--help', '-h', '--profile=', '--refresh', '--format='],
|
|
137
|
+
['--help', '-h', '--profile=', '--refresh', '--format=', '--project='],
|
|
72
138
|
{ hints: [] },
|
|
73
139
|
);
|
|
74
140
|
if (validated === null) { process.exitCode = 1; return { ok: false }; }
|
|
75
141
|
|
|
76
142
|
const profileArg = args.find(a => a.startsWith('--profile='));
|
|
77
143
|
const formatArg = args.find(a => a.startsWith('--format='));
|
|
144
|
+
const projectArg = args.find(a => a.startsWith('--project='));
|
|
78
145
|
const forceRefresh = args.includes('--refresh');
|
|
79
146
|
|
|
80
147
|
const format = formatArg ? formatArg.split('=')[1] : 'plain';
|
|
@@ -84,6 +151,13 @@ export async function runIssueTypes(args = [], opts = {}) {
|
|
|
84
151
|
return { ok: false };
|
|
85
152
|
}
|
|
86
153
|
|
|
154
|
+
const projectKey = projectArg ? projectArg.split('=')[1] : undefined;
|
|
155
|
+
if (projectArg && !projectKey) {
|
|
156
|
+
warn('Error: --project requires a value, e.g. --project=PROJ\n');
|
|
157
|
+
process.exitCode = 1;
|
|
158
|
+
return { ok: false };
|
|
159
|
+
}
|
|
160
|
+
|
|
87
161
|
const explicitProfile = profileArg ? profileArg.split('=')[1] : undefined;
|
|
88
162
|
const cwd = opts.cwd ?? process.cwd();
|
|
89
163
|
const conn = resolveConnectionFn(null, {
|
|
@@ -107,10 +181,14 @@ export async function runIssueTypes(args = [], opts = {}) {
|
|
|
107
181
|
|
|
108
182
|
const profileName = conn.profileName ?? 'default';
|
|
109
183
|
|
|
184
|
+
if (projectKey) {
|
|
185
|
+
return runSingleProject({ print, warn, format, projectKey, forceRefresh, adapter, profileName, configDir, readMetadataCacheFn, writeMetadataCacheFn });
|
|
186
|
+
}
|
|
187
|
+
|
|
110
188
|
if (!forceRefresh) {
|
|
111
189
|
const cached = readMetadataCacheFn(profileName, configDir);
|
|
112
190
|
if (isCacheComplete(cached)) {
|
|
113
|
-
render({ print, format, projects: cached.projects, issueTypesByProject: cached.issueTypesByProject, fetchedAt: cached.
|
|
191
|
+
render({ print, format, projects: cached.projects, issueTypesByProject: cached.issueTypesByProject, fetchedAt: cached.projectsFetchedAt, cached: true });
|
|
114
192
|
return { ok: true };
|
|
115
193
|
}
|
|
116
194
|
}
|
|
@@ -128,7 +206,11 @@ export async function runIssueTypes(args = [], opts = {}) {
|
|
|
128
206
|
return { ok: false };
|
|
129
207
|
}
|
|
130
208
|
|
|
131
|
-
|
|
132
|
-
|
|
209
|
+
const now = new Date().toISOString();
|
|
210
|
+
const issueTypesFetchedAt = Object.create(null);
|
|
211
|
+
for (const p of projects) issueTypesFetchedAt[p.key] = now;
|
|
212
|
+
|
|
213
|
+
writeMetadataCacheFn(profileName, { projects, issueTypesByProject, issueTypesFetchedAt, projectsFetchedAt: now }, configDir);
|
|
214
|
+
render({ print, format, projects, issueTypesByProject, fetchedAt: now, cached: false, ttlLabel: '7 days' });
|
|
133
215
|
return { ok: true };
|
|
134
216
|
}
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* of ticket-command.mjs's routing logic.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { SINGLE_PROJECT_TTL_MS, isFresh, mergeProjectIssueTypes } from './ticket-metadata-cache.mjs';
|
|
10
|
+
|
|
9
11
|
/**
|
|
10
12
|
* Detects whether a create failure is shaped like a project/issuetype
|
|
11
13
|
* mismatch — the only case cache-refresh enrichment applies to. Jira
|
|
@@ -45,22 +47,34 @@ export async function enrichCreateFailure(err, { adapter, project, profileName,
|
|
|
45
47
|
// hit as fully sufficient silently drops the other half of a later,
|
|
46
48
|
// differently-shaped error's enrichment (caught via live-instance
|
|
47
49
|
// testing, not by unit tests alone).
|
|
50
|
+
//
|
|
51
|
+
// This is a targeted, single-project fetch — "on purpose," same pattern
|
|
52
|
+
// as `issue-types --project=KEY` — so issue-types presence alone isn't
|
|
53
|
+
// enough: it must also be fresh within SINGLE_PROJECT_TTL_MS (3 days),
|
|
54
|
+
// not the longer full-scan bar.
|
|
55
|
+
const hasFreshIssueTypes = cached?.issueTypesByProject?.[project]?.length
|
|
56
|
+
&& isFresh(cached?.issueTypesFetchedAt?.[project], SINGLE_PROJECT_TTL_MS);
|
|
48
57
|
const needsProjects = shape.project && !cached?.projects?.length;
|
|
49
|
-
const needsIssueTypes = shape.type && adapter.type === 'jira' && project && !
|
|
58
|
+
const needsIssueTypes = shape.type && adapter.type === 'jira' && project && !hasFreshIssueTypes;
|
|
50
59
|
|
|
51
60
|
if (needsProjects || needsIssueTypes) {
|
|
52
61
|
try {
|
|
53
62
|
const projects = needsProjects ? await adapter.listCreatableProjects() : (cached?.projects ?? []);
|
|
54
|
-
// Object.create(null), not {} —
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
// Object.create(null), not {} — preserves any existing entries when
|
|
64
|
+
// this pass only needed `projects`, not a new issue-type fetch. See
|
|
65
|
+
// mergeProjectIssueTypes' own doc for why a null-prototype target
|
|
66
|
+
// matters once `project` (an unvalidated CLI value) reaches a key.
|
|
67
|
+
let issueTypesByProject = Object.assign(Object.create(null), cached?.issueTypesByProject ?? {});
|
|
68
|
+
let issueTypesFetchedAt = Object.assign(Object.create(null), cached?.issueTypesFetchedAt ?? {});
|
|
60
69
|
if (needsIssueTypes) {
|
|
61
|
-
issueTypesByProject
|
|
70
|
+
({ issueTypesByProject, issueTypesFetchedAt } = mergeProjectIssueTypes(cached, project, await adapter.listIssueTypes(project)));
|
|
62
71
|
}
|
|
63
|
-
cached = {
|
|
72
|
+
cached = {
|
|
73
|
+
projects,
|
|
74
|
+
issueTypesByProject,
|
|
75
|
+
issueTypesFetchedAt,
|
|
76
|
+
projectsFetchedAt: needsProjects ? new Date().toISOString() : (cached?.projectsFetchedAt ?? null),
|
|
77
|
+
};
|
|
64
78
|
writeMetadataCacheFn(profileName, cached, configDir);
|
|
65
79
|
} catch {
|
|
66
80
|
return '';
|
|
@@ -1,24 +1,66 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Project/issue-type metadata cache for `ticketlens create`
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
2
|
+
* Project/issue-type metadata cache for `ticketlens create` and
|
|
3
|
+
* `ticketlens issue-types` — stores what this profile has actually
|
|
4
|
+
* confirmed it can create against (real project keys, real Jira issue
|
|
5
|
+
* types per project). Two access patterns share this file with two
|
|
6
|
+
* different freshness bars: a full scan (`issue-types` with no
|
|
7
|
+
* `--project`) trusts data for METADATA_TTL_MS (7 days); a targeted
|
|
8
|
+
* single-project lookup (`issue-types --project=KEY`, or the reactive
|
|
9
|
+
* create-failure enrichment path) is "on purpose" and trusts data for
|
|
10
|
+
* the shorter SINGLE_PROJECT_TTL_MS (3 days) instead — tracked via its
|
|
11
|
+
* own per-project `issueTypesFetchedAt[KEY]` timestamp, independent of
|
|
12
|
+
* the whole-file `fetchedAt`/`projectsFetchedAt` markers a full scan uses.
|
|
13
|
+
* Callers own the freshness decision (this module just persists whatever
|
|
14
|
+
* timestamps they pass); `fetchedAt` alone still gates this file's own
|
|
15
|
+
* read-side garbage collection (deleted once older than the ttlMs param).
|
|
10
16
|
*
|
|
11
17
|
* Path: ~/.ticketlens/cache/PROFILE/ticket-metadata.json
|
|
12
|
-
* Format: {
|
|
13
|
-
*
|
|
14
|
-
*
|
|
18
|
+
* Format: {
|
|
19
|
+
* fetchedAt, // bumped on every write — GC liveness marker only
|
|
20
|
+
* projectsFetchedAt, // set only by a full project-list scan
|
|
21
|
+
* projects: [{key, name}],
|
|
22
|
+
* issueTypesByProject: {KEY: [{id, name}]},
|
|
23
|
+
* issueTypesFetchedAt: {KEY: iso timestamp} // per-project, either access pattern
|
|
24
|
+
* }
|
|
15
25
|
*/
|
|
16
26
|
|
|
17
27
|
import fs from 'node:fs';
|
|
18
28
|
import path from 'node:path';
|
|
19
29
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
20
30
|
|
|
21
|
-
export const METADATA_TTL_MS = 24 * 60 * 60 * 1000; //
|
|
31
|
+
export const METADATA_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days — full project-list scan
|
|
32
|
+
export const SINGLE_PROJECT_TTL_MS = 3 * 24 * 60 * 60 * 1000; // 3 days — targeted, "on purpose" lookup
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Shared freshness check for a single stored timestamp — used by both
|
|
36
|
+
* `issue-types --project=KEY` and the reactive create-failure enrichment
|
|
37
|
+
* path, so the two "on purpose, single project" access patterns can't
|
|
38
|
+
* silently drift onto different freshness logic over time.
|
|
39
|
+
*/
|
|
40
|
+
export function isFresh(isoTimestamp, ttlMs) {
|
|
41
|
+
if (!isoTimestamp) return false;
|
|
42
|
+
const age = Date.now() - new Date(isoTimestamp).getTime();
|
|
43
|
+
return !isNaN(age) && age <= ttlMs;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Merges one project's issue types into an existing (possibly null) cached
|
|
48
|
+
* map, returning a new { issueTypesByProject, issueTypesFetchedAt } pair —
|
|
49
|
+
* shared by both single-project write paths (`issue-types --project=KEY`
|
|
50
|
+
* and the reactive create-failure enrichment) so the null-prototype defense
|
|
51
|
+
* below can't independently drift or regress between them.
|
|
52
|
+
*
|
|
53
|
+
* Object.create(null), not {} — projectKey is an unvalidated CLI value
|
|
54
|
+
* reaching a key position; a plain {} lets "--project=__proto__" redirect
|
|
55
|
+
* into the object's own prototype slot instead of creating a real entry.
|
|
56
|
+
*/
|
|
57
|
+
export function mergeProjectIssueTypes(cached, projectKey, types, fetchedAt = new Date().toISOString()) {
|
|
58
|
+
const issueTypesByProject = Object.assign(Object.create(null), cached?.issueTypesByProject ?? {});
|
|
59
|
+
issueTypesByProject[projectKey] = types;
|
|
60
|
+
const issueTypesFetchedAt = Object.assign(Object.create(null), cached?.issueTypesFetchedAt ?? {});
|
|
61
|
+
issueTypesFetchedAt[projectKey] = fetchedAt;
|
|
62
|
+
return { issueTypesByProject, issueTypesFetchedAt };
|
|
63
|
+
}
|
|
22
64
|
|
|
23
65
|
/**
|
|
24
66
|
* Returns the absolute path to the ticket-metadata cache file for a profile.
|
|
@@ -41,8 +83,8 @@ export function metadataCachePath(profileName, configDir = DEFAULT_CONFIG_DIR) {
|
|
|
41
83
|
*
|
|
42
84
|
* @param {string|null} profileName
|
|
43
85
|
* @param {string} [configDir]
|
|
44
|
-
* @param {number} [ttlMs] - override TTL in ms; defaults to METADATA_TTL_MS (
|
|
45
|
-
* @returns {{ projects: {key:string,name:string}[], issueTypesByProject: object, fetchedAt: string } | null}
|
|
86
|
+
* @param {number} [ttlMs] - override TTL in ms for this file's own GC deletion; defaults to METADATA_TTL_MS (7d)
|
|
87
|
+
* @returns {{ projects: {key:string,name:string}[], issueTypesByProject: object, issueTypesFetchedAt: object, projectsFetchedAt: string|null, fetchedAt: string } | null}
|
|
46
88
|
*/
|
|
47
89
|
export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, ttlMs = METADATA_TTL_MS) {
|
|
48
90
|
const filePath = metadataCachePath(profileName, configDir);
|
|
@@ -64,6 +106,8 @@ export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, t
|
|
|
64
106
|
return {
|
|
65
107
|
projects: data.projects ?? [],
|
|
66
108
|
issueTypesByProject: data.issueTypesByProject ?? {},
|
|
109
|
+
issueTypesFetchedAt: data.issueTypesFetchedAt ?? {},
|
|
110
|
+
projectsFetchedAt: data.projectsFetchedAt ?? null,
|
|
67
111
|
fetchedAt: data.fetchedAt,
|
|
68
112
|
};
|
|
69
113
|
}
|
|
@@ -71,13 +115,21 @@ export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, t
|
|
|
71
115
|
/**
|
|
72
116
|
* Writes project/issue-type metadata to the cache. Non-fatal — a write
|
|
73
117
|
* failure must never break the caller (an enrichment attempt after an
|
|
74
|
-
* already-failed create).
|
|
118
|
+
* already-failed create). Callers own merge-before-write for partial
|
|
119
|
+
* updates (e.g. a single-project fetch merging into an existing
|
|
120
|
+
* multi-project cache) — this function persists exactly what it's given.
|
|
75
121
|
*/
|
|
76
|
-
export function writeMetadataCache(profileName, { projects = [], issueTypesByProject = {} } = {}, configDir = DEFAULT_CONFIG_DIR) {
|
|
122
|
+
export function writeMetadataCache(profileName, { projects = [], issueTypesByProject = {}, issueTypesFetchedAt = {}, projectsFetchedAt = null } = {}, configDir = DEFAULT_CONFIG_DIR) {
|
|
77
123
|
const filePath = metadataCachePath(profileName, configDir);
|
|
78
124
|
try {
|
|
79
125
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
80
|
-
fs.writeFileSync(filePath, JSON.stringify({
|
|
126
|
+
fs.writeFileSync(filePath, JSON.stringify({
|
|
127
|
+
fetchedAt: new Date().toISOString(),
|
|
128
|
+
projectsFetchedAt,
|
|
129
|
+
projects,
|
|
130
|
+
issueTypesByProject,
|
|
131
|
+
issueTypesFetchedAt,
|
|
132
|
+
}));
|
|
81
133
|
} catch {
|
|
82
134
|
// Non-fatal
|
|
83
135
|
}
|