ticketlens 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -5
- package/bin/ticketlens.mjs +25 -1
- package/package.json +2 -2
- package/skills/jtb/SKILL.md +13 -6
- package/skills/jtb/scripts/fetch-my-tickets.mjs +1 -4
- package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +54 -1
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +41 -1
- package/skills/jtb/scripts/lib/adapters/linear-adapter.mjs +66 -2
- package/skills/jtb/scripts/lib/cli.mjs +8 -0
- package/skills/jtb/scripts/lib/duplicate-scorer.mjs +69 -0
- package/skills/jtb/scripts/lib/help.mjs +58 -4
- package/skills/jtb/scripts/lib/jira-client.mjs +38 -0
- package/skills/jtb/scripts/lib/mcp-server.mjs +57 -4
- package/skills/jtb/scripts/lib/resolve-adapter.mjs +1 -1
- package/skills/jtb/scripts/lib/ticket-command.mjs +134 -0
package/README.md
CHANGED
|
@@ -411,7 +411,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
411
411
|
|
|
412
412
|
**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).
|
|
413
413
|
|
|
414
|
-
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `recall_add`, `recall_search`, `ticket_comment`, and `
|
|
414
|
+
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `recall_add`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, and `ticket_duplicates` as native tools — 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 Pro gate, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. 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).
|
|
415
415
|
|
|
416
416
|
`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.
|
|
417
417
|
|
|
@@ -419,19 +419,25 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
419
419
|
|
|
420
420
|
---
|
|
421
421
|
|
|
422
|
-
### Comment &
|
|
422
|
+
### Comment, Transition, Assign & Duplicates
|
|
423
423
|
|
|
424
424
|
```bash
|
|
425
425
|
ticketlens comment PROJ-123 --body="Looks good, merging." # Post a comment to the tracker
|
|
426
426
|
ticketlens transition PROJ-123 # List valid transitions (read-only)
|
|
427
427
|
ticketlens transition PROJ-123 --target="Done" --confirm # Execute the transition
|
|
428
|
+
ticketlens assign PROJ-123 --to=me # Assign the ticket to yourself
|
|
429
|
+
ticketlens duplicates PROJ-123 # Find likely duplicates (read-only)
|
|
428
430
|
```
|
|
429
431
|
|
|
430
|
-
Write directly to the ticket in its real tracker — Jira, GitHub, or Linear — from your terminal or an AI session via `ticket_comment`/`ticket_transition` MCP tools. Requires a Pro license.
|
|
432
|
+
Write directly to the ticket in its real tracker — Jira, GitHub, or Linear — from your terminal or an AI session via `ticket_comment`/`ticket_transition`/`ticket_assign`/`ticket_duplicates` MCP tools. Requires a Pro license.
|
|
431
433
|
|
|
432
434
|
`ticketlens transition` with just a ticket key lists the tracker's current valid options without changing anything (Jira: real workflow transitions for that issue; GitHub: open/closed; Linear: team-scoped workflow states). Add both `--target` and `--confirm` to execute — `--confirm` is a deliberate two-step gate: a behavioral nudge and forensic trail, not a hard security guarantee. Every write, once resolved, is re-validated against the tracker's current state immediately before executing — never a blind write against a stale option.
|
|
433
435
|
|
|
434
|
-
|
|
436
|
+
`ticketlens assign` is self-assign only for now — `--to` must be `me`. Assigning to someone else needs a per-tracker user-lookup step this doesn't do yet, so it's deliberately out of scope until that's built.
|
|
437
|
+
|
|
438
|
+
`ticketlens duplicates` is read-only — it never links or changes anything, just lists likely matches in the same project. No tracker (Jira/GitHub/Linear) scores similarity server-side, so ranking happens locally from title/description word overlap; treat a match as a nudge to check manually, not a verdict. `--threshold=N` (0–1, default 0.35) controls how loose a match counts.
|
|
439
|
+
|
|
440
|
+
All three write actions (comment/transition/assign) 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.
|
|
435
441
|
|
|
436
442
|
---
|
|
437
443
|
|
|
@@ -712,10 +718,13 @@ ticketlens mcp # Start the MCP stdio server (reca
|
|
|
712
718
|
ticketlens mcp install # Register it into the current project's .mcp.json
|
|
713
719
|
ticketlens mcp install --dry-run # Preview the registration without writing
|
|
714
720
|
|
|
715
|
-
# ── Comment &
|
|
721
|
+
# ── Comment, Transition, Assign & Duplicates ────────────────────────────────────
|
|
716
722
|
ticketlens comment CNV1-2 --body="Looks good, merging." # Post a comment to the tracker [Pro]
|
|
717
723
|
ticketlens transition CNV1-2 # List valid transitions (read-only) [Pro]
|
|
718
724
|
ticketlens transition CNV1-2 --target="Done" --confirm # Execute the transition [Pro]
|
|
725
|
+
ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself [Pro]
|
|
726
|
+
ticketlens duplicates CNV1-2 # Find likely duplicates (read-only) [Pro]
|
|
727
|
+
ticketlens duplicates CNV1-2 --threshold=0.5 # Tighten the match threshold [Pro]
|
|
719
728
|
|
|
720
729
|
# ── Stats ──────────────────────────────────────────────────────────────────────
|
|
721
730
|
ticketlens stats # Response-time metrics from local history
|
|
@@ -800,6 +809,8 @@ ticketlens recall settings # Show effective retry-queue settings,
|
|
|
800
809
|
ticketlens mcp # Start the MCP stdio server (recall/ticket write tools)
|
|
801
810
|
ticketlens comment CNV1-2 --body="..." # Post a comment to the tracker
|
|
802
811
|
ticketlens transition CNV1-2 --target="Done" --confirm # Transition ticket status
|
|
812
|
+
ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself
|
|
813
|
+
ticketlens duplicates CNV1-2 # Find likely duplicates (read-only)
|
|
803
814
|
ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
|
|
804
815
|
```
|
|
805
816
|
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
printCollisionsHelp, printStatsHelp,
|
|
29
29
|
printCloudKeysHelp,
|
|
30
30
|
printNoteHelp, printRecallHelp, printMcpHelp,
|
|
31
|
-
printCommentHelp, printTransitionHelp,
|
|
31
|
+
printCommentHelp, printTransitionHelp, printAssignHelp, printDuplicatesHelp,
|
|
32
32
|
} from '../skills/jtb/scripts/lib/help.mjs';
|
|
33
33
|
import { runStats } from '../skills/jtb/scripts/lib/run-stats.mjs';
|
|
34
34
|
import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
|
|
@@ -766,6 +766,30 @@ switch (command) {
|
|
|
766
766
|
break;
|
|
767
767
|
}
|
|
768
768
|
|
|
769
|
+
case 'assign': {
|
|
770
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printAssignHelp(); break; }
|
|
771
|
+
const { runTicketAssign } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
|
|
772
|
+
runTicketAssign(cmdArgs).then(({ ok }) => {
|
|
773
|
+
if (!ok) process.exitCode = 1;
|
|
774
|
+
}).catch(err => {
|
|
775
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
776
|
+
process.exitCode = 1;
|
|
777
|
+
});
|
|
778
|
+
break;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
case 'duplicates': {
|
|
782
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printDuplicatesHelp(); break; }
|
|
783
|
+
const { runTicketDuplicates } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
|
|
784
|
+
runTicketDuplicates(cmdArgs).then(({ ok }) => {
|
|
785
|
+
if (!ok) process.exitCode = 1;
|
|
786
|
+
}).catch(err => {
|
|
787
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
788
|
+
process.exitCode = 1;
|
|
789
|
+
});
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
|
|
769
793
|
case 'help':
|
|
770
794
|
default: {
|
|
771
795
|
const isInteractive = args.length === 0 && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ticketlens",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
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": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"skills/jtb/scripts/fetch-my-tickets.mjs"
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
|
-
"test": "node --test skills/jtb/scripts/test
|
|
20
|
+
"test": "node --test 'skills/jtb/scripts/test/**/*.test.mjs'",
|
|
21
21
|
"postinstall": "node scripts/postinstall.mjs",
|
|
22
22
|
"prepublishOnly": "node scripts/preflight.mjs",
|
|
23
23
|
"publish:beta": "node scripts/publish.mjs --tag=beta",
|
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.26.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.
|
|
@@ -63,6 +63,7 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
63
63
|
/jtb comment PROD-1234 --body="..." # post a comment to the tracker (Pro)
|
|
64
64
|
/jtb transition PROD-1234 # list the tracker's current valid transitions (Pro)
|
|
65
65
|
/jtb transition PROD-1234 --target="Done" --confirm # execute the transition (Pro)
|
|
66
|
+
/jtb assign PROD-1234 --to=me # assign the ticket to yourself (Pro)
|
|
66
67
|
```
|
|
67
68
|
|
|
68
69
|
## Prerequisites
|
|
@@ -263,23 +264,29 @@ Recall notes are stored locally at `~/.ticketlens/recall/`. On a Pro account wit
|
|
|
263
264
|
|
|
264
265
|
---
|
|
265
266
|
|
|
266
|
-
## Comment &
|
|
267
|
+
## Comment, Transition, Assign & Duplicates — write back to the tracker (Pro)
|
|
267
268
|
|
|
268
|
-
Unlike Recall (a local note about a ticket),
|
|
269
|
+
Unlike Recall (a local note about a ticket), comment/transition/assign write directly to the ticket's real tracker — Jira, GitHub, or Linear. Only dispatch a write when the user has actually asked for the ticket to be commented on, moved, or assigned — never as a routine end-of-session action the way Recall capture is. `duplicates` is read-only and safe to run more freely — it never mutates anything.
|
|
269
270
|
|
|
270
271
|
```bash
|
|
271
272
|
ticketlens comment PROD-1234 --body="Fixed in a2f9c1, deployed to staging."
|
|
272
273
|
ticketlens transition PROD-1234 # list valid transitions — read-only
|
|
273
274
|
ticketlens transition PROD-1234 --target="Done" --confirm # execute
|
|
275
|
+
ticketlens assign PROD-1234 --to=me # assign to yourself
|
|
276
|
+
ticketlens duplicates PROD-1234 # find likely duplicates — read-only
|
|
274
277
|
```
|
|
275
278
|
|
|
276
279
|
`transition` called with just a ticket key never mutates anything — it lists the tracker's current valid options (Jira: real workflow transitions for that issue; GitHub: open/closed; Linear: team-scoped workflow states). Only add `--target` **and** `--confirm` once the target has actually been confirmed with the user — `--confirm` is a deliberate two-step gate, not a formality to route around. Never guess a `--target` value; always list first, then use one of the names shown.
|
|
277
280
|
|
|
278
|
-
|
|
281
|
+
`assign` is self-assign only — `--to` must be `me`. There is no way to assign to anyone else yet; don't attempt a workaround (e.g. via `comment`) if the user asks for that — tell them it isn't supported.
|
|
279
282
|
|
|
280
|
-
|
|
283
|
+
`duplicates` lists likely-duplicate tickets in the same project, ranked by local title/description overlap — no tracker scores similarity server-side, so treat a match as a nudge for the user to check manually, never as a confirmed duplicate to act on unprompted (e.g. don't auto-close or auto-comment based on a match). `--threshold=N` (0–1, default 0.35) tightens or loosens what counts as a match.
|
|
281
284
|
|
|
282
|
-
|
|
285
|
+
The three write actions (comment/transition/assign) have a short local debounce (10s) against an accidental double-fire, and every write is appended to a local audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — surface the failure to the user rather than silently re-attempting, since a ticket write isn't naturally idempotent the way a Recall note save is. `duplicates` has neither, since nothing is written.
|
|
286
|
+
|
|
287
|
+
**Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (`ticketlens mcp` — `ticket_comment`/`ticket_transition`/`ticket_assign`/`ticket_duplicates` as native tools, see `ticketlens mcp --help`), prefer calling those tools directly over the bash commands above — same license gate, same cooldown, same audit log. Fall back to the bash form only when the MCP tools aren't available.
|
|
288
|
+
|
|
289
|
+
Requires a Pro license — on Free, all four no-op with an upgrade hint on stderr.
|
|
283
290
|
|
|
284
291
|
---
|
|
285
292
|
|
|
@@ -10,6 +10,7 @@ import { assembleTriageSummary } from './lib/brief-assembler.mjs';
|
|
|
10
10
|
import { styleTriageSummary } from './lib/styled-assembler.mjs';
|
|
11
11
|
import { resolveConnection, loadProfiles, saveProfile } from './lib/profile-resolver.mjs';
|
|
12
12
|
import { resolveAdapter } from './lib/resolve-adapter.mjs';
|
|
13
|
+
import { escapeJql } from './lib/jira-client.mjs';
|
|
13
14
|
import { incrementTriageRun, readAndResetActivity } from './lib/activity-counter.mjs';
|
|
14
15
|
import { DEFAULT_CONFIG_DIR } from './lib/config.mjs';
|
|
15
16
|
import { writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
@@ -42,10 +43,6 @@ async function defaultDigestDeliverer(payload, { cliToken } = {}) {
|
|
|
42
43
|
return true;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
function escapeJql(s) {
|
|
46
|
-
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
47
|
-
}
|
|
48
|
-
|
|
49
46
|
export async function run(args, envOrOpts = process.env, fetcher = globalThis.fetch, configDir = undefined) {
|
|
50
47
|
// Support both legacy positional form run(args, env, fetcher, configDir)
|
|
51
48
|
// and new opts-object form run(args, { env, fetcher, configDir, exporter, isLicensed, showUpgradePrompt, print })
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { tokenize } from '../duplicate-scorer.mjs';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Parses owner and repo from a GitHub profile baseUrl.
|
|
3
5
|
* Expected format: https://github.com/OWNER/REPO
|
|
@@ -123,7 +125,7 @@ export function createGitHubAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
123
125
|
});
|
|
124
126
|
if (!res.ok) throw new Error(`GitHub API error ${res.status} fetching current user`);
|
|
125
127
|
const raw = await res.json();
|
|
126
|
-
return { displayName: raw.name || raw.login, email: raw.email ?? null };
|
|
128
|
+
return { displayName: raw.name || raw.login, email: raw.email ?? null, login: raw.login };
|
|
127
129
|
},
|
|
128
130
|
|
|
129
131
|
async searchTickets(_query, opts = {}) {
|
|
@@ -187,5 +189,56 @@ export function createGitHubAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
187
189
|
if (!res.ok) await throwGitHubWriteError(res, 'transitioning', key);
|
|
188
190
|
return { executed: true, to: match.to };
|
|
189
191
|
},
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Self-assign only — GitHub's assignees field is an array, but this
|
|
195
|
+
* always replaces it with exactly the caller, mirroring the other
|
|
196
|
+
* adapters' self-assign scope (arbitrary-user assignment deferred).
|
|
197
|
+
*/
|
|
198
|
+
async assignToSelf(key, opts = {}) {
|
|
199
|
+
const me = await this.fetchCurrentUser(opts);
|
|
200
|
+
if (!me.login) {
|
|
201
|
+
throw new Error(`Cannot determine current user's login — GitHub did not return it for this connection.`);
|
|
202
|
+
}
|
|
203
|
+
const number = parseInt(key.split('-').pop(), 10);
|
|
204
|
+
const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}`, {
|
|
205
|
+
method: 'PATCH',
|
|
206
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
207
|
+
body: JSON.stringify({ assignees: [me.login] }),
|
|
208
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000),
|
|
209
|
+
});
|
|
210
|
+
if (!res.ok) await throwGitHubWriteError(res, 'assigning', key);
|
|
211
|
+
return { assignee: me.displayName ?? me.login };
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Candidate search for duplicate-ticket detection via GitHub's search
|
|
216
|
+
* endpoint (distinct from the issues-list endpoint `searchTickets` uses,
|
|
217
|
+
* which hardcodes "assigned to me" and ignores its query argument).
|
|
218
|
+
* Excludes the source issue and returns unranked candidates —
|
|
219
|
+
* duplicate-scorer.mjs does the actual similarity ranking.
|
|
220
|
+
*
|
|
221
|
+
* Never interpolates raw ticket text into `q` — GitHub parses the
|
|
222
|
+
* decoded query with its own qualifier grammar (`repo:`, `org:`, `is:`,
|
|
223
|
+
* etc, space-delimited), and multiple `repo:`/`org:` qualifiers are
|
|
224
|
+
* OR'd together. A crafted title/description containing e.g.
|
|
225
|
+
* `repo:otherorg/private-repo` would widen the search to a repo outside
|
|
226
|
+
* this profile's scope — a confused-deputy query-injection, not just a
|
|
227
|
+
* cosmetic bug. tokenize() strips all non-letter/digit characters
|
|
228
|
+
* (including `:`), so no qualifier syntax survives into `q`, and its
|
|
229
|
+
* cap keeps the query well under GitHub's search length limit.
|
|
230
|
+
*/
|
|
231
|
+
async findCandidates(text, sourceKey, opts = {}) {
|
|
232
|
+
const sourceNumber = parseInt(sourceKey.split('-').pop(), 10);
|
|
233
|
+
const terms = tokenize(text).slice(0, 8).join(' ');
|
|
234
|
+
const q = `repo:${owner}/${repo} is:issue ${terms}`;
|
|
235
|
+
const url = `${GITHUB_API}/search/issues?q=${encodeURIComponent(q)}`;
|
|
236
|
+
const res = await fetcher(url, { headers, signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000) });
|
|
237
|
+
if (!res.ok) await throwGitHubWriteError(res, 'searching', sourceKey);
|
|
238
|
+
const raw = await res.json();
|
|
239
|
+
return raw.items
|
|
240
|
+
.filter(item => item.number !== sourceNumber)
|
|
241
|
+
.map(item => normalizeGitHubIssue(item, [], keyPrefix));
|
|
242
|
+
},
|
|
190
243
|
};
|
|
191
244
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, postComment, getTransitions, postTransition } from '../jira-client.mjs';
|
|
1
|
+
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, postComment, getTransitions, postTransition, assignIssue, escapeJql } from '../jira-client.mjs';
|
|
2
2
|
import { buildJiraEnv } from '../config.mjs';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -7,6 +7,8 @@ import { buildJiraEnv } from '../config.mjs';
|
|
|
7
7
|
* trusts a caller-supplied id without confirming it's still a real,
|
|
8
8
|
* currently-valid option for this exact issue right now.
|
|
9
9
|
*/
|
|
10
|
+
const SEARCH_TEXT_CHAR_LIMIT = 300;
|
|
11
|
+
|
|
10
12
|
function resolveTransitionTarget(options, target) {
|
|
11
13
|
const t = String(target).toLowerCase();
|
|
12
14
|
return options.find(o => o.id === String(target) || o.name.toLowerCase() === t || (o.to ?? '').toLowerCase() === t);
|
|
@@ -43,5 +45,43 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
43
45
|
await postTransition(key, match.id, { ...base, ...opts });
|
|
44
46
|
return { executed: true, to: match.to ?? match.name };
|
|
45
47
|
},
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Self-assign only — arbitrary-user assignment would need a
|
|
51
|
+
* user-search API this codebase doesn't have yet. Reuses
|
|
52
|
+
* fetchCurrentUser, which already returns both accountId (Cloud)
|
|
53
|
+
* and name (Server/DC).
|
|
54
|
+
*/
|
|
55
|
+
async assignToSelf(key, opts = {}) {
|
|
56
|
+
const me = await fetchCurrentUser({ ...base, ...opts });
|
|
57
|
+
const field = apiVersion === 3 ? 'accountId' : 'name';
|
|
58
|
+
const value = me[field];
|
|
59
|
+
// Jira's PUT /issue/{key}/assignee treats a null identity field as
|
|
60
|
+
// "unassign", not an error — it returns 204 either way. Never send
|
|
61
|
+
// it: that would silently unassign the ticket while this command
|
|
62
|
+
// reports success.
|
|
63
|
+
if (!value) {
|
|
64
|
+
throw new Error(`Cannot determine current user's ${field} — Jira did not return it for this connection.`);
|
|
65
|
+
}
|
|
66
|
+
await assignIssue(key, { [field]: value }, { ...base, ...opts });
|
|
67
|
+
return { assignee: me.displayName ?? value };
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Candidate search for duplicate-ticket detection. Scoped to the same
|
|
72
|
+
* project as `sourceKey` (derived from its own prefix) and excludes it
|
|
73
|
+
* from results. Jira has no server-side similarity scoring — this only
|
|
74
|
+
* narrows the candidate pool; ranking happens in duplicate-scorer.mjs.
|
|
75
|
+
*/
|
|
76
|
+
async findCandidates(text, sourceKey, opts = {}) {
|
|
77
|
+
const hyphenIndex = sourceKey.lastIndexOf('-');
|
|
78
|
+
if (hyphenIndex < 1) {
|
|
79
|
+
throw new Error(`Cannot derive a project key from "${sourceKey}" — expected PROJECT-123.`);
|
|
80
|
+
}
|
|
81
|
+
const project = sourceKey.slice(0, hyphenIndex);
|
|
82
|
+
const searchText = text.slice(0, SEARCH_TEXT_CHAR_LIMIT);
|
|
83
|
+
const jql = `project = "${escapeJql(project)}" AND key != "${escapeJql(sourceKey)}" AND text ~ "${escapeJql(searchText)}" ORDER BY updated DESC`;
|
|
84
|
+
return searchTickets(jql, { ...base, ...opts });
|
|
85
|
+
},
|
|
46
86
|
};
|
|
47
87
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { tokenize } from '../duplicate-scorer.mjs';
|
|
2
|
+
|
|
1
3
|
const LINEAR_API = 'https://api.linear.app/graphql';
|
|
2
4
|
|
|
3
5
|
const PRIORITY_LABELS = { 1: 'Urgent', 2: 'High', 3: 'Medium', 4: 'Low' };
|
|
@@ -127,12 +129,12 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
127
129
|
async fetchCurrentUser(opts = {}) {
|
|
128
130
|
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
129
131
|
const data = await gql(
|
|
130
|
-
`{ viewer { name email } }`,
|
|
132
|
+
`{ viewer { id name email } }`,
|
|
131
133
|
{},
|
|
132
134
|
{ token, fetcher, signal },
|
|
133
135
|
);
|
|
134
136
|
const v = data.viewer;
|
|
135
|
-
return { displayName: v.name, email: v.email ?? null };
|
|
137
|
+
return { displayName: v.name, email: v.email ?? null, id: v.id };
|
|
136
138
|
},
|
|
137
139
|
|
|
138
140
|
async searchTickets(_query, opts = {}) {
|
|
@@ -231,5 +233,67 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
231
233
|
}
|
|
232
234
|
return { executed: true, to: match.to };
|
|
233
235
|
},
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Self-assign only — arbitrary-user assignment would need a
|
|
239
|
+
* user-search query this codebase doesn't have yet.
|
|
240
|
+
*/
|
|
241
|
+
async assignToSelf(key, opts = {}) {
|
|
242
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
243
|
+
const me = await this.fetchCurrentUser(opts);
|
|
244
|
+
if (!me.id) {
|
|
245
|
+
throw new Error(`Cannot determine current user's id — Linear did not return it for this connection.`);
|
|
246
|
+
}
|
|
247
|
+
const info = await fetchIssueStateInfo(key, { token, fetcher, signal });
|
|
248
|
+
const data = await gql(
|
|
249
|
+
`mutation ($id: String!, $assigneeId: String!) {
|
|
250
|
+
issueUpdate(id: $id, input: { assigneeId: $assigneeId }) {
|
|
251
|
+
success
|
|
252
|
+
}
|
|
253
|
+
}`,
|
|
254
|
+
{ id: info.id, assigneeId: me.id },
|
|
255
|
+
{ token, fetcher, signal },
|
|
256
|
+
);
|
|
257
|
+
if (!data.issueUpdate?.success) {
|
|
258
|
+
throw new Error(`Linear issueUpdate reported success:false assigning ${key}`);
|
|
259
|
+
}
|
|
260
|
+
return { assignee: me.displayName ?? me.id };
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Candidate search for duplicate-ticket detection. Linear's `contains`/
|
|
265
|
+
* `containsIgnoreCase` filters are literal substring matches, not
|
|
266
|
+
* full-text search (confirmed — Linear has no built-in similarity
|
|
267
|
+
* matching) — passing the whole source text as one substring filter
|
|
268
|
+
* would almost never match anything. Instead ORs across the
|
|
269
|
+
* significant tokens, ANDed with a team scope derived from the source
|
|
270
|
+
* key's prefix. Ranking happens in duplicate-scorer.mjs.
|
|
271
|
+
*/
|
|
272
|
+
async findCandidates(text, sourceKey, opts = {}) {
|
|
273
|
+
const terms = tokenize(text).slice(0, 5);
|
|
274
|
+
if (terms.length === 0) return [];
|
|
275
|
+
const hyphenIndex = sourceKey.lastIndexOf('-');
|
|
276
|
+
if (hyphenIndex < 1) {
|
|
277
|
+
throw new Error(`Cannot derive a team key from "${sourceKey}" — expected TEAM-123.`);
|
|
278
|
+
}
|
|
279
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
280
|
+
const prefix = sourceKey.slice(0, hyphenIndex);
|
|
281
|
+
const filter = {
|
|
282
|
+
team: { key: { eq: prefix } },
|
|
283
|
+
or: terms.map(term => ({ title: { containsIgnoreCase: term } })),
|
|
284
|
+
};
|
|
285
|
+
const data = await gql(
|
|
286
|
+
`query ($filter: IssueFilter) {
|
|
287
|
+
issues(filter: $filter, first: 50) {
|
|
288
|
+
nodes { ${ISSUE_FIELDS} }
|
|
289
|
+
}
|
|
290
|
+
}`,
|
|
291
|
+
{ filter },
|
|
292
|
+
{ token, fetcher, signal },
|
|
293
|
+
);
|
|
294
|
+
return (data.issues?.nodes ?? [])
|
|
295
|
+
.filter(node => node.identifier !== sourceKey)
|
|
296
|
+
.map(normalizeLinearIssue);
|
|
297
|
+
},
|
|
234
298
|
};
|
|
235
299
|
}
|
|
@@ -138,6 +138,14 @@ export function parseCommand(args) {
|
|
|
138
138
|
return { command: 'transition', args: args.slice(1) };
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
if (first === 'assign') {
|
|
142
|
+
return { command: 'assign', args: args.slice(1) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (first === 'duplicates') {
|
|
146
|
+
return { command: 'duplicates', args: args.slice(1) };
|
|
147
|
+
}
|
|
148
|
+
|
|
141
149
|
// Anything that looks like a ticket key or any non-flag arg → fetch
|
|
142
150
|
return { command: 'fetch', args };
|
|
143
151
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-dependency duplicate-ticket scoring. No tracker (Jira/GitHub/Linear)
|
|
3
|
+
* offers server-side similarity scoring — only exact/substring text filters —
|
|
4
|
+
* so candidate ranking happens here via Jaccard set overlap on tokenized text.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const STOPWORDS = new Set([
|
|
8
|
+
'the', 'a', 'an', 'to', 'of', 'in', 'on', 'for', 'and', 'or', 'is', 'are',
|
|
9
|
+
'this', 'that', 'with', 'as', 'at', 'by', 'from', 'it', 'be', 'was', 'were',
|
|
10
|
+
'has', 'have', 'had', 'not', 'but', 'if', 'will', 'can', 'do', 'does',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const TITLE_WEIGHT = 2;
|
|
14
|
+
const DESCRIPTION_WEIGHT = 1;
|
|
15
|
+
const DESCRIPTION_CHAR_LIMIT = 500;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Lowercases, strips punctuation, drops stopwords and sub-2-char tokens,
|
|
19
|
+
* and de-duplicates. Returns a plain array (order not significant — callers
|
|
20
|
+
* treat it as a set).
|
|
21
|
+
*/
|
|
22
|
+
export function tokenize(text) {
|
|
23
|
+
if (!text) return [];
|
|
24
|
+
const words = text.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').split(/\s+/).filter(Boolean);
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
for (const w of words) {
|
|
27
|
+
if (w.length >= 2 && !STOPWORDS.has(w)) seen.add(w);
|
|
28
|
+
}
|
|
29
|
+
return [...seen];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Intersection-over-union. Two empty sets are defined as 0 similarity, not 1 —
|
|
34
|
+
* an empty-vs-empty match is a lack of signal, not a confirmed duplicate.
|
|
35
|
+
*/
|
|
36
|
+
export function jaccardSimilarity(tokensA, tokensB) {
|
|
37
|
+
const setA = new Set(tokensA);
|
|
38
|
+
const setB = new Set(tokensB);
|
|
39
|
+
if (setA.size === 0 && setB.size === 0) return 0;
|
|
40
|
+
let intersection = 0;
|
|
41
|
+
for (const t of setA) {
|
|
42
|
+
if (setB.has(t)) intersection += 1;
|
|
43
|
+
}
|
|
44
|
+
const union = setA.size + setB.size - intersection;
|
|
45
|
+
return union === 0 ? 0 : intersection / union;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function weightedScore(source, candidate) {
|
|
49
|
+
const titleScore = jaccardSimilarity(tokenize(source.summary), tokenize(candidate.summary));
|
|
50
|
+
const descScore = jaccardSimilarity(
|
|
51
|
+
tokenize((source.description ?? '').slice(0, DESCRIPTION_CHAR_LIMIT)),
|
|
52
|
+
tokenize((candidate.description ?? '').slice(0, DESCRIPTION_CHAR_LIMIT)),
|
|
53
|
+
);
|
|
54
|
+
return (titleScore * TITLE_WEIGHT + descScore * DESCRIPTION_WEIGHT) / (TITLE_WEIGHT + DESCRIPTION_WEIGHT);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ranks candidates against the source ticket, excludes the source itself
|
|
59
|
+
* (defense-in-depth — callers already exclude it at the query level) and
|
|
60
|
+
* anything below `threshold`, sorted highest score first, capped at `limit`.
|
|
61
|
+
*/
|
|
62
|
+
export function scoreCandidates(source, candidates, { threshold = 0.35, limit = 5 } = {}) {
|
|
63
|
+
return candidates
|
|
64
|
+
.filter(c => c.key !== source.key)
|
|
65
|
+
.map(c => ({ key: c.key, summary: c.summary, score: weightedScore(source, c) }))
|
|
66
|
+
.filter(c => c.score >= threshold)
|
|
67
|
+
.sort((a, b) => b.score - a.score)
|
|
68
|
+
.slice(0, limit);
|
|
69
|
+
}
|
|
@@ -56,6 +56,8 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
56
56
|
` ${s.brand('ticketlens')} mcp Start the MCP stdio server for Recall ${s.dim('[Pro]')}`,
|
|
57
57
|
` ${s.brand('ticketlens')} comment ${s.dim('<TICKET-KEY> --body=...')} Post a comment to the tracker ${s.dim('[Pro]')}`,
|
|
58
58
|
` ${s.brand('ticketlens')} transition ${s.dim('<TICKET-KEY> [--target=... --confirm]')} Move ticket status ${s.dim('[Pro]')}`,
|
|
59
|
+
` ${s.brand('ticketlens')} assign ${s.dim('<TICKET-KEY> --to=me')} Assign a ticket to yourself ${s.dim('[Pro]')}`,
|
|
60
|
+
` ${s.brand('ticketlens')} duplicates ${s.dim('<TICKET-KEY> [--threshold=N]')} Find likely duplicate tickets ${s.dim('[Pro]')}`,
|
|
59
61
|
'',
|
|
60
62
|
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
|
|
61
63
|
` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
|
|
@@ -596,10 +598,12 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
596
598
|
'',
|
|
597
599
|
` Start an MCP (Model Context Protocol) stdio server exposing Recall and`,
|
|
598
600
|
` ticket writes as native tools — ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')},`,
|
|
599
|
-
` ${s.cyan('ticket_transition')} — for any MCP-compatible AI
|
|
600
|
-
` Thin adapter over the same code as
|
|
601
|
-
` above: same Pro gate, same
|
|
602
|
-
` ${s.cyan('ticket_transition')} is destructive when
|
|
601
|
+
` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')} — for any MCP-compatible AI`,
|
|
602
|
+
` harness, not just Claude Code. Thin adapter over the same code as`,
|
|
603
|
+
` ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')} above: same Pro gate, same`,
|
|
604
|
+
` local vault/tracker writes, same team sync. ${s.cyan('ticket_transition')} is destructive when`,
|
|
605
|
+
` called with \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently self-assign only;`,
|
|
606
|
+
` ${s.cyan('ticket_duplicates')} is read-only.`,
|
|
603
607
|
` Long-running — exits when the client closes stdin.`,
|
|
604
608
|
'',
|
|
605
609
|
` ${s.bold('OPTIONS')}`,
|
|
@@ -676,6 +680,56 @@ export function printTransitionHelp({ stream = process.stdout } = {}) {
|
|
|
676
680
|
stream.write(lines.join('\n') + '\n');
|
|
677
681
|
}
|
|
678
682
|
|
|
683
|
+
export function printAssignHelp({ stream = process.stdout } = {}) {
|
|
684
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
685
|
+
const lines = [
|
|
686
|
+
'',
|
|
687
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('assign')} ${s.dim('TICKET-KEY --to=me')} ${s.dim('[Pro]')}`,
|
|
688
|
+
'',
|
|
689
|
+
` Assign a ticket to yourself directly in its tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
|
|
690
|
+
` Self-assign only for now — ${s.brand('--to')} must be ${s.brand('me')}. Assigning to someone else`,
|
|
691
|
+
` isn't supported yet.`,
|
|
692
|
+
'',
|
|
693
|
+
` ${s.bold('OPTIONS')}`,
|
|
694
|
+
'',
|
|
695
|
+
` ${s.brand('--to')}=${s.dim('me')} Required — only "me" is currently supported`,
|
|
696
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
697
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
698
|
+
'',
|
|
699
|
+
` ${s.bold('EXAMPLES')}`,
|
|
700
|
+
'',
|
|
701
|
+
` ${s.dim('$')} ticketlens assign PROD-123 --to=me`,
|
|
702
|
+
'',
|
|
703
|
+
];
|
|
704
|
+
stream.write(lines.join('\n') + '\n');
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export function printDuplicatesHelp({ stream = process.stdout } = {}) {
|
|
708
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
709
|
+
const lines = [
|
|
710
|
+
'',
|
|
711
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('duplicates')} ${s.dim('TICKET-KEY [--threshold=0.35]')} ${s.dim('[Pro]')}`,
|
|
712
|
+
'',
|
|
713
|
+
` Find likely duplicate tickets in the same project (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
|
|
714
|
+
` Read-only — lists possible matches, never links or changes anything.`,
|
|
715
|
+
` No tracker scores similarity server-side, so ranking happens locally`,
|
|
716
|
+
` from title/description overlap. Not exact — treat it as a nudge to check.`,
|
|
717
|
+
'',
|
|
718
|
+
` ${s.bold('OPTIONS')}`,
|
|
719
|
+
'',
|
|
720
|
+
` ${s.brand('--threshold')}=${s.dim('N')} Minimum match score 0–1 to report ${s.dim('(default 0.35)')}`,
|
|
721
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
722
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
723
|
+
'',
|
|
724
|
+
` ${s.bold('EXAMPLES')}`,
|
|
725
|
+
'',
|
|
726
|
+
` ${s.dim('$')} ticketlens duplicates PROD-123`,
|
|
727
|
+
` ${s.dim('$')} ticketlens duplicates PROD-123 --threshold=0.5`,
|
|
728
|
+
'',
|
|
729
|
+
];
|
|
730
|
+
stream.write(lines.join('\n') + '\n');
|
|
731
|
+
}
|
|
732
|
+
|
|
679
733
|
export function printSwitchHelp({ stream = process.stdout } = {}) {
|
|
680
734
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
681
735
|
const lines = [
|
|
@@ -13,6 +13,14 @@ function toText(value) {
|
|
|
13
13
|
return adfToText(value);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Escapes a value for safe interpolation into a double-quoted JQL string
|
|
18
|
+
* literal. Single source of truth — do not duplicate in callers.
|
|
19
|
+
*/
|
|
20
|
+
export function escapeJql(s) {
|
|
21
|
+
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
/**
|
|
17
25
|
* Extracts the active sprint name from customfield_10020.
|
|
18
26
|
* Cloud v3: array of sprint objects — prefers active, falls back to last.
|
|
@@ -494,6 +502,36 @@ export async function postTransition(ticketKey, transitionId, opts = {}) {
|
|
|
494
502
|
}
|
|
495
503
|
}
|
|
496
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Sets the issue's assignee. `assignee` is sent verbatim — the caller
|
|
507
|
+
* (jira-adapter.mjs) resolves the right shape for the API version:
|
|
508
|
+
* `{ accountId }` for Cloud (v3), `{ name }` for Server/DC (v2), since
|
|
509
|
+
* Cloud has no username concept and Server/DC has no accountId.
|
|
510
|
+
*/
|
|
511
|
+
export async function assignIssue(ticketKey, assignee, opts = {}) {
|
|
512
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
513
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
514
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
515
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/assignee`;
|
|
516
|
+
|
|
517
|
+
const fetchOpts = {
|
|
518
|
+
method: 'PUT',
|
|
519
|
+
headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
|
|
520
|
+
body: JSON.stringify(assignee),
|
|
521
|
+
};
|
|
522
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
523
|
+
|
|
524
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
525
|
+
if (!response.ok) {
|
|
526
|
+
let details;
|
|
527
|
+
try { details = await response.json(); } catch { /* body not JSON — fall through with no details */ }
|
|
528
|
+
const err = new Error(`Jira API error ${response.status} assigning ${ticketKey}`);
|
|
529
|
+
err.status = response.status;
|
|
530
|
+
err.details = details;
|
|
531
|
+
throw err;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
497
535
|
export async function fetchTicket(ticketKey, opts = {}) {
|
|
498
536
|
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, allowPrivateIp = false, _visited = new Set(), _currentDepth = 0 } = opts;
|
|
499
537
|
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
@@ -23,7 +23,7 @@ import readline from 'node:readline';
|
|
|
23
23
|
import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
|
|
24
24
|
import { runNoteAdd } from './note-command.mjs';
|
|
25
25
|
import { runRecall } from './recall-command.mjs';
|
|
26
|
-
import { runTicketComment, runTicketTransitionList, runTicketTransition } from './ticket-command.mjs';
|
|
26
|
+
import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates } from './ticket-command.mjs';
|
|
27
27
|
|
|
28
28
|
const PROTOCOL_VERSION = '2025-11-25';
|
|
29
29
|
|
|
@@ -78,6 +78,30 @@ const TOOLS = [
|
|
|
78
78
|
required: ['ticket'],
|
|
79
79
|
},
|
|
80
80
|
},
|
|
81
|
+
{
|
|
82
|
+
name: 'ticket_assign',
|
|
83
|
+
description: 'Assign a ticket to yourself in its tracker (Jira/GitHub/Linear). Self-assign only — assigning to someone else is not supported yet. Requires a TicketLens Pro license.',
|
|
84
|
+
inputSchema: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
88
|
+
to: { type: 'string', description: 'Who to assign to — currently only "me" is accepted.' },
|
|
89
|
+
},
|
|
90
|
+
required: ['ticket', 'to'],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'ticket_duplicates',
|
|
95
|
+
description: 'Find likely duplicate tickets in the same project (Jira/GitHub/Linear). Read-only — never links or changes anything; no tracker scores similarity server-side, so ranking is local and approximate. Requires a TicketLens Pro license.',
|
|
96
|
+
inputSchema: {
|
|
97
|
+
type: 'object',
|
|
98
|
+
properties: {
|
|
99
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
100
|
+
threshold: { type: 'number', description: 'Minimum match score 0-1 to report. Defaults to 0.35.' },
|
|
101
|
+
},
|
|
102
|
+
required: ['ticket'],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
81
105
|
];
|
|
82
106
|
|
|
83
107
|
function jsonRpcResult(id, result) {
|
|
@@ -184,16 +208,43 @@ async function callTicketTransition(args, { configDir, runTicketTransitionListFn
|
|
|
184
208
|
return ok ? { content } : { isError: true, content };
|
|
185
209
|
}
|
|
186
210
|
|
|
211
|
+
async function callTicketAssign(args, { configDir, runTicketAssignFn }) {
|
|
212
|
+
if (!args.ticket) {
|
|
213
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
214
|
+
}
|
|
215
|
+
if (!args.to) {
|
|
216
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: to' }] };
|
|
217
|
+
}
|
|
218
|
+
const capture = capturingStream();
|
|
219
|
+
const { ok } = await runTicketAssignFn([args.ticket, `--to=${args.to}`], { configDir, stream: capture });
|
|
220
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
221
|
+
return ok ? { content } : { isError: true, content };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function callTicketDuplicates(args, { configDir, runTicketDuplicatesFn }) {
|
|
225
|
+
if (!args.ticket) {
|
|
226
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
227
|
+
}
|
|
228
|
+
const cmdArgs = [args.ticket];
|
|
229
|
+
if (args.threshold !== undefined) cmdArgs.push(`--threshold=${args.threshold}`);
|
|
230
|
+
const capture = capturingStream();
|
|
231
|
+
const { ok } = await runTicketDuplicatesFn(cmdArgs, { configDir, stream: capture });
|
|
232
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
233
|
+
return ok ? { content } : { isError: true, content };
|
|
234
|
+
}
|
|
235
|
+
|
|
187
236
|
async function handleToolsCall(params, deps) {
|
|
188
237
|
const { name, arguments: args = {} } = params ?? {};
|
|
189
238
|
if (name === 'recall_add') return callRecallAdd(args, deps);
|
|
190
239
|
if (name === 'recall_search') return callRecallSearch(args, deps);
|
|
191
240
|
if (name === 'ticket_comment') return callTicketComment(args, deps);
|
|
192
241
|
if (name === 'ticket_transition') return callTicketTransition(args, deps);
|
|
242
|
+
if (name === 'ticket_assign') return callTicketAssign(args, deps);
|
|
243
|
+
if (name === 'ticket_duplicates') return callTicketDuplicates(args, deps);
|
|
193
244
|
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
194
245
|
}
|
|
195
246
|
|
|
196
|
-
async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn }) {
|
|
247
|
+
async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn }) {
|
|
197
248
|
let msg;
|
|
198
249
|
try {
|
|
199
250
|
msg = JSON.parse(raw);
|
|
@@ -223,7 +274,7 @@ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTic
|
|
|
223
274
|
|
|
224
275
|
if (method === 'tools/call') {
|
|
225
276
|
try {
|
|
226
|
-
const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn });
|
|
277
|
+
const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn });
|
|
227
278
|
return jsonRpcResult(id, result);
|
|
228
279
|
} catch (err) {
|
|
229
280
|
return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
|
|
@@ -249,6 +300,8 @@ export function runMcpServer({
|
|
|
249
300
|
runTicketCommentFn = runTicketComment,
|
|
250
301
|
runTicketTransitionListFn = runTicketTransitionList,
|
|
251
302
|
runTicketTransitionFn = runTicketTransition,
|
|
303
|
+
runTicketAssignFn = runTicketAssign,
|
|
304
|
+
runTicketDuplicatesFn = runTicketDuplicates,
|
|
252
305
|
} = {}) {
|
|
253
306
|
// A client can disconnect mid-write (EPIPE) at any time on a long-lived
|
|
254
307
|
// process — an unhandled 'error' event on either stream would otherwise
|
|
@@ -268,7 +321,7 @@ export function runMcpServer({
|
|
|
268
321
|
// never resolving (a dropped rejection isn't a resolution) — the
|
|
269
322
|
// server would hang on shutdown instead of exiting.
|
|
270
323
|
queue = queue.then(async () => {
|
|
271
|
-
const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn });
|
|
324
|
+
const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn });
|
|
272
325
|
if (response) stdout.write(response);
|
|
273
326
|
}).catch(() => {});
|
|
274
327
|
});
|
|
@@ -19,7 +19,7 @@ export function detectTrackerType(baseUrl) {
|
|
|
19
19
|
* Instantiates the correct tracker adapter for a resolved connection.
|
|
20
20
|
* @param {{ baseUrl: string, auth?: string, email?: string, apiToken?: string, pat?: string }} conn
|
|
21
21
|
* @param {{ fetcher?: Function }} [opts]
|
|
22
|
-
* @returns {{ type: string, fetchTicket: Function, fetchCurrentUser: Function, searchTickets: Function, fetchStatuses: Function, addComment: Function, getTransitions: Function, transition: Function }}
|
|
22
|
+
* @returns {{ type: string, fetchTicket: Function, fetchCurrentUser: Function, searchTickets: Function, fetchStatuses: Function, addComment: Function, getTransitions: Function, transition: Function, assignToSelf: Function, findCandidates: Function }}
|
|
23
23
|
*/
|
|
24
24
|
export function resolveAdapter(conn, opts = {}) {
|
|
25
25
|
const type = detectTrackerType(conn?.baseUrl);
|
|
@@ -17,6 +17,7 @@ import { resolveAdapter } from './resolve-adapter.mjs';
|
|
|
17
17
|
import { checkCooldown, recordAction } from './ticket-action-cooldown.mjs';
|
|
18
18
|
import { logAction } from './ticket-action-log.mjs';
|
|
19
19
|
import { TICKET_KEY_PATTERN } from './cli.mjs';
|
|
20
|
+
import { scoreCandidates } from './duplicate-scorer.mjs';
|
|
20
21
|
|
|
21
22
|
function parseFlag(cmdArgs, name) {
|
|
22
23
|
return cmdArgs.find(a => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
@@ -63,6 +64,30 @@ function formatWriteFailure(ticketKey, err) {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Read-path counterpart to formatWriteFailure — reuses the same
|
|
69
|
+
* classification (rate-limit/timeout/server-error metadata is real and
|
|
70
|
+
* worth keeping, not specific to writes) but with read-appropriate wording,
|
|
71
|
+
* since "duplicates" never writes anything.
|
|
72
|
+
*/
|
|
73
|
+
function formatDuplicatesFailure(ticketKey, err) {
|
|
74
|
+
const classification = classifyWriteFailure(err);
|
|
75
|
+
switch (classification.kind) {
|
|
76
|
+
case 'rate-limited': {
|
|
77
|
+
const wait = classification.detail.retryAfterSeconds ?? null;
|
|
78
|
+
return wait
|
|
79
|
+
? ` Rate limited by the tracker — retry checking ${ticketKey} after ~${wait}s.\n`
|
|
80
|
+
: ` Rate limited by the tracker — try checking ${ticketKey} again later.\n`;
|
|
81
|
+
}
|
|
82
|
+
case 'network-or-timeout':
|
|
83
|
+
return ` Network error or timeout checking ${ticketKey} for duplicates. Try again.\n`;
|
|
84
|
+
case 'server-error':
|
|
85
|
+
return ` Tracker returned a server error (${classification.status}) checking ${ticketKey} for duplicates. Try again later.\n`;
|
|
86
|
+
default:
|
|
87
|
+
return ` Error checking ${ticketKey} for duplicates: ${err.message}\n`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
function requireLicense(isLicensedFn, configDir, commandName, stream) {
|
|
67
92
|
if (isLicensedFn('pro', configDir)) return true;
|
|
68
93
|
showUpgradePrompt('pro', commandName, { stream });
|
|
@@ -234,3 +259,112 @@ export async function runTicketTransition(cmdArgs, {
|
|
|
234
259
|
return { ok: false };
|
|
235
260
|
}
|
|
236
261
|
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Self-assign only — `--to` currently only accepts the literal "me".
|
|
265
|
+
* Arbitrary-user assignment needs a per-tracker user-search step this
|
|
266
|
+
* codebase doesn't have yet; kept as an explicit, rejected value now so
|
|
267
|
+
* a future `--to=someone@else.com` doesn't silently redefine what a
|
|
268
|
+
* bare/missing --to means today.
|
|
269
|
+
*
|
|
270
|
+
* @param {string[]} cmdArgs - [ticketKey, '--to=me']
|
|
271
|
+
* @returns {Promise<{ ok: boolean }>}
|
|
272
|
+
*/
|
|
273
|
+
export async function runTicketAssign(cmdArgs, {
|
|
274
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
275
|
+
stream = process.stderr,
|
|
276
|
+
isLicensedFn = isLicensed,
|
|
277
|
+
resolveConnectionFn = resolveConnection,
|
|
278
|
+
resolveAdapterFn = resolveAdapter,
|
|
279
|
+
checkCooldownFn = checkCooldown,
|
|
280
|
+
recordActionFn = recordAction,
|
|
281
|
+
logActionFn = logAction,
|
|
282
|
+
actor = os.userInfo().username,
|
|
283
|
+
} = {}) {
|
|
284
|
+
const usage = 'Usage: ticketlens assign TICKET-KEY --to=me\n';
|
|
285
|
+
if (!requireLicense(isLicensedFn, configDir, 'ticketlens assign', stream)) return { ok: false };
|
|
286
|
+
|
|
287
|
+
const ticketKey = requireTicketKey(cmdArgs, usage, stream);
|
|
288
|
+
if (!ticketKey) return { ok: false };
|
|
289
|
+
|
|
290
|
+
const to = parseFlag(cmdArgs, 'to');
|
|
291
|
+
if (to !== 'me') {
|
|
292
|
+
stream.write(to ? ` --to="${to}" is not yet supported — only --to=me (self-assign) is available.\n` : usage);
|
|
293
|
+
return { ok: false };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const cooldown = checkCooldownFn(ticketKey, 'assign', { configDir });
|
|
297
|
+
if (cooldown.active) {
|
|
298
|
+
stream.write(` Skipped — ${ticketKey} was already assigned ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
299
|
+
return { ok: false };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
303
|
+
if (!adapter) return { ok: false };
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const result = await adapter.assignToSelf(ticketKey);
|
|
307
|
+
recordActionFn(ticketKey, 'assign', { configDir });
|
|
308
|
+
logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: result.assignee } }, { configDir });
|
|
309
|
+
stream.write(` ${ticketKey} assigned to ${result.assignee}.\n`);
|
|
310
|
+
return { ok: true };
|
|
311
|
+
} catch (err) {
|
|
312
|
+
stream.write(formatWriteFailure(ticketKey, err));
|
|
313
|
+
return { ok: false };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Read-only — no cooldown, no action-log entry. Nothing is mutated, so
|
|
319
|
+
* there's nothing to debounce or audit, unlike comment/transition/assign.
|
|
320
|
+
*
|
|
321
|
+
* @param {string[]} cmdArgs - [ticketKey, ...flags], e.g. ["PROJ-1", '--threshold=0.4']
|
|
322
|
+
* @returns {Promise<{ ok: boolean, results?: Array<{key: string, summary: string, score: number}> }>}
|
|
323
|
+
*/
|
|
324
|
+
export async function runTicketDuplicates(cmdArgs, {
|
|
325
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
326
|
+
stream = process.stderr,
|
|
327
|
+
isLicensedFn = isLicensed,
|
|
328
|
+
resolveConnectionFn = resolveConnection,
|
|
329
|
+
resolveAdapterFn = resolveAdapter,
|
|
330
|
+
} = {}) {
|
|
331
|
+
const usage = 'Usage: ticketlens duplicates TICKET-KEY [--threshold=0.35]\n';
|
|
332
|
+
if (!requireLicense(isLicensedFn, configDir, 'ticketlens duplicates', stream)) return { ok: false };
|
|
333
|
+
|
|
334
|
+
const ticketKey = requireTicketKey(cmdArgs, usage, stream);
|
|
335
|
+
if (!ticketKey) return { ok: false };
|
|
336
|
+
|
|
337
|
+
const thresholdArg = parseFlag(cmdArgs, 'threshold');
|
|
338
|
+
let threshold;
|
|
339
|
+
if (thresholdArg !== undefined) {
|
|
340
|
+
threshold = Number(thresholdArg);
|
|
341
|
+
if (Number.isNaN(threshold) || threshold < 0 || threshold > 1) {
|
|
342
|
+
stream.write(` --threshold must be a number between 0 and 1 (got "${thresholdArg}").\n`);
|
|
343
|
+
return { ok: false };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
348
|
+
if (!adapter) return { ok: false };
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
const source = await adapter.fetchTicket(ticketKey);
|
|
352
|
+
const searchText = [source.summary, source.description].filter(Boolean).join(' ');
|
|
353
|
+
const candidates = await adapter.findCandidates(searchText, ticketKey);
|
|
354
|
+
const scoreOpts = threshold !== undefined ? { threshold } : {};
|
|
355
|
+
const results = scoreCandidates({ key: ticketKey, summary: source.summary, description: source.description }, candidates, scoreOpts);
|
|
356
|
+
|
|
357
|
+
if (results.length === 0) {
|
|
358
|
+
stream.write(` No likely duplicates found for ${ticketKey}.\n`);
|
|
359
|
+
return { ok: true, results: [] };
|
|
360
|
+
}
|
|
361
|
+
stream.write(` Possible duplicates of ${ticketKey}:\n`);
|
|
362
|
+
for (const r of results) {
|
|
363
|
+
stream.write(` ${r.key} (${Math.round(r.score * 100)}% match) — ${r.summary}\n`);
|
|
364
|
+
}
|
|
365
|
+
return { ok: true, results };
|
|
366
|
+
} catch (err) {
|
|
367
|
+
stream.write(formatDuplicatesFailure(ticketKey, err));
|
|
368
|
+
return { ok: false };
|
|
369
|
+
}
|
|
370
|
+
}
|