ticketlens 0.42.4 → 0.43.1

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 CHANGED
@@ -333,6 +333,7 @@ ticketlens pr <TICKET-KEY> # Generate PR description from tic
333
333
  ticketlens pr <TICKET-KEY> --profile=acme # Specify a profile
334
334
  ticketlens pr <TICKET-KEY> --plain # Plain markdown output
335
335
  ticketlens pr <TICKET-KEY> | pbcopy # Copy to clipboard
336
+ ticketlens pr <TICKET-KEY> --open # Open a real GitHub PR compare page (Pro, GitHub only)
336
337
  ```
337
338
 
338
339
  Generates a PR description template pre-filled with the ticket summary, acceptance criteria, and compliance coverage. Free — the compliance coverage section follows the `compliance` command's own free (3/month) or Pro (unlimited) limit.
@@ -1121,11 +1122,12 @@ npm test
1121
1122
  See [ROADMAP.md](ROADMAP.md) for the full plan.
1122
1123
 
1123
1124
  Recently shipped:
1125
+ - **`ticketlens pr --open`** — opens a real GitHub PR compare page prefilled with the same description `pr` already prints, instead of just printing it. GitHub only, no stored write-credential. Pro tier
1124
1126
  - **`review`/`compliance` fixes** — branch diff now uses merge-base semantics, no longer picks up unrelated unmerged-main files; AC parser now recognizes Jira wiki `Requirements`/`How to test` headers, not just literal "Acceptance Criteria"
1125
1127
  - **`ticketlens update --priority`** — a bad priority name now tells you the project's real, current valid options instead of a bare tracker error
1126
1128
  - **`ticketlens assign --to="name or email"`** — assign a ticket to another developer, not just yourself. Jira (Cloud + Server/DC); searches real assignable users first, lists matches, executes only on one confirmed match. Pro tier
1127
1129
  - **Console: Recall "select all N matching" bulk delete** — Gmail-style banner deletes every note matching your search/filters across all pages, not just the current one
1128
- - **Recall Stop-hook fix** — the end-of-session Recall reminder no longer fires because of file writes; only real ticket writes (comment, transition, assign, update) arm it. Also hardened against malformed transcripts and a session-id path-collision bug found by adversarial testing
1130
+ - **Recall Stop-hook fix** — the end-of-session Recall reminder no longer fires because of file writes; only real ticket writes (comment, transition, assign, update) arm it. Also hardened against malformed transcripts and a session-id path-collision bug found by adversarial testing. Pro+/Team accounts now get a silent pass instead of a hard block, since the autonomous background capture already covers them; free tier is unchanged
1129
1131
  - **Console: no repeat request on same-page menu clicks** — sidebar links, the header gear and Settings tabs skip the request when they point at the page you are on
1130
1132
  - **Worklog** (`ticketlens worklog`, `ticket_worklog` MCP) — log time on Jira tickets, preview first. Pro tier
1131
1133
  - **Autonomous Recall capture** — TicketLens judges and saves a note after a session, no `note add` call, reusing your login. Pro+ tier
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.42.4",
3
+ "version": "0.43.1",
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": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.47.0 -->
1
+ <!-- jtb-skill-version: 0.48.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.
@@ -220,11 +220,13 @@ ticketlens pr PROJ-123 $EXTRA_ARGS
220
220
 
221
221
  Where `$EXTRA_ARGS` are any flags passed (e.g. `--profile=acme`). Requires a ticket key as the first positional argument.
222
222
 
223
- Assembles a ready-to-paste PR description: what changed (from commits linked to the ticket), linked tickets, and — if the ticket has acceptance criteria — a requirements-coverage section. Read-only. The requirements-coverage section reuses the same Free-tier 3-checks/month counter as `--compliance`/`ticketlens compliance` — running `pr` on a ticket with acceptance criteria counts against that shared monthly limit; Pro removes the cap.
223
+ Assembles a ready-to-paste PR description: what changed (from commits linked to the ticket), linked tickets, and — if the ticket has acceptance criteria — a requirements-coverage section. Read-only by default. The requirements-coverage section reuses the same Free-tier 3-checks/month counter as `--compliance`/`ticketlens compliance` — running `pr` on a ticket with acceptance criteria counts against that shared monthly limit; Pro removes the cap.
224
+
225
+ Add `--open` (TicketLens Pro required) to open a real GitHub PR compare page prefilled with this same description, instead of just printing it. GitHub only — refuses cleanly on a non-GitHub remote, a detached HEAD, a branch not yet pushed to origin, or when already on the base branch. Requires the current branch already pushed.
224
226
 
225
227
  Display the script's stdout directly. No plan mode. Stop here.
226
228
 
227
- If this harness has TicketLens's MCP server configured (a tool named `pr` — often shown as `mcp__ticketlens__pr` — visible in your tool list), prefer it over the bash form: same output, same shared compliance-counter caveat, no shell command to construct. It accepts `ticket`/`profile`.
229
+ If this harness has TicketLens's MCP server configured (a tool named `pr` — often shown as `mcp__ticketlens__pr` — visible in your tool list), prefer it over the bash form: same output, same shared compliance-counter caveat, no shell command to construct. It accepts `ticket`/`profile`/`open`.
228
230
 
229
231
  ---
230
232
 
@@ -589,6 +589,19 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
589
589
  const adapterPr = resolveAdapter(connPr, { fetcher });
590
590
  const complianceRunnerPr = opts.runComplianceCheck ?? runComplianceCheck;
591
591
 
592
+ // Backlog #27: --open builds a real GitHub PR compare URL from the same
593
+ // markdown below and opens it — Pro-gated like every other write-back tool,
594
+ // unlike the plain preview above which stays Free.
595
+ const openFlagPr = args.includes('--open');
596
+ if (openFlagPr) {
597
+ const { isLicensed: isLicPr, showUpgradePrompt: showUpgradePr } = await import('./lib/license.mjs');
598
+ if (!isLicPr('pro', resolvedConfigDir)) {
599
+ showUpgradePr('pro', 'pr --open', { stream: errStream });
600
+ process.exitCode = 1;
601
+ return;
602
+ }
603
+ }
604
+
592
605
  try {
593
606
  const md = await assemblePr(ticketKeyArg, {
594
607
  configDir: resolvedConfigDir,
@@ -598,7 +611,24 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
598
611
  // upgrade prompt would leak to real stderr unless threaded here — same
599
612
  // treatment as the compliance dispatch block above (`stream: errStream`).
600
613
  runComplianceCheckFn: (o) => complianceRunnerPr({ ...o, stream: errStream }),
614
+ // --open needs raw markdown for the compare-URL body, regardless of the
615
+ // real terminal's TTY-ness — the plain preview path below is unaffected.
616
+ ...(openFlagPr ? { outStream: { write: () => {}, isTTY: false } } : {}),
601
617
  });
618
+
619
+ if (openFlagPr) {
620
+ const { openPr: openPrFn } = await import('./lib/pr-opener.mjs');
621
+ const openPrRunner = opts.openPr ?? openPrFn;
622
+ const result = await openPrRunner(ticketKeyArg, md, { cwd: process.cwd() });
623
+ if (!result.ok) {
624
+ printErrFn(`Error: ${result.message}\n`);
625
+ process.exitCode = 1;
626
+ return;
627
+ }
628
+ printFn(`Opening: ${result.url}\n`);
629
+ return;
630
+ }
631
+
602
632
  printFn(md + '\n');
603
633
  } catch (err) {
604
634
  printErrFn(`Error: ${err.message}\n`);
@@ -14,12 +14,22 @@ export const generateState = () => randomBytes(16).toString('hex');
14
14
  export const pickPort = () =>
15
15
  Math.floor(Math.random() * (PORT_MAX - PORT_MIN + 1)) + PORT_MIN;
16
16
 
17
- export function openBrowser(url) {
17
+ /**
18
+ * @param {string} url
19
+ * @param {object} [opts]
20
+ * @param {Function} [opts.spawnFn] - injectable child_process.spawn (default: spawn)
21
+ */
22
+ export function openBrowser(url, { spawnFn = spawn } = {}) {
18
23
  const cmd = process.platform === 'win32' ? 'cmd'
19
24
  : process.platform === 'darwin' ? 'open'
20
25
  : 'xdg-open';
21
26
  const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
22
- spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
27
+ const child = spawnFn(cmd, args, { detached: true, stdio: 'ignore' });
28
+ // A missing/blocked OS opener (headless container, restricted MCP runtime)
29
+ // emits this asynchronously; unhandled, it's an uncaught exception that
30
+ // kills the whole process — including a long-lived MCP server.
31
+ child.on('error', () => {});
32
+ child.unref();
23
33
  }
24
34
 
25
35
  /**
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared spawnSync-over-git wrapper. Extracted after code review on backlog
3
+ * #27 found `pr-assembler.mjs`, `pr-opener.mjs`, `branch-scanner.mjs`, and
4
+ * `commit-linker.mjs` each reimplementing this; the duplication is exactly
5
+ * what let `pr-assembler.mjs`'s own `detectRemoteUrl` silently drop `cwd`.
6
+ * `branch-scanner.mjs`/`commit-linker.mjs` are left untouched here — out of
7
+ * this ticket's scope, no evidence either has the same bug.
8
+ */
9
+
10
+ const SPAWN_OPTS = { encoding: 'utf8', timeout: 10_000 };
11
+
12
+ /**
13
+ * @param {Function} execFn - spawnSync-compatible function
14
+ * @param {string[]} args - args after "git", e.g. ['remote', 'get-url', 'origin']
15
+ * @param {string} [cwd]
16
+ * @returns {string|null} stdout on success, null on non-zero exit or a thrown error
17
+ */
18
+ export function runGit(execFn, args, cwd) {
19
+ try {
20
+ const result = execFn('git', args, { ...SPAWN_OPTS, cwd });
21
+ return result.status === 0 ? (result.stdout ?? '') : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * @param {Function} execFn
29
+ * @param {string} [cwd]
30
+ * @returns {string|null}
31
+ */
32
+ export function detectRemoteUrl(execFn, cwd) {
33
+ const out = runGit(execFn, ['remote', 'get-url', 'origin'], cwd);
34
+ return out ? out.trim() : null;
35
+ }
@@ -658,7 +658,9 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
658
658
  ` ${s.cyan('update')}/${s.cyan('create')}/${s.cyan('worklog')} above.`,
659
659
  ` ${s.cyan('fetch')}, ${s.cyan('doctor')}, and ${s.cyan('standup')} are Free; ${s.cyan('triage')} is Free with some Pro/Team-gated`,
660
660
  ` options (see ${s.cyan('ticketlens triage --help')}); ${s.cyan('compliance')} and ${s.cyan('pr')} are Free, sharing a`,
661
- ` 3-checks/month cap on their requirements-coverage section, Pro unlimited;`,
661
+ ` 3-checks/month cap on their requirements-coverage section, Pro unlimited; ${s.cyan('pr')}'s`,
662
+ ` \`open: true\` (Pro) opens a real GitHub PR compare page instead of just printing the`,
663
+ ` description — GitHub only, current branch must already be pushed to origin;`,
662
664
  ` ${s.cyan('compliance')}'s \`consensus: true\` (Pro) runs a multi-agent review server-side against`,
663
665
  ` a "consensus" role configured at Console > Admin > AI Roles, and always implies`,
664
666
  ` \`--yes\` under MCP, since there is no TTY for the cost-confirmation prompt;`,
@@ -1208,7 +1210,7 @@ export function printPrHelp({ stream = process.stdout } = {}) {
1208
1210
  const s = createStyler({ isTTY: stream.isTTY });
1209
1211
  const lines = [
1210
1212
  '',
1211
- ` ${s.bold(s.brand('ticketlens'))} ${s.bold('pr')} ${s.dim('<TICKET-KEY> [--profile=NAME]')}`,
1213
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('pr')} ${s.dim('<TICKET-KEY> [--profile=NAME] [--open]')}`,
1212
1214
  '',
1213
1215
  ` Assemble a pull-request description from a ticket's context —`,
1214
1216
  ` summary, acceptance criteria, and linked issues, formatted as a`,
@@ -1217,12 +1219,18 @@ export function printPrHelp({ stream = process.stdout } = {}) {
1217
1219
  ` ${s.bold('OPTIONS')}`,
1218
1220
  '',
1219
1221
  ` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific Jira profile`,
1222
+ ` ${s.brand('--open')} Open a real GitHub PR compare page prefilled with this`,
1223
+ ` description, instead of just printing it. TicketLens Pro`,
1224
+ ` required. GitHub only — refuses cleanly on a non-GitHub`,
1225
+ ` remote, detached HEAD, an unpushed branch, or when already`,
1226
+ ` on the base branch.`,
1220
1227
  ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
1221
1228
  '',
1222
1229
  ` ${s.bold('EXAMPLES')}`,
1223
1230
  '',
1224
1231
  ` ${s.dim('$')} ticketlens pr PROJ-123`,
1225
1232
  ` ${s.dim('$')} ticketlens pr PROJ-123 --profile=myteam`,
1233
+ ` ${s.dim('$')} ticketlens pr PROJ-123 --open`,
1226
1234
  '',
1227
1235
  ];
1228
1236
  stream.write(lines.join('\n') + '\n');
@@ -231,9 +231,10 @@ async function callStandup(args, deps) {
231
231
  return callFetchTicketRun(buildStandupArgs, args, deps, 'standup failed');
232
232
  }
233
233
 
234
- function buildPrArgs({ ticket, profile }) {
234
+ function buildPrArgs({ ticket, profile, open }) {
235
235
  const args = ['pr', ticket];
236
236
  if (profile) args.push(`--profile=${profile}`);
237
+ if (open) args.push('--open');
237
238
  return args;
238
239
  }
239
240
 
@@ -79,12 +79,13 @@ export const TOOLS = [
79
79
  },
80
80
  {
81
81
  name: 'pr',
82
- description: 'Assemble a ready-to-paste PR description for a ticket — what changed (from linked commits), linked tickets, and (if the ticket has acceptance criteria) a requirements-coverage section. Read-only. The requirements-coverage section reuses the same Free-tier 3-checks/month counter as the `compliance` tool — calling `pr` on a ticket with acceptance criteria counts against that shared monthly limit; TicketLens Pro removes the cap.',
82
+ description: 'Assemble a ready-to-paste PR description for a ticket — what changed (from linked commits), linked tickets, and (if the ticket has acceptance criteria) a requirements-coverage section. Read-only by default. The requirements-coverage section reuses the same Free-tier 3-checks/month counter as the `compliance` tool — calling `pr` on a ticket with acceptance criteria counts against that shared monthly limit; TicketLens Pro removes the cap. `open: true` (TicketLens Pro required) opens a real GitHub PR compare page instead, prefilled with this same description — GitHub only, requires the current branch already pushed to origin; refuses cleanly on a non-GitHub remote, detached HEAD, or an unpushed branch.',
83
83
  inputSchema: {
84
84
  type: 'object',
85
85
  properties: {
86
86
  ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
87
87
  profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
88
+ open: { type: 'boolean', description: 'Open a real GitHub PR compare page prefilled with this description, instead of just returning the text. TicketLens Pro required. GitHub only.' },
88
89
  },
89
90
  required: ['ticket'],
90
91
  },
@@ -11,25 +11,7 @@ import { findLinkedCommits } from './commit-linker.mjs';
11
11
  import { runComplianceCheck, STATUS_ICON, statusColor } from './compliance-checker.mjs';
12
12
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
13
13
  import { createStyler } from './ansi.mjs';
14
-
15
- /**
16
- * Detect the git remote URL using execFn.
17
- * Returns the URL string or null if none detected.
18
- *
19
- * @param {Function} execFn - injectable spawnSync-compatible function
20
- * @returns {string|null}
21
- */
22
- function detectRemoteUrl(execFn) {
23
- try {
24
- const result = execFn('git', ['remote', 'get-url', 'origin'], { encoding: 'utf8' });
25
- if (result.status === 0 && result.stdout) {
26
- return result.stdout.trim();
27
- }
28
- } catch {
29
- // non-fatal
30
- }
31
- return null;
32
- }
14
+ import { detectRemoteUrl } from './git-exec.mjs';
33
15
 
34
16
  /**
35
17
  * Build the "### Linked tickets" section from a ticket's linkedIssues.
@@ -129,7 +111,7 @@ export async function assemblePr(ticketKey, {
129
111
  }
130
112
 
131
113
  // Detect remote URL for auto-close footer
132
- const remoteUrl = detectRemoteUrl(execFn);
114
+ const remoteUrl = detectRemoteUrl(execFn, cwd);
133
115
  const isGitHubOrGitLab = remoteUrl && (
134
116
  remoteUrl.includes('github.com') || remoteUrl.includes('gitlab.com')
135
117
  );
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Backlog #27 — open a real GitHub PR compare page for a ticket,
3
+ * prefilled with the same markdown `ticketlens pr KEY` already prints.
4
+ * GitHub only (v1). No repo-write credential: builds a `/compare` URL
5
+ * and opens it in the browser — GitHub itself handles PR creation.
6
+ */
7
+
8
+ import { spawnSync } from 'node:child_process';
9
+ import { scanCurrentBranch } from './branch-scanner.mjs';
10
+ import { openBrowser } from './browser-login.mjs';
11
+ import { runGit, detectRemoteUrl } from './git-exec.mjs';
12
+
13
+ const MAX_BODY_LEN = 4000;
14
+ // Repo group excludes only a trailing ".git", not every dot — real GitHub
15
+ // repos commonly contain one (next.js, socket.io); code review caught the
16
+ // original ([^/.]+?) form wrongly refusing every one of them.
17
+ const GITHUB_REMOTE_RE = /github\.com[:/]([^/]+)\/(.+?)(\.git)?$/;
18
+
19
+ export function parseGitHubRemote(remoteUrl) {
20
+ if (!remoteUrl) return null;
21
+ const m = remoteUrl.match(GITHUB_REMOTE_RE);
22
+ return m ? { owner: m[1], repo: m[2] } : null;
23
+ }
24
+
25
+ // A literal "/" is common and load-bearing in real branch/repo names
26
+ // (feature/foo) and GitHub's own compare URLs handle it unencoded — a blanket
27
+ // encodeURIComponent would break that. "#" is the one character that must be
28
+ // escaped here: left raw, it starts a URL fragment and silently truncates
29
+ // everything after it (including title/body), rather than 404ing loudly.
30
+ function escapeHash(segment) {
31
+ return segment.replace(/#/g, '%23');
32
+ }
33
+
34
+ export function buildCompareUrl({ owner, repo, base, head, title, body, ticketKey }) {
35
+ const bareBase = escapeHash(base.replace(/^origin\//, ''));
36
+ const safeHead = escapeHash(head);
37
+ const safeOwner = escapeHash(owner);
38
+ const safeRepo = escapeHash(repo);
39
+ let finalBody = body;
40
+ if (finalBody.length > MAX_BODY_LEN) {
41
+ const note = `\n\n_…truncated — full description: \`ticketlens pr ${ticketKey ?? head}\`_`;
42
+ finalBody = finalBody.slice(0, MAX_BODY_LEN) + note;
43
+ }
44
+ const params = new URLSearchParams({ expand: '1', title, body: finalBody });
45
+ return `https://github.com/${safeOwner}/${safeRepo}/compare/${bareBase}...${safeHead}?${params.toString()}`;
46
+ }
47
+
48
+ /**
49
+ * @param {string} ticketKey
50
+ * @param {string} markdown - the exact markdown assemblePr() already produced
51
+ * @param {object} [opts]
52
+ * @returns {Promise<{ok: true, url: string} | {ok: false, reason: string, message: string}>}
53
+ *
54
+ * Known limitation (pre-existing in branch-scanner.mjs, not fixed here — see
55
+ * backlog): run from a repo subdirectory misreports 'no-branch' even on a
56
+ * real branch, since scanCurrentBranch only checks for .git at cwd exactly,
57
+ * not via git's own upward search. Run from the repo root.
58
+ */
59
+ export async function openPr(ticketKey, markdown, {
60
+ cwd = process.cwd(),
61
+ execFn = spawnSync,
62
+ scanCurrentBranchFn = scanCurrentBranch,
63
+ openBrowserFn = openBrowser,
64
+ } = {}) {
65
+ const remoteUrl = detectRemoteUrl(execFn, cwd);
66
+ const gh = parseGitHubRemote(remoteUrl);
67
+ if (!gh) {
68
+ return { ok: false, reason: 'not-github', message: 'No GitHub remote detected on origin — `pr --open` supports GitHub only.' };
69
+ }
70
+
71
+ const [scan] = scanCurrentBranchFn({ cwd, execFn }) ?? [];
72
+ if (!scan || !scan.branch) {
73
+ return { ok: false, reason: 'no-branch', message: 'Not on a real branch (detached HEAD, or not a git repository).' };
74
+ }
75
+ if (!scan.base) {
76
+ return { ok: false, reason: 'no-base', message: 'No base branch detected (tried origin/main, origin/master, origin/develop, main, master).' };
77
+ }
78
+ if (scan.branch === scan.base.replace(/^origin\//, '')) {
79
+ return { ok: false, reason: 'head-equals-base', message: `Already on the base branch ("${scan.branch}") — nothing to compare.` };
80
+ }
81
+
82
+ const pushed = runGit(execFn, ['rev-parse', '--verify', `origin/${scan.branch}`], cwd);
83
+ if (pushed === null) {
84
+ return { ok: false, reason: 'unpushed', message: `Branch "${scan.branch}" isn't pushed to origin yet — push it first, then re-run.` };
85
+ }
86
+
87
+ const firstNewline = markdown.indexOf('\n');
88
+ const titleLine = (firstNewline === -1 ? markdown : markdown.slice(0, firstNewline)).replace(/^##\s*/, '');
89
+ const body = firstNewline === -1 ? '' : markdown.slice(firstNewline + 1).trim();
90
+
91
+ const url = buildCompareUrl({
92
+ owner: gh.owner,
93
+ repo: gh.repo,
94
+ base: scan.base,
95
+ head: scan.branch,
96
+ title: titleLine,
97
+ body,
98
+ ticketKey,
99
+ });
100
+
101
+ try {
102
+ openBrowserFn(url);
103
+ } catch {
104
+ // best-effort — the URL is still returned/printed either way
105
+ }
106
+
107
+ return { ok: true, url };
108
+ }