ticketlens 0.1.17 → 0.1.19
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
|
@@ -160,6 +160,62 @@ Bot comments (Jira Automation, Jenkins, GitHub Actions) are automatically ignore
|
|
|
160
160
|
|
|
161
161
|
---
|
|
162
162
|
|
|
163
|
+
### Review
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
ticketlens review # Assemble PR review context from current branch
|
|
167
|
+
ticketlens review --branch=main # Compare against main (auto-detected by default)
|
|
168
|
+
ticketlens review --branch=develop # Compare against a specific branch
|
|
169
|
+
ticketlens review --base=main # Alias for --branch
|
|
170
|
+
ticketlens review --profile=acme # Use a specific profile for ticket fetching
|
|
171
|
+
ticketlens review --branch=main | pbcopy # Copy brief to clipboard
|
|
172
|
+
ticketlens review --branch=main | llm "What changed and why?"
|
|
173
|
+
ticketlens review --help # Review subcommand help
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Extracts linked ticket keys from the branch name and commit messages, fetches each ticket via the configured profile, then assembles a structured brief: branch, changed files, and ticket context. On TTY, output is ANSI-styled with colored section headers, file paths, and coverage percentages.
|
|
177
|
+
|
|
178
|
+
`--branch=BRANCH` (or `--base=BRANCH`) sets the comparison base. Defaults to auto-detecting `main`, `master`, or `develop`.
|
|
179
|
+
|
|
180
|
+
Flag validation provides actionable hints:
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
✖ Unknown flag: --branch-main
|
|
184
|
+
Did you mean --branch=main?
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
### Standup
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
ticketlens standup # Standup summary for the last 24 hours
|
|
193
|
+
ticketlens standup --since=48 # Last 48 hours
|
|
194
|
+
ticketlens standup --since=yesterday # Git date string
|
|
195
|
+
ticketlens standup --format=pr # PR body format: "What changed" + commit list
|
|
196
|
+
ticketlens standup --profile=myteam # Enrich with ticket summaries from Jira
|
|
197
|
+
ticketlens standup --plain # Plain markdown (no ANSI colour)
|
|
198
|
+
ticketlens standup --plain | pbcopy # Copy standup to clipboard
|
|
199
|
+
ticketlens standup --help # Standup subcommand help
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Scans `git log` for the configured window, extracts ticket keys from commit messages, and groups commits by ticket. Optionally fetches ticket summaries from your Jira profile to add context. Outputs a dated standup brief or a PR body depending on `--format`.
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
## Standup — Mon, May 18, 2026
|
|
206
|
+
|
|
207
|
+
### Commits by ticket
|
|
208
|
+
|
|
209
|
+
**PROJ-123** — Fix payment validation (2 commits)
|
|
210
|
+
abc1234 feat: PROJ-123 add payment validation check
|
|
211
|
+
def5678 test: PROJ-123 payment validation tests
|
|
212
|
+
|
|
213
|
+
[No ticket key] (1 commit)
|
|
214
|
+
jkl3456 chore: bump deps
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
163
219
|
### Cache
|
|
164
220
|
|
|
165
221
|
```bash
|
|
@@ -303,6 +359,25 @@ ticketlens triage --profile=acme --stale=3 --static # Combine flags
|
|
|
303
359
|
ticketlens triage --plain > my-tickets.md
|
|
304
360
|
ticketlens triage --plain | llm "Which ticket is most urgent and why?"
|
|
305
361
|
|
|
362
|
+
# ── PR Review ─────────────────────────────────────────────────────────────────
|
|
363
|
+
ticketlens review # Assemble PR review context from current branch
|
|
364
|
+
ticketlens review --branch=main # Compare against main (auto-detected by default)
|
|
365
|
+
ticketlens review --branch=develop # Compare against a specific branch
|
|
366
|
+
ticketlens review --base=main # Alias for --branch
|
|
367
|
+
ticketlens review --profile=acme # Use a specific profile for ticket fetching
|
|
368
|
+
ticketlens review --branch=main | pbcopy # Copy brief to clipboard
|
|
369
|
+
ticketlens review --branch=main --profile=myteam # Branch + profile combined
|
|
370
|
+
ticketlens review --help # Review subcommand help
|
|
371
|
+
|
|
372
|
+
# ── Standup ───────────────────────────────────────────────────────────────────
|
|
373
|
+
ticketlens standup # Standup summary for last 24 hours
|
|
374
|
+
ticketlens standup --since=48 # Last 48 hours
|
|
375
|
+
ticketlens standup --since=yesterday # Git date string
|
|
376
|
+
ticketlens standup --format=pr # PR body: "What changed" + commit list
|
|
377
|
+
ticketlens standup --profile=myteam # Enrich with Jira ticket summaries
|
|
378
|
+
ticketlens standup --plain | pbcopy # Copy to clipboard
|
|
379
|
+
ticketlens standup --help # Standup subcommand help
|
|
380
|
+
|
|
306
381
|
# ── Cache management ──────────────────────────────────────────────────────────
|
|
307
382
|
ticketlens cache # Overview + subcommand hints
|
|
308
383
|
ticketlens cache --help # Detailed help
|
|
@@ -337,6 +412,7 @@ ticketlens --help # Main help
|
|
|
337
412
|
ticketlens --version # Show installed version
|
|
338
413
|
ticketlens CNV1-2 --help # Fetch subcommand help
|
|
339
414
|
ticketlens triage --help # Triage subcommand help
|
|
415
|
+
ticketlens review --help # Review subcommand help
|
|
340
416
|
ticketlens cache --help # Cache overview help
|
|
341
417
|
ticketlens cache size --help # Cache size help
|
|
342
418
|
ticketlens cache clear --help # Cache clear help
|
|
@@ -458,14 +534,12 @@ npm test
|
|
|
458
534
|
See [ROADMAP.md](ROADMAP.md) for the full plan.
|
|
459
535
|
|
|
460
536
|
Recently shipped:
|
|
537
|
+
- **"TicketLens for PRs"** (`ticketlens review`) — assembles a code-review context brief from your current branch: extracts linked ticket keys from the branch name and commits, fetches each ticket, and outputs a structured brief with branch, changed files, and ticket context. Styled ANSI output, spinner, flag validation with typo hints, and base-branch safety check
|
|
461
538
|
- **Confluence pages** — linked Confluence pages fetched automatically and included in the brief; origin-validated, non-fatal, capped at 10 pages
|
|
462
539
|
- **Linear support** — `ticketlens init` → Linear; connects via GraphQL API key, fetches tickets, triage, and statuses
|
|
463
540
|
- **GitHub Issues support** — `ticketlens init` → GitHub Issues; PAT-based, same normalized ticket shape
|
|
464
541
|
- **Tracker-aware config** — `ticketlens config` shows the right labels and skips irrelevant prompts per tracker type; always re-validates the connection
|
|
465
542
|
|
|
466
|
-
Coming up:
|
|
467
|
-
- "TicketLens for PRs" — PR context assembly with linked tickets and compliance (F46)
|
|
468
|
-
|
|
469
543
|
---
|
|
470
544
|
|
|
471
545
|
## Contributing
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
printActivateHelp, printLicenseHelp, printDeleteHelp,
|
|
24
24
|
printProfilesHelp, printScheduleHelp,
|
|
25
25
|
printInitHelp, printSwitchHelp, printConfigHelp,
|
|
26
|
-
printReviewHelp,
|
|
26
|
+
printReviewHelp, printStandupHelp,
|
|
27
27
|
} from '../skills/jtb/scripts/lib/help.mjs';
|
|
28
28
|
import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
|
|
29
29
|
import { readCliToken, saveCliToken, deleteCliToken } from '../skills/jtb/scripts/lib/cli-auth.mjs';
|
|
@@ -271,6 +271,14 @@ switch (command) {
|
|
|
271
271
|
});
|
|
272
272
|
break;
|
|
273
273
|
|
|
274
|
+
case 'standup':
|
|
275
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printStandupHelp(); break; }
|
|
276
|
+
runFetch(['standup', ...cmdArgs]).catch(err => {
|
|
277
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
278
|
+
process.exitCode = 1;
|
|
279
|
+
});
|
|
280
|
+
break;
|
|
281
|
+
|
|
274
282
|
case 'ledger':
|
|
275
283
|
runFetch(['ledger', ...cmdArgs]).catch(err => {
|
|
276
284
|
process.stderr.write(`Error: ${err.message}\n`);
|
package/package.json
CHANGED
|
@@ -484,7 +484,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
484
484
|
|
|
485
485
|
// Validate flags before any git work
|
|
486
486
|
const reviewFlags = args.slice(1);
|
|
487
|
-
const VALID_REVIEW_FLAG = /^(--base=.+|--profile=.+)$/;
|
|
487
|
+
const VALID_REVIEW_FLAG = /^(--base=.+|--branch=.+|--profile=.+)$/;
|
|
488
488
|
for (const flag of reviewFlags) {
|
|
489
489
|
if (!flag.startsWith('-')) continue;
|
|
490
490
|
if (VALID_REVIEW_FLAG.test(flag)) continue;
|
|
@@ -501,7 +501,13 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
501
501
|
process.exitCode = 1;
|
|
502
502
|
return;
|
|
503
503
|
}
|
|
504
|
-
|
|
504
|
+
const branchDashM = flag.match(/^--branch-(.+)$/);
|
|
505
|
+
if (branchDashM) {
|
|
506
|
+
process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--branch=${branchDashM[1]}`)}?\n`);
|
|
507
|
+
process.exitCode = 1;
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Usage: ${sErr.cyan('ticketlens review [--base=BRANCH] [--branch=BRANCH] [--profile=NAME]')}\n`);
|
|
505
511
|
process.exitCode = 1;
|
|
506
512
|
return;
|
|
507
513
|
}
|
|
@@ -510,8 +516,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
510
516
|
const stderrNotes = [];
|
|
511
517
|
|
|
512
518
|
// Resolve base branch: --base=BRANCH or auto-detect main/master/develop
|
|
513
|
-
const baseArg = reviewFlags.find(a => a.startsWith('--base='));
|
|
514
|
-
let baseBranch = baseArg ? baseArg.
|
|
519
|
+
const baseArg = reviewFlags.find(a => a.startsWith('--base=') || a.startsWith('--branch='));
|
|
520
|
+
let baseBranch = baseArg ? baseArg.replace(/^--(base|branch)=/, '') : null;
|
|
515
521
|
|
|
516
522
|
spinner.update('Scanning branch…');
|
|
517
523
|
|
|
@@ -604,6 +610,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
604
610
|
|
|
605
611
|
spinner.done();
|
|
606
612
|
for (const note of stderrNotes) process.stderr.write(note + '\n');
|
|
613
|
+
if (stderrNotes.length > 0) process.stderr.write('\n');
|
|
607
614
|
|
|
608
615
|
const isLic = opts.isLicensedFn ?? ((tier) => isLicensed(tier, resolvedConfigDir));
|
|
609
616
|
const assembleFn = opts.assemblePrReviewFn ?? assemblePrReview;
|
|
@@ -623,6 +630,135 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
623
630
|
return;
|
|
624
631
|
}
|
|
625
632
|
|
|
633
|
+
if (args[0] === 'standup') {
|
|
634
|
+
const { groupCommitsByTicket, assembleStandup, styleStandupMd, VALID_STANDUP_FLAG } =
|
|
635
|
+
await import('./lib/standup-assembler.mjs');
|
|
636
|
+
|
|
637
|
+
const resolvedConfigDir = configDir ?? (await import('./lib/config.mjs')).DEFAULT_CONFIG_DIR;
|
|
638
|
+
const execFn = opts.execFn ?? spawnSync;
|
|
639
|
+
const cwd = process.cwd();
|
|
640
|
+
const sErr = createStyler({ isTTY: process.stderr.isTTY });
|
|
641
|
+
|
|
642
|
+
const standupFlags = args.slice(1);
|
|
643
|
+
|
|
644
|
+
// Validate flags
|
|
645
|
+
for (const flag of standupFlags) {
|
|
646
|
+
if (!flag.startsWith('-')) continue;
|
|
647
|
+
if (VALID_STANDUP_FLAG.test(flag)) continue;
|
|
648
|
+
|
|
649
|
+
const sinceDashM = flag.match(/^--since-(.+)$/);
|
|
650
|
+
if (sinceDashM) {
|
|
651
|
+
process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--since=${sinceDashM[1]}`)}?\n`);
|
|
652
|
+
process.exitCode = 1;
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const formatDashM = flag.match(/^--format-(.+)$/);
|
|
656
|
+
if (formatDashM) {
|
|
657
|
+
process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--format=${formatDashM[1]}`)}?\n`);
|
|
658
|
+
process.exitCode = 1;
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
const profileDashM = flag.match(/^--profile-(.+)$/);
|
|
662
|
+
if (profileDashM) {
|
|
663
|
+
process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--profile=${profileDashM[1]}`)}?\n`);
|
|
664
|
+
process.exitCode = 1;
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const formatValueM = flag.match(/^--format=(.+)$/);
|
|
668
|
+
if (formatValueM) {
|
|
669
|
+
process.stderr.write(`${sErr.red('✖')} Invalid --format value: "${formatValueM[1]}". Expected: standup or pr\n`);
|
|
670
|
+
process.exitCode = 1;
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Usage: ${sErr.cyan('ticketlens standup [--since=N] [--format=standup|pr] [--profile=NAME]')}\n`);
|
|
674
|
+
process.exitCode = 1;
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// --format value
|
|
679
|
+
const formatArgS = standupFlags.find(a => a.startsWith('--format='));
|
|
680
|
+
const format = formatArgS ? formatArgS.split('=')[1] : 'standup';
|
|
681
|
+
|
|
682
|
+
// --since: integer → "N hours ago", otherwise pass verbatim to git --since
|
|
683
|
+
const sinceArgS = standupFlags.find(a => a.startsWith('--since='));
|
|
684
|
+
const sinceVal = sinceArgS ? sinceArgS.split('=').slice(1).join('=') : '24';
|
|
685
|
+
const sinceDateStr = /^\d+$/.test(sinceVal) ? `${sinceVal} hours ago` : sinceVal;
|
|
686
|
+
|
|
687
|
+
// --profile
|
|
688
|
+
const profileArgS = standupFlags.find(a => a.startsWith('--profile='));
|
|
689
|
+
const profileNameS = profileArgS ? profileArgS.split('=')[1] : undefined;
|
|
690
|
+
|
|
691
|
+
if (profileNameS) {
|
|
692
|
+
const profiles = loadProfiles(resolvedConfigDir);
|
|
693
|
+
if (!profiles?.profiles?.[profileNameS]) {
|
|
694
|
+
process.stderr.write(`${sErr.red('✖')} Profile "${profileNameS}" not found.\n`);
|
|
695
|
+
const names = Object.keys(profiles?.profiles ?? {});
|
|
696
|
+
if (names.length > 0) process.stderr.write(` ${sErr.dim('Available:')} ${names.join(', ')}\n`);
|
|
697
|
+
else process.stderr.write(` Run ${sErr.cyan('ticketlens init')} to configure a profile.\n`);
|
|
698
|
+
process.exitCode = 1;
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const spinner = makeSpinner(sErr);
|
|
704
|
+
const stderrNotes = [];
|
|
705
|
+
|
|
706
|
+
spinner.update('Scanning git log…');
|
|
707
|
+
|
|
708
|
+
const logResult = execFn('git', ['log', '--oneline', `--since=${sinceDateStr}`], {
|
|
709
|
+
encoding: 'utf8', cwd, timeout: 10_000,
|
|
710
|
+
});
|
|
711
|
+
const logLines = logResult.status === 0
|
|
712
|
+
? (logResult.stdout ?? '').split('\n').filter(Boolean)
|
|
713
|
+
: [];
|
|
714
|
+
|
|
715
|
+
if (logResult.status !== 0) {
|
|
716
|
+
stderrNotes.push(` ${sErr.yellow('⚠')} git log failed — ensure you are in a git repository.`);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const groupCommitsFn = opts.groupCommitsByTicketFn ?? groupCommitsByTicket;
|
|
720
|
+
const groups = groupCommitsFn(logLines);
|
|
721
|
+
const ticketKeys = [...groups.keys()].filter(k => k !== '__no_key__');
|
|
722
|
+
|
|
723
|
+
// Optionally fetch ticket summaries
|
|
724
|
+
const tickets = [];
|
|
725
|
+
if (ticketKeys.length > 0) {
|
|
726
|
+
spinner.update(`Fetching ticket context (${ticketKeys.length} key${ticketKeys.length > 1 ? 's' : ''})…`);
|
|
727
|
+
spinner.startAnim();
|
|
728
|
+
const connS = resolveConnection(ticketKeys[0], {
|
|
729
|
+
env,
|
|
730
|
+
configDir: resolvedConfigDir,
|
|
731
|
+
profileName: profileNameS,
|
|
732
|
+
cwd,
|
|
733
|
+
onWarning: () => {},
|
|
734
|
+
onProfileNotFound: () => {},
|
|
735
|
+
});
|
|
736
|
+
const hasAuthS = connS.pat || (connS.email && connS.apiToken);
|
|
737
|
+
if (connS.baseUrl && hasAuthS) {
|
|
738
|
+
const adapterS = resolveAdapter(connS, { fetcher });
|
|
739
|
+
for (const key of ticketKeys) {
|
|
740
|
+
try {
|
|
741
|
+
tickets.push(await adapterS.fetchTicket(key, { depth: 0 }));
|
|
742
|
+
} catch (err) {
|
|
743
|
+
stderrNotes.push(` ${sErr.yellow('⚠')} ${key}: ${err.message ?? 'fetch failed'}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
spinner.done();
|
|
750
|
+
for (const note of stderrNotes) process.stderr.write(note + '\n');
|
|
751
|
+
if (stderrNotes.length > 0) process.stderr.write('\n');
|
|
752
|
+
|
|
753
|
+
const isPlain = standupFlags.includes('--plain');
|
|
754
|
+
const assembleStandupFn = opts.assembleStandupFn ?? assembleStandup;
|
|
755
|
+
const md = assembleStandupFn(groups, tickets, { since: sinceVal, format });
|
|
756
|
+
|
|
757
|
+
const sOut = createStyler({ isTTY: !isPlain && process.stdout.isTTY });
|
|
758
|
+
printFn((sOut.enabled ? styleStandupMd(md, sOut) : md) + '\n');
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
|
|
626
762
|
const ticketKey = args.find(a => !a.startsWith('--'));
|
|
627
763
|
if (!ticketKey) {
|
|
628
764
|
printFetchHelp({ stream: process.stderr });
|
|
@@ -86,6 +86,10 @@ export function parseCommand(args) {
|
|
|
86
86
|
return { command: 'review', args: args.slice(1) };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
if (first === 'standup') {
|
|
90
|
+
return { command: 'standup', args: args.slice(1) };
|
|
91
|
+
}
|
|
92
|
+
|
|
89
93
|
if (first === 'login') {
|
|
90
94
|
return { command: 'login', args: args.slice(1) };
|
|
91
95
|
}
|
|
@@ -38,7 +38,8 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
38
38
|
` ${s.brand('ticketlens')} ${s.dim('<TICKET-KEY>')} ${s.dim('[options]')} Fetch a ticket brief`,
|
|
39
39
|
` ${s.brand('ticketlens')} get ${s.dim('<TICKET-KEY>')} Same as above ${s.dim('(explicit alias)')}`,
|
|
40
40
|
` ${s.brand('ticketlens')} triage ${s.dim('[options]')} Scan your assigned tickets`,
|
|
41
|
-
` ${s.brand('ticketlens')} review ${s.dim('[--
|
|
41
|
+
` ${s.brand('ticketlens')} review ${s.dim('[--branch=BRANCH]')} Code-review context brief from current branch`,
|
|
42
|
+
` ${s.brand('ticketlens')} standup ${s.dim('[--since=N]')} Standup summary from git log ${s.dim('(last 24h by default)')}`,
|
|
42
43
|
` ${s.brand('ticketlens')} compliance ${s.dim('<TICKET-KEY>')} Check requirements coverage ${s.dim('[Pro/Free 3/mo]')}`,
|
|
43
44
|
'',
|
|
44
45
|
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
|
|
@@ -545,7 +546,7 @@ export function printReviewHelp({ stream = process.stdout } = {}) {
|
|
|
545
546
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
546
547
|
const lines = [
|
|
547
548
|
'',
|
|
548
|
-
` ${s.bold(s.brand('ticketlens'))} ${s.bold('review')} ${s.dim('[--
|
|
549
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('review')} ${s.dim('[--branch=BRANCH] [--profile=NAME]')}`,
|
|
549
550
|
'',
|
|
550
551
|
` Assemble a code-review context brief from your current branch.`,
|
|
551
552
|
` Extracts linked ticket keys from the branch name and commit messages,`,
|
|
@@ -555,16 +556,17 @@ export function printReviewHelp({ stream = process.stdout } = {}) {
|
|
|
555
556
|
'',
|
|
556
557
|
` ${s.bold('OPTIONS')}`,
|
|
557
558
|
'',
|
|
558
|
-
` ${s.brand('--
|
|
559
|
+
` ${s.brand('--branch')}=${s.dim('BRANCH')} Compare against this branch ${s.dim('(default: auto-detect main/master/develop)')}`,
|
|
560
|
+
` ${s.brand('--base')}=${s.dim('BRANCH')} Alias for ${s.dim('--branch')}`,
|
|
559
561
|
` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific tracker profile for ticket fetching`,
|
|
560
562
|
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
561
563
|
'',
|
|
562
564
|
` ${s.bold('EXAMPLES')}`,
|
|
563
565
|
'',
|
|
564
566
|
` ${s.dim('$')} ticketlens review`,
|
|
565
|
-
` ${s.dim('$')} ticketlens review --
|
|
566
|
-
` ${s.dim('$')} ticketlens review --
|
|
567
|
-
` ${s.dim('$')} ticketlens review --
|
|
567
|
+
` ${s.dim('$')} ticketlens review --branch=main`,
|
|
568
|
+
` ${s.dim('$')} ticketlens review --branch=main --profile=myteam`,
|
|
569
|
+
` ${s.dim('$')} ticketlens review --branch=main | pbcopy ${s.dim('# copy brief to clipboard')}`,
|
|
568
570
|
'',
|
|
569
571
|
` ${s.bold('OUTPUT SECTIONS')}`,
|
|
570
572
|
'',
|
|
@@ -574,3 +576,35 @@ export function printReviewHelp({ stream = process.stdout } = {}) {
|
|
|
574
576
|
];
|
|
575
577
|
stream.write(lines.join('\n') + '\n');
|
|
576
578
|
}
|
|
579
|
+
|
|
580
|
+
export function printStandupHelp({ stream = process.stdout } = {}) {
|
|
581
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
582
|
+
const lines = [
|
|
583
|
+
'',
|
|
584
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('standup')} ${s.dim('[--since=N] [--format=standup|pr] [--profile=NAME] [--plain]')}`,
|
|
585
|
+
'',
|
|
586
|
+
` Generate a standup summary or PR body from your recent git commits.`,
|
|
587
|
+
` Reads git log for the last 24 hours, groups commits by ticket key,`,
|
|
588
|
+
` and optionally enriches output with ticket summaries via a Jira profile.`,
|
|
589
|
+
'',
|
|
590
|
+
` ${s.bold('OPTIONS')}`,
|
|
591
|
+
'',
|
|
592
|
+
` ${s.brand('--since')}=${s.dim('N')} Look back N hours ${s.dim('(default: 24)')}`,
|
|
593
|
+
` Also accepts git date strings: ${s.dim('--since=yesterday')}, ${s.dim('--since=2024-01-15')}`,
|
|
594
|
+
` ${s.brand('--format')}=${s.dim('standup')} Bullet list grouped by ticket ${s.dim('(default)')}`,
|
|
595
|
+
` ${s.brand('--format')}=${s.dim('pr')} PR body: "What changed" + commit list`,
|
|
596
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific tracker profile to fetch ticket summaries`,
|
|
597
|
+
` ${s.brand('--plain')} Plain markdown output ${s.dim('(no ANSI colour)')}`,
|
|
598
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
599
|
+
'',
|
|
600
|
+
` ${s.bold('EXAMPLES')}`,
|
|
601
|
+
'',
|
|
602
|
+
` ${s.dim('$')} ticketlens standup`,
|
|
603
|
+
` ${s.dim('$')} ticketlens standup --since=48`,
|
|
604
|
+
` ${s.dim('$')} ticketlens standup --format=pr`,
|
|
605
|
+
` ${s.dim('$')} ticketlens standup --profile=myteam`,
|
|
606
|
+
` ${s.dim('$')} ticketlens standup --plain | pbcopy ${s.dim('# copy standup to clipboard')}`,
|
|
607
|
+
'',
|
|
608
|
+
];
|
|
609
|
+
stream.write(lines.join('\n') + '\n');
|
|
610
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { extractTicketKeys } from './pr-review-assembler.mjs';
|
|
2
|
+
|
|
3
|
+
export const VALID_STANDUP_FLAG = /^(--since=.+|--format=(standup|pr)|--profile=.+|--plain)$/;
|
|
4
|
+
|
|
5
|
+
export function groupCommitsByTicket(logLines) {
|
|
6
|
+
const groups = new Map();
|
|
7
|
+
for (const line of logLines) {
|
|
8
|
+
if (!line.trim()) continue;
|
|
9
|
+
const keys = extractTicketKeys(line);
|
|
10
|
+
if (keys.length === 0) {
|
|
11
|
+
const bucket = groups.get('__no_key__') ?? [];
|
|
12
|
+
bucket.push(line);
|
|
13
|
+
groups.set('__no_key__', bucket);
|
|
14
|
+
} else {
|
|
15
|
+
for (const key of keys) {
|
|
16
|
+
const bucket = groups.get(key) ?? [];
|
|
17
|
+
if (!bucket.includes(line)) bucket.push(line);
|
|
18
|
+
groups.set(key, bucket);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return groups;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function assemblePrBody(groups, ticketMap) {
|
|
26
|
+
const lines = ['## What changed', ''];
|
|
27
|
+
const keyedGroups = [...groups.entries()].filter(([k]) => k !== '__no_key__');
|
|
28
|
+
for (const [key] of keyedGroups) {
|
|
29
|
+
const ticket = ticketMap.get(key);
|
|
30
|
+
const summary = ticket?.fields?.summary ?? ticket?.summary ?? null;
|
|
31
|
+
lines.push(summary ? `- ${key}: ${summary}` : `- ${key}`);
|
|
32
|
+
}
|
|
33
|
+
lines.push('', '## Commits', '');
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
for (const commits of groups.values()) {
|
|
36
|
+
for (const c of commits) {
|
|
37
|
+
if (!seen.has(c)) { seen.add(c); lines.push(`- ${c.trim()}`); }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return lines.join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function assembleStandup(groups, tickets, opts = {}) {
|
|
44
|
+
const { since = '24', format = 'standup' } = opts;
|
|
45
|
+
const ticketMap = new Map((tickets ?? []).map(t => [t.key, t]));
|
|
46
|
+
|
|
47
|
+
if (format !== 'standup' && format !== 'pr') throw new Error(`Unknown standup format: "${format}"`);
|
|
48
|
+
if (format === 'pr') return assemblePrBody(groups, ticketMap);
|
|
49
|
+
|
|
50
|
+
const dateStr = new Date().toLocaleDateString('en-US', {
|
|
51
|
+
weekday: 'short', day: 'numeric', month: 'short', year: 'numeric',
|
|
52
|
+
});
|
|
53
|
+
const lines = [`## Standup — ${dateStr}`, ''];
|
|
54
|
+
|
|
55
|
+
const keyedGroups = [...groups.entries()].filter(([k]) => k !== '__no_key__');
|
|
56
|
+
const noKeyCommits = groups.get('__no_key__') ?? [];
|
|
57
|
+
|
|
58
|
+
if (keyedGroups.length === 0 && noKeyCommits.length === 0) {
|
|
59
|
+
const hourLabel = /^\d+$/.test(since)
|
|
60
|
+
? `${since} hour${since === '1' ? '' : 's'}`
|
|
61
|
+
: since;
|
|
62
|
+
lines.push(`_No commits in the last ${hourLabel}._`);
|
|
63
|
+
return lines.join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (keyedGroups.length > 0) {
|
|
67
|
+
lines.push('### Commits by ticket', '');
|
|
68
|
+
for (const [key, commits] of keyedGroups) {
|
|
69
|
+
const ticket = ticketMap.get(key);
|
|
70
|
+
const summary = ticket?.fields?.summary ?? ticket?.summary ?? null;
|
|
71
|
+
const count = commits.length;
|
|
72
|
+
const countStr = `${count} commit${count > 1 ? 's' : ''}`;
|
|
73
|
+
const header = summary
|
|
74
|
+
? `**${key}** — ${summary} (${countStr})`
|
|
75
|
+
: `**${key}** (${countStr})`;
|
|
76
|
+
lines.push(header);
|
|
77
|
+
for (const c of commits) lines.push(` ${c.trim()}`);
|
|
78
|
+
lines.push('');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (noKeyCommits.length > 0) {
|
|
83
|
+
const count = noKeyCommits.length;
|
|
84
|
+
lines.push(`[No ticket key] (${count} commit${count > 1 ? 's' : ''})`);
|
|
85
|
+
for (const c of noKeyCommits) lines.push(` ${c.trim()}`);
|
|
86
|
+
lines.push('');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return lines.join('\n').trimEnd();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function styleStandupMd(md, s) {
|
|
93
|
+
if (!s.enabled) return md;
|
|
94
|
+
return md
|
|
95
|
+
.split('\n')
|
|
96
|
+
.map(line => {
|
|
97
|
+
if (line.startsWith('## Standup')) {
|
|
98
|
+
return `\n ${s.brand(s.bold('◆ ' + line.slice(3)))}`;
|
|
99
|
+
}
|
|
100
|
+
if (line.startsWith('### ')) {
|
|
101
|
+
return `\n ${s.bold(line.slice(4))}`;
|
|
102
|
+
}
|
|
103
|
+
const tkM = line.match(/^\*\*([A-Z][A-Z0-9]+-\d+)\*\*(.*)$/);
|
|
104
|
+
if (tkM) {
|
|
105
|
+
return ` ${s.cyan(s.bold(tkM[1]))}${tkM[2]}`;
|
|
106
|
+
}
|
|
107
|
+
if (line.startsWith('[No ticket key]')) {
|
|
108
|
+
return ` ${s.dim(line)}`;
|
|
109
|
+
}
|
|
110
|
+
if (line.startsWith(' ')) {
|
|
111
|
+
const shaM = line.trimStart().match(/^([0-9a-f]{6,})\s(.+)$/);
|
|
112
|
+
if (shaM) return ` ${s.dim(shaM[1])} ${shaM[2]}`;
|
|
113
|
+
}
|
|
114
|
+
return line;
|
|
115
|
+
})
|
|
116
|
+
.join('\n');
|
|
117
|
+
}
|