ticketlens 0.42.3 → 0.43.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 +3 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +5 -3
- package/skills/jtb/hooks/recall-auto-capture.mjs +12 -3
- package/skills/jtb/hooks/recall-nudge-stop.mjs +26 -3
- package/skills/jtb/scripts/fetch-ticket.mjs +30 -0
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/browser-login.mjs +12 -2
- package/skills/jtb/scripts/lib/git-exec.mjs +35 -0
- package/skills/jtb/scripts/lib/help.mjs +3 -1
- package/skills/jtb/scripts/lib/mcp-server.mjs +2 -1
- package/skills/jtb/scripts/lib/mcp-tool-schemas.mjs +2 -1
- package/skills/jtb/scripts/lib/pr-assembler.mjs +2 -20
- package/skills/jtb/scripts/lib/pr-opener.mjs +108 -0
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
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 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
|
|
|
@@ -29,7 +29,7 @@ import { appendFileSync } from 'node:fs';
|
|
|
29
29
|
import { join } from 'node:path';
|
|
30
30
|
import { isLicensed } from '../scripts/lib/license.mjs';
|
|
31
31
|
import { readCliToken } from '../scripts/lib/cli-auth.mjs';
|
|
32
|
-
import { buildCaptureExcerpt, privateTmpDir } from './recall-nudge-lib.mjs';
|
|
32
|
+
import { buildCaptureExcerpt, privateTmpDir, writeLastCaptureAt } from './recall-nudge-lib.mjs';
|
|
33
33
|
import { autoCapture } from '../scripts/lib/summarizer.mjs';
|
|
34
34
|
import { runNoteAdd } from '../scripts/lib/note-command.mjs';
|
|
35
35
|
import { DEFAULT_CONFIG_DIR } from '../scripts/lib/config.mjs';
|
|
@@ -56,17 +56,20 @@ function logLine(message) {
|
|
|
56
56
|
* @param {string} opts.transcriptPath
|
|
57
57
|
* @param {string} [opts.ticketKey]
|
|
58
58
|
* @param {string} [opts.configDir]
|
|
59
|
+
* @param {string} [opts.cwd] - directory whose shared capture marker gets updated on a real capture (backlog #44: makes a successful background capture visible to a later sync Stop check, which otherwise has no way to know this ran)
|
|
59
60
|
* @returns {Promise<{outcome: 'skipped'|'captured'|'not-written'|'error', reason?: string, title?: string, error?: string}>}
|
|
60
61
|
*/
|
|
61
62
|
export async function runAutoCapture({
|
|
62
63
|
transcriptPath,
|
|
63
64
|
ticketKey,
|
|
64
65
|
configDir = DEFAULT_CONFIG_DIR,
|
|
66
|
+
cwd = process.cwd(),
|
|
65
67
|
isLicensedFn = isLicensed,
|
|
66
68
|
readCliTokenFn = readCliToken,
|
|
67
69
|
buildCaptureExcerptFn = buildCaptureExcerpt,
|
|
68
70
|
autoCaptureFn = autoCapture,
|
|
69
71
|
runNoteAddFn = runNoteAdd,
|
|
72
|
+
writeLastCaptureAtFn = writeLastCaptureAt,
|
|
70
73
|
} = {}) {
|
|
71
74
|
if (!isLicensedFn('pro', configDir)) {
|
|
72
75
|
logLine('skipped: not licensed');
|
|
@@ -112,10 +115,16 @@ export async function runAutoCapture({
|
|
|
112
115
|
});
|
|
113
116
|
|
|
114
117
|
logLine(written ? `captured: ${result.title}` : `not-written: ${result.title}`);
|
|
118
|
+
if (written) writeLastCaptureAtFn(cwd, Date.now());
|
|
115
119
|
return written ? { outcome: 'captured', title: result.title } : { outcome: 'not-written', title: result.title };
|
|
116
120
|
}
|
|
117
121
|
|
|
118
122
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
// cwdArg (backlog #44 code review): passed explicitly by the spawning Stop
|
|
124
|
+
// hook rather than relying on this child's own process.cwd() — Node
|
|
125
|
+
// resolves process.cwd() through symlinks, so a cwd with a symlinked
|
|
126
|
+
// component would otherwise hash to a different marker file than the one
|
|
127
|
+
// a later Stop's hasRecentCapture(cwd) reads, silently breaking the bridge.
|
|
128
|
+
const [, , transcriptPath, ticketKeyArg, cwdArg] = process.argv;
|
|
129
|
+
runAutoCapture({ transcriptPath, ticketKey: ticketKeyArg || undefined, cwd: cwdArg || undefined }).catch(() => { /* never escape — nothing is watching this process */ });
|
|
121
130
|
}
|
|
@@ -89,11 +89,31 @@ if (!sessionId || !transcriptPath) process.exit(0);
|
|
|
89
89
|
|
|
90
90
|
const { sawFetch, sawMutatingAction, sawRecallFlag, sawNoteAdd, ticketKey } = scanTranscript(transcriptPath);
|
|
91
91
|
const cliToken = readCliToken();
|
|
92
|
+
// Backlog #44 (9th/10th reports): entitled accounts already run the async
|
|
93
|
+
// auto-capture judge below — throttled to at most once per CAPTURE_FRESHNESS_MS
|
|
94
|
+
// per cwd (hasRecentAutoCaptureAttempt below), not on every single Stop. Once
|
|
95
|
+
// it succeeds, its outcome is now visible via writeLastCaptureAt
|
|
96
|
+
// (recall-auto-capture.mjs) to a LATER Stop's hasRecentCapture() check — but
|
|
97
|
+
// THIS Stop's own decision below still can't see an attempt spawned moments
|
|
98
|
+
// ago (detached, still running), nor is a fresh one spawned if the window's
|
|
99
|
+
// one attempt already ran and moved on. For that residual window, a hard
|
|
100
|
+
// block duplicates a safety net that's already in flight or already spent:
|
|
101
|
+
// exit 0 instead of 2 so this never blocks the turn for these accounts. Free
|
|
102
|
+
// tier has no such net, so it keeps the hard block unchanged — this is its
|
|
103
|
+
// only enforcement.
|
|
104
|
+
const hasSafetyNet = isLicensed('pro') && Boolean(cliToken);
|
|
92
105
|
|
|
93
|
-
if (
|
|
106
|
+
if (hasSafetyNet && !hasRecentAutoCaptureAttempt(cwd)) {
|
|
94
107
|
try {
|
|
95
108
|
writeLastAutoCaptureAttemptAt(cwd, Date.now());
|
|
96
|
-
|
|
109
|
+
// cwd is passed explicitly as argv (not left to the child's own
|
|
110
|
+
// process.cwd()) — code review finding: Node resolves process.cwd()
|
|
111
|
+
// through symlinks, so a cwd with any symlink component (macOS os.tmpdir()
|
|
112
|
+
// itself, an iCloud-synced Desktop, a Docker bind mount) would make the
|
|
113
|
+
// child's own process.cwd() differ from the string used here, hashing to
|
|
114
|
+
// a different marker file and silently breaking the bridge this fix exists
|
|
115
|
+
// to build.
|
|
116
|
+
const child = spawn(process.execPath, [AUTO_CAPTURE_SCRIPT, transcriptPath, ticketKey ?? '', cwd], {
|
|
97
117
|
cwd,
|
|
98
118
|
detached: true,
|
|
99
119
|
stdio: 'ignore',
|
|
@@ -156,4 +176,7 @@ if (sawRecallFlag) {
|
|
|
156
176
|
'`ticketlens note add`. If genuinely nothing qualified, just say so — then finish.\n',
|
|
157
177
|
);
|
|
158
178
|
}
|
|
159
|
-
|
|
179
|
+
// hasSafetyNet: the message above is still written (a diagnostic trail if
|
|
180
|
+
// the async job silently fails) but exit 0 means Claude Code never surfaces
|
|
181
|
+
// it or blocks the turn — only exit 2 feeds stderr back (backlog #44).
|
|
182
|
+
process.exit(hasSafetyNet ? 0 : 2);
|
|
@@ -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`);
|
|
@@ -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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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;`,
|
|
@@ -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
|
+
}
|