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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
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": {
@@ -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
- process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Usage: ${sErr.cyan('ticketlens review [--base=BRANCH] [--profile=NAME]')}\n`);
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.split('=')[1] : null;
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('[--base=BRANCH]')} Code-review context brief from current branch`,
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('[--base=BRANCH] [--profile=NAME]')}`,
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('--base')}=${s.dim('BRANCH')} Compare against this branch ${s.dim('(default: auto-detect main/master/develop)')}`,
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 --base=main`,
566
- ` ${s.dim('$')} ticketlens review --base=main --profile=myteam`,
567
- ` ${s.dim('$')} ticketlens review --base=main | pbcopy ${s.dim('# copy brief to clipboard')}`,
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
+ }