ticketlens 0.5.1 → 0.6.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 +44 -0
- package/bin/ticketlens.mjs +25 -0
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +4 -1
- package/skills/jtb/scripts/fetch-my-tickets.mjs +141 -13
- package/skills/jtb/scripts/lib/ansi.mjs +3 -0
- package/skills/jtb/scripts/lib/attention-scorer.mjs +37 -3
- package/skills/jtb/scripts/lib/cli.mjs +4 -0
- package/skills/jtb/scripts/lib/help.mjs +5 -0
- package/skills/jtb/scripts/lib/profile-resolver.mjs +1 -0
- package/skills/jtb/scripts/lib/schedule-wizard.mjs +117 -10
- package/skills/jtb/scripts/lib/triage-history.mjs +68 -0
package/README.md
CHANGED
|
@@ -137,6 +137,8 @@ ticketlens triage --export=csv # Export results to CSV [Team]
|
|
|
137
137
|
ticketlens triage --export=json # Export results to JSON [Team]
|
|
138
138
|
ticketlens triage --push # Push snapshot to Console queue [Team]
|
|
139
139
|
ticketlens triage --share # Generate 24h share URL (no login for recipient) [Team]
|
|
140
|
+
ticketlens triage --all # Triage all profiles at once, merged output [Pro]
|
|
141
|
+
ticketlens triage --save=~/triage.txt # Save ANSI-stripped output to file [Pro]
|
|
140
142
|
ticketlens triage --digest # POST scored results to digest endpoint [Pro]
|
|
141
143
|
ticketlens triage --plain # Plain markdown — pipe to file or LLM
|
|
142
144
|
ticketlens triage --static # Static table, no interactive mode
|
|
@@ -331,12 +333,48 @@ Cache locations:
|
|
|
331
333
|
ticketlens schedule # Interactive wizard — set digest time, timezone, profile [Pro]
|
|
332
334
|
ticketlens schedule --stop # Cancel the scheduled digest
|
|
333
335
|
ticketlens schedule --status # Show current schedule
|
|
336
|
+
ticketlens schedule --local # Local-only cron (no Console auth) — saves triage to file [Pro]
|
|
334
337
|
```
|
|
335
338
|
|
|
336
339
|
Stores the schedule as a cron entry. Delivers your triage digest at the configured time without an open terminal. Requires a Pro license.
|
|
337
340
|
|
|
338
341
|
---
|
|
339
342
|
|
|
343
|
+
### History
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
ticketlens history <TICKET-KEY> # Show urgency timeline for a ticket [Pro]
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Reads from your local `~/.ticketlens/triage-history/` snapshots (written automatically on each `ticketlens triage` run) and renders a day-by-day urgency timeline for the requested ticket. Entries where urgency changed direction are flagged as "bounced" — useful for spotting tickets that keep reverting between Code Review and In Progress.
|
|
350
|
+
|
|
351
|
+
Requires a Pro license. No network call — reads local snapshots only.
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
### Custom Attention Rules
|
|
356
|
+
|
|
357
|
+
Add an `attentionRules` array to any profile in `~/.ticketlens/profiles.json` to override how `ticketlens triage` scores specific tickets:
|
|
358
|
+
|
|
359
|
+
```json
|
|
360
|
+
{
|
|
361
|
+
"profiles": {
|
|
362
|
+
"work": {
|
|
363
|
+
"baseUrl": "https://jira.example.com",
|
|
364
|
+
"attentionRules": [
|
|
365
|
+
{ "match": { "priority": "Highest" }, "action": "force-urgent", "reason": "P1 always urgent" },
|
|
366
|
+
{ "match": { "label": "backlog" }, "action": "ignore", "reason": "skip backlog" },
|
|
367
|
+
{ "match": { "status": "Parked" }, "action": "ignore", "reason": "parked tickets" }
|
|
368
|
+
]
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Rules are evaluated in order — first match wins. Supported `match` keys: `priority`, `label`, `status`, `keyPrefix`. Supported `action` values: `force-urgent` (bumps to needs-response) and `ignore` (excludes from output). Requires a Pro license.
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
340
378
|
### Login
|
|
341
379
|
|
|
342
380
|
```bash
|
|
@@ -486,6 +524,8 @@ ticketlens triage --export=csv # Export to CSV [Team]
|
|
|
486
524
|
ticketlens triage --export=json # Export to JSON [Team]
|
|
487
525
|
ticketlens triage --push # Push snapshot to Console queue [Team]
|
|
488
526
|
ticketlens triage --share # Generate 24h share URL (no login for recipient) [Team]
|
|
527
|
+
ticketlens triage --all # Triage all profiles at once, merged output [Pro]
|
|
528
|
+
ticketlens triage --save=~/triage.txt # Save ANSI-stripped output to file [Pro]
|
|
489
529
|
ticketlens triage --digest # POST results to digest endpoint [Pro]
|
|
490
530
|
ticketlens triage --profile=acme --stale=3 --static # Combine flags
|
|
491
531
|
|
|
@@ -537,6 +577,10 @@ ticketlens cache clear --older-than=30d --yes # Skip confirmation (CI
|
|
|
537
577
|
ticketlens schedule # Interactive wizard — set time, timezone, profile [Pro]
|
|
538
578
|
ticketlens schedule --stop # Cancel the scheduled digest [Pro]
|
|
539
579
|
ticketlens schedule --status # Show current schedule [Pro]
|
|
580
|
+
ticketlens schedule --local # Local-only cron/LaunchAgent — no Console auth needed [Pro]
|
|
581
|
+
|
|
582
|
+
# ── History ───────────────────────────────────────────────────────────────────
|
|
583
|
+
ticketlens history <TICKET-KEY> # Show urgency timeline for a ticket [Pro]
|
|
540
584
|
|
|
541
585
|
# ── Compliance ────────────────────────────────────────────────────────────────
|
|
542
586
|
ticketlens compliance <TICKET-KEY> # Check ticket requirements against local diff [Pro/Free 3/mo]
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -78,6 +78,31 @@ switch (command) {
|
|
|
78
78
|
break;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
case 'history': {
|
|
82
|
+
if (!isLicensed('pro')) { showUpgradePrompt('pro', 'ticketlens history'); break; }
|
|
83
|
+
const ticketKey = cmdArgs[0];
|
|
84
|
+
if (!ticketKey || ticketKey.startsWith('-')) {
|
|
85
|
+
process.stderr.write('Usage: ticketlens history TICKET-KEY\n');
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
const { queryTicketHistory } = await import('../skills/jtb/scripts/lib/triage-history.mjs');
|
|
90
|
+
const entries = queryTicketHistory(ticketKey);
|
|
91
|
+
if (entries.length === 0) {
|
|
92
|
+
process.stdout.write(`No triage history found for ${ticketKey}.\n`);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
const hs = createStyler({ isTTY: process.stdout.isTTY });
|
|
96
|
+
process.stdout.write(`\nHistory for ${hs.bold(ticketKey)} (${entries.length} entries)\n\n`);
|
|
97
|
+
for (const e of entries) {
|
|
98
|
+
const bounce = e.bounced ? hs.yellow(' ⟳ bounced') : '';
|
|
99
|
+
const urg = e.urgency === 'needs-response' ? hs.red(e.urgency) : e.urgency === 'aging' ? hs.yellow(e.urgency) : hs.green(e.urgency);
|
|
100
|
+
process.stdout.write(` ${hs.dim(e.date)} [${e.profile}] ${urg}${bounce} ${hs.dim(e.reason)}\n`);
|
|
101
|
+
}
|
|
102
|
+
process.stdout.write('\n');
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
|
|
81
106
|
case 'init':
|
|
82
107
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printInitHelp(); break; }
|
|
83
108
|
runInit().catch(err => {
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.6.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.
|
|
@@ -27,8 +27,11 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
27
27
|
/jtb triage --stale=3 # custom aging threshold (days)
|
|
28
28
|
/jtb triage --status=CR,QA # only check specific statuses
|
|
29
29
|
/jtb triage --profile=acme # explicit profile override
|
|
30
|
+
/jtb triage --all # triage all configured profiles at once, merged (Pro)
|
|
31
|
+
/jtb triage --save=~/triage.txt # save ANSI-stripped output to file (Pro)
|
|
30
32
|
/jtb triage --push # push snapshot + git branches to Console (Team)
|
|
31
33
|
/jtb triage --share # generate 24h share URL (Team)
|
|
34
|
+
/jtb history PROD-1234 # show urgency timeline for a ticket (Pro)
|
|
32
35
|
/jtb collisions # show branch collisions with teammates (Team)
|
|
33
36
|
/jtb collisions --json # machine-readable output
|
|
34
37
|
```
|
|
@@ -10,6 +10,8 @@ import { assembleTriageSummary } from './lib/brief-assembler.mjs';
|
|
|
10
10
|
import { styleTriageSummary } from './lib/styled-assembler.mjs';
|
|
11
11
|
import { resolveConnection, loadProfiles, saveProfile } from './lib/profile-resolver.mjs';
|
|
12
12
|
import { resolveAdapter } from './lib/resolve-adapter.mjs';
|
|
13
|
+
import { writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { resolve as resolvePath, dirname } from 'node:path';
|
|
13
15
|
import { createSpinner } from './lib/spinner.mjs';
|
|
14
16
|
import { createSession } from './lib/banner.mjs';
|
|
15
17
|
import { classifyError } from './lib/error-classifier.mjs';
|
|
@@ -20,6 +22,7 @@ import { handleUnknownFlags } from './lib/arg-validator.mjs';
|
|
|
20
22
|
import { isLicensed, showUpgradePrompt, revalidateIfStale } from './lib/license.mjs';
|
|
21
23
|
import { readCliToken } from './lib/cli-auth.mjs';
|
|
22
24
|
import { apiBase } from './lib/api-utils.mjs';
|
|
25
|
+
import { stripAnsi, bold, cyan, dim, red, green } from './lib/ansi.mjs';
|
|
23
26
|
|
|
24
27
|
const DEFAULT_STATUSES = ['In Progress', 'Code Review', 'QA'];
|
|
25
28
|
|
|
@@ -74,7 +77,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
74
77
|
|
|
75
78
|
const validatedArgs = await handleUnknownFlags(
|
|
76
79
|
args,
|
|
77
|
-
['--help', '-h', '--static', '--plain', '--styled', '--profile=', '--stale=', '--status=', '--assignee=', '--sprint=', '--export=', '--digest', '--push', '--share'],
|
|
80
|
+
['--help', '-h', '--static', '--plain', '--styled', '--profile=', '--stale=', '--status=', '--assignee=', '--sprint=', '--export=', '--digest', '--push', '--share', '--all', '--save='],
|
|
78
81
|
{ hints: ['--depth=', '--no-attachments', '--no-cache'] } // fetch-only flags — shown as hints, not applied
|
|
79
82
|
);
|
|
80
83
|
if (validatedArgs === null) { process.exitCode = 1; return; }
|
|
@@ -93,6 +96,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
93
96
|
const digestFlag = args.includes('--digest');
|
|
94
97
|
const pushFlag = args.includes('--push');
|
|
95
98
|
const shareFlag = args.includes('--share');
|
|
99
|
+
const allFlag = args.includes('--all');
|
|
100
|
+
const saveArg = args.find(a => a.startsWith('--save='))?.split('=').slice(1).join('=') ?? null;
|
|
96
101
|
|
|
97
102
|
if (exportArg && exportArg !== 'csv' && exportArg !== 'json') {
|
|
98
103
|
process.stderr.write(`Error: --export must be csv or json, got: ${exportArg}\n`);
|
|
@@ -103,6 +108,111 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
103
108
|
const licensedFn = opts.isLicensed ?? isLicensed;
|
|
104
109
|
const upgradeFn = opts.showUpgradePrompt ?? showUpgradePrompt;
|
|
105
110
|
|
|
111
|
+
// --save=FILE: Pro gate + validate path is not a directory
|
|
112
|
+
if (saveArg) {
|
|
113
|
+
if (!licensedFn('pro', configDir)) {
|
|
114
|
+
upgradeFn('pro', '--save');
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const resolvedSave = resolvePath(saveArg);
|
|
119
|
+
try {
|
|
120
|
+
if (statSync(resolvedSave).isDirectory()) {
|
|
121
|
+
process.stderr.write(`Error: --save path must be a file, not a directory: ${resolvedSave}\n`);
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
} catch { /* doesn't exist yet — ok */ }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --all: triage all configured profiles in parallel with live status block
|
|
129
|
+
if (allFlag) {
|
|
130
|
+
if (!licensedFn('pro', configDir)) {
|
|
131
|
+
upgradeFn('pro', '--all');
|
|
132
|
+
process.exitCode = 1;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const profilesConfig = loadProfiles(configDir);
|
|
136
|
+
const profileNames = profilesConfig?.profiles ? Object.keys(profilesConfig.profiles) : [];
|
|
137
|
+
if (profileNames.length === 0) {
|
|
138
|
+
process.stderr.write('Error: No profiles configured. Run `ticketlens init` first.\n');
|
|
139
|
+
process.exitCode = 1;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const usePlain = args.includes('--plain');
|
|
144
|
+
const printFn = opts.print ?? ((s) => process.stdout.write(s));
|
|
145
|
+
const argsBase = args.filter(a => a !== '--all' && !a.startsWith('--profile='));
|
|
146
|
+
// --static disables interactive mode in sub-runs (isTTY is still true but we're capturing output)
|
|
147
|
+
const modeFlags = usePlain ? ['--plain'] : ['--styled', '--static'];
|
|
148
|
+
const isTTY = process.stderr.isTTY;
|
|
149
|
+
const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
150
|
+
const COL = Math.max(...profileNames.map(n => n.length)) + 2;
|
|
151
|
+
|
|
152
|
+
// Per-profile state
|
|
153
|
+
const entries = profileNames.map(name => ({ name, state: 'pending', output: [], summary: '' }));
|
|
154
|
+
|
|
155
|
+
const statusLine = (e, frame = 0) => {
|
|
156
|
+
const nameCol = e.name.padEnd(COL);
|
|
157
|
+
if (e.state === 'pending') return ` ${SPIN[frame % SPIN.length]} ${nameCol}${dim('fetching...')}`;
|
|
158
|
+
if (e.state === 'done') return ` ${green('✔')} ${nameCol}${dim(e.summary)}`;
|
|
159
|
+
return ` ${red('✗')} ${nameCol}${dim('could not connect')}`;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Suppress all sub-run stderr so banners/spinners don't corrupt the status block.
|
|
163
|
+
// Status updates bypass suppression via `toStatus` which holds the original reference.
|
|
164
|
+
const toStatus = isTTY ? process.stderr.write.bind(process.stderr) : null;
|
|
165
|
+
if (isTTY) {
|
|
166
|
+
process.stderr.write = () => true; // silence sub-runs
|
|
167
|
+
toStatus('\x1b[?25l');
|
|
168
|
+
for (const e of entries) toStatus(statusLine(e) + '\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let frame = 0;
|
|
172
|
+
const timer = isTTY ? setInterval(() => {
|
|
173
|
+
frame++;
|
|
174
|
+
toStatus(`\x1b[${entries.length}A`);
|
|
175
|
+
for (const e of entries) toStatus(`\r\x1b[K${statusLine(e, frame)}\n`);
|
|
176
|
+
}, 80) : null;
|
|
177
|
+
|
|
178
|
+
await Promise.allSettled(entries.map((e, idx) =>
|
|
179
|
+
run([...argsBase, `--profile=${e.name}`, ...modeFlags],
|
|
180
|
+
{ ...opts, env, fetcher, configDir, print: s => e.output.push(s) })
|
|
181
|
+
.then(() => {
|
|
182
|
+
const combined = e.output.join('');
|
|
183
|
+
const m = combined.match(/(\d+) found/);
|
|
184
|
+
entries[idx].state = e.output.length === 0 ? 'error' : 'done';
|
|
185
|
+
entries[idx].summary = m ? `${m[1]} ticket${m[1] === '1' ? '' : 's'}` : 'done';
|
|
186
|
+
})
|
|
187
|
+
.catch(() => { entries[idx].state = 'error'; })
|
|
188
|
+
));
|
|
189
|
+
|
|
190
|
+
if (isTTY) {
|
|
191
|
+
clearInterval(timer);
|
|
192
|
+
toStatus(`\x1b[${entries.length}A`);
|
|
193
|
+
for (const e of entries) toStatus(`\r\x1b[K${statusLine(e)}\n`);
|
|
194
|
+
toStatus('\x1b[?25h\n');
|
|
195
|
+
process.stderr.write = toStatus; // restore
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Render results in profile order
|
|
199
|
+
for (const e of entries) {
|
|
200
|
+
printFn(usePlain
|
|
201
|
+
? `\n── ${e.name} ──\n`
|
|
202
|
+
: `\n${dim('──')} ${bold(cyan(e.name))} ${dim('──')}\n`);
|
|
203
|
+
|
|
204
|
+
if (e.output.length === 0) {
|
|
205
|
+
const hint = `ticketlens triage --profile=${e.name}`;
|
|
206
|
+
printFn(usePlain
|
|
207
|
+
? ` [could not connect — run: ${hint}]\n`
|
|
208
|
+
: ` ${red('✗')} ${dim('Could not connect — run')} ${cyan(hint)} ${dim('for details')}\n`);
|
|
209
|
+
} else {
|
|
210
|
+
printFn(e.output.join(''));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
106
216
|
// Team-tier gate: --assignee and --sprint require a Team license
|
|
107
217
|
if ((assigneeArg || sprintArg) && !licensedFn('team', configDir)) {
|
|
108
218
|
upgradeFn('team', assigneeArg ? '--assignee' : '--sprint');
|
|
@@ -283,11 +393,17 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
283
393
|
process.stderr.write(`Viewing ${assigneeName}'s tickets\n\n`);
|
|
284
394
|
}
|
|
285
395
|
|
|
286
|
-
const scored = tickets.map(t => scoreAttention(t, effectiveUser, { staleDays }));
|
|
287
|
-
const actionable = scored.filter(s => s.urgency !== 'clear');
|
|
396
|
+
const scored = tickets.map(t => scoreAttention(t, effectiveUser, { staleDays, customRules: conn.attentionRules }));
|
|
397
|
+
const actionable = scored.filter(s => s.urgency !== 'clear' && s.urgency !== 'ignore');
|
|
288
398
|
const sorted = sortByUrgency(actionable);
|
|
289
399
|
const rawTicketMap = new Map(tickets.map(t => [t.key, t]));
|
|
290
400
|
|
|
401
|
+
// Always save a daily snapshot for history tracking (non-fatal)
|
|
402
|
+
try {
|
|
403
|
+
const { saveTriageSnapshot } = await import('./lib/triage-history.mjs');
|
|
404
|
+
saveTriageSnapshot(scored, { profile: profileName ?? 'default', configDir });
|
|
405
|
+
} catch { /* non-fatal */ }
|
|
406
|
+
|
|
291
407
|
// --digest: POST scored results to the digest backend endpoint
|
|
292
408
|
if (digestFlag) {
|
|
293
409
|
if (!licensedFn('pro', configDir)) {
|
|
@@ -298,12 +414,11 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
298
414
|
const deliverer = opts.digestDeliverer ?? defaultDigestDeliverer;
|
|
299
415
|
const digestCliToken = opts.cliToken ?? readCliToken(configDir) ?? null;
|
|
300
416
|
|
|
301
|
-
// Triage history delta (non-fatal —
|
|
417
|
+
// Triage history delta (non-fatal — snapshot already saved above)
|
|
302
418
|
let delta = null;
|
|
303
419
|
try {
|
|
304
|
-
const {
|
|
420
|
+
const { loadYesterdaySnapshot, diffSnapshots, buildDeltaSection } =
|
|
305
421
|
await import('./lib/triage-history.mjs');
|
|
306
|
-
saveTriageSnapshot(sorted, { profile: profileName ?? 'default', configDir });
|
|
307
422
|
const yesterday = loadYesterdaySnapshot({ profile: profileName ?? 'default', configDir });
|
|
308
423
|
if (yesterday) {
|
|
309
424
|
const deltas = diffSnapshots(sorted, yesterday);
|
|
@@ -314,11 +429,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
314
429
|
await deliverer({
|
|
315
430
|
profile: profileName ?? 'default',
|
|
316
431
|
staleDays,
|
|
317
|
-
summary: {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
432
|
+
summary: (() => {
|
|
433
|
+
let needsResponse = 0, aging = 0;
|
|
434
|
+
for (const t of sorted) {
|
|
435
|
+
if (t.urgency === 'needs-response') needsResponse++;
|
|
436
|
+
else if (t.urgency === 'aging') aging++;
|
|
437
|
+
}
|
|
438
|
+
return { total: sorted.length, needsResponse, aging };
|
|
439
|
+
})(),
|
|
322
440
|
tickets: sorted,
|
|
323
441
|
delta,
|
|
324
442
|
}, { cliToken: digestCliToken });
|
|
@@ -372,7 +490,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
372
490
|
}
|
|
373
491
|
|
|
374
492
|
// Interactive mode: TTY + not --plain + not --static
|
|
375
|
-
const wantInteractive = process.stdout.isTTY && !args.includes('--plain') && !args.includes('--static');
|
|
493
|
+
const wantInteractive = process.stdout.isTTY && !args.includes('--plain') && !args.includes('--static') && !saveArg;
|
|
376
494
|
if (wantInteractive && process.stdin.setRawMode) {
|
|
377
495
|
const result = await runInteractiveList(sorted, { baseUrl: conn.baseUrl, staleDays, styled: true });
|
|
378
496
|
if (result === 'switch') {
|
|
@@ -386,7 +504,17 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
386
504
|
const summary = useStyled
|
|
387
505
|
? styleTriageSummary(sorted, { styled: true, staleDays, baseUrl: conn.baseUrl })
|
|
388
506
|
: assembleTriageSummary(sorted, { staleDays, baseUrl: conn.baseUrl });
|
|
389
|
-
|
|
507
|
+
|
|
508
|
+
// --save=FILE: write ANSI-stripped output to file
|
|
509
|
+
if (saveArg) {
|
|
510
|
+
const resolvedSave = resolvePath(saveArg);
|
|
511
|
+
mkdirSync(dirname(resolvedSave), { recursive: true });
|
|
512
|
+
const plain = stripAnsi(summary);
|
|
513
|
+
writeFileSync(resolvedSave, plain + '\n', 'utf8');
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const printFn = opts.print ?? ((s) => process.stdout.write(s));
|
|
517
|
+
printFn(summary + '\n');
|
|
390
518
|
}
|
|
391
519
|
|
|
392
520
|
// Run if invoked directly
|
|
@@ -85,3 +85,6 @@ export const brightYellow = defaultStyler.brightYellow;
|
|
|
85
85
|
export const brightCyan = defaultStyler.brightCyan;
|
|
86
86
|
export const brand = defaultStyler.brand;
|
|
87
87
|
export const isStyled = () => defaultStyler.enabled;
|
|
88
|
+
|
|
89
|
+
export const ANSI_RE = /\[[0-9;]*m/g;
|
|
90
|
+
export function stripAnsi(s) { return s.replace(ANSI_RE, ''); }
|
|
@@ -75,8 +75,42 @@ export function findLastEffectiveComment(comments, currentUser) {
|
|
|
75
75
|
return { comment: null, fromCurrentUser: false };
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Apply a single custom rule against a ticket.
|
|
80
|
+
* Returns 'force-urgent' | 'ignore' | null.
|
|
81
|
+
* A rule must have a valid `match` object and a known `action` to fire.
|
|
82
|
+
*/
|
|
83
|
+
function applyCustomRule(ticket, rule) {
|
|
84
|
+
if (!rule || typeof rule.match !== 'object' || !rule.match) return null;
|
|
85
|
+
if (rule.action !== 'force-urgent' && rule.action !== 'ignore') return null;
|
|
86
|
+
|
|
87
|
+
const { priority, label, status, keyPrefix } = rule.match;
|
|
88
|
+
if (priority !== undefined && ticket.priority !== priority) return null;
|
|
89
|
+
if (status !== undefined && ticket.status !== status) return null;
|
|
90
|
+
if (label !== undefined) {
|
|
91
|
+
const labels = Array.isArray(ticket.labels) ? ticket.labels : [];
|
|
92
|
+
if (!labels.includes(label)) return null;
|
|
93
|
+
}
|
|
94
|
+
if (keyPrefix !== undefined && !String(ticket.key).startsWith(keyPrefix)) return null;
|
|
95
|
+
|
|
96
|
+
return rule.action;
|
|
97
|
+
}
|
|
98
|
+
|
|
78
99
|
export function scoreAttention(ticket, currentUser, opts = {}) {
|
|
79
|
-
const { staleDays = 5, now = new Date() } = opts;
|
|
100
|
+
const { staleDays = 5, now = new Date(), customRules } = opts;
|
|
101
|
+
|
|
102
|
+
// Custom rules evaluated first — first match wins
|
|
103
|
+
if (Array.isArray(customRules)) {
|
|
104
|
+
for (const rule of customRules) {
|
|
105
|
+
const action = applyCustomRule(ticket, rule);
|
|
106
|
+
if (action === 'ignore') {
|
|
107
|
+
return { ticketKey: ticket.key, summary: ticket.summary, status: ticket.status, urgency: 'ignore', reason: rule.reason ?? 'Ignored by custom rule', lastComment: null };
|
|
108
|
+
}
|
|
109
|
+
if (action === 'force-urgent') {
|
|
110
|
+
return { ticketKey: ticket.key, summary: ticket.summary, status: ticket.status, urgency: 'needs-response', reason: rule.reason ?? 'Flagged by custom rule', lastComment: null };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
80
114
|
|
|
81
115
|
const { comment: lastComment, fromCurrentUser } = findLastEffectiveComment(
|
|
82
116
|
ticket.comments || [], currentUser
|
|
@@ -145,8 +179,8 @@ export function sortByUrgency(scores) {
|
|
|
145
179
|
const orderDiff = URGENCY_ORDER[a.urgency] - URGENCY_ORDER[b.urgency];
|
|
146
180
|
if (orderDiff !== 0) return orderDiff;
|
|
147
181
|
// Within same urgency, sort by most recent activity (lastComment date or ticket updated)
|
|
148
|
-
const dateA = a.lastComment?.created ? new Date(a.lastComment.created) :
|
|
149
|
-
const dateB = b.lastComment?.created ? new Date(b.lastComment.created) :
|
|
182
|
+
const dateA = a.lastComment?.created ? new Date(a.lastComment.created).getTime() : 0;
|
|
183
|
+
const dateB = b.lastComment?.created ? new Date(b.lastComment.created).getTime() : 0;
|
|
150
184
|
return dateB - dateA; // most recent first
|
|
151
185
|
});
|
|
152
186
|
}
|
|
@@ -110,6 +110,10 @@ export function parseCommand(args) {
|
|
|
110
110
|
return { command: 'collisions', args: args.slice(1) };
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
if (first === 'history') {
|
|
114
|
+
return { command: 'history', args: args.slice(1) };
|
|
115
|
+
}
|
|
116
|
+
|
|
113
117
|
// Anything that looks like a ticket key or any non-flag arg → fetch
|
|
114
118
|
return { command: 'fetch', args };
|
|
115
119
|
}
|
|
@@ -42,6 +42,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
42
42
|
` ${s.brand('ticketlens')} review ${s.dim('[--branch=BRANCH]')} Code-review context brief from current branch`,
|
|
43
43
|
` ${s.brand('ticketlens')} standup ${s.dim('[--since=N]')} Standup summary from git log ${s.dim('(last 24h by default)')}`,
|
|
44
44
|
` ${s.brand('ticketlens')} compliance ${s.dim('<TICKET-KEY>')} Check requirements coverage ${s.dim('[Pro/Free 3/mo]')}`,
|
|
45
|
+
` ${s.brand('ticketlens')} history ${s.dim('<TICKET-KEY>')} Urgency timeline for a ticket ${s.dim('[Pro]')}`,
|
|
45
46
|
'',
|
|
46
47
|
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
|
|
47
48
|
` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
|
|
@@ -79,6 +80,8 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
79
80
|
` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
|
|
80
81
|
` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
|
|
81
82
|
` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
|
|
83
|
+
` ${s.brand('--all')} Triage all configured profiles at once ${s.dim('[Pro]')}`,
|
|
84
|
+
` ${s.brand('--save')}=${s.dim('FILE')} Save ANSI-stripped output to file ${s.dim('[Pro]')}`,
|
|
82
85
|
` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
|
|
83
86
|
` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
|
|
84
87
|
` ${s.brand('--plain')} Plain markdown output ${s.dim('(for piping / LLM)')}`,
|
|
@@ -549,6 +552,8 @@ export function printTriageHelp({ stream = process.stdout } = {}) {
|
|
|
549
552
|
` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
|
|
550
553
|
` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
|
|
551
554
|
` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
|
|
555
|
+
` ${s.brand('--all')} Triage all configured profiles at once ${s.dim('[Pro]')}`,
|
|
556
|
+
` ${s.brand('--save')}=${s.dim('FILE')} Save ANSI-stripped output to file ${s.dim('[Pro]')}`,
|
|
552
557
|
` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
|
|
553
558
|
` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
|
|
554
559
|
` ${s.brand('--plain')} Plain markdown output`,
|
|
@@ -231,6 +231,7 @@ export function resolveConnection(ticketKey, opts = {}) {
|
|
|
231
231
|
pat: profileCreds.pat || null,
|
|
232
232
|
triageStatuses: profile.triageStatuses || null,
|
|
233
233
|
ticketPrefixes: profile.ticketPrefixes || null,
|
|
234
|
+
attentionRules: profile.attentionRules ?? null,
|
|
234
235
|
source: 'profile',
|
|
235
236
|
profileName: profile.name,
|
|
236
237
|
};
|
|
@@ -7,13 +7,37 @@ import { apiBase, warnIfInsecure } from './api-utils.mjs';
|
|
|
7
7
|
|
|
8
8
|
const SCHEDULE_PATH = '/v1/schedule';
|
|
9
9
|
|
|
10
|
+
// Safe path: printable ASCII, no shell metacharacters or XML special chars.
|
|
11
|
+
// Allows: letters, digits, /, ., _, -, ~, space (none of which are shell-special in this context).
|
|
12
|
+
const SAFE_PATH_RE = /^[A-Za-z0-9/._~-]+$/;
|
|
13
|
+
|
|
14
|
+
function xmlEscape(s) {
|
|
15
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function validateOutputFile(outputFile) {
|
|
19
|
+
if (!outputFile || !SAFE_PATH_RE.test(outputFile)) {
|
|
20
|
+
throw new Error(`Invalid output file path: must contain only letters, digits, /, ., _, -, ~ (no spaces or shell metacharacters)`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function validateTime(hour, minute) {
|
|
25
|
+
if (!Number.isInteger(hour) || !Number.isInteger(minute) ||
|
|
26
|
+
hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
|
27
|
+
throw new Error(`Invalid time: hour must be 0–23, minute must be 0–59`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseHHMM(time) {
|
|
32
|
+
const [hourStr, minuteStr] = time.split(':');
|
|
33
|
+
return { hour: parseInt(hourStr, 10), minute: parseInt(minuteStr, 10) };
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
/**
|
|
11
37
|
* Build a macOS LaunchAgent plist string.
|
|
12
38
|
*/
|
|
13
39
|
export function buildPlist({ hour, minute, ticketlensBin }) {
|
|
14
|
-
|
|
15
|
-
throw new Error('hour and minute must be integers');
|
|
16
|
-
}
|
|
40
|
+
validateTime(hour, minute);
|
|
17
41
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
18
42
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
19
43
|
<plist version="1.0">
|
|
@@ -68,9 +92,7 @@ export async function runScheduleWizard({
|
|
|
68
92
|
return { ok: false };
|
|
69
93
|
}
|
|
70
94
|
const { time, email, timezone } = answers;
|
|
71
|
-
const
|
|
72
|
-
const hour = parseInt(hourStr, 10);
|
|
73
|
-
const minute = parseInt(minuteStr, 10);
|
|
95
|
+
const { hour, minute } = parseHHMM(time);
|
|
74
96
|
|
|
75
97
|
try {
|
|
76
98
|
const res = await fetcher(`${apiBase()}${SCHEDULE_PATH}`, {
|
|
@@ -146,7 +168,8 @@ export async function runScheduleStop({
|
|
|
146
168
|
try { unlinkSync(plistPath); } catch { /* already removed */ }
|
|
147
169
|
} else {
|
|
148
170
|
const existing = spawnSync('crontab', ['-l'], { encoding: 'utf8' }).stdout ?? '';
|
|
149
|
-
|
|
171
|
+
// Remove both cloud-digest and local-save ticketlens cron lines
|
|
172
|
+
const updated = existing.replace(/.*ticketlens triage --(digest|'--save=).*\n?/g, '');
|
|
150
173
|
const tmp = `/tmp/ticketlens-crontab-${Date.now()}`;
|
|
151
174
|
writeFileSync(tmp, updated, 'utf8');
|
|
152
175
|
spawnSync('crontab', [tmp], { encoding: 'utf8' });
|
|
@@ -189,10 +212,93 @@ export async function runScheduleStatus({
|
|
|
189
212
|
print('\n');
|
|
190
213
|
}
|
|
191
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Build a local-only cron/plist entry that runs triage --save=FILE.
|
|
217
|
+
* No cloud auth required — purely local scheduling.
|
|
218
|
+
*/
|
|
219
|
+
export function buildLocalPlist({ hour, minute, ticketlensBin, outputFile }) {
|
|
220
|
+
const safeFile = xmlEscape(outputFile);
|
|
221
|
+
const safeBin = xmlEscape(ticketlensBin);
|
|
222
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
223
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
224
|
+
<plist version="1.0">
|
|
225
|
+
<dict>
|
|
226
|
+
<key>Label</key>
|
|
227
|
+
<string>io.ticketlens.triage-local</string>
|
|
228
|
+
<key>ProgramArguments</key>
|
|
229
|
+
<array>
|
|
230
|
+
<string>${safeBin}</string>
|
|
231
|
+
<string>triage</string>
|
|
232
|
+
<string>--save=${safeFile}</string>
|
|
233
|
+
</array>
|
|
234
|
+
<key>StartCalendarInterval</key>
|
|
235
|
+
<dict>
|
|
236
|
+
<key>Hour</key>
|
|
237
|
+
<integer>${hour}</integer>
|
|
238
|
+
<key>Minute</key>
|
|
239
|
+
<integer>${minute}</integer>
|
|
240
|
+
</dict>
|
|
241
|
+
<key>StandardOutPath</key>
|
|
242
|
+
<string>${safeFile}</string>
|
|
243
|
+
<key>StandardErrorPath</key>
|
|
244
|
+
<string>${safeFile}.err</string>
|
|
245
|
+
</dict>
|
|
246
|
+
</plist>`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function buildLocalCronLine({ hour, minute, ticketlensBin, outputFile }) {
|
|
250
|
+
// Single-quote the --save argument to prevent shell word-splitting.
|
|
251
|
+
// SAFE_PATH_RE already guarantees no single-quote chars in outputFile.
|
|
252
|
+
return `${minute} ${hour} * * * ${ticketlensBin} triage '--save=${outputFile}' >> '${outputFile}.err' 2>&1`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Set up a local-only scheduled triage (no Console auth, no cloud push).
|
|
257
|
+
* Writes a cron/LaunchAgent entry that runs `ticketlens triage --save=FILE`.
|
|
258
|
+
*/
|
|
259
|
+
export async function runScheduleLocal({
|
|
260
|
+
answers,
|
|
261
|
+
platform = osPlatform(),
|
|
262
|
+
writeLocalJob = defaultWriteLocalJob,
|
|
263
|
+
print = s => process.stdout.write(s),
|
|
264
|
+
// fetcher is accepted but intentionally ignored — no network calls
|
|
265
|
+
}) {
|
|
266
|
+
const { time, outputFile } = answers ?? {};
|
|
267
|
+
if (!time || !outputFile) {
|
|
268
|
+
print(` ${red('✗')} Local schedule requires a time and output file.\n`);
|
|
269
|
+
return { ok: false };
|
|
270
|
+
}
|
|
271
|
+
const { hour, minute } = parseHHMM(time);
|
|
272
|
+
try {
|
|
273
|
+
validateTime(hour, minute);
|
|
274
|
+
validateOutputFile(outputFile);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
print(` ${red('✗')} ${err.message}\n`);
|
|
277
|
+
return { ok: false };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const ticketlensBin = resolveTicketlensBin();
|
|
281
|
+
const content = platform === 'darwin'
|
|
282
|
+
? buildLocalPlist({ hour, minute, ticketlensBin, outputFile })
|
|
283
|
+
: buildLocalCronLine({ hour, minute, ticketlensBin, outputFile });
|
|
284
|
+
|
|
285
|
+
writeLocalJob(content, platform);
|
|
286
|
+
|
|
287
|
+
print(`\n ${green('✔')} ${bold('Local triage scheduled')}\n`);
|
|
288
|
+
print(` ${dim('Time: ')} ${cyan(time)} daily\n`);
|
|
289
|
+
print(` ${dim('Output: ')} ${cyan(outputFile)}\n\n`);
|
|
290
|
+
|
|
291
|
+
return { ok: true, time, outputFile };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let _ticketlensBin;
|
|
192
295
|
function resolveTicketlensBin() {
|
|
296
|
+
if (_ticketlensBin) return _ticketlensBin;
|
|
193
297
|
const which = spawnSync('which', ['ticketlens'], { encoding: 'utf8' });
|
|
194
|
-
|
|
195
|
-
|
|
298
|
+
_ticketlensBin = (which.status === 0 && which.stdout.trim())
|
|
299
|
+
? which.stdout.trim()
|
|
300
|
+
: `${homedir()}/.npm/bin/ticketlens`;
|
|
301
|
+
return _ticketlensBin;
|
|
196
302
|
}
|
|
197
303
|
|
|
198
304
|
function defaultWriteLocalJob(content, platform) {
|
|
@@ -204,7 +310,8 @@ function defaultWriteLocalJob(content, platform) {
|
|
|
204
310
|
spawnSync('launchctl', ['load', plistPath], { encoding: 'utf8' });
|
|
205
311
|
} else {
|
|
206
312
|
const existing = spawnSync('crontab', ['-l'], { encoding: 'utf8' }).stdout ?? '';
|
|
207
|
-
|
|
313
|
+
// Remove both cloud-digest and local-save ticketlens cron lines before re-adding
|
|
314
|
+
const updated = existing.replace(/.*ticketlens triage --(digest|'--save=).*\n?/g, '').trimEnd() + '\n' + content + '\n';
|
|
208
315
|
const tmp = `/tmp/ticketlens-crontab-${Date.now()}`;
|
|
209
316
|
writeFileSync(tmp, updated, 'utf8');
|
|
210
317
|
spawnSync('crontab', [tmp], { encoding: 'utf8' });
|
|
@@ -162,3 +162,71 @@ export function buildDeltaSection(deltas) {
|
|
|
162
162
|
|
|
163
163
|
return lines.join('\n') + '\n';
|
|
164
164
|
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Query the full history for a single ticket key across all dated snapshots.
|
|
168
|
+
*
|
|
169
|
+
* Reads ~/.ticketlens/triage-history/YYYY-MM-DD/*.json, finds entries matching
|
|
170
|
+
* the given ticketKey, and returns a chronologically sorted timeline.
|
|
171
|
+
* Entries with urgency oscillating in the same direction on consecutive days
|
|
172
|
+
* are flagged with `bounced: true`.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} ticketKey
|
|
175
|
+
* @param {object} [opts]
|
|
176
|
+
* @param {string} [opts.configDir]
|
|
177
|
+
* @param {object} [opts.fsModule]
|
|
178
|
+
* @returns {{ date: string, profile: string, urgency: string, status: string, reason: string, bounced: boolean }[]}
|
|
179
|
+
*/
|
|
180
|
+
export function queryTicketHistory(ticketKey, {
|
|
181
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
182
|
+
fsModule = defaultFs,
|
|
183
|
+
} = {}) {
|
|
184
|
+
const histDir = join(configDir, 'triage-history');
|
|
185
|
+
if (!fsModule.existsSync(histDir)) return [];
|
|
186
|
+
|
|
187
|
+
const dates = fsModule.readdirSync(histDir)
|
|
188
|
+
.filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d))
|
|
189
|
+
.sort(); // lexicographic = chronological for YYYY-MM-DD
|
|
190
|
+
|
|
191
|
+
const entries = [];
|
|
192
|
+
for (const date of dates) {
|
|
193
|
+
const dayDir = join(histDir, date);
|
|
194
|
+
let profileFiles;
|
|
195
|
+
try {
|
|
196
|
+
profileFiles = fsModule.readdirSync(dayDir).filter(f => f.endsWith('.json'));
|
|
197
|
+
} catch {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
for (const file of profileFiles) {
|
|
201
|
+
const profile = file.slice(0, -5); // strip .json
|
|
202
|
+
let tickets;
|
|
203
|
+
try {
|
|
204
|
+
tickets = JSON.parse(fsModule.readFileSync(join(dayDir, file), 'utf8'));
|
|
205
|
+
} catch {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const found = Array.isArray(tickets) && tickets.find(t => t.ticketKey === ticketKey);
|
|
209
|
+
if (found) {
|
|
210
|
+
entries.push({
|
|
211
|
+
date,
|
|
212
|
+
profile,
|
|
213
|
+
urgency: found.urgency ?? 'unknown',
|
|
214
|
+
status: found.status ?? '',
|
|
215
|
+
reason: found.reason ?? '',
|
|
216
|
+
bounced: false,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Detect bounces: flag entries where urgency changed direction on consecutive days
|
|
223
|
+
for (let i = 1; i < entries.length; i++) {
|
|
224
|
+
const prev = entries[i - 1];
|
|
225
|
+
const cur = entries[i];
|
|
226
|
+
if (cur.urgency !== prev.urgency && cur.profile === prev.profile) {
|
|
227
|
+
cur.bounced = true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return entries;
|
|
232
|
+
}
|