@bli-cockpit/cli 0.2.241 → 0.2.243
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/dist/adapters/git-state.js +1 -0
- package/dist/adapters/raw-evidence-collection-state.js +22 -11
- package/dist/adapters/raw-evidence-git-diff.js +1 -0
- package/dist/agent-rules.js +8 -0
- package/dist/autostart-contract.js +12 -0
- package/dist/autostart-darwin-plist.js +2 -2
- package/dist/autostart-darwin.js +1 -1
- package/dist/autostart-remind.js +245 -0
- package/dist/autostart-self-heal.js +17 -1
- package/dist/autostart-windows-scripts.js +7 -7
- package/dist/autostart.js +16 -15
- package/dist/commands/approvals-check-where.js +14 -0
- package/dist/commands/approvals-render.js +8 -1
- package/dist/commands/approvals.js +3 -2
- package/dist/commands/autostart-command.js +10 -0
- package/dist/commands/autostart-heal.js +36 -1
- package/dist/commands/brain.js +1 -1
- package/dist/commands/cal-agenda.js +44 -0
- package/dist/commands/cal-availability-share.js +97 -0
- package/dist/commands/cal-booking.js +147 -0
- package/dist/commands/cal-join.js +62 -0
- package/dist/commands/cal-links.js +68 -0
- package/dist/commands/cal-people.js +136 -0
- package/dist/commands/cal-remind.js +176 -0
- package/dist/commands/cal-reminders.js +33 -0
- package/dist/commands/cal-suggest.js +89 -0
- package/dist/commands/cal.js +41 -1
- package/dist/commands/cycle-args.js +127 -0
- package/dist/commands/cycles.js +117 -0
- package/dist/commands/docs-comments-args.js +85 -0
- package/dist/commands/docs-comments.js +128 -0
- package/dist/commands/docs-files-args.js +49 -0
- package/dist/commands/docs-files.js +179 -0
- package/dist/commands/docs-formatting.js +45 -0
- package/dist/commands/docs-inbox-args.js +93 -0
- package/dist/commands/docs-inbox.js +126 -0
- package/dist/commands/docs-notion.js +5 -0
- package/dist/commands/docs-read.js +2 -1
- package/dist/commands/docs-recovery.js +173 -0
- package/dist/commands/docs-refs-args.js +104 -0
- package/dist/commands/docs-refs.js +153 -0
- package/dist/commands/docs-search-args.js +79 -0
- package/dist/commands/docs-search.js +64 -0
- package/dist/commands/docs-sync-args.js +82 -0
- package/dist/commands/docs-sync.js +197 -0
- package/dist/commands/docs-tree.js +136 -0
- package/dist/commands/docs.js +85 -51
- package/dist/commands/doctor-update.js +4 -1
- package/dist/commands/inbox-args.js +81 -0
- package/dist/commands/inbox.js +88 -0
- package/dist/commands/issue-batch.js +61 -0
- package/dist/commands/issue-relations.js +151 -0
- package/dist/commands/issue-write.js +38 -5
- package/dist/commands/issue.js +32 -1
- package/dist/commands/local-args-tower-approvals.js +16 -4
- package/dist/commands/local-args-tower-cal-booking.js +81 -0
- package/dist/commands/local-args-tower-cal-links.js +42 -0
- package/dist/commands/local-args-tower-cal-remind.js +74 -0
- package/dist/commands/local-args-tower-cal-share.js +41 -0
- package/dist/commands/local-args-tower-cal.js +81 -8
- package/dist/commands/local-args-tower-docs-msg.js +98 -9
- package/dist/commands/local-args-tower-msg.js +279 -10
- package/dist/commands/local-args-tower-search.js +32 -5
- package/dist/commands/local-args-tower-work.js +110 -34
- package/dist/commands/local-args-tower.js +1 -0
- package/dist/commands/local-args.js +2 -0
- package/dist/commands/local-auth.js +1 -1
- package/dist/commands/local-help-commands-tower-ops.js +2 -0
- package/dist/commands/local-help-commands-tower.js +48 -32
- package/dist/commands/local-help-commands.js +1 -0
- package/dist/commands/local-help-cycles.js +9 -0
- package/dist/commands/local-help-docs-files.js +10 -0
- package/dist/commands/local-help-docs-formatting.js +7 -0
- package/dist/commands/local-help-docs-inbox.js +10 -0
- package/dist/commands/local-help-docs-recovery.js +10 -0
- package/dist/commands/local-help-docs-refs.js +11 -0
- package/dist/commands/local-help-docs-sync.js +9 -0
- package/dist/commands/local-help-inbox.js +9 -0
- package/dist/commands/local-help-project.js +21 -0
- package/dist/commands/local-help-templates.js +9 -0
- package/dist/commands/local-help-views.js +10 -0
- package/dist/commands/local-help-work-notify.js +6 -0
- package/dist/commands/local-help.js +4 -2
- package/dist/commands/local.js +2 -0
- package/dist/commands/mcp-stdio-probe.js +1 -0
- package/dist/commands/msg-activity.js +134 -0
- package/dist/commands/msg-channel-args.js +92 -0
- package/dist/commands/msg-channel.js +125 -0
- package/dist/commands/msg-context-args.js +67 -0
- package/dist/commands/msg-context.js +65 -0
- package/dist/commands/msg-dm.js +28 -0
- package/dist/commands/msg-draft.js +193 -0
- package/dist/commands/msg-file.js +185 -0
- package/dist/commands/msg-mark-args.js +78 -0
- package/dist/commands/msg-mark.js +148 -0
- package/dist/commands/msg-notify-args.js +100 -0
- package/dist/commands/msg-notify.js +122 -0
- package/dist/commands/msg-schedule-args.js +114 -0
- package/dist/commands/msg-schedule.js +226 -0
- package/dist/commands/msg-search-args.js +71 -0
- package/dist/commands/msg-search.js +48 -0
- package/dist/commands/msg-write.js +15 -1
- package/dist/commands/msg.js +77 -39
- package/dist/commands/project-args.js +82 -0
- package/dist/commands/project.js +71 -7
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/push.js +50 -0
- package/dist/commands/search.js +23 -0
- package/dist/commands/session-sync-failures.js +17 -4
- package/dist/commands/session-sync-health.js +7 -2
- package/dist/commands/session-sync-hold.js +42 -1
- package/dist/commands/session-sync-upload.js +2 -0
- package/dist/commands/session-sync.js +1 -0
- package/dist/commands/sync-doctor.js +14 -1
- package/dist/commands/sync-report.js +1 -1
- package/dist/commands/template-args.js +122 -0
- package/dist/commands/templates.js +162 -0
- package/dist/commands/view-args.js +131 -0
- package/dist/commands/views.js +138 -0
- package/dist/commands/work-notify-args.js +67 -0
- package/dist/commands/work-notify.js +63 -0
- package/dist/delivery-hold-notice.js +34 -0
- package/dist/log-rotation.js +2 -2
- package/dist/notifier.js +83 -0
- package/dist/process-runner.js +4 -0
- package/dist/repo-identity-git.js +1 -0
- package/package.json +4 -4
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit cal remind` - ONE tick of the laptop meeting reminder (BLI-21564, calendar slice 11).
|
|
3
|
+
*
|
|
4
|
+
* The OS scheduler runs this every minute (`com.bli.cockpit.remind` / `BLI Cockpit Remind`); there is no
|
|
5
|
+
* always-on process. Each tick is cheap and bounded:
|
|
6
|
+
*
|
|
7
|
+
* 1. Read the local cache `reminders.json` (collector state dir).
|
|
8
|
+
* 2. Handle every cached reminder whose time has come, BEFORE refreshing. A laptop that slept over the
|
|
9
|
+
* boundary wakes with a stale cache, and this order is what lets the 5-minute grace decide `shown`
|
|
10
|
+
* versus `missed` instead of the nudge silently vanishing.
|
|
11
|
+
* 3. Refresh from `/api/cal/reminders/due` at most every 5 minutes when reminders are on, every 15 when off.
|
|
12
|
+
*
|
|
13
|
+
* Handling one reminder: claim it (the server answers 409 `already_claimed` when another laptop won), then
|
|
14
|
+
* show it, then post the outcome. A claim after `start + 5 min` is recorded `missed` and shows nothing.
|
|
15
|
+
* Nothing here is silent: every branch writes one reason line to stderr (ids and counts, never a title).
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs/promises";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { getCollectorRuntimePaths } from "../local-state-paths.js";
|
|
20
|
+
import { showNotification } from "../notifier.js";
|
|
21
|
+
import { askAgentDoor, emitAgentDoor, openAgentDoor } from "./agent-door.js";
|
|
22
|
+
import { writeLine } from "./cli-io.js";
|
|
23
|
+
const TAG = "[cal remind]";
|
|
24
|
+
const GRACE_MS = 5 * 60_000;
|
|
25
|
+
const ENABLED_REFRESH_MS = 5 * 60_000;
|
|
26
|
+
const DISABLED_REFRESH_MS = 15 * 60_000;
|
|
27
|
+
const KEEP_AFTER_START_MS = 30 * 60_000;
|
|
28
|
+
const REQUEST_TIMEOUT_MS = 20_000;
|
|
29
|
+
export function cachePathFor(homeDir) {
|
|
30
|
+
return path.join(getCollectorRuntimePaths(homeDir).state_dir, "reminders.json");
|
|
31
|
+
}
|
|
32
|
+
function isReminder(value) {
|
|
33
|
+
const row = value;
|
|
34
|
+
return !!row && typeof row === "object" && typeof row.event_id === "string" && typeof row.occurrence_start === "string"
|
|
35
|
+
&& Number.isFinite(Date.parse(row.occurrence_start)) && Number.isFinite(Date.parse(row.deliver_at))
|
|
36
|
+
&& typeof row.lead_minutes === "number" && typeof row.title === "string";
|
|
37
|
+
}
|
|
38
|
+
export async function readReminderCache(cachePath, log = () => undefined) {
|
|
39
|
+
let text;
|
|
40
|
+
try {
|
|
41
|
+
text = await fs.readFile(cachePath, "utf8");
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code !== "ENOENT")
|
|
45
|
+
log(`${TAG} cache unreadable ${JSON.stringify({ reason: "read_failed", code: error.code ?? null })}`);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(text);
|
|
50
|
+
if (!Number.isFinite(Date.parse(parsed.checked_at)) || typeof parsed.settings?.enabled !== "boolean" || !Array.isArray(parsed.reminders))
|
|
51
|
+
throw new Error("shape");
|
|
52
|
+
return { ...parsed, reminders: parsed.reminders.filter(isReminder) };
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
log(`${TAG} cache discarded ${JSON.stringify({ reason: "unparseable" })}`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function writeReminderCache(cachePath, cache) {
|
|
60
|
+
await fs.mkdir(path.dirname(cachePath), { recursive: true, mode: 0o700 });
|
|
61
|
+
const temp = `${cachePath}.${process.pid}.tmp`;
|
|
62
|
+
await fs.writeFile(temp, JSON.stringify(cache), { mode: 0o600 });
|
|
63
|
+
await fs.rename(temp, cachePath);
|
|
64
|
+
}
|
|
65
|
+
function bannerText(reminder, now) {
|
|
66
|
+
const minutes = Math.round((Date.parse(reminder.occurrence_start) - now.getTime()) / 60_000);
|
|
67
|
+
if (minutes > 0)
|
|
68
|
+
return `${reminder.title} starts in ${minutes} min`;
|
|
69
|
+
return minutes === 0 ? `${reminder.title} starts now` : `${reminder.title} started ${-minutes} min ago`;
|
|
70
|
+
}
|
|
71
|
+
export async function remindTick(input) {
|
|
72
|
+
const log = input.log ?? ((line) => console.error(line));
|
|
73
|
+
const notify = input.notify ?? ((request) => showNotification(request));
|
|
74
|
+
const now = input.now;
|
|
75
|
+
const report = { refreshed: false, refresh_reason: "fresh_enough", enabled: false, due_handled: 0, shown: 0, missed: 0, permission_denied: 0, notifier_failed: 0, claimed_elsewhere: 0, retry_later: 0, cached: 0 };
|
|
76
|
+
let cache = await readReminderCache(input.cachePath, log);
|
|
77
|
+
// What this tick already handled, so a refresh that still lists it cannot nudge twice in one run.
|
|
78
|
+
const handled = new Set();
|
|
79
|
+
async function handleDue(current) {
|
|
80
|
+
const keep = [];
|
|
81
|
+
for (const reminder of current.reminders) {
|
|
82
|
+
const start = Date.parse(reminder.occurrence_start);
|
|
83
|
+
if (now.getTime() > start + KEEP_AFTER_START_MS) {
|
|
84
|
+
log(`${TAG} dropped ${JSON.stringify({ reason: "long_past", lead_minutes: reminder.lead_minutes })}`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (Date.parse(reminder.deliver_at) > now.getTime()) {
|
|
88
|
+
keep.push(reminder);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const handledKey = `${reminder.event_id}|${Date.parse(reminder.occurrence_start)}|${reminder.lead_minutes}`;
|
|
92
|
+
if (handled.has(handledKey))
|
|
93
|
+
continue;
|
|
94
|
+
handled.add(handledKey);
|
|
95
|
+
report.due_handled += 1;
|
|
96
|
+
const claim = await input.ask("POST", "/api/cal/reminders/claim", { event_id: reminder.event_id, occurrence_start: reminder.occurrence_start, lead_minutes: reminder.lead_minutes, device_label: input.deviceLabel ?? process.platform });
|
|
97
|
+
if (!claim.ok) {
|
|
98
|
+
if (claim.httpStatus === 409 && claim.reason === "already_claimed") {
|
|
99
|
+
report.claimed_elsewhere += 1;
|
|
100
|
+
log(`${TAG} skipped ${JSON.stringify({ reason: "already_claimed" })}`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (claim.httpStatus === 409) {
|
|
104
|
+
current.checked_at = new Date(0).toISOString();
|
|
105
|
+
log(`${TAG} skipped ${JSON.stringify({ reason: claim.reason })}`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (claim.httpStatus === 404) {
|
|
109
|
+
log(`${TAG} dropped ${JSON.stringify({ reason: claim.reason })}`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
report.retry_later += 1;
|
|
113
|
+
keep.push(reminder);
|
|
114
|
+
log(`${TAG} claim failed, will retry ${JSON.stringify({ reason: claim.reason, http_status: claim.httpStatus ?? null })}`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
let outcome;
|
|
118
|
+
if (now.getTime() > start + GRACE_MS) {
|
|
119
|
+
outcome = { outcome: "missed", reason: `late_${Math.round((now.getTime() - start) / 60_000)}m` };
|
|
120
|
+
report.missed += 1;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
outcome = await notify({ title: "TOWER", message: bannerText(reminder, now), sound: current.settings.sound });
|
|
124
|
+
report[outcome.outcome === "shown" ? "shown" : outcome.outcome] += 1;
|
|
125
|
+
}
|
|
126
|
+
const posted = await input.ask("POST", "/api/cal/reminders/outcome", { event_id: reminder.event_id, occurrence_start: reminder.occurrence_start, lead_minutes: reminder.lead_minutes, outcome: outcome.outcome, ...(outcome.reason ? { reason: outcome.reason } : {}) });
|
|
127
|
+
log(`${TAG} ${outcome.outcome} ${JSON.stringify({ reason: outcome.reason, lead_minutes: reminder.lead_minutes, outcome_recorded: posted.ok, ...(posted.ok ? {} : { record_error: posted.reason }) })}`);
|
|
128
|
+
}
|
|
129
|
+
current.reminders = keep;
|
|
130
|
+
}
|
|
131
|
+
if (cache)
|
|
132
|
+
await handleDue(cache);
|
|
133
|
+
const interval = cache?.settings.enabled ? ENABLED_REFRESH_MS : DISABLED_REFRESH_MS;
|
|
134
|
+
const age = cache ? now.getTime() - Date.parse(cache.checked_at) : Number.POSITIVE_INFINITY;
|
|
135
|
+
if (!cache || age >= interval || age < 0) {
|
|
136
|
+
report.refresh_reason = cache ? "interval_elapsed" : "no_cache";
|
|
137
|
+
const due = await input.ask("GET", "/api/cal/reminders/due?within_minutes=15");
|
|
138
|
+
if (due.ok) {
|
|
139
|
+
const body = due.body;
|
|
140
|
+
cache = { checked_at: now.toISOString(), settings: { enabled: body.settings?.enabled === true, lead_minutes: body.settings?.lead_minutes ?? 10, sound: body.settings?.sound !== false }, reminders: (body.reminders ?? []).filter(isReminder) };
|
|
141
|
+
report.refreshed = true;
|
|
142
|
+
await handleDue(cache);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
report.refresh_reason = `refresh_failed:${due.reason}`;
|
|
146
|
+
log(`${TAG} refresh failed ${JSON.stringify({ reason: due.reason, http_status: due.httpStatus ?? null, had_cache: cache !== null })}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (cache) {
|
|
150
|
+
report.enabled = cache.settings.enabled;
|
|
151
|
+
report.cached = cache.reminders.length;
|
|
152
|
+
await writeReminderCache(input.cachePath, cache);
|
|
153
|
+
}
|
|
154
|
+
// An idle tick (nothing due, cache still fresh) stays out of the log: the job runs every minute. Anything that
|
|
155
|
+
// decided something, refreshed, or failed says so.
|
|
156
|
+
const idle = !report.refreshed && report.due_handled === 0 && report.refresh_reason === "fresh_enough";
|
|
157
|
+
if (!idle)
|
|
158
|
+
log(`${TAG} tick ${JSON.stringify(report)}`);
|
|
159
|
+
return report;
|
|
160
|
+
}
|
|
161
|
+
/** `cockpit cal remind [--json] [--quiet]`: one tick. An unpaired machine says so and exits 0, so the scheduler is not marked failing. */
|
|
162
|
+
export async function runCalRemind(command, io) {
|
|
163
|
+
let door;
|
|
164
|
+
try {
|
|
165
|
+
door = await openAgentDoor("cal remind", command, io);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
writeLine(io.stderr, `${TAG} skipped ${JSON.stringify({ reason: "not_paired" })}`);
|
|
169
|
+
if (command.json)
|
|
170
|
+
writeLine(io.stdout, JSON.stringify({ ok: false, error: "not_paired", detail: error instanceof Error ? error.message : "no paired session" }));
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
const ask = (method, requestPath, body) => askAgentDoor(door, { method, path: requestPath, label: `cal remind ${method}`, timeoutMs: REQUEST_TIMEOUT_MS, ...(body === undefined ? {} : { body }) });
|
|
174
|
+
const report = await remindTick({ ask, cachePath: cachePathFor(command.homeDir), now: new Date(), log: (line) => writeLine(io.stderr, line) });
|
|
175
|
+
return command.json ? emitAgentDoor(door, { ok: true, ...report }) : 0;
|
|
176
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** `cockpit cal reminders [--on|--off] [--lead <min>] [--sound on|off]`, `cal snooze` and `cal dismiss` (BLI-21564): the same doors the web switch uses. */
|
|
2
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
3
|
+
import { writeLine } from "./cli-io.js";
|
|
4
|
+
const TAG = "[cal cli]";
|
|
5
|
+
const TIMEOUT_MS = 30000;
|
|
6
|
+
export async function runCalReminders(command, door) {
|
|
7
|
+
const patch = command.reminderPatch;
|
|
8
|
+
const answer = await askAgentDoor(door, patch
|
|
9
|
+
? { method: "PATCH", path: "/api/cal/reminders/settings", body: patch, label: "cal reminders", timeoutMs: TIMEOUT_MS }
|
|
10
|
+
: { method: "GET", path: "/api/cal/reminders/settings", label: "cal reminders", timeoutMs: TIMEOUT_MS });
|
|
11
|
+
if (!answer.ok)
|
|
12
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
13
|
+
if (door.json)
|
|
14
|
+
return emitAgentDoor(door, answer.body);
|
|
15
|
+
const settings = answer.body;
|
|
16
|
+
writeLine(door.io.stdout, `${patch ? "Reminders saved. " : ""}Laptop meeting reminders are ${settings.enabled ? "ON" : "OFF"}: ${settings.lead_minutes} min before, sound ${settings.sound ? "on" : "off"}${settings.saved ? "" : " (defaults, never saved)"}.`);
|
|
17
|
+
if (!settings.enabled)
|
|
18
|
+
writeLine(door.io.stdout, "Turn them on with: cockpit cal reminders --on");
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
/** `cal snooze <event id> --occurrence <iso> --minutes 10` and `cal dismiss <event id> --occurrence <iso>`. */
|
|
22
|
+
export async function runCalReminderChange(command, door) {
|
|
23
|
+
const snooze = command.action === "snooze";
|
|
24
|
+
const body = { event_id: command.subjectId, occurrence_start: command.occurrenceStart, ...(snooze ? { minutes: command.snoozeMinutes } : {}) };
|
|
25
|
+
const answer = await askAgentDoor(door, { method: "POST", path: `/api/cal/reminders/${snooze ? "snooze" : "dismiss"}`, body, label: `cal ${command.action}`, timeoutMs: TIMEOUT_MS });
|
|
26
|
+
if (!answer.ok)
|
|
27
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
28
|
+
if (door.json)
|
|
29
|
+
return emitAgentDoor(door, answer.body);
|
|
30
|
+
const done = answer.body;
|
|
31
|
+
writeLine(door.io.stdout, snooze ? `Snoozed until ${done.snoozed_until ?? "later"}.` : "Dismissed: no more reminders for this meeting.");
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
2
|
+
import { resolveLabel, readPipedBody } from "./cal-people.js";
|
|
3
|
+
import { isInteractiveStdin, writeLine } from "./cli-io.js";
|
|
4
|
+
const FAIL_TAG = "[cal cli]";
|
|
5
|
+
const REQUEST_TIMEOUT_MS = 60000;
|
|
6
|
+
const DEFAULT_WORDS = { duration_minutes: "30 minutes", hours: "09:00 to 17:00 in your zone", include_weekends: "weekends off" };
|
|
7
|
+
function fitWords(slot) {
|
|
8
|
+
if (slot.fit === "all_free")
|
|
9
|
+
return "everyone free";
|
|
10
|
+
const parts = [];
|
|
11
|
+
if (slot.soft.length)
|
|
12
|
+
parts.push(`outside working hours for ${slot.soft.join(", ")}`);
|
|
13
|
+
if (slot.unconfirmed.length)
|
|
14
|
+
parts.push(`unknown, not shared with you: ${slot.unconfirmed.join(", ")}`);
|
|
15
|
+
if (slot.own_unconfirmed)
|
|
16
|
+
parts.push("your own calendars were not fully read");
|
|
17
|
+
return parts.join("; ");
|
|
18
|
+
}
|
|
19
|
+
/** `cal suggest` (BLI-21542): ranked common free slots, the same door the web "Find a time" form calls. */
|
|
20
|
+
export async function runCalSuggest(command, door) {
|
|
21
|
+
let labels = command.peopleWith ?? [];
|
|
22
|
+
let from = command.from;
|
|
23
|
+
let to = command.to;
|
|
24
|
+
let tz = command.timeZone;
|
|
25
|
+
let account = command.accountName;
|
|
26
|
+
let duration = command.duration;
|
|
27
|
+
let hours = command.hoursRange;
|
|
28
|
+
let weekends = command.weekends;
|
|
29
|
+
let limit = command.limit;
|
|
30
|
+
if (!labels.length && !isInteractiveStdin(door.io)) {
|
|
31
|
+
const piped = await readPipedBody(door);
|
|
32
|
+
if (piped === null)
|
|
33
|
+
return failAgentDoor(door, FAIL_TAG, "invalid_body", "Pipe a JSON object like {\"with\":[\"vee@x.ca\"],\"from\":\"2026-10-19\",\"to\":\"2026-10-23\"}, or use --with.");
|
|
34
|
+
const listed = piped["with"] ?? piped["emails"];
|
|
35
|
+
labels = Array.isArray(listed) ? listed.filter((v) => typeof v === "string") : [];
|
|
36
|
+
from ??= typeof piped["from"] === "string" ? piped["from"] : undefined;
|
|
37
|
+
to ??= typeof piped["to"] === "string" ? piped["to"] : undefined;
|
|
38
|
+
tz ??= typeof piped["tz"] === "string" ? piped["tz"] : undefined;
|
|
39
|
+
account ??= typeof piped["account_id"] === "string" ? piped["account_id"] : undefined;
|
|
40
|
+
duration ??= typeof piped["duration_minutes"] === "number" ? piped["duration_minutes"] : undefined;
|
|
41
|
+
limit ??= typeof piped["limit"] === "number" ? piped["limit"] : undefined;
|
|
42
|
+
weekends ??= typeof piped["include_weekends"] === "boolean" ? piped["include_weekends"] : undefined;
|
|
43
|
+
const h = piped["hours"];
|
|
44
|
+
if (!hours && h && typeof h.start === "string" && typeof h.end === "string")
|
|
45
|
+
hours = { start: h.start, end: h.end };
|
|
46
|
+
}
|
|
47
|
+
if (!labels.length)
|
|
48
|
+
return failAgentDoor(door, FAIL_TAG, "invalid_body", "Name who to meet: --with vee@bluepearl.ca (repeat it), or a name as on the team list.");
|
|
49
|
+
if (!from || !to)
|
|
50
|
+
return failAgentDoor(door, FAIL_TAG, "invalid_body", "Give the window: --from 2026-10-19 --to 2026-10-23 (a date or an ISO instant).");
|
|
51
|
+
const emails = [];
|
|
52
|
+
for (const label of labels) {
|
|
53
|
+
const resolved = await resolveLabel(door, label);
|
|
54
|
+
if (!resolved.ok)
|
|
55
|
+
return resolved.code;
|
|
56
|
+
emails.push(resolved.email);
|
|
57
|
+
}
|
|
58
|
+
const body = {
|
|
59
|
+
emails: [...new Set(emails)], from, to, tz: tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
60
|
+
...(account ? { account_id: account } : {}), ...(duration === undefined ? {} : { duration_minutes: duration }),
|
|
61
|
+
...(hours ? { hours } : {}), ...(weekends === undefined ? {} : { include_weekends: weekends }), ...(limit === undefined ? {} : { limit }),
|
|
62
|
+
};
|
|
63
|
+
const answer = await askAgentDoor(door, { method: "POST", path: "/api/cal/suggestions", body, label: "cal suggest", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
64
|
+
if (!answer.ok)
|
|
65
|
+
return failAgentDoor(door, FAIL_TAG, answer.reason, answer.detail);
|
|
66
|
+
if (door.json)
|
|
67
|
+
return emitAgentDoor(door, answer.body);
|
|
68
|
+
const result = answer.body;
|
|
69
|
+
const zone = result.window.time_zone;
|
|
70
|
+
const out = door.io.stdout;
|
|
71
|
+
if (!result.slots.length)
|
|
72
|
+
writeLine(out, "No common free slot in this window. Widen the dates or the hours.");
|
|
73
|
+
for (const slot of result.slots) {
|
|
74
|
+
const when = new Intl.DateTimeFormat("en-GB", { timeZone: zone, weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(slot.start));
|
|
75
|
+
const others = slot.local.map((l) => `${l.email} ${l.start_local.slice(11)} (${l.time_zone})`).join(" · ");
|
|
76
|
+
writeLine(out, `${when} ${zone} · ${fitWords(slot)}${others ? ` · ${others}` : ""}`);
|
|
77
|
+
writeLine(out, ` slot: ${slot.id}`);
|
|
78
|
+
}
|
|
79
|
+
for (const person of result.people)
|
|
80
|
+
if (person.status === "unknown")
|
|
81
|
+
writeLine(out, `${person.email}: unknown (${person.reason ?? "no answer"}). Never shown as free.`);
|
|
82
|
+
const c = result.coverage;
|
|
83
|
+
if (result.defaults_used.length)
|
|
84
|
+
writeLine(out, `Tower defaults used: ${result.defaults_used.map((d) => DEFAULT_WORDS[d] ?? d).join(", ")}`);
|
|
85
|
+
for (const note of c.notes)
|
|
86
|
+
writeLine(out, note);
|
|
87
|
+
writeLine(out, `${c.slots_returned} of ${c.slots_found} slots · ${c.answered} of ${c.requested} answered, ${c.unknown} unknown · ${c.own_calendars_checked} of your calendars checked${c.own_incomplete ? " (incomplete)" : ""} · times in ${zone} · as of ${c.as_of}`);
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
package/dist/commands/cal.js
CHANGED
|
@@ -20,11 +20,20 @@
|
|
|
20
20
|
* own zone from `Intl`, because a window computed server-side would be
|
|
21
21
|
* computed in UTC and "today" would end at 5pm in Vancouver.
|
|
22
22
|
*/
|
|
23
|
-
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor
|
|
23
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
|
|
24
24
|
import { runCalAddIcal, runCalDetach, runCalShare, runCalSync } from "./cal-calendars-write.js";
|
|
25
25
|
import { runCalDetail } from "./cal-detail.js";
|
|
26
|
+
import { runCalJoin } from "./cal-join.js";
|
|
27
|
+
import { runCalContext } from "./cal-links.js";
|
|
28
|
+
import { runCalRemind } from "./cal-remind.js";
|
|
29
|
+
import { runCalReminderChange, runCalReminders } from "./cal-reminders.js";
|
|
26
30
|
import { runCalOverlay } from "./cal-overlay.js";
|
|
27
31
|
import { runCalViewSettings } from "./cal-view-settings.js";
|
|
32
|
+
import { runCalPeople } from "./cal-people.js";
|
|
33
|
+
import { runCalAvailabilityRevoke, runCalAvailabilityShare } from "./cal-availability-share.js";
|
|
34
|
+
import { runCalBookingGuest, runCalBookingPage } from "./cal-booking.js";
|
|
35
|
+
import { BOOKING_GUEST_ACTIONS } from "./local-args-tower-cal-booking.js";
|
|
36
|
+
import { runCalSuggest } from "./cal-suggest.js";
|
|
28
37
|
import { runCalDelete } from "./cal-delete.js";
|
|
29
38
|
import { detailFields, runCalRsvp, scopeFields, writeScopeFacts } from "./cal-scope.js";
|
|
30
39
|
import { runCalDirect } from "./cal-direct.js";
|
|
@@ -35,8 +44,28 @@ const TAG = "[cal cli]";
|
|
|
35
44
|
const READ_DEADLINE_MS = 30_000;
|
|
36
45
|
const WRITE_DEADLINE_MS = 60_000;
|
|
37
46
|
export async function runCal(command, io) {
|
|
47
|
+
if (BOOKING_GUEST_ACTIONS.includes(command.action))
|
|
48
|
+
return runCalBookingGuest(command, io);
|
|
49
|
+
if (command.action === "remind")
|
|
50
|
+
return runCalRemind(command, io);
|
|
38
51
|
const door = await openAgentDoor("cal", command, io);
|
|
39
52
|
switch (command.action) {
|
|
53
|
+
case "join":
|
|
54
|
+
return runCalJoin(command, door);
|
|
55
|
+
case "links":
|
|
56
|
+
case "agenda": return runCalContext(command, door);
|
|
57
|
+
case "reminders":
|
|
58
|
+
return runCalReminders(command, door);
|
|
59
|
+
case "snooze":
|
|
60
|
+
case "dismiss":
|
|
61
|
+
return runCalReminderChange(command, door);
|
|
62
|
+
case "booking-page":
|
|
63
|
+
return runCalBookingPage(command, door);
|
|
64
|
+
case "booking-slots":
|
|
65
|
+
case "book":
|
|
66
|
+
case "booking-change":
|
|
67
|
+
case "booking-cancel":
|
|
68
|
+
return runCalBookingGuest(command, io);
|
|
40
69
|
case "event":
|
|
41
70
|
case "export":
|
|
42
71
|
return runCalDetail(command, door);
|
|
@@ -50,6 +79,16 @@ export async function runCal(command, io) {
|
|
|
50
79
|
case "view-settings":
|
|
51
80
|
case "view-settings-change":
|
|
52
81
|
return runCalViewSettings(command, door);
|
|
82
|
+
case "people":
|
|
83
|
+
case "availability":
|
|
84
|
+
case "overlays":
|
|
85
|
+
return runCalPeople(command, door);
|
|
86
|
+
case "suggest":
|
|
87
|
+
return runCalSuggest(command, door);
|
|
88
|
+
case "availability-share":
|
|
89
|
+
return runCalAvailabilityShare(command, door);
|
|
90
|
+
case "availability-revoke":
|
|
91
|
+
return runCalAvailabilityRevoke(command, door);
|
|
53
92
|
case "today":
|
|
54
93
|
case "week":
|
|
55
94
|
case "next":
|
|
@@ -311,6 +350,7 @@ async function create(command, door) {
|
|
|
311
350
|
...(command.allDay ? { all_day: true } : {}),
|
|
312
351
|
...(command.attendees.length > 0 ? { attendees: command.attendees } : {}),
|
|
313
352
|
...(command.meet ? { meet: true } : {}),
|
|
353
|
+
...(command.recheck ? { recheck_availability: true } : {}),
|
|
314
354
|
...detailFields(command),
|
|
315
355
|
...(command.idempotencyKey ? { idempotency_key: command.idempotencyKey } : {}),
|
|
316
356
|
...(command.reason ? { reason: command.reason } : {}),
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit ticket cycles [list|show|create|update|map]` argument parsing (BLI-21809, P12), plus the
|
|
3
|
+
* `--cycle <ref|none>` field of `ticket create|update`. `cycles` is an `issue` action; its flags are
|
|
4
|
+
* read here so `local-args-tower-work.ts` only names them, the way `template-args.ts` does.
|
|
5
|
+
*
|
|
6
|
+
* A cycle ref is a uuid or an exact, case-insensitive name; two cycles with one name are refused by the
|
|
7
|
+
* door (`cycle_ref_ambiguous`) with their ids, never guessed.
|
|
8
|
+
*/
|
|
9
|
+
import { optionalNonEmpty, optionalPositiveInteger, optionalUrl } from "./local-arg-values.js";
|
|
10
|
+
export const CYCLE_VALUE_FLAGS = ["--starts", "--ends", "--timezone", "--phase", "--cycle"];
|
|
11
|
+
export const CYCLE_BOOLEAN_FLAGS = ["--close", "--reopen"];
|
|
12
|
+
export const CYCLE_VERBS = ["list", "show", "create", "update", "map"];
|
|
13
|
+
export const CYCLE_PHASES = ["upcoming", "active", "ended", "closed"];
|
|
14
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
15
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
16
|
+
function validDay(flag, value) {
|
|
17
|
+
if (value === undefined)
|
|
18
|
+
return undefined;
|
|
19
|
+
if (!DAY.test(value) || new Date(`${value}T12:00:00Z`).toISOString().slice(0, 10) !== value) {
|
|
20
|
+
throw new Error(`${flag} must be a calendar date as YYYY-MM-DD.`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
export function parseCycleArgs(values, rest) {
|
|
25
|
+
const first = rest[0];
|
|
26
|
+
const verb = (first === undefined ? "list" : first);
|
|
27
|
+
if (!CYCLE_VERBS.includes(verb))
|
|
28
|
+
throw new Error(`Unknown cycles command: ${first}. Try list, show, create, update or map.`);
|
|
29
|
+
const positional = rest.slice(first === undefined ? 0 : 1);
|
|
30
|
+
const needsRef = verb === "show" || verb === "update" || verb === "map";
|
|
31
|
+
if (needsRef && positional.length !== 1) {
|
|
32
|
+
throw new Error(`ticket cycles ${verb} takes exactly one ${verb === "map" ? "Linear cycle id" : "cycle id or name"}.`);
|
|
33
|
+
}
|
|
34
|
+
if (!needsRef && positional.length > 0)
|
|
35
|
+
throw new Error(`ticket cycles ${verb} does not take "${positional[0]}".`);
|
|
36
|
+
const given = (flag) => values.flags.has(flag) || values.booleans.has(flag);
|
|
37
|
+
const writes = verb === "create" || verb === "update";
|
|
38
|
+
for (const flag of ["--name", "--description", "--starts", "--ends", "--timezone"]) {
|
|
39
|
+
if (given(flag) && !writes)
|
|
40
|
+
throw new Error(`${flag} belongs to \`cockpit ticket cycles create\` and \`update\`.`);
|
|
41
|
+
}
|
|
42
|
+
if (verb !== "update" && (given("--close") || given("--reopen") || given("--expected-updated-at"))) {
|
|
43
|
+
throw new Error("--close, --reopen and --expected-updated-at belong to `cockpit ticket cycles update`.");
|
|
44
|
+
}
|
|
45
|
+
if (verb !== "list" && (given("--phase") || given("--limit") || given("--cursor"))) {
|
|
46
|
+
throw new Error("--phase, --limit and --cursor belong to `cockpit ticket cycles list`.");
|
|
47
|
+
}
|
|
48
|
+
if (given("--cycle"))
|
|
49
|
+
throw new Error("--cycle belongs to `cockpit ticket create` and `update`.");
|
|
50
|
+
if (given("--request-id") && verb !== "create" && verb !== "update" && verb !== "map") {
|
|
51
|
+
throw new Error("--request-id belongs to `cockpit ticket cycles create`, `update` and `map`.");
|
|
52
|
+
}
|
|
53
|
+
if (given("--close") && given("--reopen"))
|
|
54
|
+
throw new Error("Give --close or --reopen, not both.");
|
|
55
|
+
const name = optionalNonEmpty(values.flags.get("--name"));
|
|
56
|
+
const startsOn = validDay("--starts", optionalNonEmpty(values.flags.get("--starts")));
|
|
57
|
+
const endsOn = validDay("--ends", optionalNonEmpty(values.flags.get("--ends")));
|
|
58
|
+
const timezone = optionalNonEmpty(values.flags.get("--timezone"));
|
|
59
|
+
const rawDescription = values.flags.get("--description");
|
|
60
|
+
if (verb === "create" && (!name || !startsOn || !endsOn)) {
|
|
61
|
+
throw new Error('ticket cycles create needs --name "<name>" --starts YYYY-MM-DD --ends YYYY-MM-DD.');
|
|
62
|
+
}
|
|
63
|
+
if (startsOn && endsOn && endsOn < startsOn)
|
|
64
|
+
throw new Error("--ends must not be before --starts.");
|
|
65
|
+
const phase = optionalNonEmpty(values.flags.get("--phase"));
|
|
66
|
+
if (phase && !CYCLE_PHASES.includes(phase))
|
|
67
|
+
throw new Error(`--phase must be one of: ${CYCLE_PHASES.join(", ")}.`);
|
|
68
|
+
const closing = given("--close") || given("--reopen");
|
|
69
|
+
const fieldGiven = name !== undefined || rawDescription !== undefined || startsOn !== undefined || endsOn !== undefined || timezone !== undefined;
|
|
70
|
+
if (verb === "update" && closing && fieldGiven)
|
|
71
|
+
throw new Error("--close and --reopen travel alone; change the fields in a separate update.");
|
|
72
|
+
if (verb === "update" && !closing && !fieldGiven) {
|
|
73
|
+
throw new Error("ticket cycles update needs a field to change: --name, --description, --starts, --ends, --timezone, --close or --reopen.");
|
|
74
|
+
}
|
|
75
|
+
const expectedUpdatedAt = optionalNonEmpty(values.flags.get("--expected-updated-at"));
|
|
76
|
+
if (expectedUpdatedAt && Number.isNaN(Date.parse(expectedUpdatedAt))) {
|
|
77
|
+
throw new Error("--expected-updated-at must be the updated_at timestamp you last read.");
|
|
78
|
+
}
|
|
79
|
+
const requestId = optionalNonEmpty(values.flags.get("--request-id"));
|
|
80
|
+
if (requestId && !UUID.test(requestId))
|
|
81
|
+
throw new Error("--request-id must be a UUID.");
|
|
82
|
+
const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
|
|
83
|
+
const cursor = optionalNonEmpty(values.flags.get("--cursor"));
|
|
84
|
+
const cycles = {
|
|
85
|
+
verb,
|
|
86
|
+
...(needsRef ? { ref: positional[0] } : {}),
|
|
87
|
+
...(name ? { name } : {}),
|
|
88
|
+
...(rawDescription === undefined ? {} : { description: verb === "update" && rawDescription.trim().toLowerCase() === "none" ? null : rawDescription }),
|
|
89
|
+
...(startsOn ? { startsOn } : {}),
|
|
90
|
+
...(endsOn ? { endsOn } : {}),
|
|
91
|
+
...(timezone ? { timezone } : {}),
|
|
92
|
+
...(phase ? { phase } : {}),
|
|
93
|
+
...(limit === undefined ? {} : { limit }),
|
|
94
|
+
...(cursor ? { cursor } : {}),
|
|
95
|
+
...(given("--close") ? { closed: true } : given("--reopen") ? { closed: false } : {}),
|
|
96
|
+
...(expectedUpdatedAt ? { expectedUpdatedAt } : {}),
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
kind: "issue",
|
|
100
|
+
action: "cycles",
|
|
101
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
102
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
103
|
+
...(requestId ? { requestId } : {}),
|
|
104
|
+
json: values.booleans.has("--json"),
|
|
105
|
+
cycles,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* `ticket create|update --cycle <name|id|none>`: `none` clears (update only). Any cycle-only flag on another
|
|
110
|
+
* ticket verb is refused here, so nothing is silently ignored.
|
|
111
|
+
*/
|
|
112
|
+
export function readCycleField(action, values) {
|
|
113
|
+
const stray = [...CYCLE_VALUE_FLAGS.filter((f) => f !== "--cycle"), ...CYCLE_BOOLEAN_FLAGS].find((f) => values.flags.has(f) || values.booleans.has(f));
|
|
114
|
+
if (stray)
|
|
115
|
+
throw new Error(`${stray} belongs to \`cockpit ticket cycles\`.`);
|
|
116
|
+
const cycle = optionalNonEmpty(values.flags.get("--cycle"));
|
|
117
|
+
if (cycle === undefined)
|
|
118
|
+
return {};
|
|
119
|
+
if (action !== "create" && action !== "update")
|
|
120
|
+
throw new Error("--cycle belongs to `cockpit ticket create` and `update`.");
|
|
121
|
+
if (cycle.toLowerCase() === "none") {
|
|
122
|
+
if (action === "create")
|
|
123
|
+
throw new Error("--cycle none clears a cycle on `cockpit ticket update`; leave --cycle off to create without one.");
|
|
124
|
+
return { cycle: null };
|
|
125
|
+
}
|
|
126
|
+
return { cycle };
|
|
127
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
2
|
+
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
3
|
+
import { BODY_MAX_CHARS, READ_DEADLINE_MS, WRITE_DEADLINE_MS } from "./issue-contracts.js";
|
|
4
|
+
const TAG = "[ticket cycles cli]";
|
|
5
|
+
const cycleLine = (c) => `${c.id} ${c.name} ${c.starts_on} to ${c.ends_on} (${c.timezone}) [${c.phase}${c.linear_cycle_id ? `, imported ${c.linear_cycle_id}` : ""}]`;
|
|
6
|
+
/** Prints the door's refusal with its reason and message (an ambiguous name lists the ids). */
|
|
7
|
+
function refuse(door, answer) {
|
|
8
|
+
const mutation = answer.body?.mutation;
|
|
9
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail, mutation ? { mutation } : {});
|
|
10
|
+
}
|
|
11
|
+
export async function runCycles(command, io, door) {
|
|
12
|
+
const c = command.cycles;
|
|
13
|
+
const ref = c.ref ? encodeURIComponent(c.ref) : "";
|
|
14
|
+
switch (c.verb) {
|
|
15
|
+
case "list": {
|
|
16
|
+
const query = new URLSearchParams();
|
|
17
|
+
if (c.phase)
|
|
18
|
+
query.set("phase", c.phase);
|
|
19
|
+
if (c.limit !== undefined)
|
|
20
|
+
query.set("limit", String(c.limit));
|
|
21
|
+
if (c.cursor)
|
|
22
|
+
query.set("cursor", c.cursor);
|
|
23
|
+
const answer = await askAgentDoor(door, {
|
|
24
|
+
path: `/api/work/cycles${query.size > 0 ? `?${query}` : ""}`, method: "GET", label: "ticket cycles list", timeoutMs: READ_DEADLINE_MS,
|
|
25
|
+
});
|
|
26
|
+
if (!answer.ok)
|
|
27
|
+
return refuse(door, answer);
|
|
28
|
+
const body = answer.body;
|
|
29
|
+
const cycles = body.cycles ?? [];
|
|
30
|
+
console.error(`${TAG} list done`, JSON.stringify({ returned: cycles.length, has_more: Boolean(body.has_more), phase: c.phase ?? "all" }));
|
|
31
|
+
if (door.json)
|
|
32
|
+
return emitAgentDoor(door, { ok: true, ...body });
|
|
33
|
+
if (cycles.length === 0)
|
|
34
|
+
writeLine(door.io.stdout, "No cycles here. Create one with `cockpit ticket cycles create --name <n> --starts <day> --ends <day>`.");
|
|
35
|
+
for (const row of cycles)
|
|
36
|
+
writeLine(door.io.stdout, cycleLine(row));
|
|
37
|
+
if (body.coverage)
|
|
38
|
+
writeLine(door.io.stdout, `\n${body.coverage}`);
|
|
39
|
+
if (body.has_more && body.next_cursor)
|
|
40
|
+
writeLine(door.io.stdout, `More cycles: --cursor ${body.next_cursor}`);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
case "show": {
|
|
44
|
+
const answer = await askAgentDoor(door, { path: `/api/work/cycles/${ref}`, method: "GET", label: "ticket cycles show", timeoutMs: READ_DEADLINE_MS });
|
|
45
|
+
if (!answer.ok)
|
|
46
|
+
return refuse(door, answer);
|
|
47
|
+
const body = answer.body;
|
|
48
|
+
console.error(`${TAG} show done`, JSON.stringify({ cycle_id: body.cycle.id, members: body.issues?.length ?? 0, phase: body.cycle.phase }));
|
|
49
|
+
if (door.json)
|
|
50
|
+
return emitAgentDoor(door, { ok: true, ...body });
|
|
51
|
+
writeLine(door.io.stdout, cycleLine(body.cycle));
|
|
52
|
+
if (body.cycle.description)
|
|
53
|
+
writeLine(door.io.stdout, `\n${body.cycle.description}`);
|
|
54
|
+
writeLine(door.io.stdout, `\nProgress: ${body.progress?.summary ?? "no progress summary"}`);
|
|
55
|
+
if (typeof body.imported_unassigned === "number") {
|
|
56
|
+
const n = body.imported_unassigned;
|
|
57
|
+
writeLine(door.io.stdout, n === 1 ? "1 imported ticket is not yet in this cycle." : `${n} imported tickets are not yet in this cycle.`);
|
|
58
|
+
}
|
|
59
|
+
for (const issue of body.issues ?? [])
|
|
60
|
+
writeLine(door.io.stdout, `${(issue.identifier ?? "").padEnd(9)} ${(issue.state ?? "").padEnd(11)} ${issue.title ?? ""}`);
|
|
61
|
+
if (body.coverage)
|
|
62
|
+
writeLine(door.io.stdout, `\n${body.coverage}`);
|
|
63
|
+
if (body.has_more && body.next_cursor)
|
|
64
|
+
writeLine(door.io.stdout, `More tickets: the --json answer carries next_cursor ${body.next_cursor}.`);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
case "map":
|
|
68
|
+
return write(command, door, { path: "/api/work/cycles/map", method: "POST", body: { ...(command.requestId ? { request_id: command.requestId } : {}), linear_cycle_id: c.ref } });
|
|
69
|
+
case "create":
|
|
70
|
+
case "update": {
|
|
71
|
+
const description = await readDescription(c.description, io);
|
|
72
|
+
if (!description.ok)
|
|
73
|
+
return failAgentDoor(door, TAG, "invalid_body", description.detail);
|
|
74
|
+
const payload = {
|
|
75
|
+
...(command.requestId ? { request_id: command.requestId } : {}),
|
|
76
|
+
...(c.verb === "update" && c.expectedUpdatedAt ? { expected_updated_at: c.expectedUpdatedAt } : {}),
|
|
77
|
+
...(c.name === undefined ? {} : { name: c.name }),
|
|
78
|
+
...(description.text === undefined ? {} : { description: description.text }),
|
|
79
|
+
...(c.startsOn === undefined ? {} : { starts_on: c.startsOn }),
|
|
80
|
+
...(c.endsOn === undefined ? {} : { ends_on: c.endsOn }),
|
|
81
|
+
...(c.timezone === undefined ? {} : { timezone: c.timezone }),
|
|
82
|
+
...(c.closed === undefined ? {} : { closed: c.closed }),
|
|
83
|
+
};
|
|
84
|
+
return write(command, door, c.verb === "create"
|
|
85
|
+
? { path: "/api/work/cycles", method: "POST", body: payload }
|
|
86
|
+
: { path: `/api/work/cycles/${ref}`, method: "PATCH", body: payload });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** `--description -` reads the text from stdin; anything else is the text itself. */
|
|
91
|
+
async function readDescription(value, io) {
|
|
92
|
+
if (value !== "-")
|
|
93
|
+
return { ok: true, text: value };
|
|
94
|
+
if (isInteractiveStdin(io))
|
|
95
|
+
return { ok: false, detail: "--description - reads the text from stdin, and nothing is piped in." };
|
|
96
|
+
try {
|
|
97
|
+
return { ok: true, text: await readPipedText(io.stdin, { maxChars: BODY_MAX_CHARS, overflowMessage: `A cycle description is limited to ${BODY_MAX_CHARS} characters.` }) };
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function write(command, door, req) {
|
|
104
|
+
const verb = command.cycles.verb;
|
|
105
|
+
const answer = await askAgentDoor(door, { ...req, label: `ticket cycles ${verb}`, timeoutMs: WRITE_DEADLINE_MS });
|
|
106
|
+
if (!answer.ok)
|
|
107
|
+
return refuse(door, answer);
|
|
108
|
+
const body = answer.body;
|
|
109
|
+
console.error(`${TAG} ${verb} done`, JSON.stringify({ cycle_id: body.cycle.id, phase: body.cycle.phase, already_mapped: Boolean(body.already_mapped) }));
|
|
110
|
+
if (door.json)
|
|
111
|
+
return emitAgentDoor(door, { ok: true, ...body });
|
|
112
|
+
const label = verb === "create" ? "Created" : verb === "map" ? (body.already_mapped ? "Already mapped" : "Mapped") : command.cycles.closed === true ? "Closed" : command.cycles.closed === false ? "Reopened" : "Updated";
|
|
113
|
+
writeLine(door.io.stdout, `${label}: ${cycleLine(body.cycle)}`);
|
|
114
|
+
if (verb === "map")
|
|
115
|
+
writeLine(door.io.stdout, "Mapping links the cycles; no ticket was moved.");
|
|
116
|
+
return 0;
|
|
117
|
+
}
|