ticketlens 0.1.18 → 0.1.20
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
|
@@ -630,6 +630,135 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
630
630
|
return;
|
|
631
631
|
}
|
|
632
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
|
+
|
|
633
762
|
const ticketKey = args.find(a => !a.startsWith('--'));
|
|
634
763
|
if (!ticketKey) {
|
|
635
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
|
}
|
|
@@ -39,6 +39,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
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
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`,
|
|
@@ -575,3 +576,35 @@ export function printReviewHelp({ stream = process.stdout } = {}) {
|
|
|
575
576
|
];
|
|
576
577
|
stream.write(lines.join('\n') + '\n');
|
|
577
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,133 @@
|
|
|
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
|
+
if (keyedGroups.length === 0) {
|
|
29
|
+
lines.push('_No ticket references found in commits._');
|
|
30
|
+
} else {
|
|
31
|
+
for (const [key] of keyedGroups) {
|
|
32
|
+
const ticket = ticketMap.get(key);
|
|
33
|
+
const summary = ticket?.fields?.summary ?? ticket?.summary ?? null;
|
|
34
|
+
lines.push(summary ? `- **${key}** — ${summary}` : `- **${key}**`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
const allCommits = [];
|
|
39
|
+
for (const commits of groups.values()) {
|
|
40
|
+
for (const c of commits) {
|
|
41
|
+
if (!seen.has(c)) { seen.add(c); allCommits.push(c); }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
lines.push('', `## Commits (${allCommits.length})`, '');
|
|
45
|
+
for (const c of allCommits) {
|
|
46
|
+
const trimmed = c.trim();
|
|
47
|
+
const shaM = trimmed.match(/^([0-9a-f]{6,})\s(.+)$/);
|
|
48
|
+
lines.push(shaM ? `- \`${shaM[1]}\` ${shaM[2]}` : `- ${trimmed}`);
|
|
49
|
+
}
|
|
50
|
+
return lines.join('\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function assembleStandup(groups, tickets, opts = {}) {
|
|
54
|
+
const { since = '24', format = 'standup' } = opts;
|
|
55
|
+
const ticketMap = new Map((tickets ?? []).map(t => [t.key, t]));
|
|
56
|
+
|
|
57
|
+
if (format !== 'standup' && format !== 'pr') throw new Error(`Unknown standup format: "${format}"`);
|
|
58
|
+
if (format === 'pr') return assemblePrBody(groups, ticketMap);
|
|
59
|
+
|
|
60
|
+
const dateStr = new Date().toLocaleDateString('en-US', {
|
|
61
|
+
weekday: 'short', day: 'numeric', month: 'short', year: 'numeric',
|
|
62
|
+
});
|
|
63
|
+
const lines = [`## Standup — ${dateStr}`, ''];
|
|
64
|
+
|
|
65
|
+
const keyedGroups = [...groups.entries()].filter(([k]) => k !== '__no_key__');
|
|
66
|
+
const noKeyCommits = groups.get('__no_key__') ?? [];
|
|
67
|
+
|
|
68
|
+
if (keyedGroups.length === 0 && noKeyCommits.length === 0) {
|
|
69
|
+
const hourLabel = /^\d+$/.test(since)
|
|
70
|
+
? `${since} hour${since === '1' ? '' : 's'}`
|
|
71
|
+
: since;
|
|
72
|
+
lines.push(`_No commits in the last ${hourLabel}._`);
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (keyedGroups.length > 0) {
|
|
77
|
+
lines.push('### Commits by ticket', '');
|
|
78
|
+
for (const [key, commits] of keyedGroups) {
|
|
79
|
+
const ticket = ticketMap.get(key);
|
|
80
|
+
const summary = ticket?.fields?.summary ?? ticket?.summary ?? null;
|
|
81
|
+
const count = commits.length;
|
|
82
|
+
const countStr = `${count} commit${count > 1 ? 's' : ''}`;
|
|
83
|
+
const header = summary
|
|
84
|
+
? `**${key}** — ${summary} (${countStr})`
|
|
85
|
+
: `**${key}** (${countStr})`;
|
|
86
|
+
lines.push(header);
|
|
87
|
+
for (const c of commits) lines.push(` ${c.trim()}`);
|
|
88
|
+
lines.push('');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (noKeyCommits.length > 0) {
|
|
93
|
+
const count = noKeyCommits.length;
|
|
94
|
+
lines.push(`[No ticket key] (${count} commit${count > 1 ? 's' : ''})`);
|
|
95
|
+
for (const c of noKeyCommits) lines.push(` ${c.trim()}`);
|
|
96
|
+
lines.push('');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return lines.join('\n').trimEnd();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function styleStandupMd(md, s) {
|
|
103
|
+
if (!s.enabled) return md;
|
|
104
|
+
return md
|
|
105
|
+
.split('\n')
|
|
106
|
+
.map(line => {
|
|
107
|
+
if (line.startsWith('## Standup')) {
|
|
108
|
+
return `\n ${s.brand(s.bold('◆ ' + line.slice(3)))}`;
|
|
109
|
+
}
|
|
110
|
+
if (line.startsWith('## What changed')) {
|
|
111
|
+
return `\n ${s.brand(s.bold('◆ What changed'))}`;
|
|
112
|
+
}
|
|
113
|
+
if (line.match(/^## Commits \(\d+\)/)) {
|
|
114
|
+
return `\n ${s.bold(line.slice(3))}`;
|
|
115
|
+
}
|
|
116
|
+
if (line.startsWith('### ')) {
|
|
117
|
+
return `\n ${s.bold(line.slice(4))}`;
|
|
118
|
+
}
|
|
119
|
+
const tkM = line.match(/^\*\*([A-Z][A-Z0-9]+-\d+)\*\*(.*)$/);
|
|
120
|
+
if (tkM) {
|
|
121
|
+
return ` ${s.cyan(s.bold(tkM[1]))}${tkM[2]}`;
|
|
122
|
+
}
|
|
123
|
+
if (line.startsWith('[No ticket key]')) {
|
|
124
|
+
return ` ${s.dim(line)}`;
|
|
125
|
+
}
|
|
126
|
+
if (line.startsWith(' ')) {
|
|
127
|
+
const shaM = line.trimStart().match(/^([0-9a-f]{6,})\s(.+)$/);
|
|
128
|
+
if (shaM) return ` ${s.dim(shaM[1])} ${shaM[2]}`;
|
|
129
|
+
}
|
|
130
|
+
return line;
|
|
131
|
+
})
|
|
132
|
+
.join('\n');
|
|
133
|
+
}
|