@retasc/cli 1.15.0 → 1.16.1
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/CHANGELOG.md +45 -0
- package/dist/api.js +7 -0
- package/dist/commands/import.js +206 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,51 @@ release commits and the issues they reference.
|
|
|
6
6
|
|
|
7
7
|
Dates are the npm publish date. Each entry names the RTSC issue behind it.
|
|
8
8
|
|
|
9
|
+
## 1.16.1 (2026-08-02)
|
|
10
|
+
|
|
11
|
+
- **RTSC-528** — two things the first real `retasc import` run turned up.
|
|
12
|
+
|
|
13
|
+
**The progress bar never appeared.** It only started drawing once it had seen the run
|
|
14
|
+
reported as running, and a 15-issue import finishes before that is ever observed, so the
|
|
15
|
+
output went straight from `Importing…` to `✓ Imported.` with a silent gap. It now draws
|
|
16
|
+
the moment the run starts, sweeping while it waits for counts and switching to the real
|
|
17
|
+
bar once they arrive. Still silent without a terminal and under `NO_COLOR`.
|
|
18
|
+
|
|
19
|
+
**The column prompt didn't say what to do.** It read
|
|
20
|
+
`to do [todo] (todo / doing / done / canceled):`, where nothing is a verb, so the first
|
|
21
|
+
person to run it had to guess that you type one of the words. Now:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
to do
|
|
25
|
+
Enter to keep todo, or type: doing, done, canceled
|
|
26
|
+
>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The suggestion is no longer repeated among the alternatives, which is what made the old
|
|
30
|
+
line read as four equal options behind a mysterious bracket.
|
|
31
|
+
|
|
32
|
+
## 1.16.0 (2026-08-02)
|
|
33
|
+
|
|
34
|
+
- **RTSC-526** — `retasc import` catches up with the Dash on four things.
|
|
35
|
+
|
|
36
|
+
**It warns before a second import.** Re-importing does not duplicate anything, but it
|
|
37
|
+
does re-sync: status, labels, title and body are replaced with whatever the source says
|
|
38
|
+
now. Import from Jira, spend two weeks moving issues along in Retasc, re-import to pick up
|
|
39
|
+
new tickets, and those two weeks of changes snap back. You now get told, with the date of
|
|
40
|
+
the last import, before the confirmation.
|
|
41
|
+
|
|
42
|
+
**The run shows progress**, as a bar that fills while it works, rather than a silent wait
|
|
43
|
+
that looks like it has hung. The run is server-side, so pressing Ctrl-C out of boredom
|
|
44
|
+
never stopped it anyway.
|
|
45
|
+
|
|
46
|
+
**The column list is grouped** under your own tool's sections (Not started, Active, Done,
|
|
47
|
+
Closed) instead of arriving flat. On a twenty-column board that is the difference between
|
|
48
|
+
scannable and a wall.
|
|
49
|
+
|
|
50
|
+
**Columns that were not mapped are named.** If a column appears between the moment you set
|
|
51
|
+
the mapping and the moment the import runs, its issues land in `todo`. They used to show
|
|
52
|
+
as a number; now they are listed, with what to do about it.
|
|
53
|
+
|
|
9
54
|
## 1.15.0 (2026-08-02)
|
|
10
55
|
|
|
11
56
|
- **RTSC-524** — new command: `retasc import`. Bring a Linear, Jira, Asana, ClickUp or
|
package/dist/api.js
CHANGED
|
@@ -35,6 +35,11 @@ const fns = {
|
|
|
35
35
|
listImportStatuses: makeFunctionReference("import:listImportStatuses"),
|
|
36
36
|
listReviewCandidates: makeFunctionReference("import:listReviewCandidates"),
|
|
37
37
|
runImport: makeFunctionReference("import:runImport"),
|
|
38
|
+
// RTSC-526: `latestImport` is LIVE progress and is swept after 24h; `importHistory` is
|
|
39
|
+
// permanent and per source, so it is the only thing that can answer "has this org
|
|
40
|
+
// imported from X before?" once the progress row is gone.
|
|
41
|
+
latestImport: makeFunctionReference("import:latestImport"),
|
|
42
|
+
importHistory: makeFunctionReference("import:importHistory"),
|
|
38
43
|
claimableGhosts: makeFunctionReference("ghosts:claimableGhosts"),
|
|
39
44
|
claimGhost: makeFunctionReference("ghosts:claimGhost"),
|
|
40
45
|
dismissGhostPrompt: makeFunctionReference("ghosts:dismissGhostPrompt"),
|
|
@@ -171,6 +176,8 @@ export const api = {
|
|
|
171
176
|
listImportStatuses: (args) => withAuth(() => client().action(fns.listImportStatuses, args)),
|
|
172
177
|
listReviewCandidates: (args) => withAuth(() => client().query(fns.listReviewCandidates, args)),
|
|
173
178
|
runImport: (args) => withAuth(() => client().action(fns.runImport, args)),
|
|
179
|
+
latestImport: (args) => withAuth(() => client().query(fns.latestImport, args)),
|
|
180
|
+
importHistory: (args) => withAuth(() => client().query(fns.importHistory, args)),
|
|
174
181
|
claimableGhosts: (args) => withAuth(() => client().query(fns.claimableGhosts, args)),
|
|
175
182
|
claimGhost: (args) => withAuth(() => client().mutation(fns.claimGhost, args)),
|
|
176
183
|
dismissGhostPrompt: (args) => withAuth(() => client().mutation(fns.dismissGhostPrompt, args)),
|
package/dist/commands/import.js
CHANGED
|
@@ -6,6 +6,104 @@ import { ask, confirm, isInteractive } from "../lib/prompt.js";
|
|
|
6
6
|
import { pickExisting } from "./bind.js";
|
|
7
7
|
/** The five destinations a column can be mapped to. Mirrors `MappedStatus`. */
|
|
8
8
|
const MAPPED = ["todo", "doing", "review", "done", "canceled"];
|
|
9
|
+
/**
|
|
10
|
+
* The source's own section names, so the list reads as the shape the human already knows
|
|
11
|
+
* from their tool. Mirrors `GROUP_LABEL` in `dash/src/lib/statusMapping.ts` (RTSC-436).
|
|
12
|
+
*/
|
|
13
|
+
const GROUP_LABEL = {
|
|
14
|
+
not_started: "Not started",
|
|
15
|
+
active: "Active",
|
|
16
|
+
done: "Done",
|
|
17
|
+
closed: "Closed",
|
|
18
|
+
};
|
|
19
|
+
/** Section order, matching the Dash. */
|
|
20
|
+
const GROUP_ORDER = ["not_started", "active", "done", "closed"];
|
|
21
|
+
/**
|
|
22
|
+
* What to say before a SECOND import into the same org (RTSC-526).
|
|
23
|
+
*
|
|
24
|
+
* Not a formality. `convex/importWrite.ts` upserts by `sourceIssueId` and patches the match,
|
|
25
|
+
* so a re-import does not duplicate — the identifier and number survive even a rename — but
|
|
26
|
+
* it DOES replace status, labels, title and body with whatever the source says now. Two
|
|
27
|
+
* weeks of moving issues along in Retasc snap back to the source's version.
|
|
28
|
+
*
|
|
29
|
+
* Read from `importHistory`, NOT `latestImport`. The latter is live progress and is swept
|
|
30
|
+
* after 24h, so gating on it means the warning stops firing for exactly the person most at
|
|
31
|
+
* risk: the one who imported last week and has been working in Retasc since.
|
|
32
|
+
*
|
|
33
|
+
* Null when this source has never been imported, so a first run says nothing.
|
|
34
|
+
*/
|
|
35
|
+
export function reimportWarning(history, source, label) {
|
|
36
|
+
const prior = history.find((h) => h.source === source);
|
|
37
|
+
if (!prior)
|
|
38
|
+
return null;
|
|
39
|
+
const when = prior.lastImportedAt
|
|
40
|
+
? ` (last on ${new Date(prior.lastImportedAt).toISOString().slice(0, 10)})`
|
|
41
|
+
: "";
|
|
42
|
+
return (`\n! You've imported from ${clean(label)} into this org before${when}.\n` +
|
|
43
|
+
` Re-importing re-syncs those issues, so any edits you made in Retasc to them\n` +
|
|
44
|
+
` (status, labels, and so on) will be replaced by ${clean(label)}'s version.`);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The summary after a run, with the one field that needs words rather than a number.
|
|
48
|
+
*
|
|
49
|
+
* `unmappedStatuses` holds columns that appeared BETWEEN the mapping being set and the run;
|
|
50
|
+
* their issues landed in `todo`. As a count it is a silent slice of work in the wrong place,
|
|
51
|
+
* which is the whole reason the Dash names them.
|
|
52
|
+
*/
|
|
53
|
+
export function summaryLines(summary, label) {
|
|
54
|
+
const out = [];
|
|
55
|
+
const unmapped = [];
|
|
56
|
+
for (const [k, v] of Object.entries(summary ?? {})) {
|
|
57
|
+
if (k === "unmappedStatuses") {
|
|
58
|
+
if (Array.isArray(v))
|
|
59
|
+
unmapped.push(...v.map(String));
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (typeof v === "number" || typeof v === "string")
|
|
63
|
+
out.push(` ${k.padEnd(20)}${clean(v)}`);
|
|
64
|
+
}
|
|
65
|
+
if (unmapped.length) {
|
|
66
|
+
out.push("", `! These ${clean(label)} statuses weren't mapped, so their issues landed in todo:`, ` ${unmapped.map((u) => clean(u)).join(", ")}`, " They appeared after you set the mapping. Re-import to place them.");
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
// --- the progress bar --------------------------------------------------------
|
|
71
|
+
/** Lime, in 256-colour. Bright enough to read on both a light and a dark terminal. */
|
|
72
|
+
const LIME = "\x1b[38;5;154m";
|
|
73
|
+
const DIM = "\x1b[38;5;240m";
|
|
74
|
+
const RESET = "\x1b[0m";
|
|
75
|
+
/**
|
|
76
|
+
* An indeterminate sweep, for the stretch before the server reports counts (RTSC-528).
|
|
77
|
+
*
|
|
78
|
+
* Pure and frame-indexed rather than time-based, so a test can pin every frame. A block
|
|
79
|
+
* that travels is the honest shape when the total is unknown: a 0% bar reads as stalled,
|
|
80
|
+
* which is the very impression this is here to prevent.
|
|
81
|
+
*/
|
|
82
|
+
export function sweep(frame, width = 24, color = true) {
|
|
83
|
+
const pos = frame % (width * 2 - 2);
|
|
84
|
+
const at = pos < width ? pos : width * 2 - 2 - pos; // bounce, so it never jumps
|
|
85
|
+
const cells = Array.from({ length: width }, (_, i) => Math.abs(i - at) <= 1 ? "\u2588" : "\u2591").join("");
|
|
86
|
+
return ` ${color ? LIME : ""}${cells}${color ? RESET : ""} working…`;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* One line of progress bar.
|
|
90
|
+
*
|
|
91
|
+
* Pure, so the shape can be tested without a terminal. Colour is opt-out via `color`, which
|
|
92
|
+
* the caller ties to a TTY and to `NO_COLOR` — a bar full of escape sequences in a piped log
|
|
93
|
+
* is worse than no bar.
|
|
94
|
+
*/
|
|
95
|
+
export function progressBar(done, total, width = 24, color = true) {
|
|
96
|
+
const known = typeof total === "number" && total > 0;
|
|
97
|
+
// Clamp: a server that reports more done than total must not print a bar wider than the
|
|
98
|
+
// terminal, or a negative one.
|
|
99
|
+
const frac = known ? Math.max(0, Math.min(1, done / total)) : 0;
|
|
100
|
+
const filled = Math.round(frac * width);
|
|
101
|
+
const bar = known
|
|
102
|
+
? `${color ? LIME : ""}${"█".repeat(filled)}${color ? DIM : ""}${"░".repeat(width - filled)}${color ? RESET : ""}`
|
|
103
|
+
: `${color ? DIM : ""}${"░".repeat(width)}${color ? RESET : ""}`;
|
|
104
|
+
const count = known ? `${Math.round(frac * 100)}% ${done}/${total}` : `${done} so far`;
|
|
105
|
+
return ` ${bar} ${count}`;
|
|
106
|
+
}
|
|
9
107
|
/**
|
|
10
108
|
* May this column be mapped to `review`?
|
|
11
109
|
*
|
|
@@ -82,15 +180,37 @@ export async function mapStatuses(statuses, reviewers, askFn) {
|
|
|
82
180
|
const statusMap = {};
|
|
83
181
|
const reviewerByStatus = {};
|
|
84
182
|
console.log(`\nWhat does each column mean? ${statuses.length} to confirm.\n` +
|
|
85
|
-
" Enter
|
|
86
|
-
|
|
87
|
-
|
|
183
|
+
" Press Enter to keep the suggested answer, or type a different one.\n" +
|
|
184
|
+
" The suggestion comes from the column's type in your tool, not its name.");
|
|
185
|
+
// Grouped under the source's own section names, in the source's own order within each
|
|
186
|
+
// (RTSC-436/526). On a twenty-column Jira board a flat list is a wall; the sections are
|
|
187
|
+
// the shape the human already has in front of them in their tool.
|
|
188
|
+
const ordered = GROUP_ORDER.flatMap((g) => {
|
|
189
|
+
const rows = statuses.filter((s) => s.group === g).sort((a, b) => a.order - b.order);
|
|
190
|
+
return rows.map((s, i) => ({ s, heading: i === 0 ? GROUP_LABEL[g] : null }));
|
|
191
|
+
});
|
|
192
|
+
for (const { s, heading } of ordered) {
|
|
193
|
+
if (heading)
|
|
194
|
+
console.log(`\n ${heading}`);
|
|
88
195
|
const allowed = MAPPED.filter((m) => m !== "review" || (reviewAllowed(s.group) && reviewers.length > 0));
|
|
89
196
|
const suggested = allowed.includes(s.suggested)
|
|
90
197
|
? s.suggested
|
|
91
198
|
: "todo";
|
|
92
199
|
for (let attempt = 0;; attempt++) {
|
|
93
|
-
|
|
200
|
+
// RTSC-528 — the prompt has to name the ACTION. It used to read
|
|
201
|
+
// `to do [todo] (todo / doing / done / canceled):`
|
|
202
|
+
// where nothing is a verb: the bracket is the default and the parenthesis is a bare
|
|
203
|
+
// word list, so the first person to run it had to guess that you type one of them.
|
|
204
|
+
// The reviewer picker two lines below says "Choose a number" and is unambiguous,
|
|
205
|
+
// which made the contrast worse.
|
|
206
|
+
//
|
|
207
|
+
// The suggestion is also removed from the alternatives — it is already what Enter
|
|
208
|
+
// does, and listing it again is what made the line read as four equal options with a
|
|
209
|
+
// mysterious bracket in front.
|
|
210
|
+
const others = allowed.filter((m) => m !== suggested);
|
|
211
|
+
const answer = (await askFn(`\n ${clean(s.name)}\n` +
|
|
212
|
+
` Enter to keep ${suggested}, or type: ${others.join(", ")}\n` +
|
|
213
|
+
` > `)).trim().toLowerCase();
|
|
94
214
|
const choice = answer === "" ? suggested : answer;
|
|
95
215
|
if (allowed.includes(choice)) {
|
|
96
216
|
statusMap[s.id] = choice;
|
|
@@ -119,6 +239,71 @@ export async function mapStatuses(statuses, reviewers, askFn) {
|
|
|
119
239
|
}
|
|
120
240
|
return { statusMap, reviewerByStatus };
|
|
121
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Poll `latestImport` and redraw one line until told to stop. Returns the stopper.
|
|
244
|
+
*
|
|
245
|
+
* One LINE, rewritten in place, rather than a scrolling log: the interesting number is the
|
|
246
|
+
* current one. Silent without a TTY — a progress bar in a piped log is noise, and the
|
|
247
|
+
* escape codes would end up in whatever reads it.
|
|
248
|
+
*
|
|
249
|
+
* Every failure here is swallowed. This is decoration on top of a run that is happening
|
|
250
|
+
* server-side regardless; a hiccup in the progress query must never be what surfaces as an
|
|
251
|
+
* import failure.
|
|
252
|
+
*/
|
|
253
|
+
function followProgress(orgId) {
|
|
254
|
+
if (!stdout.isTTY)
|
|
255
|
+
return () => { };
|
|
256
|
+
const color = !process.env.NO_COLOR;
|
|
257
|
+
let stopped = false;
|
|
258
|
+
let frame = 0;
|
|
259
|
+
// Declared before the paint timer that reads it. Safe either way (the callback fires
|
|
260
|
+
// after this line runs), but reading a variable above its declaration is a trap to leave
|
|
261
|
+
// for the next person.
|
|
262
|
+
let latest = null;
|
|
263
|
+
// RTSC-528 — DRAW IMMEDIATELY, and keep drawing, rather than waiting to observe
|
|
264
|
+
// `status === "running"`.
|
|
265
|
+
//
|
|
266
|
+
// The first live run showed nothing at all: a 15-issue import is over in a couple of
|
|
267
|
+
// seconds, so the first poll landed before `runImport` had created the row and the second
|
|
268
|
+
// landed after it finished. `running` was never seen, so nothing was ever drawn — and the
|
|
269
|
+
// silent gap this exists to remove was exactly what the human got. A bar that only works
|
|
270
|
+
// on slow imports is a bar nobody sees while testing.
|
|
271
|
+
//
|
|
272
|
+
// So the render loop is independent of the data: it ticks on its own, showing an
|
|
273
|
+
// indeterminate sweep until counts arrive and the real bar once they do.
|
|
274
|
+
const draw = (done, total) => {
|
|
275
|
+
stdout.write(`\r\x1b[2K${done === null ? sweep(frame++, 24, color) : progressBar(done, total, 24, color)}`);
|
|
276
|
+
};
|
|
277
|
+
draw(null, null);
|
|
278
|
+
const paint = setInterval(() => {
|
|
279
|
+
if (!stopped && latest === null)
|
|
280
|
+
draw(null, null);
|
|
281
|
+
}, 120);
|
|
282
|
+
// Counts, whenever the server has them. Every failure is swallowed: this is decoration
|
|
283
|
+
// on a run happening regardless, and must never be what surfaces as an import failure.
|
|
284
|
+
const poll = async () => {
|
|
285
|
+
while (!stopped) {
|
|
286
|
+
try {
|
|
287
|
+
const p = (await api.latestImport({ orgId }));
|
|
288
|
+
if (!stopped && p && p.status === "running") {
|
|
289
|
+
latest = { done: p.issuesDone ?? 0, total: p.issuesTotal ?? null };
|
|
290
|
+
draw(latest.done, latest.total);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
/* progress never speaks for the run */
|
|
295
|
+
}
|
|
296
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
void poll();
|
|
300
|
+
return () => {
|
|
301
|
+
stopped = true;
|
|
302
|
+
clearInterval(paint);
|
|
303
|
+
// Clear the line so the summary never lands on a half-drawn bar.
|
|
304
|
+
stdout.write("\r\x1b[2K");
|
|
305
|
+
};
|
|
306
|
+
}
|
|
122
307
|
export async function importAction(opts) {
|
|
123
308
|
if (!isInteractive() && !opts.yes) {
|
|
124
309
|
cliError("NEEDS_TERMINAL", "Importing asks what each of your columns means, so it needs a terminal.", "Run it interactively, or use the Dash.");
|
|
@@ -182,12 +367,24 @@ export async function importAction(opts) {
|
|
|
182
367
|
// undoing a mistake means an org-level cleanup.
|
|
183
368
|
console.log(`\nAbout to import ${clean(target.name)} from ${clean(src.label)} into org ${orgLabel},\n` +
|
|
184
369
|
`as a new project. Issues, comments and their authors come across.`);
|
|
370
|
+
// RTSC-526 — the second run is the dangerous one, and the terminal is where a command
|
|
371
|
+
// gets re-run casually. Read from `importHistory` (permanent) rather than `latestImport`
|
|
372
|
+
// (live progress, swept after 24h), so this still fires for someone who imported last
|
|
373
|
+
// week and has been working in Retasc since — the person with the most to lose.
|
|
374
|
+
const history = (await api.importHistory({ orgId: orgId }));
|
|
375
|
+
const warning = reimportWarning(history, src.source, src.label);
|
|
376
|
+
if (warning)
|
|
377
|
+
console.log(warning);
|
|
185
378
|
if (!opts.yes && !(await confirm("This can't be undone. Go ahead?"))) {
|
|
186
379
|
console.log("Nothing imported.");
|
|
187
380
|
return;
|
|
188
381
|
}
|
|
189
382
|
// --- run -------------------------------------------------------------------
|
|
190
|
-
console.log("\nImporting
|
|
383
|
+
console.log("\nImporting…\n");
|
|
384
|
+
// RTSC-526 — show it moving. The run is server-side and can take minutes on a big
|
|
385
|
+
// project; a silent wait is exactly when someone decides it has hung and presses Ctrl-C,
|
|
386
|
+
// which is the worst moment to do it because the server keeps going regardless.
|
|
387
|
+
const stop = followProgress(orgId);
|
|
191
388
|
let res;
|
|
192
389
|
try {
|
|
193
390
|
res = (await api.runImport({
|
|
@@ -200,6 +397,7 @@ export async function importAction(opts) {
|
|
|
200
397
|
}));
|
|
201
398
|
}
|
|
202
399
|
catch (e) {
|
|
400
|
+
stop();
|
|
203
401
|
// The run is server-side, so a dead connection here does NOT mean a dead import.
|
|
204
402
|
// Saying "failed" would be a guess, and the wrong one sends someone re-importing on
|
|
205
403
|
// top of a run that is still writing.
|
|
@@ -211,10 +409,10 @@ export async function importAction(opts) {
|
|
|
211
409
|
console.error(" Check the Dash before running it again.");
|
|
212
410
|
throw e;
|
|
213
411
|
}
|
|
412
|
+
stop();
|
|
214
413
|
console.log("\n✓ Imported.");
|
|
215
|
-
for (const
|
|
216
|
-
|
|
217
|
-
console.log(` ${k.padEnd(18)}${clean(v)}`);
|
|
414
|
+
for (const line of summaryLines(res.summary, src.label)) {
|
|
415
|
+
console.log(line);
|
|
218
416
|
}
|
|
219
417
|
// The import just minted a placeholder for whoever authored that work, and the person
|
|
220
418
|
// who ran it is very often one of them. `identityLoop` is the same prompt `join` uses and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.1",
|
|
4
4
|
"description": "Retasc CLI \u2014 the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|