ticketlens 0.38.56 → 0.39.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 +21 -10
- package/bin/ticketlens.mjs +13 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +10 -6
- package/skills/jtb/hooks/recall-nudge-lib.mjs +4 -2
- package/skills/jtb/hooks/recall-nudge-stop.mjs +16 -2
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +7 -0
- 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 +45 -2
- package/skills/jtb/scripts/lib/jira-worklog-client.mjs +84 -0
- package/skills/jtb/scripts/lib/mcp-server.mjs +20 -1
- package/skills/jtb/scripts/lib/mcp-tool-schemas.mjs +27 -0
- package/skills/jtb/scripts/lib/ticket-action-log.mjs +1 -1
- package/skills/jtb/scripts/lib/ticket-command.mjs +4 -4
- package/skills/jtb/scripts/lib/ticket-worklog.mjs +342 -0
- package/skills/jtb/scripts/lib/worklog-duration.mjs +85 -0
package/README.md
CHANGED
|
@@ -462,7 +462,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
462
462
|
|
|
463
463
|
**Removing a note:** `ticketlens note delete --id="..." [--ticket=KEY]` removes a note from your local vault. Local only — if it was already pushed to a team, teammates who pulled it keep their copy; deleting it there too is a manager action from the Console (Admin > Recall).
|
|
464
464
|
|
|
465
|
-
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `review`, `standup`, `pr`, `stats`, `issue_types`, `history`, `collisions`, `ledger`, `doctor`, `recall_add`, `recall_update`, `recall_delete`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `
|
|
465
|
+
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `review`, `standup`, `pr`, `stats`, `issue_types`, `history`, `collisions`, `ledger`, `doctor`, `recall_add`, `recall_update`, `recall_delete`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, `ticket_create`, and `ticket_worklog` as native tools — every CLI action now has an MCP tool, so any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same license gate per tool, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. `fetch`, `doctor`, and `standup` are Free; `triage`'s base scan is Free with some options gated Pro/Team, same as the CLI (`ticketlens triage --help`); `compliance` and `pr` are Free, sharing a 3-checks/month cap on their requirements-coverage section, Pro unlimited; `compliance` additionally accepts `consensus: true` (Pro) to replace the local deterministic matcher with a multi-agent AI review, run server-side against a "consensus" role configured at Console > Admin > AI Roles (2+ providers from the team's shared pool), and always implies `--yes` under MCP since there's no TTY for the cost-confirmation prompt; `review` is Free for branch/files/ticket context, with its coverage/focus section requiring Pro as a plain license check — it does not draw from that same monthly counter; `stats` is Free with a 7-day lookback cap, Pro extends it to 30 days, same split as the CLI (`ticketlens stats --help`); `issue_types` is Free and Jira-only — pre-fetches and caches a profile's real creatable projects and issue types ahead of a `ticket_create` attempt, sharing its cache with that tool's own reactive enrichment; accepts an optional `project` to skip the full scan and return just that one project's types (3-day cache, vs. 7-day for the full scan); Linear/GitHub profiles get a clear "not available" instead of an empty result; `history` reads local triage history only (zero network) and requires Pro; `collisions` requires `ticketlens login` (Console access) plus a Team license; `ledger` exports the local, signed compliance audit trail (zero network) and requires Pro; every other tool needs Pro. `recall_update` overwrites an existing Recall note's body — internal plumbing for the note quality loop, not typically called directly; its `attachments` array appends new files to whatever the note already has, same as `recall_add`'s, never replacing existing ones. `recall_delete` is destructive and local-vault-only — requires `confirm: true` alongside `id` to actually execute; there is no interactive y/N prompt under MCP (no real terminal to prompt against), so omitting it always fails rather than silently blocking. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
|
|
466
466
|
|
|
467
467
|
`note add`'s save confirmation and `recall`'s search results are styled by default in a terminal; add `--plain` to either for bare, pipe-safe output. `recall` always shows each note's file ID (e.g. `[1784135399545-fe01c4.md]`) so you can open it directly (`cat ~/.ticketlens/recall/<PREFIX>/<id>`), or pass `--full` to print the full body content inline instead. Each result shows a relative time (`2h ago`, `3d ago`) rather than a bare date — the full-precision timestamp is always in the note file's own frontmatter.
|
|
468
468
|
|
|
@@ -490,14 +490,18 @@ ticketlens update PROJ-123 --add-labels=urgent,backend --remove-labels=stale
|
|
|
490
490
|
ticketlens create --project=PROJ --type="Task" --summary="Fix login on mobile" # Create a new ticket
|
|
491
491
|
ticketlens create --project=ENG --summary="New Linear issue" --profile=linear-team
|
|
492
492
|
ticketlens create --project=PROJ --type="Bug" --summary="Broken layout" --attach=./screenshot.png
|
|
493
|
+
ticketlens worklog PROJ-123=1h30m # Preview a worklog — writes nothing (Jira only)
|
|
494
|
+
ticketlens worklog PROJ-123=1h30m PROJ-456=45m --comment="Sprint work" --confirm # Log time on several tickets
|
|
493
495
|
```
|
|
494
496
|
|
|
495
|
-
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`/`ticket_link`/`ticket_update`/`ticket_create` MCP tools. Requires a Pro license.
|
|
497
|
+
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`/`ticket_link`/`ticket_update`/`ticket_create`/`ticket_worklog` MCP tools. Requires a Pro license.
|
|
496
498
|
|
|
497
499
|
`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.
|
|
498
500
|
|
|
499
501
|
`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.
|
|
500
502
|
|
|
503
|
+
`ticketlens worklog` logs time on one or several **Jira** tickets, always as you — GitHub and Linear have no worklog API and are refused. Durations are hours and minutes only (`2h`, `90m`, `1h30m`, up to 24h each; days and weeks are rejected because Jira defines them per instance). Every entry's format and tracker are checked before any is written (Jira-side failures like an unknown ticket are reported per ticket, naming what already landed so a retry never repeats it), and since a worklog can't be deleted through TicketLens, nothing is written without `--confirm` — without it you get a preview. `--started=` takes ISO 8601 with a time (default now; not in the future, not older than a year). One call logs at most 24h in total, and a write that times out holds that ticket for 10 minutes because it may have landed. The `ticket_worklog` MCP tool takes an `entries` array with a per-ticket `comment`/`started`; the CLI applies one `--comment`/`--started` to every ticket.
|
|
504
|
+
|
|
501
505
|
`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. That local scoring can also miss a real duplicate — an empty result means none were found by this heuristic, not a confirmed absence. `--threshold=N` (0–1, default 0.35) controls how loose a match counts.
|
|
502
506
|
|
|
503
507
|
`ticketlens link SOURCE-KEY TARGET-KEY` links two tickets — direction matters: SOURCE "types" TARGET (e.g. `link A B --type=Duplicate` means A duplicates B, not the other way around). With just the two keys it lists the tracker's current valid link types without changing anything — always fetched live for Jira, since link type names are per-instance configurable there. GitHub is different from Jira/Linear: it has no generic link relationship, so linking on a GitHub-tracked ticket *closes SOURCE as a duplicate of TARGET* — a state change, not just a relationship add — and prints an explicit warning immediately before that happens, on top of the same `--confirm` gate.
|
|
@@ -837,6 +841,8 @@ ticketlens update CNV1-2 --add-labels=urgent --remove-labels=stale # Add/remove
|
|
|
837
841
|
ticketlens create --project=CNV1 --type="Task" --summary="New ticket" # Create a new ticket [Pro]
|
|
838
842
|
ticketlens create --project=ENG --summary="New issue" --profile=linear-team # Create on a different profile [Pro]
|
|
839
843
|
ticketlens create --project=CNV1 --type="Bug" --summary="Broken layout" --attach=./screenshot.png # Create with an attachment [Pro]
|
|
844
|
+
ticketlens worklog CNV1-2=1h30m # Preview a worklog — writes nothing (Jira only) [Pro]
|
|
845
|
+
ticketlens worklog CNV1-2=1h30m CNV1-3=45m --confirm # Log time on several tickets (Jira only) [Pro]
|
|
840
846
|
|
|
841
847
|
# ── Stats ──────────────────────────────────────────────────────────────────────
|
|
842
848
|
ticketlens stats # Response-time metrics from local history
|
|
@@ -940,6 +946,7 @@ ticketlens duplicates CNV1-2 # Find likely duplicates (read-only)
|
|
|
940
946
|
ticketlens link CNV1-2 CNV1-3 --type="Duplicate" --confirm # Link two tickets
|
|
941
947
|
ticketlens update CNV1-2 --title="..." # Update title/description/labels/priority
|
|
942
948
|
ticketlens create --project=CNV1 --type="Task" --summary="..." # Create a new ticket
|
|
949
|
+
ticketlens worklog CNV1-2=1h30m --confirm # Log time on a Jira ticket
|
|
943
950
|
ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
|
|
944
951
|
```
|
|
945
952
|
|
|
@@ -1099,14 +1106,18 @@ npm test
|
|
|
1099
1106
|
See [ROADMAP.md](ROADMAP.md) for the full plan.
|
|
1100
1107
|
|
|
1101
1108
|
Recently shipped:
|
|
1102
|
-
- **
|
|
1103
|
-
- **
|
|
1104
|
-
- **
|
|
1105
|
-
- **
|
|
1106
|
-
- **
|
|
1107
|
-
-
|
|
1108
|
-
- **
|
|
1109
|
-
-
|
|
1109
|
+
- **Worklog** (`ticketlens worklog KEY=DURATION ... --confirm` / `ticket_worklog` MCP tool) — log time on one or several Jira tickets, preview-first, all entries validated before any is written. Pro tier
|
|
1110
|
+
- **Autonomous Recall capture** — TicketLens judges and saves a note after a session, no `note add` call, reusing your login. Pro+ tier
|
|
1111
|
+
- **Multi-agent AI consensus compliance** (`ticketlens compliance TICKET --consensus`) — routes requirements-vs-diff review through your team's AI provider pool, majority vote after a refinement round. Pro tier
|
|
1112
|
+
- **Dynamic AI Provider Registry** (Console > Admin > AI Provider Pool / AI Roles) — manage arbitrary AI providers and role-based routing, no fixed vendor list
|
|
1113
|
+
- **Full MCP tool coverage** (`ticketlens mcp`) — every CLI action (fetch, triage, compliance, recall, ticket writes) has a matching MCP tool
|
|
1114
|
+
- **`ticketlens issue-types`** — pre-fetches and caches a project's valid issue types, speeds up `ticket_create`
|
|
1115
|
+
- **Recall team sync** (`note add` / `recall`) — shared notes with Console verification, attachments, configurable capture strictness. Pro/Team tier
|
|
1116
|
+
- **`ticketlens doctor`** — diagnoses profile/connection/license/MCP problems; `--fix` for common issues
|
|
1117
|
+
- **Ticket write-back** (`comment`/`transition`/`assign`/`duplicates`/`link`/`update`/`create`) — write directly to Jira/GitHub/Linear from the CLI. Pro tier
|
|
1118
|
+
- **Stale status detection** — flags tickets stuck in a status too long, team-configurable thresholds. Pro tier
|
|
1119
|
+
- **Shared team Jira config** (Console > Admin > Jira) — manager sets the connection once, team inherits it. Pro+ tier
|
|
1120
|
+
- **Slack/Teams alerts** — needs-response, aging, and compliance-gap alerts plus a weekly digest. Team tier
|
|
1110
1121
|
|
|
1111
1122
|
---
|
|
1112
1123
|
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
printCollisionsHelp, printStatsHelp, printIssueTypesHelp, printDoctorHelp,
|
|
31
31
|
printCloudKeysHelp,
|
|
32
32
|
printNoteHelp, printRecallHelp, printMcpHelp,
|
|
33
|
-
printCommentHelp, printTransitionHelp, printAssignHelp, printDuplicatesHelp, printLinkHelp, printUpdateHelp, printCreateHelp,
|
|
33
|
+
printCommentHelp, printTransitionHelp, printAssignHelp, printWorklogHelp, 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
36
|
import { runIssueTypes } from '../skills/jtb/scripts/lib/run-issue-types.mjs';
|
|
@@ -835,6 +835,18 @@ switch (command) {
|
|
|
835
835
|
break;
|
|
836
836
|
}
|
|
837
837
|
|
|
838
|
+
case 'worklog': {
|
|
839
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printWorklogHelp(); break; }
|
|
840
|
+
const { runTicketWorklog } = await import('../skills/jtb/scripts/lib/ticket-worklog.mjs');
|
|
841
|
+
runTicketWorklog(cmdArgs).then(({ ok }) => {
|
|
842
|
+
if (!ok) process.exitCode = 1;
|
|
843
|
+
}).catch(err => {
|
|
844
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
845
|
+
process.exitCode = 1;
|
|
846
|
+
});
|
|
847
|
+
break;
|
|
848
|
+
}
|
|
849
|
+
|
|
838
850
|
case 'duplicates': {
|
|
839
851
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printDuplicatesHelp(); break; }
|
|
840
852
|
const { runTicketDuplicates } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.44.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.
|
|
@@ -418,9 +418,9 @@ Recall notes are stored locally at `~/.ticketlens/recall/`. On a Pro account wit
|
|
|
418
418
|
|
|
419
419
|
---
|
|
420
420
|
|
|
421
|
-
## Comment, Transition, Assign, Duplicates, Link, Update &
|
|
421
|
+
## Comment, Transition, Assign, Duplicates, Link, Update, Create & Worklog — write back to the tracker (Pro)
|
|
422
422
|
|
|
423
|
-
Unlike Recall (a local note about a ticket), these
|
|
423
|
+
Unlike Recall (a local note about a ticket), these eight commands write directly to the ticket's real tracker — Jira, GitHub, or Linear (`worklog` is Jira only). Only dispatch a write when the user has actually asked for it — 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.
|
|
424
424
|
|
|
425
425
|
```bash
|
|
426
426
|
ticketlens comment PROD-1234 --body="Fixed in a2f9c1, deployed to staging."
|
|
@@ -436,6 +436,8 @@ ticketlens link PROD-1234 PROD-5678 --type="Duplicate" --confirm # execute the
|
|
|
436
436
|
ticketlens update PROD-1234 --title="Fix login on mobile" # update title/description/labels/priority
|
|
437
437
|
ticketlens update PROD-1234 --add-labels=urgent --remove-labels=stale
|
|
438
438
|
ticketlens create --project=PROD --type="Task" --summary="Fix login on mobile" # create a new ticket
|
|
439
|
+
ticketlens worklog PROD-1234=1h30m # preview what would be logged — writes nothing
|
|
440
|
+
ticketlens worklog PROD-1234=1h30m PROD-5678=45m --comment="Sprint work" --confirm # log time on several tickets (Jira only)
|
|
439
441
|
```
|
|
440
442
|
|
|
441
443
|
`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.
|
|
@@ -450,15 +452,17 @@ ticketlens create --project=PROD --type="Task" --summary="Fix login on mobile"
|
|
|
450
452
|
|
|
451
453
|
`create` makes a brand-new ticket — there's no existing ticket to target, so `--project` (Jira project key / Linear team key) and `--type` (Jira issue type, ignored elsewhere) pick the destination instead of a ticket key. This is the highest-blast-radius command in the family: a bad `--project`/`--type` fabricates a real, hard-to-walk-back item in a live tracker. No `--confirm` gate — double-check the values with the user before calling it, since an invalid value surfaces the tracker's own error rather than a silent guess.
|
|
452
454
|
|
|
455
|
+
`worklog KEY=DURATION [KEY=DURATION ...]` logs time worked on one or several tickets, always as the authenticated user — logging for someone else isn't supported, so don't attempt a workaround. Jira only: GitHub and Linear have no worklog API and are refused. Durations are hours and minutes only (`2h`, `90m`, `1h30m`, up to 24h each) — days and weeks are rejected because Jira defines them per instance; never guess a conversion, ask the user. `--started=` takes ISO 8601 with a time (default now; not in the future, not older than a year). Every entry's format and tracker are checked before any is written, so a malformed entry blocks the whole call; Jira-side failures (unknown ticket, no permission, time tracking off) are reported per ticket while writing, and a partial result names which tickets already landed — retry only the rest, never repeat them. A rate limit or 401 stops the batch. The same ticket twice in one call is refused, total time per call is capped at 24h, and comments at 2000 characters. A write that times out or gets a server error leaves a 10-minute hold on that ticket, because it may have landed — check Jira before logging it again. A worklog cannot be deleted through TicketLens, so without `--confirm` it only prints a preview — add `--confirm` only once the user has confirmed the durations and tickets, never as a formality to route around. Jira applies its own remaining-estimate and watcher-notification defaults. The MCP tool takes an `entries` array with a per-ticket `comment`/`started`; the CLI applies one `--comment`/`--started` to every ticket.
|
|
456
|
+
|
|
453
457
|
`--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.
|
|
454
458
|
|
|
455
|
-
The
|
|
459
|
+
The write actions (comment/transition/assign/link/update/create/worklog) 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.
|
|
456
460
|
|
|
457
|
-
**Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (tools named `ticket_comment`/`ticket_transition`/`ticket_assign`/`ticket_duplicates`/`ticket_link`/`ticket_update`/`ticket_create` — often shown as `mcp__ticketlens__ticket_comment` etc. — visible in your tool list), **use those tools, not the bash commands above** — same license gate, same cooldown, same audit log. Only fall back to the bash form when the MCP tools are genuinely absent from your tool list; if that's because this project has never registered the server, see the `ticketlens mcp install` note above (Recall section) — same guidance applies here.
|
|
461
|
+
**Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (tools named `ticket_comment`/`ticket_transition`/`ticket_assign`/`ticket_duplicates`/`ticket_link`/`ticket_update`/`ticket_create`/`ticket_worklog` — often shown as `mcp__ticketlens__ticket_comment` etc. — visible in your tool list), **use those tools, not the bash commands above** — same license gate, same cooldown, same audit log. Only fall back to the bash form when the MCP tools are genuinely absent from your tool list; if that's because this project has never registered the server, see the `ticketlens mcp install` note above (Recall section) — same guidance applies here.
|
|
458
462
|
|
|
459
463
|
The same stale-schema caveat applies here — if a call rejects a parameter this document says exists (e.g. `attachments` on `ticket_comment`/`ticket_create`) right after an upgrade, see the MCP tool-cache staleness note above (Recall section).
|
|
460
464
|
|
|
461
|
-
Requires a Pro license — on Free, all
|
|
465
|
+
Requires a Pro license — on Free, all eight no-op with an upgrade hint on stderr.
|
|
462
466
|
|
|
463
467
|
---
|
|
464
468
|
|
|
@@ -68,8 +68,10 @@ export function writeState(sessionId, state) {
|
|
|
68
68
|
} catch { /* best-effort — a lost nudge counter is not worth failing the hook over */ }
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
// Two hours — how long a real capture in one directory counts as
|
|
72
|
-
// enough" to skip the Stop hook's nag, even from a brand-new session_id.
|
|
71
|
+
// Two hours of IDLE time — how long a real capture in one directory counts as
|
|
72
|
+
// "recent enough" to skip the Stop hook's nag, even from a brand-new session_id.
|
|
73
|
+
// Sliding, not fixed: ongoing ticket work renews the marker (see
|
|
74
|
+
// recall-nudge-stop.mjs, backlog #24), so only a full idle window lets it lapse.
|
|
73
75
|
export const CAPTURE_FRESHNESS_MS = 2 * 60 * 60 * 1000;
|
|
74
76
|
|
|
75
77
|
/**
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
* the same boundary for a DISMISSED nag: without it, a session that already
|
|
25
25
|
* got its one nag and was told "genuinely nothing qualified" would nag
|
|
26
26
|
* again after the next compaction/resume rollover, since that dismissal
|
|
27
|
-
* was never recorded anywhere — only a real capture was.
|
|
27
|
+
* was never recorded anywhere — only a real capture was. Both markers slide:
|
|
28
|
+
* ongoing ticket work renews them while fresh, so only a full idle window
|
|
29
|
+
* (CAPTURE_FRESHNESS_MS) lets either lapse (backlog #24).
|
|
28
30
|
*
|
|
29
31
|
* Which of the two cases above actually blocks is governed by the effective
|
|
30
32
|
* recallStrictness — the active profile's own explicit config-set value, or
|
|
@@ -102,7 +104,19 @@ if (isLicensed('pro') && cliToken && !hasRecentAutoCaptureAttempt(cwd)) {
|
|
|
102
104
|
// Refreshed on every check, independent of the once-per-session gate below —
|
|
103
105
|
// a capture that happens AFTER this session already nagged once must still
|
|
104
106
|
// update the marker, or a later session_id rollover would find it stale.
|
|
105
|
-
|
|
107
|
+
//
|
|
108
|
+
// Sliding window (backlog #24, 7th report): ongoing ticket work also renews a
|
|
109
|
+
// marker that is still fresh, so it measures idle time, not time since the
|
|
110
|
+
// last capture/nag. A fixed window expired mid-work (capture 17:48, nag 19:48
|
|
111
|
+
// in a session with 23 silent Stops before it) and falsely claimed "nothing
|
|
112
|
+
// was ever captured". Only a session that could itself nag (fetch + mutation)
|
|
113
|
+
// renews, so non-ticket work and read-only lookups in the same cwd can't keep
|
|
114
|
+
// a marker alive; a lapsed marker is never revived. Accepted trade-off: such
|
|
115
|
+
// sessions chained in one cwd with gaps under the window keep suppressing.
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const isActiveTicketWork = sawFetch && sawMutatingAction;
|
|
118
|
+
if (sawNoteAdd || (isActiveTicketWork && hasRecentCapture(cwd, now))) writeLastCaptureAt(cwd, now);
|
|
119
|
+
if (isActiveTicketWork && hasRecentNag(cwd, now)) writeLastNagAt(cwd, now);
|
|
106
120
|
|
|
107
121
|
const state = readState(sessionId);
|
|
108
122
|
if (state.stopChecked) process.exit(0); // already asked once this session — respect the answer
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, fetchProjects, fetchIssueTypes, postComment, getTransitions, postTransition, assignIssue, escapeJql, getIssueLinkTypes, postIssueLink, updateIssue, createIssue, DEFAULT_SEARCH_FIELDS } from '../jira-client.mjs';
|
|
2
|
+
import { postWorklog } from '../jira-worklog-client.mjs';
|
|
2
3
|
import { uploadAttachment, resolveMediaId } from '../jira-attachment-client.mjs';
|
|
3
4
|
import { readAttachments } from '../attachment-uploader.mjs';
|
|
4
5
|
import { buildMediaNode } from '../adf-converter.mjs';
|
|
@@ -39,6 +40,12 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
39
40
|
searchTickets: (query, opts = {}) => searchTickets(query, { ...base, ...opts }),
|
|
40
41
|
fetchStatuses: (opts = {}) => fetchStatuses({ ...base, ...opts }),
|
|
41
42
|
addComment: (key, body, opts = {}) => postComment(key, body, { ...base, ...opts }),
|
|
43
|
+
/**
|
|
44
|
+
* Jira-only — GitHub and Linear have no worklog API, so their adapters
|
|
45
|
+
* deliberately lack this method and ticket-worklog.mjs refuses them
|
|
46
|
+
* before any write. Always logs as the authenticated user.
|
|
47
|
+
*/
|
|
48
|
+
logWork: (key, entry, opts = {}) => postWorklog(key, entry, { ...base, ...opts }),
|
|
42
49
|
getTransitions: (key, opts = {}) => getTransitions(key, { ...base, ...opts }),
|
|
43
50
|
/**
|
|
44
51
|
* Always re-fetches transitions fresh and resolves `target` against
|
|
@@ -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,
|
|
@@ -168,6 +168,10 @@ export function parseCommand(args) {
|
|
|
168
168
|
return { command: 'assign', args: args.slice(1) };
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
if (first === 'worklog') {
|
|
172
|
+
return { command: 'worklog', args: args.slice(1) };
|
|
173
|
+
}
|
|
174
|
+
|
|
171
175
|
if (first === 'duplicates') {
|
|
172
176
|
return { command: 'duplicates', args: args.slice(1) };
|
|
173
177
|
}
|
|
@@ -58,6 +58,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
58
58
|
` ${s.brand('ticketlens')} comment ${s.dim('<TICKET-KEY> --body=... [--attach=...]')} Post a comment to the tracker ${s.dim('[Pro]')}`,
|
|
59
59
|
` ${s.brand('ticketlens')} transition ${s.dim('<TICKET-KEY> [--target=... --confirm]')} Move ticket status ${s.dim('[Pro]')}`,
|
|
60
60
|
` ${s.brand('ticketlens')} assign ${s.dim('<TICKET-KEY> --to=me')} Assign a ticket to yourself ${s.dim('[Pro]')}`,
|
|
61
|
+
` ${s.brand('ticketlens')} worklog ${s.dim('<KEY=DURATION ...> [--comment=... --started=...] --confirm')} Log time on Jira tickets ${s.dim('[Pro] [Jira only]')}`,
|
|
61
62
|
` ${s.brand('ticketlens')} duplicates ${s.dim('<TICKET-KEY> [--threshold=N]')} Find likely duplicate tickets ${s.dim('[Pro]')}`,
|
|
62
63
|
` ${s.brand('ticketlens')} link ${s.dim('<SOURCE> <TARGET> [--type=... --confirm]')} Link two tickets ${s.dim('[Pro]')}`,
|
|
63
64
|
` ${s.brand('ticketlens')} update ${s.dim('<TICKET-KEY> [--title=... --description=... --priority=... --add-labels=... --remove-labels=...]')} Update fields ${s.dim('[Pro]')}`,
|
|
@@ -649,12 +650,12 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
649
650
|
` 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')},`,
|
|
650
651
|
` ${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')},`,
|
|
651
652
|
` ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')}, ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')},`,
|
|
652
|
-
` ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')}, ${s.cyan('ticket_create')} — every CLI action`,
|
|
653
|
+
` ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')}, ${s.cyan('ticket_create')}, ${s.cyan('ticket_worklog')} — every CLI action`,
|
|
653
654
|
` now has an MCP tool, for any MCP-compatible AI harness, not just Claude Code.`,
|
|
654
655
|
` Thin adapter over the same code as ${s.cyan('TICKET-KEY')}/${s.cyan('doctor')}/${s.cyan('triage')}/${s.cyan('compliance')}/${s.cyan('review')}/`,
|
|
655
656
|
` ${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')}/`,
|
|
656
657
|
` ${s.cyan('note delete')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')}/`,
|
|
657
|
-
` ${s.cyan('update')}/${s.cyan('create')} above.`,
|
|
658
|
+
` ${s.cyan('update')}/${s.cyan('create')}/${s.cyan('worklog')} above.`,
|
|
658
659
|
` ${s.cyan('fetch')}, ${s.cyan('doctor')}, and ${s.cyan('standup')} are Free; ${s.cyan('triage')} is Free with some Pro/Team-gated`,
|
|
659
660
|
` options (see ${s.cyan('ticketlens triage --help')}); ${s.cyan('compliance')} and ${s.cyan('pr')} are Free, sharing a`,
|
|
660
661
|
` 3-checks/month cap on their requirements-coverage section, Pro unlimited;`,
|
|
@@ -670,6 +671,8 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
670
671
|
` "not available" instead of an empty result — every other tool needs Pro.`,
|
|
671
672
|
` ${s.cyan('ticket_transition')} is destructive when called with`,
|
|
672
673
|
` \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently self-assign only;`,
|
|
674
|
+
` ${s.cyan('ticket_worklog')} is Jira-only, logs as you only, and writes nothing without`,
|
|
675
|
+
` \`confirm: true\` (it previews instead) — a worklog cannot be deleted through TicketLens;`,
|
|
673
676
|
` ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the source issue as a`,
|
|
674
677
|
` duplicate — different semantics than Jira/Linear's relationship-only add;`,
|
|
675
678
|
` ${s.cyan('ticket_update')} has no priority field on GitHub and can partially succeed;`,
|
|
@@ -793,6 +796,46 @@ export function printAssignHelp({ stream = process.stdout } = {}) {
|
|
|
793
796
|
stream.write(lines.join('\n') + '\n');
|
|
794
797
|
}
|
|
795
798
|
|
|
799
|
+
export function printWorklogHelp({ stream = process.stdout } = {}) {
|
|
800
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
801
|
+
const lines = [
|
|
802
|
+
'',
|
|
803
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('worklog')} ${s.dim('KEY=DURATION [KEY=DURATION ...] --confirm')} ${s.dim('[Pro]')}`,
|
|
804
|
+
'',
|
|
805
|
+
` Log time worked on one or more tickets. ${s.dim('[Pro]')}`,
|
|
806
|
+
` Jira only — GitHub and Linear have no worklog API. Always logs as you, the`,
|
|
807
|
+
` authenticated user; logging for someone else isn't supported.`,
|
|
808
|
+
'',
|
|
809
|
+
` Every entry's format and tracker are checked before any is written, so a typo blocks the`,
|
|
810
|
+
` whole call. Jira-side failures (unknown ticket, no permission, time tracking off) are reported`,
|
|
811
|
+
` per ticket while writing, naming what already landed so a retry never repeats it.`,
|
|
812
|
+
` A worklog cannot be deleted through TicketLens, so nothing is written without`,
|
|
813
|
+
` ${s.brand('--confirm')} — without it you get a preview. Jira applies its own`,
|
|
814
|
+
` remaining-estimate and watcher-notification defaults.`,
|
|
815
|
+
'',
|
|
816
|
+
` ${s.bold('OPTIONS')}`,
|
|
817
|
+
'',
|
|
818
|
+
` ${s.brand('KEY=DURATION')} One or more tickets, e.g. ${s.dim('PROJ-1=1h30m PROJ-2=45m')}. Hours and minutes`,
|
|
819
|
+
` only (${s.dim('2h')}, ${s.dim('90m')}, ${s.dim('1h30m')}), up to 24h each. Days and weeks are`,
|
|
820
|
+
` not accepted — Jira defines them per instance. Max 20 tickets, one`,
|
|
821
|
+
` entry per ticket, 24h in total per call.`,
|
|
822
|
+
` ${s.brand('--comment')}=${s.dim('TEXT')} Note attached to every worklog, up to 2000 characters ${s.dim('(optional; per-ticket notes need the MCP tool)')}`,
|
|
823
|
+
` ${s.brand('--started')}=${s.dim('ISO')} When the work started, with a time, e.g. ${s.dim('2026-09-18T10:00:00-07:00')}.`,
|
|
824
|
+
` Defaults to now; not in the future, not older than a year ${s.dim('(optional)')}`,
|
|
825
|
+
` ${s.brand('--confirm')} Required to write anything`,
|
|
826
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
827
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
828
|
+
'',
|
|
829
|
+
` ${s.bold('EXAMPLES')}`,
|
|
830
|
+
'',
|
|
831
|
+
` ${s.dim('$')} ticketlens worklog PROJ-123=1h30m`,
|
|
832
|
+
` ${s.dim('$')} ticketlens worklog PROJ-123=1h30m --confirm`,
|
|
833
|
+
` ${s.dim('$')} ticketlens worklog PROJ-1=2h PROJ-2=45m --comment="Sprint work" --confirm`,
|
|
834
|
+
'',
|
|
835
|
+
];
|
|
836
|
+
stream.write(lines.join('\n') + '\n');
|
|
837
|
+
}
|
|
838
|
+
|
|
796
839
|
export function printDuplicatesHelp({ stream = process.stdout } = {}) {
|
|
797
840
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
798
841
|
const lines = [
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jira worklog write — split from jira-client.mjs (which sits at the file-size
|
|
3
|
+
* cap) the same way jira-attachment-client.mjs is: it reuses the client's own
|
|
4
|
+
* URL validation, auth header, and guardedFetch (SSRF/redirect guard), never
|
|
5
|
+
* its own copies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { textToAdf } from './adf-converter.mjs';
|
|
9
|
+
import { sanitizeUntrustedText } from './ansi.mjs';
|
|
10
|
+
import { TICKET_KEY_PATTERN } from './cli.mjs';
|
|
11
|
+
import { buildAuthHeader, guardedFetch, validateBaseUrl, defaultLookupFor } from './jira-client.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Jira's own 400 text (errorMessages + per-field errors), so "Time tracking is
|
|
15
|
+
* disabled" or "You do not have permission to add worklog" reaches the caller
|
|
16
|
+
* instead of a bare status. Defensive about shape — the body is external data,
|
|
17
|
+
* so terminal control characters (an OSC 52 clipboard write, cursor movement)
|
|
18
|
+
* are stripped before the text can reach a caller's terminal.
|
|
19
|
+
*/
|
|
20
|
+
function summarizeJiraErrors(details) {
|
|
21
|
+
const messages = Array.isArray(details?.errorMessages) ? details.errorMessages : [];
|
|
22
|
+
const fields = details?.errors && typeof details.errors === 'object' ? Object.entries(details.errors).map(([field, msg]) => `${field}: ${msg}`) : [];
|
|
23
|
+
return sanitizeUntrustedText([...messages, ...fields].map(String).join('; ')).slice(0, 300);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Retry-After is delta-seconds or an HTTP date; only the former is usable as a wait, anything else is null. */
|
|
27
|
+
function parseRetryAfterSeconds(value) {
|
|
28
|
+
const seconds = Number(value);
|
|
29
|
+
return Number.isInteger(seconds) && seconds > 0 ? seconds : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Adds a worklog to an issue, always as the authenticated user — Jira offers
|
|
34
|
+
* no way to log on someone else's behalf. `timeSpentSeconds` (not Jira's
|
|
35
|
+
* `timeSpent` string, whose `d`/`w` units are instance-configured) and
|
|
36
|
+
* `started` (required by Jira on create, `+0000` offset form only) are
|
|
37
|
+
* mandatory; both are checked before any network call. `comment` follows the
|
|
38
|
+
* same v3-ADF / v2-plain-string split as postComment. Jira's own estimate and
|
|
39
|
+
* watcher-notification defaults apply — deliberately not overridden here.
|
|
40
|
+
*/
|
|
41
|
+
export async function postWorklog(ticketKey, { timeSpentSeconds, started, comment } = {}, opts = {}) {
|
|
42
|
+
if (typeof ticketKey !== 'string' || !TICKET_KEY_PATTERN.test(ticketKey)) {
|
|
43
|
+
throw new TypeError('postWorklog: invalid ticket key — expected PROJ-123');
|
|
44
|
+
}
|
|
45
|
+
if (!Number.isInteger(timeSpentSeconds) || timeSpentSeconds <= 0) {
|
|
46
|
+
throw new TypeError('postWorklog: timeSpentSeconds must be a positive integer');
|
|
47
|
+
}
|
|
48
|
+
if (typeof started !== 'string' || !started) {
|
|
49
|
+
throw new TypeError('postWorklog: started is required — Jira rejects a worklog without it');
|
|
50
|
+
}
|
|
51
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
52
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
53
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
54
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/worklog`;
|
|
55
|
+
|
|
56
|
+
const payload = { timeSpentSeconds, started };
|
|
57
|
+
if (comment !== undefined) payload.comment = apiVersion === 3 ? textToAdf(comment) : comment;
|
|
58
|
+
const fetchOpts = {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
|
|
61
|
+
body: JSON.stringify(payload),
|
|
62
|
+
};
|
|
63
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
64
|
+
|
|
65
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
let details;
|
|
68
|
+
try { details = await response.json(); } catch { /* body not JSON — fall through with no details */ }
|
|
69
|
+
const summary = summarizeJiraErrors(details);
|
|
70
|
+
const err = new Error(`Jira API error ${response.status} logging work on ${ticketKey}${summary ? `: ${summary}` : ''}`);
|
|
71
|
+
err.status = response.status;
|
|
72
|
+
err.details = details;
|
|
73
|
+
if (response.status === 429) err.rateLimit = { retryAfterSeconds: parseRetryAfterSeconds(response.headers?.get?.('retry-after')) };
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
const raw = await response.json();
|
|
77
|
+
// Jira-supplied, then printed and audit-logged by callers: only a plain numeric id is trusted.
|
|
78
|
+
const id = /^\d+$/.test(String(raw.id)) ? String(raw.id) : undefined;
|
|
79
|
+
return {
|
|
80
|
+
id,
|
|
81
|
+
timeSpent: typeof raw.timeSpent === 'string' ? sanitizeUntrustedText(raw.timeSpent) : undefined,
|
|
82
|
+
url: `${baseUrl}/browse/${encodeURIComponent(ticketKey)}${id ? `?focusedWorklogId=${id}` : ''}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -25,6 +25,7 @@ import { runDoctor } from './doctor-command.mjs';
|
|
|
25
25
|
import { runNoteAdd, runNotePatch, runNoteDelete } from './note-command.mjs';
|
|
26
26
|
import { runRecall } from './recall-command.mjs';
|
|
27
27
|
import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates, runTicketLinkList, runTicketLink, runTicketUpdate, runTicketCreate } from './ticket-command.mjs';
|
|
28
|
+
import { runTicketWorklogEntries } from './ticket-worklog.mjs';
|
|
28
29
|
import { run as runFetchTicket } from '../fetch-ticket.mjs';
|
|
29
30
|
import { run as runTriage } from '../fetch-my-tickets.mjs';
|
|
30
31
|
import { runStats } from './run-stats.mjs';
|
|
@@ -526,6 +527,22 @@ async function callTicketAssign(args, { configDir, runTicketAssignFn }) {
|
|
|
526
527
|
return ok ? { content } : { isError: true, content };
|
|
527
528
|
}
|
|
528
529
|
|
|
530
|
+
/**
|
|
531
|
+
* `entries` goes to the runner as structured data, never re-serialized into
|
|
532
|
+
* an argv — a comment reading `--confirm` cannot become a flag because no
|
|
533
|
+
* flag parsing happens on this path. Confirmation is the boolean `true`
|
|
534
|
+
* exactly; a truthy string like "yes" is not consent to write billable time.
|
|
535
|
+
*/
|
|
536
|
+
async function callTicketWorklog(args, { configDir, runTicketWorklogFn }) {
|
|
537
|
+
if (!Array.isArray(args.entries) || args.entries.length === 0) {
|
|
538
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: entries (a non-empty array of { ticket, time })' }] };
|
|
539
|
+
}
|
|
540
|
+
const capture = capturingStream();
|
|
541
|
+
const { ok } = await runTicketWorklogFn(args.entries, { configDir, stream: capture, confirm: args.confirm === true, cliHints: false });
|
|
542
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
543
|
+
return ok ? { content } : { isError: true, content };
|
|
544
|
+
}
|
|
545
|
+
|
|
529
546
|
async function callTicketDuplicates(args, { configDir, runTicketDuplicatesFn }) {
|
|
530
547
|
if (!args.ticket) {
|
|
531
548
|
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
@@ -647,6 +664,7 @@ async function handleToolsCall(params, deps) {
|
|
|
647
664
|
if (name === 'ticket_comment') return callTicketComment(args, deps);
|
|
648
665
|
if (name === 'ticket_transition') return callTicketTransition(args, deps);
|
|
649
666
|
if (name === 'ticket_assign') return callTicketAssign(args, deps);
|
|
667
|
+
if (name === 'ticket_worklog') return callTicketWorklog(args, deps);
|
|
650
668
|
if (name === 'ticket_duplicates') return callTicketDuplicates(args, deps);
|
|
651
669
|
if (name === 'ticket_link') return callTicketLink(args, deps);
|
|
652
670
|
if (name === 'ticket_update') return callTicketUpdate(args, deps);
|
|
@@ -720,6 +738,7 @@ export function runMcpServer({
|
|
|
720
738
|
runTicketTransitionListFn = runTicketTransitionList,
|
|
721
739
|
runTicketTransitionFn = runTicketTransition,
|
|
722
740
|
runTicketAssignFn = runTicketAssign,
|
|
741
|
+
runTicketWorklogFn = runTicketWorklogEntries,
|
|
723
742
|
runTicketDuplicatesFn = runTicketDuplicates,
|
|
724
743
|
runTicketLinkListFn = runTicketLinkList,
|
|
725
744
|
runTicketLinkFn = runTicketLink,
|
|
@@ -736,7 +755,7 @@ export function runMcpServer({
|
|
|
736
755
|
// Assembled once and passed straight through handleMessage to handleToolsCall,
|
|
737
756
|
// which is the only place the individual functions are read — so a new tool
|
|
738
757
|
// needs its dependency named here and in the parameter list above, nowhere else.
|
|
739
|
-
const deps = { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runIssueTypesFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runNotePatchFn, runNoteDeleteFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn };
|
|
758
|
+
const deps = { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runIssueTypesFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runNotePatchFn, runNoteDeleteFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketWorklogFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn };
|
|
740
759
|
|
|
741
760
|
const rl = readline.createInterface({ input: stdin, terminal: false });
|
|
742
761
|
let queue = Promise.resolve();
|
|
@@ -249,6 +249,33 @@ export const TOOLS = [
|
|
|
249
249
|
required: ['ticket', 'to'],
|
|
250
250
|
},
|
|
251
251
|
},
|
|
252
|
+
{
|
|
253
|
+
name: 'ticket_worklog',
|
|
254
|
+
description: 'Log time worked on one or more tickets in their tracker. Jira only — GitHub and Linear have no worklog API and are refused. Always logs as you, the authenticated user; logging for someone else is not supported. Destructive — writes directly to the live tracker, and there is no delete tool. Every entry\'s format and tracker are checked before any is written, so a malformed entry blocks the whole call; Jira-side failures (unknown ticket, no permission, time tracking off) are reported per ticket while writing, and a partial result names which tickets already landed — retry only the rest. A rate limit or 401 stops the batch. Called without `confirm: true` it writes nothing and returns a preview of what would be logged; call again with `confirm: true` to log. Jira applies its own remaining-estimate and watcher-notification defaults. Requires a TicketLens Pro license.',
|
|
255
|
+
inputSchema: {
|
|
256
|
+
type: 'object',
|
|
257
|
+
properties: {
|
|
258
|
+
entries: {
|
|
259
|
+
type: 'array',
|
|
260
|
+
minItems: 1,
|
|
261
|
+
maxItems: 20,
|
|
262
|
+
description: 'One entry per ticket, at most 20 per call, at most 24h in total per call. The same ticket twice in one call is refused.',
|
|
263
|
+
items: {
|
|
264
|
+
type: 'object',
|
|
265
|
+
properties: {
|
|
266
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
267
|
+
time: { type: 'string', description: 'Time spent as hours and minutes — "1h30m", "90m", "2h". Up to 24h. Days and weeks are not accepted (Jira defines them per instance).' },
|
|
268
|
+
started: { type: 'string', description: 'When the work started, ISO 8601 with a time, e.g. "2026-09-18T10:00:00-07:00". Defaults to now. Cannot be in the future or older than a year.' },
|
|
269
|
+
comment: { type: 'string', description: 'Optional note attached to the worklog, up to 2000 characters.' },
|
|
270
|
+
},
|
|
271
|
+
required: ['ticket', 'time'],
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
confirm: { type: 'boolean', description: 'Must be true to actually log the time. Omit it to get a preview with nothing written.' },
|
|
275
|
+
},
|
|
276
|
+
required: ['entries'],
|
|
277
|
+
},
|
|
278
|
+
},
|
|
252
279
|
{
|
|
253
280
|
name: 'ticket_duplicates',
|
|
254
281
|
description: 'Find likely duplicate tickets in the same project (Jira/GitHub/Linear). Read-only — never links or changes anything. On Jira, any ticket already linked as a "Duplicate" is always included first (a confirmed relationship, not a guess); everything else comes from a local, approximate title/description overlap score, since no tracker scores similarity server-side. That scorer can miss real duplicates as easily as it over-matches, so an empty result means none were found, not a guarantee that none exist. Requires a TicketLens Pro license.',
|
|
@@ -32,7 +32,7 @@ function logPath(configDir) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* @param {{ ticketKey: string, action:
|
|
35
|
+
* @param {{ ticketKey: string, action: string, actor: string, tracker: string, detail?: object }} entry
|
|
36
36
|
* @param {{ configDir?: string, now?: () => Date }} [opts]
|
|
37
37
|
*/
|
|
38
38
|
export function logAction({ ticketKey, action, actor, tracker, detail = {} }, {
|
|
@@ -23,7 +23,7 @@ import { scoreCandidates } from './duplicate-scorer.mjs';
|
|
|
23
23
|
import { MAX_ATTACHMENTS } from './attachment-uploader.mjs';
|
|
24
24
|
import { createStyler } from './ansi.mjs';
|
|
25
25
|
|
|
26
|
-
function parseFlag(cmdArgs, name) {
|
|
26
|
+
export function parseFlag(cmdArgs, name) {
|
|
27
27
|
return cmdArgs.find(a => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
28
28
|
}
|
|
29
29
|
|
|
@@ -77,7 +77,7 @@ export function classifyWriteFailure(err) {
|
|
|
77
77
|
return { kind: 'terminal', status: err.status, details: err.details };
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
function formatWriteFailure(ticketKey, err) {
|
|
80
|
+
export function formatWriteFailure(ticketKey, err) {
|
|
81
81
|
const classification = classifyWriteFailure(err);
|
|
82
82
|
switch (classification.kind) {
|
|
83
83
|
case 'rate-limited': {
|
|
@@ -201,7 +201,7 @@ function formatUpdateResult(ticketKey, { applied, errors }, s) {
|
|
|
201
201
|
return ` Nothing updated on ${ticketKey}. Failed: ${errorText}.\n`;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
function requireLicense(isLicensedFn, configDir, commandName, stream) {
|
|
204
|
+
export function requireLicense(isLicensedFn, configDir, commandName, stream) {
|
|
205
205
|
if (isLicensedFn('pro', configDir)) return true;
|
|
206
206
|
showUpgradePrompt('pro', commandName, { stream });
|
|
207
207
|
return false;
|
|
@@ -236,7 +236,7 @@ function requireTicketKey(cmdArgs, usage, stream) {
|
|
|
236
236
|
* `runTicketCreate`'s profile/project mismatch safety net — have it without
|
|
237
237
|
* re-resolving.
|
|
238
238
|
*/
|
|
239
|
-
function resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream }) {
|
|
239
|
+
export function resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream }) {
|
|
240
240
|
const profileName = parseFlag(cmdArgs, 'profile');
|
|
241
241
|
const conn = resolveConnectionFn(ticketKey, {
|
|
242
242
|
configDir,
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Implements `ticketlens worklog` and the `ticket_worklog` MCP tool: logs time
|
|
3
|
+
* against one or many tickets. Jira-only (GitHub/Linear have no worklog API),
|
|
4
|
+
* always as the authenticated user, Pro-gated like every ticket write.
|
|
5
|
+
*
|
|
6
|
+
* Time entries feed billing/payroll and have no delete path here, so the shape
|
|
7
|
+
* is deliberately stricter than ticket_comment:
|
|
8
|
+
* - every entry is validated and resolved BEFORE any write — a typo in entry
|
|
9
|
+
* 3 never leaves entries 1–2 logged;
|
|
10
|
+
* - nothing is written without an explicit confirm (a preview is shown);
|
|
11
|
+
* - runtime failures are reported per ticket, with what already landed named
|
|
12
|
+
* so a retry never repeats it; a rate-limit or 401 halts the batch; and
|
|
13
|
+
* local bookkeeping failing after a landed write never reads as failure
|
|
14
|
+
* (a retry would double the time).
|
|
15
|
+
* Jira has no public bulk-worklog endpoint, so a batch is sequential POSTs.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
20
|
+
import { isLicensed } from './license.mjs';
|
|
21
|
+
import { resolveConnection } from './profile-resolver.mjs';
|
|
22
|
+
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
23
|
+
import { checkCooldown, recordAction } from './ticket-action-cooldown.mjs';
|
|
24
|
+
import { logAction } from './ticket-action-log.mjs';
|
|
25
|
+
import { TICKET_KEY_PATTERN, normalizeTicketKey } from './cli.mjs';
|
|
26
|
+
import { createStyler, sanitizeUntrustedText } from './ansi.mjs';
|
|
27
|
+
import { parseFlag, requireLicense, resolveTicketAdapter, formatWriteFailure, classifyWriteFailure } from './ticket-command.mjs';
|
|
28
|
+
import { parseDuration, parseStarted, formatDuration } from './worklog-duration.mjs';
|
|
29
|
+
|
|
30
|
+
export const MAX_WORKLOG_ENTRIES = 20;
|
|
31
|
+
/** One call may log at most a day's worth in total — the per-entry 24h cap alone allows 20 x 24h. */
|
|
32
|
+
export const MAX_WORKLOG_TOTAL_SECONDS = 86_400;
|
|
33
|
+
export const MAX_COMMENT_CHARS = 2000;
|
|
34
|
+
/**
|
|
35
|
+
* A timed-out or 5xx write may have landed. The 10s double-fire debounce is far
|
|
36
|
+
* too short to stop a retry of that — minutes, until the user has checked Jira.
|
|
37
|
+
*/
|
|
38
|
+
const UNCONFIRMED_WINDOW_MS = 10 * 60 * 1000;
|
|
39
|
+
const USAGE = 'Usage: ticketlens worklog KEY=DURATION [KEY=DURATION ...] [--comment="..."] [--started=ISO] [--profile=NAME] --confirm\n';
|
|
40
|
+
const COMMENT_PREVIEW_CHARS = 60;
|
|
41
|
+
|
|
42
|
+
const invalid = (error) => ({ error });
|
|
43
|
+
const refused = (reason) => ({ ok: false, reason, results: [] });
|
|
44
|
+
const fromResult = (result, valueKey) => (result.ok ? { value: result[valueKey] } : invalid(result.error));
|
|
45
|
+
|
|
46
|
+
function parseTicket(raw) {
|
|
47
|
+
if (typeof raw !== 'string') return invalid('"ticket" must be a string like PROJ-123.');
|
|
48
|
+
const key = normalizeTicketKey(raw.trim());
|
|
49
|
+
if (!TICKET_KEY_PATTERN.test(key)) return invalid(`${JSON.stringify(raw)} is not a valid ticket key.`);
|
|
50
|
+
// Jira numbers never have leading zeros; PROJ-01 would slip past duplicate/cooldown checks keyed on PROJ-1.
|
|
51
|
+
if (/-0\d/.test(key)) return invalid(`${JSON.stringify(raw)} has a leading zero in its number — did you mean ${key.replace(/-0+(?=\d)/, '-')}?`);
|
|
52
|
+
return { value: key };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseComment(raw) {
|
|
56
|
+
if (raw === undefined || raw === null || raw === '') return { value: undefined };
|
|
57
|
+
if (typeof raw !== 'string') return invalid('"comment" must be a string.');
|
|
58
|
+
return raw.length <= MAX_COMMENT_CHARS ? { value: raw } : invalid(`"comment" is ${raw.length} characters — at most ${MAX_COMMENT_CHARS}.`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @returns {{ item?: object, errors?: string[] }} */
|
|
62
|
+
function parseEntry(raw, index, now) {
|
|
63
|
+
const label = `Entry ${index + 1}`;
|
|
64
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
65
|
+
return { errors: [`${label}: expected an object with "ticket" and "time".`] };
|
|
66
|
+
}
|
|
67
|
+
const parts = {
|
|
68
|
+
ticket: parseTicket(raw.ticket),
|
|
69
|
+
seconds: fromResult(parseDuration(raw.time), 'seconds'),
|
|
70
|
+
started: fromResult(parseStarted(raw.started ?? undefined, { now }), 'started'),
|
|
71
|
+
comment: parseComment(raw.comment),
|
|
72
|
+
};
|
|
73
|
+
const errors = Object.values(parts).filter(p => p.error).map(p => `${label}: ${p.error}`);
|
|
74
|
+
if (errors.length) return { errors };
|
|
75
|
+
return { item: { ticket: parts.ticket.value, seconds: parts.seconds.value, started: parts.started.value, comment: parts.comment.value } };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function findDuplicates(parsed) {
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
const errors = [];
|
|
81
|
+
parsed.forEach((p, index) => {
|
|
82
|
+
if (!p.item) return;
|
|
83
|
+
if (seen.has(p.item.ticket)) errors.push(`Entry ${index + 1}: ${p.item.ticket} appears more than once — one worklog per ticket per call.`);
|
|
84
|
+
seen.add(p.item.ticket);
|
|
85
|
+
});
|
|
86
|
+
return errors;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function findTotalOverrun(parsed) {
|
|
90
|
+
const total = parsed.reduce((sum, p) => sum + (p.item?.seconds ?? 0), 0);
|
|
91
|
+
return total > MAX_WORKLOG_TOTAL_SECONDS ? [`Total time across entries is ${formatDuration(total)} — at most 24h per call. Split it across separate calls.`] : [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @returns {object[] | null} validated items, or null after writing every error found */
|
|
95
|
+
function parseEntries(entries, { now, stream }) {
|
|
96
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
97
|
+
stream.write(' Provide at least one entry.\n');
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
if (entries.length > MAX_WORKLOG_ENTRIES) {
|
|
101
|
+
stream.write(` Too many entries (${entries.length}) — at most ${MAX_WORKLOG_ENTRIES} per call.\n`);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const parsed = entries.map((raw, index) => parseEntry(raw, index, now));
|
|
105
|
+
const errors = [...parsed.flatMap(p => p.errors ?? []), ...findDuplicates(parsed), ...findTotalOverrun(parsed)];
|
|
106
|
+
if (errors.length) {
|
|
107
|
+
stream.write(errors.map(e => ` ${e}\n`).join(''));
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
return parsed.map(p => p.item);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @returns {object[] | null} items with their adapter attached, or null after writing every problem */
|
|
114
|
+
/** Connection resolution warns once per entry; an identical line (a profile-ambiguity warning) is shown once. */
|
|
115
|
+
function onceStream(stream) {
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
return { isTTY: stream.isTTY, write: (text) => (seen.has(text) ? true : (seen.add(text), stream.write(text))) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveItems(items, { profile, configDir, resolveConnectionFn, resolveAdapterFn, stream }) {
|
|
121
|
+
const profileArgs = profile ? [`--profile=${profile}`] : [];
|
|
122
|
+
const resolveStream = onceStream(stream);
|
|
123
|
+
const resolved = items.map(item => {
|
|
124
|
+
const found = resolveTicketAdapter(item.ticket, profileArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream: resolveStream });
|
|
125
|
+
if (found && typeof found.adapter.logWork !== 'function') {
|
|
126
|
+
stream.write(` ${item.ticket}: worklogs are only supported on Jira — this ticket is on ${found.adapter.type}.\n`);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return found && { ...item, adapter: found.adapter };
|
|
130
|
+
});
|
|
131
|
+
return resolved.includes(null) ? null : resolved;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function writePreview(items, { stream, cliHints }) {
|
|
135
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
136
|
+
stream.write(` Would log ${items.length} worklog${items.length === 1 ? '' : 's'}:\n\n`);
|
|
137
|
+
for (const item of items) {
|
|
138
|
+
const note = item.comment ? ` — ${sanitizeUntrustedText(item.comment.replace(/\s+/g, ' ')).slice(0, COMMENT_PREVIEW_CHARS)}` : '';
|
|
139
|
+
stream.write(` ${s.brand(s.bold(item.ticket))} ${formatDuration(item.seconds)} started ${item.started}${note}\n`);
|
|
140
|
+
}
|
|
141
|
+
stream.write(cliHints
|
|
142
|
+
? `\n Refusing to log without --confirm. Re-run with --confirm once you've reviewed the entries.\n`
|
|
143
|
+
: `\n Refusing to log without confirm: true. Call again with confirm: true once you've reviewed the entries.\n`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Runs after the write already landed — a failure here must never be reported
|
|
148
|
+
* as a failed write. Audit first (it is the record of billable time), each in
|
|
149
|
+
* its own try so one failing never skips the other.
|
|
150
|
+
*/
|
|
151
|
+
function recordBookkeeping(item, result, { configDir, recordActionFn, logActionFn, actor, stream, source }) {
|
|
152
|
+
const attempt = (label, fn) => {
|
|
153
|
+
try { fn(); } catch (err) {
|
|
154
|
+
stream.write(` ${item.ticket} logged, but local ${label} record failed: ${sanitizeUntrustedText(String(err.message))}. Do not re-run — the time is already on the ticket.\n`);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
attempt('audit', () => logActionFn({
|
|
158
|
+
ticketKey: item.ticket,
|
|
159
|
+
action: 'worklog',
|
|
160
|
+
actor,
|
|
161
|
+
tracker: item.adapter.type,
|
|
162
|
+
detail: { id: result.id, seconds: item.seconds, started: item.started, hasComment: item.comment !== undefined, source },
|
|
163
|
+
}, { configDir }));
|
|
164
|
+
attempt('cooldown', () => recordActionFn(item.ticket, 'worklog', { configDir }));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Tracker text is untrusted: strip terminal control characters per line, keeping the line structure. */
|
|
168
|
+
const safeLines = (text) => text.split('\n').map(sanitizeUntrustedText).join('\n');
|
|
169
|
+
|
|
170
|
+
/** Checked before every write: a recent worklog, or a recent write whose outcome is still unknown. */
|
|
171
|
+
function skipReason(item, { configDir, checkCooldownFn }) {
|
|
172
|
+
const unconfirmed = checkCooldownFn(item.ticket, 'worklog-unconfirmed', { configDir, cooldownMs: UNCONFIRMED_WINDOW_MS });
|
|
173
|
+
if (unconfirmed.active) {
|
|
174
|
+
return ` Skipped ${item.ticket} — an earlier write to it ended without a confirmed result (timeout or server error) ${Math.ceil(unconfirmed.remainingMs / 60000)}m ago and may have already landed. Check the ticket in Jira before logging again.\n`;
|
|
175
|
+
}
|
|
176
|
+
const recent = checkCooldownFn(item.ticket, 'worklog', { configDir });
|
|
177
|
+
if (recent.active) {
|
|
178
|
+
return ` Skipped ${item.ticket} — a worklog was already logged ${Math.ceil(recent.remainingMs / 1000)}s ago and is not repeated. Check the ticket before logging again.\n`;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Timeout / 5xx: the POST may have landed. Leave a long cooldown and an audit line so a retry can't silently double-bill. */
|
|
184
|
+
function recordUnconfirmed(item, classification, { configDir, recordActionFn, logActionFn, actor, stream, source }) {
|
|
185
|
+
if (classification.kind !== 'network-or-timeout' && classification.kind !== 'server-error') return;
|
|
186
|
+
try {
|
|
187
|
+
logActionFn({ ticketKey: item.ticket, action: 'worklog-unconfirmed', actor, tracker: item.adapter.type, detail: { seconds: item.seconds, started: item.started, kind: classification.kind, source } }, { configDir });
|
|
188
|
+
recordActionFn(item.ticket, 'worklog-unconfirmed', { configDir });
|
|
189
|
+
} catch (err) {
|
|
190
|
+
stream.write(` Could not record the unconfirmed write to ${item.ticket} locally: ${sanitizeUntrustedText(err.message)}.\n`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function logOne(item, ctx) {
|
|
195
|
+
const { stream } = ctx;
|
|
196
|
+
const skip = skipReason(item, ctx);
|
|
197
|
+
if (skip) {
|
|
198
|
+
stream.write(skip);
|
|
199
|
+
return { ticket: item.ticket, status: 'skipped', reason: 'cooldown' };
|
|
200
|
+
}
|
|
201
|
+
const entry = { timeSpentSeconds: item.seconds, started: item.started, ...(item.comment !== undefined && { comment: item.comment }) };
|
|
202
|
+
let result;
|
|
203
|
+
try {
|
|
204
|
+
result = await item.adapter.logWork(item.ticket, entry);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
const classification = classifyWriteFailure(err);
|
|
207
|
+
stream.write(safeLines(formatWriteFailure(item.ticket, err)));
|
|
208
|
+
recordUnconfirmed(item, classification, ctx);
|
|
209
|
+
return { ticket: item.ticket, status: 'failed', error: sanitizeUntrustedText(String(err.message)), kind: classification.kind, httpStatus: err.status };
|
|
210
|
+
}
|
|
211
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
212
|
+
stream.write(` ${s.green('✔')} ${s.brand(s.bold(item.ticket))} logged ${formatDuration(item.seconds)}${result.url ? ` (${sanitizeUntrustedText(result.url)})` : ''}\n`);
|
|
213
|
+
recordBookkeeping(item, result, ctx);
|
|
214
|
+
return { ticket: item.ticket, status: 'logged', id: result.id, seconds: item.seconds, url: result.url };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A rate limit or a 401 fails every remaining entry the same way — stop instead of firing doomed POSTs. */
|
|
218
|
+
function haltReason(result) {
|
|
219
|
+
if (result.kind === 'rate-limited' || result.httpStatus === 429) return 'a tracker rate limit';
|
|
220
|
+
if (result.httpStatus === 401) return 'an authentication failure (401)';
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Names what landed and what to retry, so a caller (often an AI) never re-sends a worklog that already went through. */
|
|
225
|
+
function writeBatchSummary(results, stream) {
|
|
226
|
+
const logged = results.filter(r => r.status === 'logged').map(r => r.ticket);
|
|
227
|
+
const pending = results.filter(r => r.status !== 'logged').map(r => r.ticket);
|
|
228
|
+
stream.write(`\n Logged ${logged.length} of ${results.length} worklogs.\n`);
|
|
229
|
+
if (logged.length && pending.length) stream.write(` Already logged (do not repeat): ${logged.join(', ')}.\n`);
|
|
230
|
+
if (pending.length) stream.write(` Retry only: ${pending.join(', ')}.\n`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function logAll(items, ctx) {
|
|
234
|
+
const results = [];
|
|
235
|
+
let haltedBy = null;
|
|
236
|
+
for (const item of items) {
|
|
237
|
+
if (haltedBy) {
|
|
238
|
+
ctx.stream.write(` ${item.ticket} not attempted — the batch stopped after ${haltedBy}. Run ${item.ticket} again later.\n`);
|
|
239
|
+
results.push({ ticket: item.ticket, status: 'skipped', reason: 'batch-halted' });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const result = await logOne(item, ctx);
|
|
243
|
+
results.push(result);
|
|
244
|
+
haltedBy = haltReason(result);
|
|
245
|
+
}
|
|
246
|
+
if (results.length > 1) writeBatchSummary(results, ctx.stream);
|
|
247
|
+
return { ok: results.every(r => r.status === 'logged'), results };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Core entry point, shared by the CLI parser below and the MCP tool (which
|
|
252
|
+
* passes structured entries directly — a comment never round-trips through
|
|
253
|
+
* an argv string).
|
|
254
|
+
*
|
|
255
|
+
* @param {Array<{ ticket: string, time: string, started?: string, comment?: string }>} entries
|
|
256
|
+
* @param {{ confirm?: boolean, profile?: string, cliHints?: boolean }} [deps]
|
|
257
|
+
* @returns {Promise<{ ok: boolean, reason?: string, results: object[] }>}
|
|
258
|
+
*/
|
|
259
|
+
export async function runTicketWorklogEntries(entries, {
|
|
260
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
261
|
+
stream = process.stderr,
|
|
262
|
+
confirm = false,
|
|
263
|
+
profile,
|
|
264
|
+
cliHints = true,
|
|
265
|
+
isLicensedFn = isLicensed,
|
|
266
|
+
resolveConnectionFn = resolveConnection,
|
|
267
|
+
resolveAdapterFn = resolveAdapter,
|
|
268
|
+
checkCooldownFn = checkCooldown,
|
|
269
|
+
recordActionFn = recordAction,
|
|
270
|
+
logActionFn = logAction,
|
|
271
|
+
actor = os.userInfo().username,
|
|
272
|
+
now = () => Date.now(),
|
|
273
|
+
} = {}) {
|
|
274
|
+
if (!requireLicense(isLicensedFn, configDir, 'ticketlens worklog', stream)) return refused();
|
|
275
|
+
|
|
276
|
+
const items = parseEntries(entries, { now, stream });
|
|
277
|
+
if (!items) return refused();
|
|
278
|
+
const resolved = resolveItems(items, { profile, configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
279
|
+
if (!resolved) return refused();
|
|
280
|
+
|
|
281
|
+
if (confirm !== true) {
|
|
282
|
+
writePreview(resolved, { stream, cliHints });
|
|
283
|
+
return refused('confirm-required');
|
|
284
|
+
}
|
|
285
|
+
return logAll(resolved, { configDir, checkCooldownFn, recordActionFn, logActionFn, actor, stream, source: cliHints ? 'cli' : 'mcp' });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function parsePair(arg) {
|
|
289
|
+
const at = arg.indexOf('=');
|
|
290
|
+
return at > 0 ? { ticket: arg.slice(0, at), time: arg.slice(at + 1) } : null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const VALUE_FLAGS = ['comment', 'started', 'profile'];
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Refuses what parseFlag would silently ignore: a misspelled `--startd=` would
|
|
297
|
+
* quietly bill "now", an empty `--started=` likewise, and a repeated flag would
|
|
298
|
+
* silently keep only the first. Time entries are too costly to guess about.
|
|
299
|
+
* @returns {string[]} one message per problem, empty when the flags are clean
|
|
300
|
+
*/
|
|
301
|
+
function flagErrors(flags) {
|
|
302
|
+
const errors = [];
|
|
303
|
+
const seen = new Set();
|
|
304
|
+
for (const flag of flags) {
|
|
305
|
+
const [name, ...rest] = flag.slice(2).split('=');
|
|
306
|
+
const hasValue = flag.includes('=');
|
|
307
|
+
if (flag === '--confirm') continue;
|
|
308
|
+
if (!VALUE_FLAGS.includes(name) || !hasValue) {
|
|
309
|
+
errors.push(`Unknown option ${hasValue ? `--${name}` : flag}${VALUE_FLAGS.includes(name) ? ` (use --${name}=VALUE)` : ''}.`);
|
|
310
|
+
} else if (!rest.join('=')) {
|
|
311
|
+
errors.push(`--${name} needs a value, e.g. --${name}=...`);
|
|
312
|
+
} else if (seen.has(name)) {
|
|
313
|
+
errors.push(`--${name} was given more than once.`);
|
|
314
|
+
}
|
|
315
|
+
seen.add(name);
|
|
316
|
+
}
|
|
317
|
+
return errors;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* @param {string[]} cmdArgs - ['PROJ-1=1h30m', 'PROJ-2=45m', '--comment=...', '--started=...', '--confirm']
|
|
322
|
+
* `--comment`/`--started` apply to every ticket; per-ticket values need the MCP tool.
|
|
323
|
+
*/
|
|
324
|
+
export async function runTicketWorklog(cmdArgs, { stream = process.stderr, ...deps } = {}) {
|
|
325
|
+
const pairs = cmdArgs.filter(a => !a.startsWith('--')).map(parsePair);
|
|
326
|
+
if (pairs.length === 0 || pairs.includes(null)) {
|
|
327
|
+
stream.write(USAGE);
|
|
328
|
+
return refused();
|
|
329
|
+
}
|
|
330
|
+
const errors = flagErrors(cmdArgs.filter(a => a.startsWith('--')));
|
|
331
|
+
if (errors.length) {
|
|
332
|
+
stream.write(errors.map(e => ` ${e}\n`).join('') + USAGE);
|
|
333
|
+
return refused();
|
|
334
|
+
}
|
|
335
|
+
const shared = Object.fromEntries(['comment', 'started'].map(name => [name, parseFlag(cmdArgs, name)]).filter(([, value]) => value));
|
|
336
|
+
return runTicketWorklogEntries(pairs.map(pair => ({ ...pair, ...shared })), {
|
|
337
|
+
...deps,
|
|
338
|
+
stream,
|
|
339
|
+
confirm: cmdArgs.includes('--confirm'),
|
|
340
|
+
profile: parseFlag(cmdArgs, 'profile'),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duration and start-time parsing for worklogs. Jira's own `timeSpent` string
|
|
3
|
+
* treats `d`/`w` as instance-configured (a "day" is whatever the admin set) and
|
|
4
|
+
* is mutually exclusive with `timeSpentSeconds`, so durations are parsed here
|
|
5
|
+
* into exact seconds — hours and minutes only, deterministic on every instance.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** A single worklog above a full day is far more likely a typo (`90h` for `90m`) than real. */
|
|
9
|
+
export const MAX_WORKLOG_SECONDS = 86_400;
|
|
10
|
+
|
|
11
|
+
/** Tolerance for a `started` slightly ahead of this machine's clock. */
|
|
12
|
+
export const MAX_FUTURE_SKEW_MS = 5 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
/** Older than this is far more likely a year typo (2016 for 2026) than a real backfill. */
|
|
15
|
+
export const MAX_PAST_MS = 365 * 24 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
const DURATION_PATTERN = /^(?:(\d{1,4})h)?(?:(\d{1,4})m)?$/;
|
|
18
|
+
const DAYS_OR_WEEKS_PATTERN = /\d[dw]/;
|
|
19
|
+
const STARTED_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}/;
|
|
20
|
+
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
21
|
+
|
|
22
|
+
const fail = (error) => ({ ok: false, error });
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {unknown} input - e.g. "1h30m", "1h 30m", "90m"
|
|
26
|
+
* @returns {{ ok: true, seconds: number } | { ok: false, error: string }}
|
|
27
|
+
*/
|
|
28
|
+
export function parseDuration(input) {
|
|
29
|
+
if (typeof input !== 'string' || !input.trim()) {
|
|
30
|
+
return fail('Duration is required, e.g. "1h30m" or "45m".');
|
|
31
|
+
}
|
|
32
|
+
const compact = input.replace(/\s+/g, '').toLowerCase();
|
|
33
|
+
if (DAYS_OR_WEEKS_PATTERN.test(compact)) {
|
|
34
|
+
return fail(`Days and weeks are not supported — Jira defines them per instance. Express ${JSON.stringify(input)} in hours, e.g. "8h".`);
|
|
35
|
+
}
|
|
36
|
+
const match = DURATION_PATTERN.exec(compact);
|
|
37
|
+
if (!match) {
|
|
38
|
+
return fail(`Invalid duration ${JSON.stringify(input)} — use hours and minutes, e.g. "1h30m" or "90m".`);
|
|
39
|
+
}
|
|
40
|
+
const seconds = Number(match[1] ?? 0) * 3600 + Number(match[2] ?? 0) * 60;
|
|
41
|
+
if (seconds <= 0) return fail('Duration must be greater than zero.');
|
|
42
|
+
if (seconds > MAX_WORKLOG_SECONDS) return fail(`Duration ${JSON.stringify(input)} exceeds the 24h per-entry limit.`);
|
|
43
|
+
return { ok: true, seconds };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @param {number} seconds - a positive multiple of 60 */
|
|
47
|
+
export function formatDuration(seconds) {
|
|
48
|
+
const hours = Math.floor(seconds / 3600);
|
|
49
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
50
|
+
return [hours && `${hours}h`, minutes && `${minutes}m`].filter(Boolean).join(' ') || '0m';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Jira rejects `Z` and `+00:00` here — only the `+0000` form is accepted. */
|
|
54
|
+
export function toJiraDateTime(date) {
|
|
55
|
+
return date.toISOString().replace('Z', '+0000');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Rejects an out-of-range day (2026-02-30) that `Date` would silently roll into the next month. */
|
|
59
|
+
function hasValidDay(year, month, day) {
|
|
60
|
+
return day >= 1 && day <= new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {unknown} input - ISO 8601 with a time component; omitted means now.
|
|
65
|
+
* A timestamp with no offset is read as this machine's local time.
|
|
66
|
+
* @param {{ now?: () => number }} [opts]
|
|
67
|
+
* @returns {{ ok: true, started: string } | { ok: false, error: string }}
|
|
68
|
+
*/
|
|
69
|
+
export function parseStarted(input, { now = () => Date.now() } = {}) {
|
|
70
|
+
if (input === undefined) return { ok: true, started: toJiraDateTime(new Date(now())) };
|
|
71
|
+
|
|
72
|
+
const invalid = fail(`Invalid started value ${JSON.stringify(input)} — use ISO 8601, e.g. "2026-09-18T10:00:00-07:00".`);
|
|
73
|
+
if (typeof input !== 'string') return invalid;
|
|
74
|
+
|
|
75
|
+
const trimmed = input.trim();
|
|
76
|
+
if (DATE_ONLY_PATTERN.test(trimmed)) {
|
|
77
|
+
return fail('Started needs a time, not just a date — e.g. "2026-09-18T10:00:00-07:00".');
|
|
78
|
+
}
|
|
79
|
+
const match = STARTED_PATTERN.exec(trimmed);
|
|
80
|
+
const ms = Date.parse(trimmed);
|
|
81
|
+
if (!match || Number.isNaN(ms) || !hasValidDay(Number(match[1]), Number(match[2]), Number(match[3]))) return invalid;
|
|
82
|
+
if (ms > now() + MAX_FUTURE_SKEW_MS) return fail('Started is in the future — worklogs record time already spent.');
|
|
83
|
+
if (ms < now() - MAX_PAST_MS) return fail('Started is older than a year — check the year. Backfilling that far is not supported here.');
|
|
84
|
+
return { ok: true, started: toJiraDateTime(new Date(ms)) };
|
|
85
|
+
}
|