@bli-cockpit/cli 0.2.239 → 0.2.240

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.
@@ -1,23 +1,33 @@
1
1
  import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
2
2
  import { writeLine } from "./cli-io.js";
3
+ import { writeScopeFacts } from "./cal-scope.js";
3
4
  const TAG = "[cal cli]";
4
5
  const DELETE_DEADLINE_MS = 60_000;
5
6
  export async function runCalDelete(command, door) {
7
+ const query = new URLSearchParams();
8
+ if (command.accountName)
9
+ query.set("account", command.accountName);
10
+ if (command.scope)
11
+ query.set("scope", command.scope);
12
+ if (command.occurrenceStart)
13
+ query.set("occurrence_start", command.occurrenceStart);
6
14
  const answer = await askAgentDoor(door, {
7
- path: `/api/cal/events/${encodeURIComponent(command.subjectId ?? "")}${command.accountName ? `?account=${encodeURIComponent(command.accountName)}` : ""}`,
15
+ path: `/api/cal/events/${encodeURIComponent(command.subjectId ?? "")}${query.size > 0 ? `?${query.toString()}` : ""}`,
8
16
  method: "DELETE",
9
17
  label: "cal delete",
10
18
  timeoutMs: DELETE_DEADLINE_MS,
11
19
  });
12
20
  if (!answer.ok)
13
21
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
14
- const deleted = answer.body.deleted;
22
+ const full = answer.body;
23
+ const deleted = full.deleted;
15
24
  if (door.json)
16
- return emitAgentDoor(door, { ok: true, deleted });
25
+ return emitAgentDoor(door, { ok: true, ...full });
17
26
  if (!deleted) {
18
27
  writeLine(door.io.stdout, "Tower answered without a result. Nothing to report.");
19
28
  return 0;
20
29
  }
30
+ writeScopeFacts(door, full);
21
31
  if (deleted.account_name)
22
32
  writeLine(door.io.stdout, `Account: ${deleted.account_name}.`);
23
33
  const alreadyGone = deleted.google === "already_gone_at_google";
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Recurring-event scope, RSVP and event-detail flags for `cockpit cal`
3
+ * (BLI-21311, slice 5). The scope words ("this event", "this and following
4
+ * events", "all events") come from the door and are printed as it sent them;
5
+ * this module only builds the request fields and says what came back.
6
+ */
7
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
8
+ import { writeLine } from "./cli-io.js";
9
+ const TAG = "[cal cli]";
10
+ const WRITE_DEADLINE_MS = 60_000;
11
+ /** `scope` and `occurrence_start`, only when the caller gave them. */
12
+ export function scopeFields(command) {
13
+ return {
14
+ ...(command.scope ? { scope: command.scope } : {}),
15
+ ...(command.occurrenceStart ? { occurrence_start: command.occurrenceStart } : {}),
16
+ };
17
+ }
18
+ /** `color_id`, `reminders`, `visibility`, `transparency`, only when given. */
19
+ export function detailFields(command) {
20
+ return {
21
+ ...(command.colorId !== undefined ? { color_id: command.colorId } : {}),
22
+ ...(command.reminders !== undefined ? { reminders: command.reminders } : {}),
23
+ ...(command.visibility ? { visibility: command.visibility } : {}),
24
+ ...(command.transparency ? { transparency: command.transparency } : {}),
25
+ };
26
+ }
27
+ /** Prints the scope label, occurrence, split ids and notice the door answered with. */
28
+ export function writeScopeFacts(door, facts) {
29
+ if (facts.scope_label)
30
+ writeLine(door.io.stdout, `Applied to: ${facts.scope_label}${facts.occurrence_start ? ` (occurrence ${facts.occurrence_start})` : ""}.`);
31
+ if (facts.trimmed_until)
32
+ writeLine(door.io.stdout, `Series now ends: ${facts.trimmed_until}.`);
33
+ const split = facts.series_split;
34
+ if (split) {
35
+ writeLine(door.io.stdout, `New series: ${split.new_series_id ?? "unknown"}. Trimmed series: ${split.trimmed_series_id ?? "unknown"}${split.trimmed_until ? ` (until ${split.trimmed_until})` : ""}.`);
36
+ if (split.atomic === false)
37
+ writeLine(door.io.stdout, "The split ran in steps and is not atomic; check both series.");
38
+ }
39
+ if (facts.notice)
40
+ writeLine(door.io.stdout, facts.notice);
41
+ }
42
+ export async function runCalRsvp(command, door) {
43
+ const answer = await askAgentDoor(door, {
44
+ path: `/api/cal/events/${encodeURIComponent(command.subjectId ?? "")}/rsvp`,
45
+ method: "POST",
46
+ label: "cal rsvp",
47
+ timeoutMs: WRITE_DEADLINE_MS,
48
+ body: {
49
+ response: command.rsvpResponse,
50
+ ...(command.rsvpComment ? { comment: command.rsvpComment } : {}),
51
+ ...(command.accountName ? { account: command.accountName } : {}),
52
+ ...scopeFields(command),
53
+ ...(command.expectedVersion ? { expected_version: command.expectedVersion } : {}),
54
+ ...(command.idempotencyKey ? { idempotency_key: command.idempotencyKey } : {}),
55
+ ...(command.reason ? { reason: command.reason } : {}),
56
+ },
57
+ });
58
+ if (!answer.ok)
59
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
60
+ if (door.json)
61
+ return emitAgentDoor(door, answer.body);
62
+ const body = answer.body;
63
+ const rsvp = body.rsvp;
64
+ writeLine(door.io.stdout, `RSVP ${rsvp?.response ?? command.rsvpResponse}${rsvp?.previous_response ? ` (was ${rsvp.previous_response})` : ""}${body.replayed ? " - already recorded, nothing sent again" : ""}.`);
65
+ writeScopeFacts(door, { scope_label: rsvp?.scope_label, occurrence_start: rsvp?.occurrence_start, notice: body.notice });
66
+ if (body.change_id)
67
+ writeLine(door.io.stdout, `Change: ${body.change_id}`);
68
+ if (body.version)
69
+ writeLine(door.io.stdout, `Version: ${body.version}`);
70
+ return 0;
71
+ }
@@ -25,6 +25,7 @@ import { runCalAddIcal, runCalDetach, runCalShare, runCalSync } from "./cal-cale
25
25
  import { runCalDetail } from "./cal-detail.js";
26
26
  import { runCalOverlay } from "./cal-overlay.js";
27
27
  import { runCalDelete } from "./cal-delete.js";
28
+ import { detailFields, runCalRsvp, scopeFields, writeScopeFacts } from "./cal-scope.js";
28
29
  import { runCalDirect } from "./cal-direct.js";
29
30
  import { runCalSyncStatus } from "./cal-sync-status.js";
30
31
  import { writeLine } from "./cli-io.js";
@@ -67,6 +68,8 @@ export async function runCal(command, io) {
67
68
  return runCalSyncStatus(command, door);
68
69
  case "change":
69
70
  return changeEvent(command, door);
71
+ case "rsvp":
72
+ return runCalRsvp(command, door);
70
73
  case "cancel":
71
74
  case "delete":
72
75
  return runCalDelete(command, door);
@@ -304,6 +307,7 @@ async function create(command, door) {
304
307
  ...(command.allDay ? { all_day: true } : {}),
305
308
  ...(command.attendees.length > 0 ? { attendees: command.attendees } : {}),
306
309
  ...(command.meet ? { meet: true } : {}),
310
+ ...detailFields(command),
307
311
  ...(command.idempotencyKey ? { idempotency_key: command.idempotencyKey } : {}),
308
312
  ...(command.reason ? { reason: command.reason } : {}),
309
313
  time_zone: command.timeZone ?? localTimeZone(),
@@ -356,6 +360,8 @@ async function changeEvent(command, door) {
356
360
  // BLI-21296: all-day conversion either way, and the write controls.
357
361
  ...(command.allDay ? { all_day: true } : command.timed ? { all_day: false } : {}),
358
362
  ...(command.timeZone && (command.allDay || command.timed) ? { time_zone: command.timeZone } : {}),
363
+ ...scopeFields(command),
364
+ ...detailFields(command),
359
365
  ...(command.expectedVersion ? { expected_version: command.expectedVersion } : {}),
360
366
  ...(command.idempotencyKey ? { idempotency_key: command.idempotencyKey } : {}),
361
367
  ...(command.reason ? { reason: command.reason } : {}),
@@ -366,6 +372,7 @@ async function changeEvent(command, door) {
366
372
  return emitAgentDoor(door, { ok: true, ...answer.body });
367
373
  const changed = answer.body;
368
374
  writeLine(door.io.stdout, `Changed event in ${changed.event?.account_name ?? "its account"}.`);
375
+ writeScopeFacts(door, changed);
369
376
  if (changed.change_id)
370
377
  writeLine(door.io.stdout, `Change: ${changed.change_id} (undo with: cockpit cal undo ${changed.change_id})`);
371
378
  if (changed.version)
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `cockpit docs share` and `cockpit docs access` — named page editors from a terminal (BLI-21313).
3
+ * A sibling of `docs.ts`, like `docs-notion.ts`.
4
+ *
5
+ * `access <id|slug>` → `GET /api/docs/documents/[id]/grants`
6
+ * `share <id|slug> --add <email>` → `POST /api/docs/documents/[id]/grants`
7
+ * `share <id|slug> --remove <email>` → `DELETE /api/docs/documents/[id]/grants`
8
+ *
9
+ * The door answers with the shared permission explanation, and every refusal keeps its own reason
10
+ * label verbatim (`private_page_grant_needs_decision`, `not_grant_manager`, `person_not_found`, ...).
11
+ * A person is an exact email; the server resolves it, so this file never guesses who someone is.
12
+ */
13
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
14
+ import { writeLine } from "./cli-io.js";
15
+ import { resolveDocId } from "./docs.js";
16
+ const TAG = "[docs cli]";
17
+ const READ_DEADLINE_MS = 30_000;
18
+ const WRITE_DEADLINE_MS = 60_000;
19
+ async function resolveOrFail(command, door) {
20
+ const ref = command.docRef ?? "";
21
+ const resolved = await resolveDocId(door, ref);
22
+ if (resolved.status === "list_failed")
23
+ return { exit: failAgentDoor(door, TAG, resolved.reason, resolved.detail) };
24
+ if (resolved.status === "not_found") {
25
+ return { exit: failAgentDoor(door, TAG, "document_not_found_or_unreadable", `That document does not exist, or you cannot read it: ${ref}`) };
26
+ }
27
+ return { id: resolved.id };
28
+ }
29
+ function printPermission(door, permission) {
30
+ for (const line of permission.reasons)
31
+ writeLine(door.io.stdout, line);
32
+ for (const editor of permission.editors)
33
+ writeLine(door.io.stdout, ` editor: ${editor.display_name}${editor.email ? ` <${editor.email}>` : ""}`);
34
+ }
35
+ export async function accessDocs(command, door) {
36
+ const target = await resolveOrFail(command, door);
37
+ if ("exit" in target)
38
+ return target.exit;
39
+ const answer = await askAgentDoor(door, {
40
+ path: `/api/docs/documents/${encodeURIComponent(target.id)}/grants`,
41
+ method: "GET",
42
+ label: "docs access",
43
+ timeoutMs: READ_DEADLINE_MS,
44
+ });
45
+ if (!answer.ok)
46
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
47
+ const body = answer.body;
48
+ writeLine(door.io.stderr, `${TAG} access ${JSON.stringify({ document_id: target.id, role: body.permission.role, editors: body.permission.editors.length })}`);
49
+ if (door.json)
50
+ return emitAgentDoor(door, { ok: true, ...body });
51
+ printPermission(door, body.permission);
52
+ for (const event of body.events ?? []) {
53
+ writeLine(door.io.stdout, ` ${event.created_at}: ${event.actor_name} ${event.action === "granted" ? "added" : "removed"} ${event.grantee_name}`);
54
+ }
55
+ return 0;
56
+ }
57
+ export async function shareDocs(command, door) {
58
+ const target = await resolveOrFail(command, door);
59
+ if ("exit" in target)
60
+ return target.exit;
61
+ const adding = command.addPerson !== undefined;
62
+ const answer = await askAgentDoor(door, {
63
+ path: `/api/docs/documents/${encodeURIComponent(target.id)}/grants`,
64
+ method: adding ? "POST" : "DELETE",
65
+ label: adding ? "docs share add" : "docs share remove",
66
+ timeoutMs: WRITE_DEADLINE_MS,
67
+ body: { person: adding ? command.addPerson : command.removePerson, ...(command.reason ? { reason: command.reason } : {}) },
68
+ });
69
+ if (!answer.ok)
70
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
71
+ const body = answer.body;
72
+ writeLine(door.io.stderr, `${TAG} share ${JSON.stringify({ document_id: target.id, action: adding ? "add" : "remove", outcome: body.outcome, reason: body.reason, request_id: body.request_id })}`);
73
+ if (door.json)
74
+ return emitAgentDoor(door, { ok: true, ...body });
75
+ const person = adding ? command.addPerson : command.removePerson;
76
+ writeLine(door.io.stdout, body.outcome === "unchanged"
77
+ ? `Nothing changed: ${person} ${adding ? "is already an editor" : "was not an editor"}.`
78
+ : `${adding ? "Added" : "Removed"} ${person} ${adding ? "as an editor of" : "from the editors of"} ${target.id}.`);
79
+ printPermission(door, body.permission);
80
+ return 0;
81
+ }
@@ -33,6 +33,7 @@ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
33
33
  import { readDoc } from "./docs-read.js";
34
34
  import { exportDoc } from "./docs-export.js";
35
35
  import { linkDoc, syncStatusDocs } from "./docs-notion.js";
36
+ import { accessDocs, shareDocs } from "./docs-share.js";
36
37
  import { readNoteFile } from "./notes-file.js";
37
38
  import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
38
39
  const TAG = "[docs cli]";
@@ -62,6 +63,10 @@ export async function runDocs(command, io) {
62
63
  return exportDoc(command, door);
63
64
  case "mutation-status":
64
65
  return mutationStatusDocs(command, door);
66
+ case "share":
67
+ return shareDocs(command, door);
68
+ case "access":
69
+ return accessDocs(command, door);
65
70
  }
66
71
  }
67
72
  /** `--parent`, `--query`, `--word`, `--limit`, `--bodies` (BLI-10450) as the door's own query parameters (BLI-3737). */
@@ -21,9 +21,9 @@ export function overviewLines(body) {
21
21
  lines.push(`${body.queue.newLeft} new drill${body.queue.newLeft === 1 ? "" : "s"} waiting. Run: cockpit drills start`, "");
22
22
  else
23
23
  lines.push("You are all caught up. Nothing new is waiting.", "");
24
- lines.push(`Theme: ${body.theme.label}${body.theme.chosen ? "" : " (the default; change it with `cockpit drills theme set <theme>`)"}`, "");
24
+ lines.push(body.theme.plain ? "Theme: Plain (no theme; if you like, pick one with `cockpit drills theme set <theme>`)" : `Theme: ${body.theme.label}`, "");
25
25
  if (body.bosses.length > 0) {
26
- lines.push("Bosses");
26
+ lines.push(body.theme.plain ? "Mistakes you are working on" : "Bosses");
27
27
  for (const boss of body.bosses)
28
28
  lines.push(` ${boss.bossName}: ${boss.progressLine}`);
29
29
  lines.push("");
@@ -114,11 +114,11 @@ async function themeShow(door) {
114
114
  writeLine(door.io.stderr, `${TAG} theme answered ${JSON.stringify({ key: body.theme.key, chosen: body.theme.chosen, ready: body.ready })}`);
115
115
  if (door.json)
116
116
  return emitAgentDoor(door, response.body);
117
- writeLine(door.io.stdout, `Your theme: ${body.theme.label}${body.theme.chosen ? "" : " (the default)"}`);
118
- writeLine(door.io.stdout, "Choices:");
117
+ writeLine(door.io.stdout, `Your theme: ${body.theme.label}${body.theme.chosen ? "" : " (the default: drills are shown straight, with no theme)"}`);
118
+ writeLine(door.io.stdout, "If you like, pick a theme (plain turns it off):");
119
119
  for (const choice of body.themes)
120
- writeLine(door.io.stdout, ` ${choice.key}: ${choice.blurb}${choice.key === body.suggestedTheme && !body.theme.chosen ? " (suggested)" : ""}`);
121
- writeLine(door.io.stdout, "Or use your own words. Change it with: cockpit drills theme set <theme> [--play <what you play>] [--watch <what you watch>] [--train <what you train>]");
120
+ writeLine(door.io.stdout, ` ${choice.key}: ${choice.blurb}${choice.key === body.suggestedTheme && choice.key !== "plain" && !body.theme.chosen ? " (suggested)" : ""}`);
121
+ writeLine(door.io.stdout, "Or type anything you're into. Change it with: cockpit drills theme set <theme> [--play <what you play>] [--watch <what you watch>] [--train <what you train>]");
122
122
  return 0;
123
123
  }
124
124
  async function themeSet(command, door) {
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The cursor contract on the CLI (BLI-21315): the page fields a list prints,
3
+ * the extra query a page carries, and `--all`, which walks every page and
4
+ * streams one JSON object per line so a large export never buffers.
5
+ *
6
+ * The door (`GET /api/work/issues`) owns the filters, the order and the
7
+ * cursor; this file only carries them over and reports what came back. A
8
+ * walk that stops early still ends with a final line naming the coverage and
9
+ * the reason, and the command exits non-zero.
10
+ */
11
+ import { askAgentDoor } from "./agent-door.js";
12
+ import { writeLine } from "./cli-io.js";
13
+ import { READ_DEADLINE_MS, TAG, withSizeLabel } from "./issue-contracts.js";
14
+ const ALL_PAGE_SIZE = 500;
15
+ /** The additive page fields as `--json` output, only the ones the door sent. */
16
+ export function pageFieldsOf(body) {
17
+ const fields = {};
18
+ for (const key of ["next_cursor", "has_more", "coverage", "requested", "returned", "unique", "failure_reason"]) {
19
+ if (body[key] !== undefined)
20
+ fields[key] = body[key];
21
+ }
22
+ return fields;
23
+ }
24
+ /** The sentence under a text page; null when the door sent no page fields or the page was whole. */
25
+ export function pageFooter(body, what) {
26
+ if (body.coverage === undefined)
27
+ return null;
28
+ if (body.coverage === "complete")
29
+ return null;
30
+ return body.next_cursor
31
+ ? `More ${what} available (${body.returned ?? "?"} shown). Continue with --cursor ${body.next_cursor}, or use --all.`
32
+ : `More ${what} may exist; this Tower gave no cursor to continue from.`;
33
+ }
34
+ /** Filters, sort and cursor beyond the long-standing ones, as door query parameters. */
35
+ export function addIssuePageQuery(query, command) {
36
+ const pairs = [
37
+ ["label", command.label],
38
+ ["q", command.q],
39
+ ["created_from", command.createdAfter],
40
+ ["created_to", command.createdBefore],
41
+ ["updated_from", command.updatedAfter],
42
+ ["updated_to", command.updatedBefore],
43
+ ["sort", command.sort],
44
+ ["dir", command.dir],
45
+ ["cursor", command.cursor],
46
+ ];
47
+ for (const [name, value] of pairs)
48
+ if (value)
49
+ query.set(name, value);
50
+ }
51
+ /**
52
+ * `list --all`: NDJSON on stdout, one issue per line, then ONE final line
53
+ * `{"type":"coverage",...}`. Exit 0 only when the walk was complete.
54
+ */
55
+ export async function streamAllIssues(command, door, base) {
56
+ const query = new URLSearchParams(base);
57
+ query.set("limit", String(Math.min(command.limit ?? ALL_PAGE_SIZE, ALL_PAGE_SIZE)));
58
+ query.delete("cursor");
59
+ const seen = new Set();
60
+ let returned = 0;
61
+ let pages = 0;
62
+ let expected = null;
63
+ let cursor = null;
64
+ for (;;) {
65
+ if (cursor)
66
+ query.set("cursor", cursor);
67
+ const answer = await askAgentDoor(door, {
68
+ path: `/api/work/issues?${query.toString()}`,
69
+ method: "GET",
70
+ label: "issue list",
71
+ timeoutMs: READ_DEADLINE_MS,
72
+ });
73
+ if (!answer.ok) {
74
+ console.error(`${TAG} walk interrupted`, JSON.stringify({ reason: answer.reason, pages, returned }));
75
+ writeLine(door.io.stdout, JSON.stringify({
76
+ type: "coverage", coverage: "failed_partial", reason: answer.reason, detail: answer.detail,
77
+ pages, returned, unique: seen.size, expected, next_cursor: cursor,
78
+ }));
79
+ return 1;
80
+ }
81
+ const body = answer.body;
82
+ pages += 1;
83
+ if (pages === 1 && typeof body.counts?.total === "number")
84
+ expected = body.counts.total;
85
+ for (const issue of body.issues ?? []) {
86
+ returned += 1;
87
+ seen.add(issue.id);
88
+ writeLine(door.io.stdout, JSON.stringify(withSizeLabel(issue)));
89
+ }
90
+ console.error(`${TAG} page ${JSON.stringify({ page: pages, returned: body.issues?.length ?? 0, coverage: body.coverage ?? null })}`);
91
+ cursor = body.next_cursor ?? null;
92
+ if (!cursor)
93
+ break;
94
+ }
95
+ const exact = expected === null || expected === returned;
96
+ writeLine(door.io.stdout, JSON.stringify({
97
+ type: "coverage", coverage: "complete", pages, returned, unique: seen.size, expected,
98
+ ...(returned === seen.size && exact ? {} : { note: "returned, unique and expected do not all agree; rows changed during the walk" }),
99
+ }));
100
+ return 0;
101
+ }
@@ -31,6 +31,7 @@ import { assigneeLabel, beginAssigneeDirectoryFetch, finishAssigneeDirectory, wi
31
31
  import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
32
32
  import { readTicketBoard } from "./tickets-board.js";
33
33
  import { showIssue } from "./issue-show.js";
34
+ import { addIssuePageQuery, pageFieldsOf, pageFooter, streamAllIssues } from "./issue-list-page.js";
34
35
  import { READ_DEADLINE_MS, TAG, resolveProjectId, sizeForEstimate, withSizeLabel, } from "./issue-contracts.js";
35
36
  import { commentIssue, createIssue, moveIssue, updateIssue } from "./issue-write.js";
36
37
  import { listIssueVersions, restoreIssueVersion } from "./issue-versions.js";
@@ -85,7 +86,7 @@ export function listGapLines(body, requestedLimit) {
85
86
  const returned = body.issues?.length ?? 0;
86
87
  const completeness = body.completeness;
87
88
  if (completeness?.status === "partial") {
88
- lines.push(`${TAG} partial list: ${completeness.returned} of ${completeness.total} matching issues; narrow the filter or pass --limit (at most 1000).`);
89
+ lines.push(`${TAG} partial list: ${completeness.returned} of ${completeness.total} matching issues; narrow the filter, continue with --cursor, or use --all.`);
89
90
  }
90
91
  else if (completeness?.status === "unknown") {
91
92
  lines.push(`${TAG} list may be partial: ${completeness.returned} issues filled the limit and Tower could not count the rest.`);
@@ -110,6 +111,10 @@ async function listIssues(command, door) {
110
111
  const built = await buildIssueListQuery(command, door);
111
112
  if (!built.ok)
112
113
  return built.exitCode;
114
+ addIssuePageQuery(built.query, command);
115
+ // BLI-21315: a streamed export never goes through the read cache or buffers.
116
+ if (command.all)
117
+ return streamAllIssues(command, door, built.query);
113
118
  const suffix = built.query.toString();
114
119
  // BLI-10579: the assignee directory (`/api/team/members`) is the whole org
115
120
  // roster regardless of which issues come back — it never actually depended
@@ -131,7 +136,7 @@ async function listIssues(command, door) {
131
136
  const body = answer.body;
132
137
  const issues = body.issues ?? [];
133
138
  // BLI-10447: the gaps go to stderr in both modes, so `--json` stdout stays one object.
134
- for (const line of listGapLines(body, command.limit ?? 1000))
139
+ for (const line of listGapLines(body, command.limit ?? 100))
135
140
  writeLine(door.io.stderr, line);
136
141
  // BLI-10450: one extra call for the whole list, never one per row — the
137
142
  // uuid `assignee_id` alone forced a second `cockpit team members` call and
@@ -144,6 +149,7 @@ async function listIssues(command, door) {
144
149
  ...cacheCoverage(answer),
145
150
  issues: issues.map((issue) => withSizeLabel(withAssigneeIdentity(issue, directory))),
146
151
  ...(body.completeness ? { completeness: body.completeness } : {}),
152
+ ...pageFieldsOf(body),
147
153
  ...(body.outside_project ? { outside_project: body.outside_project } : {}),
148
154
  });
149
155
  }
@@ -157,6 +163,9 @@ async function listIssues(command, door) {
157
163
  writeLine(door.io.stdout, issueLine(issue, directory));
158
164
  writeLine(door.io.stdout, "");
159
165
  writeLine(door.io.stdout, `${issues.length} issue(s).`);
166
+ const footer = pageFooter(body, "issues");
167
+ if (footer)
168
+ writeLine(door.io.stdout, footer);
160
169
  return 0;
161
170
  }
162
171
  /** The list query for the filters given, or the exit code of the project lookup that failed. */
@@ -230,7 +239,12 @@ function historyChange(row) {
230
239
  }
231
240
  async function issueHistory(command, door) {
232
241
  const ref = command.issueRef ?? "";
233
- const query = command.limit === undefined ? "" : `?limit=${command.limit}`;
242
+ const params = new URLSearchParams();
243
+ if (command.limit !== undefined)
244
+ params.set("limit", String(command.limit));
245
+ if (command.cursor)
246
+ params.set("cursor", command.cursor);
247
+ const query = params.toString() ? `?${params.toString()}` : "";
234
248
  const answer = await askAgentDoor(door, {
235
249
  path: `/api/work/issues/${encodeURIComponent(ref)}/history${query}`,
236
250
  method: "GET",
@@ -239,9 +253,10 @@ async function issueHistory(command, door) {
239
253
  });
240
254
  if (!answer.ok)
241
255
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
242
- const history = answer.body.history ?? [];
256
+ const historyBody = answer.body;
257
+ const history = historyBody.history ?? [];
243
258
  if (door.json)
244
- return emitAgentDoor(door, { ok: true, history });
259
+ return emitAgentDoor(door, { ok: true, history, ...pageFieldsOf(historyBody) });
245
260
  if (history.length === 0) {
246
261
  writeLine(door.io.stdout, `No recorded changes for ${ref}.`);
247
262
  return 0;
@@ -251,5 +266,8 @@ async function issueHistory(command, door) {
251
266
  }
252
267
  writeLine(door.io.stdout, "");
253
268
  writeLine(door.io.stdout, `${history.length} change(s).`);
269
+ const footer = pageFooter(historyBody, "changes");
270
+ if (footer)
271
+ writeLine(door.io.stdout, footer);
254
272
  return 0;
255
273
  }
@@ -27,7 +27,7 @@ const CAL_ACTIONS = new Set([
27
27
  "find",
28
28
  "calendars",
29
29
  "add-ical",
30
- "change", "cancel",
30
+ "change", "cancel", "rsvp",
31
31
  "create",
32
32
  "share",
33
33
  "detach",
@@ -36,7 +36,7 @@ const CAL_ACTIONS = new Set([
36
36
  "delete",
37
37
  ]);
38
38
  /** Every verb that names ONE thing first — a calendar id, or (delete) an event id. */
39
- const CAL_ACTIONS_NEEDING_A_SUBJECT = new Set(["event", "export", "duplicate", "calendar-change", "sync", "detach", "share", "delete", "change", "cancel"]);
39
+ const CAL_ACTIONS_NEEDING_A_SUBJECT = new Set(["event", "export", "duplicate", "calendar-change", "sync", "detach", "share", "delete", "change", "cancel", "rsvp"]);
40
40
  /** `sync-status [<calendar id>]` — every calendar, or one's. Ticket 10730. */
41
41
  const CAL_ACTIONS_WITH_OPTIONAL_SUBJECT = new Set(["sync-status", "undo"]);
42
42
  const CAL_ALLOWED_FLAGS = [
@@ -71,6 +71,7 @@ const CAL_ALLOWED_FLAGS = [
71
71
  "--idempotency-key",
72
72
  "--expected-version",
73
73
  "--reason",
74
+ "--scope", "--occurrence", "--response", "--comment", "--reminder", "--visibility", "--transparency", "--key",
74
75
  ];
75
76
  const CAL_VALUE_FLAGS = [
76
77
  "--home",
@@ -94,6 +95,7 @@ const CAL_VALUE_FLAGS = [
94
95
  "--idempotency-key",
95
96
  "--expected-version",
96
97
  "--reason",
98
+ "--scope", "--occurrence", "--response", "--comment", "--reminder", "--visibility", "--transparency", "--key",
97
99
  ];
98
100
  export function parseCalArgs(args) {
99
101
  const values = parseNamedArgs(args, { allowedFlags: CAL_ALLOWED_FLAGS, valueFlags: CAL_VALUE_FLAGS });
@@ -106,7 +108,8 @@ export function parseCalArgs(args) {
106
108
  // two of them wrong is told about --limit first, as before.
107
109
  const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
108
110
  const offset = readCalOffset(values);
109
- return buildCalCommand(values, action, subject, query, title, startsAt, endsAt, offset, limit);
111
+ const command = buildCalCommand(values, action, subject, query, title, startsAt, endsAt, offset, limit);
112
+ return { ...command, ...readCalEventScope(action, values) };
110
113
  }
111
114
  function readCalAction(values) {
112
115
  const first = values.positionals[0];
@@ -122,7 +125,7 @@ function readCalSubjectOrQuery(action, rest) {
122
125
  if (CAL_ACTIONS_NEEDING_A_SUBJECT.has(action)) {
123
126
  subject = optionalNonEmpty(rest[0]);
124
127
  if (!subject) {
125
- throw new Error(action === "delete" || action === "duplicate"
128
+ throw new Error(action === "delete" || action === "duplicate" || action === "change" || action === "cancel" || action === "rsvp"
126
129
  ? `cal ${action} needs an event id — take one from a window verb's output (today/week/next/find).`
127
130
  : `cal ${action} needs a calendar id — take one from \`cockpit cal calendars\`.`);
128
131
  }
@@ -182,7 +185,7 @@ function readCalOffset(values) {
182
185
  function buildCalCommand(values, action, subject, query, title, startsAt, endsAt, offset, limit) {
183
186
  return {
184
187
  kind: "cal",
185
- occurrenceStart: optionalNonEmpty(values.flags.get("--occurrence-start")),
188
+ occurrenceStart: optionalNonEmpty(values.flags.get("--occurrence") ?? values.flags.get("--occurrence-start")),
186
189
  color: optionalNonEmpty(values.flags.get("--color")),
187
190
  action,
188
191
  homeDir: optionalNonEmpty(values.flags.get("--home")),
@@ -211,7 +214,7 @@ function buildCalCommand(values, action, subject, query, title, startsAt, endsAt
211
214
  allDay: values.booleans.has("--all-day"),
212
215
  timed: values.booleans.has("--timed"),
213
216
  keepInvitees: values.booleans.has("--keep-invitees"),
214
- idempotencyKey: optionalNonEmpty(values.flags.get("--idempotency-key")),
217
+ idempotencyKey: optionalNonEmpty(values.flags.get("--idempotency-key") ?? values.flags.get("--key")),
215
218
  expectedVersion: optionalNonEmpty(values.flags.get("--expected-version")),
216
219
  reason: optionalNonEmpty(values.flags.get("--reason")),
217
220
  meet: values.booleans.has("--meet"),
@@ -230,4 +233,92 @@ function splitAddresses(raw) {
230
233
  .split(",")
231
234
  .map((value) => value.trim())
232
235
  .filter((value) => value !== "");
236
+ }
237
+ const SCOPES = ["instance", "following", "series"];
238
+ const RSVP_RESPONSES = ["accepted", "declined", "tentative"];
239
+ const VISIBILITIES = ["default", "public", "private", "confidential"];
240
+ const TRANSPARENCIES = ["opaque", "transparent"];
241
+ const WRITE_ACTIONS = new Set(["create", "change"]);
242
+ /**
243
+ * BLI-21311: the scope, RSVP and event-detail flags. Validated here so a typo
244
+ * never costs a request; the door still owns every rule that needs the event.
245
+ */
246
+ function readCalEventScope(action, values) {
247
+ const out = {};
248
+ const scope = optionalNonEmpty(values.flags.get("--scope"));
249
+ if (scope !== undefined) {
250
+ if (!SCOPES.includes(scope))
251
+ throw new Error("--scope takes instance, following or series.");
252
+ if (action !== "change" && action !== "cancel" && action !== "delete" && action !== "rsvp")
253
+ throw new Error(`cal ${action} does not take --scope.`);
254
+ if (action === "rsvp" && scope === "following")
255
+ throw new Error("An RSVP takes --scope instance or series.");
256
+ out.scope = scope;
257
+ }
258
+ if (action === "rsvp") {
259
+ const response = optionalNonEmpty(values.flags.get("--response"));
260
+ if (!response || !RSVP_RESPONSES.includes(response)) {
261
+ throw new Error("cal rsvp needs --response accepted, declined or tentative.");
262
+ }
263
+ out.rsvpResponse = response;
264
+ out.rsvpComment = optionalNonEmpty(values.flags.get("--comment"));
265
+ return out;
266
+ }
267
+ if (values.flags.has("--response") || values.flags.has("--comment"))
268
+ throw new Error("--response and --comment belong to cal rsvp.");
269
+ if (!WRITE_ACTIONS.has(action)) {
270
+ for (const flag of ["--reminder", "--visibility", "--transparency"]) {
271
+ if (values.flags.has(flag))
272
+ throw new Error(`cal ${action} does not take ${flag}.`);
273
+ }
274
+ return out;
275
+ }
276
+ const color = optionalNonEmpty(values.flags.get("--color"));
277
+ if (color !== undefined) {
278
+ if (color === "none")
279
+ out.colorId = null;
280
+ else if (/^(?:[1-9]|10|11)$/.test(color))
281
+ out.colorId = Number(color);
282
+ else
283
+ throw new Error("--color takes a colour number 1 to 11, or none.");
284
+ }
285
+ const reminders = readReminders(values.flagValues.get("--reminder") ?? []);
286
+ if (reminders !== undefined)
287
+ out.reminders = reminders;
288
+ const visibility = optionalNonEmpty(values.flags.get("--visibility"));
289
+ if (visibility !== undefined) {
290
+ if (!VISIBILITIES.includes(visibility))
291
+ throw new Error(`--visibility takes ${VISIBILITIES.join(", ")}.`);
292
+ out.visibility = visibility;
293
+ }
294
+ const transparency = optionalNonEmpty(values.flags.get("--transparency"));
295
+ if (transparency !== undefined) {
296
+ if (!TRANSPARENCIES.includes(transparency))
297
+ throw new Error(`--transparency takes ${TRANSPARENCIES.join(" or ")}.`);
298
+ out.transparency = transparency;
299
+ }
300
+ return out;
301
+ }
302
+ /** `--reminder 10 --reminder 60:email` or `--reminder default`. Max 5, 0..40320 minutes. */
303
+ function readReminders(raw) {
304
+ if (raw.length === 0)
305
+ return undefined;
306
+ if (raw.includes("default")) {
307
+ if (raw.length > 1)
308
+ throw new Error("--reminder default stands alone: it means the calendar's own reminders.");
309
+ return "default";
310
+ }
311
+ if (raw.length > 5)
312
+ throw new Error("An event holds at most 5 reminders.");
313
+ return raw.map((entry) => {
314
+ const [minutesText, method, extra] = entry.split(":");
315
+ if (extra !== undefined || !/^\d+$/.test(minutesText ?? ""))
316
+ throw new Error("--reminder takes minutes (0 to 40320), optionally minutes:popup or minutes:email, or default.");
317
+ const minutes = Number(minutesText);
318
+ if (minutes > 40320)
319
+ throw new Error("--reminder minutes run from 0 to 40320 (four weeks).");
320
+ if (method !== undefined && method !== "popup" && method !== "email")
321
+ throw new Error("A reminder method is popup or email.");
322
+ return { minutes, ...(method ? { method } : {}) };
323
+ });
233
324
  }