ticketlens 0.38.35 → 0.38.37
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 +24 -4
- package/bin/ticketlens.mjs +11 -1
- package/package.json +2 -2
- package/skills/jtb/SKILL.md +22 -1
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/cli.mjs +4 -0
- package/skills/jtb/scripts/lib/help.mjs +39 -4
- package/skills/jtb/scripts/lib/mcp-server.mjs +16 -1
- package/skills/jtb/scripts/lib/mcp-tool-schemas.mjs +12 -0
- package/skills/jtb/scripts/lib/note-command.mjs +6 -2
- package/skills/jtb/scripts/lib/run-issue-types.mjs +134 -0
- package/skills/jtb/scripts/lib/secret-scanner.mjs +51 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<img src="https://img.shields.io/npm/dm/ticketlens?style=flat-square&color=06b6d4&label=downloads" />
|
|
8
8
|
<img src="https://github.com/ralphmoran/ticket-lens/actions/workflows/test.yml/badge.svg?style=flat-square" />
|
|
9
9
|
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" />
|
|
10
|
-
<img src="https://img.shields.io/badge/node-%3E%
|
|
10
|
+
<img src="https://img.shields.io/badge/node-%3E%3D22-brightgreen?style=flat-square" />
|
|
11
11
|
</div>
|
|
12
12
|
|
|
13
13
|
</div>
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
- [Recall](#recall)
|
|
45
45
|
- [Comment, Transition, Assign, Duplicates, Link, Update & Create](#comment-transition-assign-duplicates-link-update--create)
|
|
46
46
|
- [Response-Time Stats](#response-time-stats)
|
|
47
|
+
- [Pre-fetch Issue Types](#pre-fetch-issue-types)
|
|
47
48
|
- [Doctor](#doctor)
|
|
48
49
|
- [Custom Attention Rules](#custom-attention-rules)
|
|
49
50
|
- [Login](#login)
|
|
@@ -99,7 +100,7 @@ npx ticketlens CNV1-2
|
|
|
99
100
|
|
|
100
101
|
Tip: `tl` works everywhere `ticketlens` does — running `tl`/`ticketlens config` before anything is configured also launches guided setup, no dead end. Pass `--no-input` to force non-interactive behavior even in a terminal (scripts, CI).
|
|
101
102
|
|
|
102
|
-
**Prerequisites:** Node.js >=
|
|
103
|
+
**Prerequisites:** Node.js >=22.6
|
|
103
104
|
|
|
104
105
|
---
|
|
105
106
|
|
|
@@ -453,7 +454,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
453
454
|
|
|
454
455
|
**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).
|
|
455
456
|
|
|
456
|
-
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `review`, `standup`, `pr`, `stats`, `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; `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`); `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. `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).
|
|
457
|
+
**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; `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. `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).
|
|
457
458
|
|
|
458
459
|
`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.
|
|
459
460
|
|
|
@@ -497,7 +498,7 @@ Write directly to the ticket in its real tracker — Jira, GitHub, or Linear —
|
|
|
497
498
|
|
|
498
499
|
`--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.
|
|
499
500
|
|
|
500
|
-
**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 24h so a burst of failed attempts doesn't re-fetch every time.
|
|
501
|
+
**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 24h 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.
|
|
501
502
|
|
|
502
503
|
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.
|
|
503
504
|
|
|
@@ -526,6 +527,19 @@ A one-line summary footer is also appended automatically to `ticketlens triage`
|
|
|
526
527
|
|
|
527
528
|
---
|
|
528
529
|
|
|
530
|
+
### Pre-fetch Issue Types
|
|
531
|
+
|
|
532
|
+
```bash
|
|
533
|
+
ticketlens issue-types # Pre-fetch/cache the active profile's real projects + Jira issue types
|
|
534
|
+
ticketlens issue-types --profile=acme # Target a specific profile
|
|
535
|
+
ticketlens issue-types --refresh # Force a live fetch, bypassing the cache
|
|
536
|
+
ticketlens issue-types --format=json # JSON output for scripting
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
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`, 24h TTL), so a `create` failure right after this command is a pure cache hit, no extra network round-trip.
|
|
540
|
+
|
|
541
|
+
---
|
|
542
|
+
|
|
529
543
|
### Doctor
|
|
530
544
|
|
|
531
545
|
```bash
|
|
@@ -818,6 +832,12 @@ ticketlens stats --profile=acme # Metrics for a specific profile
|
|
|
818
832
|
ticketlens stats --days=14 # Extend lookback window (Pro, max 30)
|
|
819
833
|
ticketlens stats --format=json # JSON output for scripting
|
|
820
834
|
|
|
835
|
+
# ── Issue Types ───────────────────────────────────────────────────────────────
|
|
836
|
+
ticketlens issue-types # Pre-fetch/cache the active profile's projects + Jira issue types
|
|
837
|
+
ticketlens issue-types --profile=acme # Target a specific profile
|
|
838
|
+
ticketlens issue-types --refresh # Force a live fetch, bypassing the cache
|
|
839
|
+
ticketlens issue-types --format=json # JSON output for scripting
|
|
840
|
+
|
|
821
841
|
# ── Doctor ────────────────────────────────────────────────────────────────────
|
|
822
842
|
ticketlens doctor # Diagnose profile/license/connectivity/cache/MCP-registration/queue problems
|
|
823
843
|
ticketlens doctor --fix # Attempt safe automatic fixes
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -27,12 +27,13 @@ import {
|
|
|
27
27
|
printInitHelp, printSwitchHelp, printConfigHelp,
|
|
28
28
|
printReviewHelp, printStandupHelp, printUpdateSkillHelp,
|
|
29
29
|
printComplianceHelp, printLedgerHelp, printPrHelp, printInstallHooksHelp,
|
|
30
|
-
printCollisionsHelp, printStatsHelp, printDoctorHelp,
|
|
30
|
+
printCollisionsHelp, printStatsHelp, printIssueTypesHelp, printDoctorHelp,
|
|
31
31
|
printCloudKeysHelp,
|
|
32
32
|
printNoteHelp, printRecallHelp, printMcpHelp,
|
|
33
33
|
printCommentHelp, printTransitionHelp, printAssignHelp, printDuplicatesHelp, printLinkHelp, printUpdateHelp, printCreateHelp,
|
|
34
34
|
} from '../skills/jtb/scripts/lib/help.mjs';
|
|
35
35
|
import { runStats } from '../skills/jtb/scripts/lib/run-stats.mjs';
|
|
36
|
+
import { runIssueTypes } from '../skills/jtb/scripts/lib/run-issue-types.mjs';
|
|
36
37
|
import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
|
|
37
38
|
import { readCliToken, deleteCliToken } from '../skills/jtb/scripts/lib/cli-auth.mjs';
|
|
38
39
|
import { runLogin } from '../skills/jtb/scripts/lib/login-flow.mjs';
|
|
@@ -159,6 +160,15 @@ switch (command) {
|
|
|
159
160
|
break;
|
|
160
161
|
}
|
|
161
162
|
|
|
163
|
+
case 'issue-types': {
|
|
164
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printIssueTypesHelp(); break; }
|
|
165
|
+
runIssueTypes(cmdArgs).catch(err => {
|
|
166
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
167
|
+
process.exitCode = 1;
|
|
168
|
+
});
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
|
|
162
172
|
case 'doctor': {
|
|
163
173
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printDoctorHelp(); break; }
|
|
164
174
|
runDoctor(cmdArgs).then(({ ok }) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ticketlens",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.37",
|
|
4
4
|
"description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,6 +40,6 @@
|
|
|
40
40
|
},
|
|
41
41
|
"homepage": "https://github.com/ralphmoran/ticket-lens",
|
|
42
42
|
"engines": {
|
|
43
|
-
"node": ">=
|
|
43
|
+
"node": ">=22.6.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.40.0 -->
|
|
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,6 +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 a profile's valid Jira issue types
|
|
46
|
+
/jtb issue-types --refresh # force a live fetch, bypassing the cache
|
|
45
47
|
/jtb collisions # show branch collisions with teammates (Team)
|
|
46
48
|
/jtb collisions --json # machine-readable output
|
|
47
49
|
/jtb review # code-review context brief from current branch
|
|
@@ -148,6 +150,25 @@ If this harness has TicketLens's MCP server configured (a tool named `stats` —
|
|
|
148
150
|
|
|
149
151
|
---
|
|
150
152
|
|
|
153
|
+
### Issue-types subcommand
|
|
154
|
+
|
|
155
|
+
If the first argument is `issue-types`:
|
|
156
|
+
|
|
157
|
+
Run:
|
|
158
|
+
```bash
|
|
159
|
+
ticketlens issue-types $EXTRA_ARGS
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Where `$EXTRA_ARGS` are any flags passed (e.g. `--profile=acme --refresh --format=json`).
|
|
163
|
+
|
|
164
|
+
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 complete cache 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`, 24h TTL) — a create failure right after this command is a pure cache hit.
|
|
165
|
+
|
|
166
|
+
Display the script's stdout directly. No plan mode. Stop here.
|
|
167
|
+
|
|
168
|
+
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`.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
151
172
|
### History subcommand
|
|
152
173
|
|
|
153
174
|
If the first argument is `history`:
|
|
@@ -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 = 'https://api.ticketlens.app';
|
|
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,
|
|
@@ -118,6 +118,10 @@ export function parseCommand(args) {
|
|
|
118
118
|
return { command: 'stats', args: args.slice(1) };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
if (first === 'issue-types') {
|
|
122
|
+
return { command: 'issue-types', args: args.slice(1) };
|
|
123
|
+
}
|
|
124
|
+
|
|
121
125
|
if (first === 'doctor') {
|
|
122
126
|
return { command: 'doctor', args: args.slice(1) };
|
|
123
127
|
}
|
|
@@ -62,6 +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
66
|
'',
|
|
66
67
|
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
|
|
67
68
|
` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
|
|
@@ -646,12 +647,12 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
646
647
|
'',
|
|
647
648
|
` Start an MCP (Model Context Protocol) stdio server exposing CLI actions as`,
|
|
648
649
|
` native tools — ${s.cyan('fetch')}, ${s.cyan('triage')}, ${s.cyan('compliance')}, ${s.cyan('review')}, ${s.cyan('standup')}, ${s.cyan('pr')}, ${s.cyan('stats')}, ${s.cyan('history')},`,
|
|
649
|
-
` ${s.cyan('collisions')}, ${s.cyan('ledger')}, ${s.cyan('doctor')}, ${s.cyan('recall_add')}, ${s.cyan('recall_update')}, ${s.cyan('recall_delete')},`,
|
|
650
|
+
` ${s.cyan('collisions')}, ${s.cyan('ledger')}, ${s.cyan('doctor')}, ${s.cyan('issue_types')}, ${s.cyan('recall_add')}, ${s.cyan('recall_update')}, ${s.cyan('recall_delete')},`,
|
|
650
651
|
` ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')}, ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')},`,
|
|
651
652
|
` ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')}, ${s.cyan('ticket_create')} — every CLI action`,
|
|
652
653
|
` now has an MCP tool, for any MCP-compatible AI harness, not just Claude Code.`,
|
|
653
654
|
` Thin adapter over the same code as ${s.cyan('TICKET-KEY')}/${s.cyan('doctor')}/${s.cyan('triage')}/${s.cyan('compliance')}/${s.cyan('review')}/`,
|
|
654
|
-
` ${s.cyan('standup')}/${s.cyan('pr')}/${s.cyan('stats')}/${s.cyan('history')}/${s.cyan('collisions')}/${s.cyan('ledger')}/${s.cyan('note add')}/${s.cyan('note patch')}/`,
|
|
655
|
+
` ${s.cyan('standup')}/${s.cyan('pr')}/${s.cyan('stats')}/${s.cyan('history')}/${s.cyan('collisions')}/${s.cyan('ledger')}/${s.cyan('issue-types')}/${s.cyan('note add')}/${s.cyan('note patch')}/`,
|
|
655
656
|
` ${s.cyan('note delete')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')}/`,
|
|
656
657
|
` ${s.cyan('update')}/${s.cyan('create')} above.`,
|
|
657
658
|
` ${s.cyan('fetch')}, ${s.cyan('doctor')}, and ${s.cyan('standup')} are Free; ${s.cyan('triage')} is Free with some Pro/Team-gated`,
|
|
@@ -661,8 +662,10 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
661
662
|
` requires Pro as a plain license check, not a draw on that same counter;`,
|
|
662
663
|
` ${s.cyan('stats')} is Free with a 7-day lookback, Pro extends it to 30 days, same split`,
|
|
663
664
|
` as ${s.cyan('ticketlens stats --help')}; ${s.cyan('history')} is entirely local and requires Pro;`,
|
|
664
|
-
` ${s.cyan('collisions')} requires ${s.cyan('ticketlens login')} (Console access) plus a Team license
|
|
665
|
-
`
|
|
665
|
+
` ${s.cyan('collisions')} requires ${s.cyan('ticketlens login')} (Console access) plus a Team license;`,
|
|
666
|
+
` ${s.cyan('issue_types')} is Free and Jira-only — Linear/GitHub profiles get a clear`,
|
|
667
|
+
` "not available" instead of an empty result — every other tool needs Pro.`,
|
|
668
|
+
` ${s.cyan('ticket_transition')} is destructive when called with`,
|
|
666
669
|
` \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently self-assign only;`,
|
|
667
670
|
` ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the source issue as a`,
|
|
668
671
|
` duplicate — different semantics than Jira/Linear's relationship-only add;`,
|
|
@@ -1314,6 +1317,38 @@ export function printStatsHelp({ stream = process.stdout } = {}) {
|
|
|
1314
1317
|
stream.write(lines.join('\n') + '\n');
|
|
1315
1318
|
}
|
|
1316
1319
|
|
|
1320
|
+
export function printIssueTypesHelp({ stream = process.stdout } = {}) {
|
|
1321
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
1322
|
+
const lines = [
|
|
1323
|
+
'',
|
|
1324
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('issue-types')} ${s.dim('[--profile=NAME] [--refresh] [--format=plain|json]')}`,
|
|
1325
|
+
'',
|
|
1326
|
+
` Pre-fetch and cache a profile's real creatable projects and their valid`,
|
|
1327
|
+
` Jira issue types, ahead of a ${s.brand('ticketlens create')} attempt — a ready lookup`,
|
|
1328
|
+
` instead of only learning them from a failed create's error message.`,
|
|
1329
|
+
` Jira only — Linear has no per-project issue-type concept and GitHub has`,
|
|
1330
|
+
` neither, so both report a clear "not available" instead of an empty result.`,
|
|
1331
|
+
` Shares its cache with ${s.brand('ticketlens create')}'s own failure-message enrichment`,
|
|
1332
|
+
` (${s.dim('~/.ticketlens/cache/PROFILE/ticket-metadata.json')}, 24h TTL).`,
|
|
1333
|
+
'',
|
|
1334
|
+
` ${s.bold('OPTIONS')}`,
|
|
1335
|
+
'',
|
|
1336
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific tracker profile`,
|
|
1337
|
+
` ${s.brand('--refresh')} Force a live fetch even if a complete cache exists`,
|
|
1338
|
+
` ${s.brand('--format')}=${s.dim('plain')} Human-readable table ${s.dim('(default)')}`,
|
|
1339
|
+
` ${s.brand('--format')}=${s.dim('json')} JSON output for scripting/piping`,
|
|
1340
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
1341
|
+
'',
|
|
1342
|
+
` ${s.bold('EXAMPLES')}`,
|
|
1343
|
+
'',
|
|
1344
|
+
` ${s.dim('$')} ticketlens issue-types`,
|
|
1345
|
+
` ${s.dim('$')} ticketlens issue-types --profile=myteam --refresh`,
|
|
1346
|
+
` ${s.dim('$')} ticketlens issue-types --format=json | jq .`,
|
|
1347
|
+
'',
|
|
1348
|
+
];
|
|
1349
|
+
stream.write(lines.join('\n') + '\n');
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1317
1352
|
export function printStandupHelp({ stream = process.stdout } = {}) {
|
|
1318
1353
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
1319
1354
|
const lines = [
|
|
@@ -28,6 +28,7 @@ import { runTicketComment, runTicketTransitionList, runTicketTransition, runTick
|
|
|
28
28
|
import { run as runFetchTicket } from '../fetch-ticket.mjs';
|
|
29
29
|
import { run as runTriage } from '../fetch-my-tickets.mjs';
|
|
30
30
|
import { runStats } from './run-stats.mjs';
|
|
31
|
+
import { runIssueTypes } from './run-issue-types.mjs';
|
|
31
32
|
import { runHistory } from './run-history.mjs';
|
|
32
33
|
import { runCollisions } from './run-collisions.mjs';
|
|
33
34
|
import { TOOLS } from './mcp-tool-schemas.mjs';
|
|
@@ -308,6 +309,18 @@ async function callStats(args, { configDir, runStatsFn }) {
|
|
|
308
309
|
return callPrintWarnRun(buildStatsArgs, args, { configDir, runFn: runStatsFn }, 'stats failed');
|
|
309
310
|
}
|
|
310
311
|
|
|
312
|
+
function buildIssueTypesArgs({ profile, refresh, format }) {
|
|
313
|
+
const args = [];
|
|
314
|
+
if (profile) args.push(`--profile=${profile}`);
|
|
315
|
+
if (refresh === true) args.push('--refresh');
|
|
316
|
+
if (format) args.push(`--format=${format}`);
|
|
317
|
+
return args;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function callIssueTypes(args, { configDir, runIssueTypesFn }) {
|
|
321
|
+
return callPrintWarnRun(buildIssueTypesArgs, args, { configDir, runFn: runIssueTypesFn }, 'issue-types failed');
|
|
322
|
+
}
|
|
323
|
+
|
|
311
324
|
function buildHistoryArgs({ ticket }) {
|
|
312
325
|
return [ticket];
|
|
313
326
|
}
|
|
@@ -616,6 +629,7 @@ async function handleToolsCall(params, deps) {
|
|
|
616
629
|
if (name === 'ledger') return callLedger(args, deps);
|
|
617
630
|
if (name === 'doctor') return callDoctor(args, deps);
|
|
618
631
|
if (name === 'stats') return callStats(args, deps);
|
|
632
|
+
if (name === 'issue_types') return callIssueTypes(args, deps);
|
|
619
633
|
if (name === 'history') return callHistory(args, deps);
|
|
620
634
|
if (name === 'collisions') return callCollisions(args, deps);
|
|
621
635
|
if (name === 'recall_add') return callRecallAdd(args, deps);
|
|
@@ -687,6 +701,7 @@ export function runMcpServer({
|
|
|
687
701
|
runTriageFn = runTriage,
|
|
688
702
|
runDoctorFn = runDoctor,
|
|
689
703
|
runStatsFn = runStats,
|
|
704
|
+
runIssueTypesFn = runIssueTypes,
|
|
690
705
|
runHistoryFn = runHistory,
|
|
691
706
|
runCollisionsFn = runCollisions,
|
|
692
707
|
runNoteAddFn = runNoteAdd,
|
|
@@ -713,7 +728,7 @@ export function runMcpServer({
|
|
|
713
728
|
// Assembled once and passed straight through handleMessage to handleToolsCall,
|
|
714
729
|
// which is the only place the individual functions are read — so a new tool
|
|
715
730
|
// needs its dependency named here and in the parameter list above, nowhere else.
|
|
716
|
-
const deps = { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runNotePatchFn, runNoteDeleteFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn };
|
|
731
|
+
const deps = { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runIssueTypesFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runNotePatchFn, runNoteDeleteFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn };
|
|
717
732
|
|
|
718
733
|
const rl = readline.createInterface({ input: stdin, terminal: false });
|
|
719
734
|
let queue = Promise.resolve();
|
|
@@ -110,6 +110,18 @@ export const TOOLS = [
|
|
|
110
110
|
},
|
|
111
111
|
},
|
|
112
112
|
},
|
|
113
|
+
{
|
|
114
|
+
name: 'issue_types',
|
|
115
|
+
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 (24h TTL) — a create failure right after this call is a pure cache hit, no extra network round-trip.',
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: {
|
|
119
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
120
|
+
refresh: { type: 'boolean', description: 'Force a live fetch even if a complete cache already exists for this profile.' },
|
|
121
|
+
format: { type: 'string', enum: ['plain', 'json'], description: 'Output shape: "plain" (default) is a human-readable table; "json" is structured for scripting.' },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
113
125
|
{
|
|
114
126
|
name: 'history',
|
|
115
127
|
description: 'Show a ticket\'s urgency timeline from local triage history — every prior triage scan that surfaced it, with the urgency level and reason computed at that point in time. Read-only, entirely local — no network call. Requires a TicketLens Pro license.',
|
|
@@ -131,7 +131,11 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
const ticketKeys = ticketKey ? [ticketKey] : [];
|
|
134
|
-
|
|
134
|
+
// Captured once and threaded into writeNoteFn's now() override so the local
|
|
135
|
+
// vault file's `created` and the pushed payload's captured_at can never skew
|
|
136
|
+
// by the (short but real) gap between the local write and the push below.
|
|
137
|
+
const capturedAt = new Date();
|
|
138
|
+
const { id } = writeNoteFn({ title, ticketKeys, tags, author, body }, { configDir, now: () => capturedAt });
|
|
135
139
|
incrementDraftKeptFn(configDir);
|
|
136
140
|
const styled = !cmdArgs.includes('--plain') && stream.isTTY;
|
|
137
141
|
const s = createStyler({ forceColor: styled, noColor: !styled });
|
|
@@ -144,7 +148,7 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
144
148
|
// the backend wire contract, not the local vault's internal camelCase shape.
|
|
145
149
|
const profile = resolveProfileFn(ticketKey || null, { configDir, cwd: process.cwd() });
|
|
146
150
|
const recallTeamId = profile ? loadProfileRecallTeamIdFn(profile.name, configDir) : null;
|
|
147
|
-
const payload = { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body };
|
|
151
|
+
const payload = { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body, captured_at: capturedAt.toISOString() };
|
|
148
152
|
if (recallTeamId !== null) payload.group_id = recallTeamId;
|
|
149
153
|
const result = await pushNoteFn(payload, { cliToken, configDir, warn });
|
|
150
154
|
if (isRetryableFailureFn(result)) {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { DEFAULT_CONFIG_DIR, timeAgo } from './config.mjs';
|
|
2
|
+
import { resolveConnection } from './profile-resolver.mjs';
|
|
3
|
+
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
4
|
+
import { readMetadataCache, writeMetadataCache } from './ticket-metadata-cache.mjs';
|
|
5
|
+
import { createStyler } from './ansi.mjs';
|
|
6
|
+
import { handleUnknownFlags } from './arg-validator.mjs';
|
|
7
|
+
import { printIssueTypesHelp } from './help.mjs';
|
|
8
|
+
import { formatTable } from './table-formatter.mjs';
|
|
9
|
+
|
|
10
|
+
// A cache hit must cover every project it lists — a cache seeded only by
|
|
11
|
+
// ticket-create-enrichment.mjs's reactive path (one project at a time, on a
|
|
12
|
+
// create failure) can have projects with no recorded issue types yet. Showing
|
|
13
|
+
// that as "the" answer would silently omit the rest of the profile's projects.
|
|
14
|
+
function isCacheComplete(cached) {
|
|
15
|
+
if (!cached || cached.projects.length === 0) return false;
|
|
16
|
+
return cached.projects.every(p => (cached.issueTypesByProject[p.key] || []).length > 0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function render({ print, format, projects, issueTypesByProject, fetchedAt, cached }) {
|
|
20
|
+
if (format === 'json') {
|
|
21
|
+
print(JSON.stringify({ projects, issueTypesByProject, fetchedAt, cached }, null, 2) + '\n');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const s = createStyler({ isTTY: process.stdout.isTTY });
|
|
26
|
+
|
|
27
|
+
if (projects.length === 0) {
|
|
28
|
+
print(`\n ${s.dim('No creatable projects found for this profile.')}\n\n`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const rows = projects.map(p => {
|
|
33
|
+
const types = issueTypesByProject[p.key] || [];
|
|
34
|
+
return [p.key, types.length ? types.map(t => t.name).join(', ') : s.dim('(none)')];
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
print('\n');
|
|
38
|
+
print(formatTable(['Project', 'Issue Types'], rows) + '\n');
|
|
39
|
+
print(`\n ${s.dim(cached
|
|
40
|
+
? `Cached ${timeAgo(fetchedAt)} — pass --refresh to force a live fetch.`
|
|
41
|
+
: 'Fetched live and cached for 24h.')}\n\n`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `ticketlens issue-types` — proactive counterpart to
|
|
46
|
+
* ticket-create-enrichment.mjs's reactive cache-refresh: fetches every
|
|
47
|
+
* creatable project and its Jira issue types ahead of a `ticketlens create`
|
|
48
|
+
* attempt instead of only learning them from a failed create's error.
|
|
49
|
+
* Writes through the same ticket-metadata-cache.mjs file/TTL that enrichment
|
|
50
|
+
* reads, so a create failure right after this command is a pure cache hit.
|
|
51
|
+
*
|
|
52
|
+
* @param {string[]} args
|
|
53
|
+
* @returns {Promise<{ ok: boolean }>}
|
|
54
|
+
*/
|
|
55
|
+
export async function runIssueTypes(args = [], opts = {}) {
|
|
56
|
+
const print = opts.print ?? ((s) => process.stdout.write(s));
|
|
57
|
+
const warn = opts.warn ?? ((s) => process.stderr.write(s));
|
|
58
|
+
const configDir = opts.configDir ?? DEFAULT_CONFIG_DIR;
|
|
59
|
+
const resolveConnectionFn = opts.resolveConnectionFn ?? resolveConnection;
|
|
60
|
+
const resolveAdapterFn = opts.resolveAdapterFn ?? resolveAdapter;
|
|
61
|
+
const readMetadataCacheFn = opts.readMetadataCacheFn ?? readMetadataCache;
|
|
62
|
+
const writeMetadataCacheFn = opts.writeMetadataCacheFn ?? writeMetadataCache;
|
|
63
|
+
|
|
64
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
65
|
+
printIssueTypesHelp();
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const validated = await handleUnknownFlags(
|
|
70
|
+
args,
|
|
71
|
+
['--help', '-h', '--profile=', '--refresh', '--format='],
|
|
72
|
+
{ hints: [] },
|
|
73
|
+
);
|
|
74
|
+
if (validated === null) { process.exitCode = 1; return { ok: false }; }
|
|
75
|
+
|
|
76
|
+
const profileArg = args.find(a => a.startsWith('--profile='));
|
|
77
|
+
const formatArg = args.find(a => a.startsWith('--format='));
|
|
78
|
+
const forceRefresh = args.includes('--refresh');
|
|
79
|
+
|
|
80
|
+
const format = formatArg ? formatArg.split('=')[1] : 'plain';
|
|
81
|
+
if (format !== 'plain' && format !== 'json') {
|
|
82
|
+
warn(`Error: --format must be plain or json, got: ${format}\n`);
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
return { ok: false };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const explicitProfile = profileArg ? profileArg.split('=')[1] : undefined;
|
|
88
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
89
|
+
const conn = resolveConnectionFn(null, {
|
|
90
|
+
configDir,
|
|
91
|
+
profileName: explicitProfile,
|
|
92
|
+
cwd,
|
|
93
|
+
onWarning: (msg) => warn(` ⚠ ${msg}\n`),
|
|
94
|
+
});
|
|
95
|
+
if (!conn.baseUrl) {
|
|
96
|
+
warn(' No connection configured. Run `ticketlens init` or pass --profile=NAME.\n');
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
return { ok: false };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const adapter = resolveAdapterFn(conn);
|
|
102
|
+
if (adapter.type !== 'jira') {
|
|
103
|
+
warn(` Issue types are not available for this tracker (${adapter.type}) — only Jira exposes per-project issue types.\n`);
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
return { ok: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const profileName = conn.profileName ?? 'default';
|
|
109
|
+
|
|
110
|
+
if (!forceRefresh) {
|
|
111
|
+
const cached = readMetadataCacheFn(profileName, configDir);
|
|
112
|
+
if (isCacheComplete(cached)) {
|
|
113
|
+
render({ print, format, projects: cached.projects, issueTypesByProject: cached.issueTypesByProject, fetchedAt: cached.fetchedAt, cached: true });
|
|
114
|
+
return { ok: true };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let projects, issueTypesByProject;
|
|
119
|
+
try {
|
|
120
|
+
projects = await adapter.listCreatableProjects();
|
|
121
|
+
issueTypesByProject = Object.create(null);
|
|
122
|
+
for (const p of projects) {
|
|
123
|
+
issueTypesByProject[p.key] = await adapter.listIssueTypes(p.key);
|
|
124
|
+
}
|
|
125
|
+
} catch (err) {
|
|
126
|
+
warn(` Could not fetch issue types: ${err.message}\n`);
|
|
127
|
+
process.exitCode = 1;
|
|
128
|
+
return { ok: false };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
writeMetadataCacheFn(profileName, { projects, issueTypesByProject }, configDir);
|
|
132
|
+
render({ print, format, projects, issueTypesByProject, fetchedAt: new Date().toISOString(), cached: false });
|
|
133
|
+
return { ok: true };
|
|
134
|
+
}
|
|
@@ -32,6 +32,30 @@ const MAX_JOINED_CHUNKS = 4;
|
|
|
32
32
|
const MAX_COMPOUND_SEGMENT_LENGTH = 15;
|
|
33
33
|
const HYPHENATED_COMPOUND_RE = /^[A-Za-z]+(-[A-Za-z]+)+$/;
|
|
34
34
|
|
|
35
|
+
// A candidate containing an unstripped '(', ')', '[', or ']' reads as code
|
|
36
|
+
// syntax (an array/list literal element, or a function-call argument — e.g.
|
|
37
|
+
// "['compliance', '--help']" or "matches(x)") rather than a secret fragment.
|
|
38
|
+
// Consulted ONLY from looksLikeCodeSyntax below, which downgrades a matching
|
|
39
|
+
// high-entropy candidate to a warning — deliberately NOT wired into
|
|
40
|
+
// isLabelWord (backlog #14 residual, code review caught this on the first
|
|
41
|
+
// pass): making a bracket-bearing token a hard isLabelWord stop would end a
|
|
42
|
+
// joinedChunkRuns run there unconditionally, the same way GIT_REFERENCE_WORD_RE/
|
|
43
|
+
// isHyphenatedWordCompound/looksLikeFilenameReference already do — but unlike
|
|
44
|
+
// those, a bracket character is trivial for an attacker to insert anywhere
|
|
45
|
+
// ("a(b"), and doing so would fully and SILENTLY stop a genuine fragmented
|
|
46
|
+
// secret split around it from ever being reassembled for the entropy check
|
|
47
|
+
// (confirmed live: a real 36-char secret split into two 18-char halves around
|
|
48
|
+
// a bare "a(b" separator went from rejected:true to a fully silent
|
|
49
|
+
// rejected:false/warnings:[] once isLabelWord treated brackets as a stop).
|
|
50
|
+
// The downgrade-only design below avoids that: the join still happens (a
|
|
51
|
+
// bracket-bearing token is ordinary, never a label word), so any joined
|
|
52
|
+
// candidate spanning real secret content still trips the entropy check —
|
|
53
|
+
// and since that joined candidate necessarily still contains the bracket
|
|
54
|
+
// character too, looksLikeCodeSyntax downgrades it to a WARNING rather than
|
|
55
|
+
// silently exempting it, unlike the fully-silent gap the hard-wall version
|
|
56
|
+
// would have reopened.
|
|
57
|
+
const CODE_SYNTAX_RE = /[()[\]]/;
|
|
58
|
+
|
|
35
59
|
// U+200B (ZERO WIDTH SPACE) is added explicitly: despite the name, it does
|
|
36
60
|
// NOT carry the Unicode White_Space property (General_Category=Cf, not Zs),
|
|
37
61
|
// so it's excluded from JS's native \s (ECMA-262 WhiteSpace production) —
|
|
@@ -147,6 +171,29 @@ function looksLikeFilenameReference(strippedToken) {
|
|
|
147
171
|
return FILENAME_REFERENCE_RE.test(strippedToken);
|
|
148
172
|
}
|
|
149
173
|
|
|
174
|
+
/**
|
|
175
|
+
* True for a candidate — a raw token OR a joinedChunkRuns result — that
|
|
176
|
+
* itself contains an unstripped '(', ')', '[', or ']': code syntax (a
|
|
177
|
+
* function-call argument or array/list-literal element) rather than a secret
|
|
178
|
+
* fragment. Deliberately NOT wired into isLabelWord/joinedChunkRuns (see
|
|
179
|
+
* CODE_SYNTAX_RE's own comment for why that hard-wall approach reopened a
|
|
180
|
+
* silent reassembly bypass) — instead this only downgrades a candidate that
|
|
181
|
+
* ALREADY tripped looksRandom, same never-exempt treatment as
|
|
182
|
+
* looksLikeCodeFilename below. Covers both backlog #14 residual shapes: a
|
|
183
|
+
* token already 20+ chars on its own with no join needed (e.g.
|
|
184
|
+
* "matches(['compliance'," — whitespace-split with no space after '(' or
|
|
185
|
+
* '[', so it's one raw token from the very first split), and a joined run
|
|
186
|
+
* that only crosses the entropy threshold once several bracket-literal
|
|
187
|
+
* tokens glue together (e.g. "['compliance','--help','-h','debug']") — the
|
|
188
|
+
* bracket character survives into the joined string either way, so this
|
|
189
|
+
* still catches it. Fully exempting this shape would let a 20+ char secret
|
|
190
|
+
* dodge rejection just by wrapping it in a fake "f(" / "[" — see the
|
|
191
|
+
* security regression test alongside looksLikeCodeFilename's.
|
|
192
|
+
*/
|
|
193
|
+
function looksLikeCodeSyntax(rawToken) {
|
|
194
|
+
return CODE_SYNTAX_RE.test(stripEdgePunctuation(rawToken));
|
|
195
|
+
}
|
|
196
|
+
|
|
150
197
|
function shannonEntropy(token) {
|
|
151
198
|
const counts = new Map();
|
|
152
199
|
for (const ch of token) counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
|
@@ -396,12 +443,15 @@ export function scanForSecrets({ title = '', tags = [], body = '' } = {}) {
|
|
|
396
443
|
// Downgrading to a warning — never silently dropping the signal — matches
|
|
397
444
|
// how an email address is already handled below.
|
|
398
445
|
const randomCandidates = candidates.filter(token => !EMAIL_RE.test(token) && looksRandom(token, combined));
|
|
399
|
-
if (randomCandidates.some(token => !looksLikeCodeFilename(token))) {
|
|
446
|
+
if (randomCandidates.some(token => !looksLikeCodeFilename(token) && !looksLikeCodeSyntax(token))) {
|
|
400
447
|
reasons.push('Contains a long, random-looking string that could be a secret.');
|
|
401
448
|
}
|
|
402
449
|
if (randomCandidates.some(token => looksLikeCodeFilename(token))) {
|
|
403
450
|
warnings.push('Contains a code-filename-shaped token that also reads as high-entropy — double-check it is not a credential.');
|
|
404
451
|
}
|
|
452
|
+
if (randomCandidates.some(token => looksLikeCodeSyntax(token))) {
|
|
453
|
+
warnings.push('Contains a code-syntax-shaped token (brackets or parentheses) that also reads as high-entropy — double-check it is not a credential.');
|
|
454
|
+
}
|
|
405
455
|
|
|
406
456
|
if (EMAIL_RE.test(combined)) {
|
|
407
457
|
warnings.push('Contains an email address.');
|