letthemquibble 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -16
  3. package/dist/quibble.js +2247 -454
  4. package/package.json +3 -2
package/dist/quibble.js CHANGED
@@ -1156,18 +1156,18 @@ var require_suggestSimilar = __commonJS({
1156
1156
  }
1157
1157
  return d[a.length][b.length];
1158
1158
  }
1159
- function suggestSimilar(word, candidates) {
1160
- if (!candidates || candidates.length === 0) return "";
1161
- candidates = Array.from(new Set(candidates));
1159
+ function suggestSimilar(word, candidates2) {
1160
+ if (!candidates2 || candidates2.length === 0) return "";
1161
+ candidates2 = Array.from(new Set(candidates2));
1162
1162
  const searchingOptions = word.startsWith("--");
1163
1163
  if (searchingOptions) {
1164
1164
  word = word.slice(2);
1165
- candidates = candidates.map((candidate) => candidate.slice(2));
1165
+ candidates2 = candidates2.map((candidate) => candidate.slice(2));
1166
1166
  }
1167
1167
  let similar = [];
1168
1168
  let bestDistance = maxDistance;
1169
1169
  const minSimilarity = 0.4;
1170
- candidates.forEach((candidate) => {
1170
+ candidates2.forEach((candidate) => {
1171
1171
  if (candidate.length <= 1) return;
1172
1172
  const distance = editDistance(word, candidate);
1173
1173
  const length = Math.max(word.length, candidate.length);
@@ -3490,110 +3490,6 @@ var {
3490
3490
  Help
3491
3491
  } = import_index.default;
3492
3492
 
3493
- // src/config.ts
3494
- import { readFileSync as readFileSync2 } from "node:fs";
3495
- import { dirname as dirname3, isAbsolute, join as join3, relative, resolve, sep } from "node:path";
3496
-
3497
- // ../core/dist/types.js
3498
- var SEVERITIES = ["low", "medium", "high"];
3499
- var STATUSES = ["open", "resolved", "dismissed", "deleted"];
3500
- var DELETED_STATUS = "deleted";
3501
- var MESSAGE_MAX = 4e3;
3502
- var RESOLUTION_MAX = 500;
3503
- var TAGS_MAX = 10;
3504
- var TAG_MAX = 32;
3505
- var ALLOWED_TAGS_MAX = 50;
3506
- var OTHER_TAG = "other";
3507
- var OTHER_TAG_PREFIX = `${OTHER_TAG}:`;
3508
- var OTHER_TAG_HINT = `${OTHER_TAG_PREFIX}<suggestion>`;
3509
- var ALLOWED_TAG_RE = /^[a-z0-9][a-z0-9._-]*$/;
3510
- var AGENT_MAX = 64;
3511
- var REPORTER_MAX = 128;
3512
- var EMAIL_MAX = REPORTER_MAX;
3513
- var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3514
- var TOKEN_NAME_MAX = 64;
3515
- var CWD_MAX = 1024;
3516
- var UNTAGGED_TAG = "-";
3517
- var STATS_MAX_WINDOW_DAYS = 90;
3518
- var ROLES = ["filer", "viewer", "maintainer"];
3519
- var ORG_ROLES = ["member", "admin", "owner"];
3520
- var ORG_COLOURS = [
3521
- "slate",
3522
- "moss",
3523
- "ember",
3524
- "rust",
3525
- "teal",
3526
- "plum",
3527
- "snowcat",
3528
- "bone"
3529
- ];
3530
- var DEFAULT_ORG_COLOUR = "slate";
3531
- var ORG_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,37}[a-z0-9]$/;
3532
- var RESERVED_SLUGS = [
3533
- "api",
3534
- "admin",
3535
- "login",
3536
- "logout",
3537
- "o",
3538
- "orgs",
3539
- "new",
3540
- "platform",
3541
- "local",
3542
- "www",
3543
- "app",
3544
- "settings",
3545
- "signup"
3546
- ];
3547
- var LOCAL_ORG = "local";
3548
- var DISPLAY_NAME_MAX = 64;
3549
- var PROJECT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
3550
- var DEFAULT_LIMIT = 50;
3551
- var MAX_LIMIT = 200;
3552
- var DEFAULT_SEVERITY = "medium";
3553
- var DEFAULT_STATUS = "open";
3554
- var DEFAULT_AGENT = "unknown";
3555
- var DEFAULT_REPORTER = "unknown";
3556
- var ACT_AS_HEADER = "x-quibble-act-as";
3557
-
3558
- // ../core/dist/errors.js
3559
- var QuibbleError = class extends Error {
3560
- /** Stable machine-readable code, also used by the API error body. */
3561
- code;
3562
- constructor(code, message, options) {
3563
- super(message, options);
3564
- this.code = code;
3565
- this.name = new.target.name;
3566
- }
3567
- };
3568
- var ValidationError = class extends QuibbleError {
3569
- /** One readable line per problem, such as `message: must be 1-4000 characters`. */
3570
- issues;
3571
- constructor(issues, options) {
3572
- const list = issues.length > 0 ? issues : ["invalid input"];
3573
- super("validation_error", list.join("; "), options);
3574
- this.issues = [...list];
3575
- }
3576
- };
3577
- var StorageError = class extends QuibbleError {
3578
- constructor(message, options) {
3579
- super("storage_error", message, options);
3580
- }
3581
- };
3582
- var ConfigError = class extends QuibbleError {
3583
- /** The file that holds the bad configuration, when one exists. */
3584
- path;
3585
- constructor(message, path, options) {
3586
- super("config_error", message, options);
3587
- this.path = path;
3588
- }
3589
- };
3590
- function isNotFound(error62) {
3591
- return typeof error62 === "object" && error62 !== null && error62.code === "ENOENT";
3592
- }
3593
- function describeError(error62) {
3594
- return error62 instanceof Error ? error62.message : String(error62);
3595
- }
3596
-
3597
3493
  // ../../node_modules/.pnpm/zod@4.6.4/node_modules/zod/v4/classic/external.js
3598
3494
  var external_exports = {};
3599
3495
  __export(external_exports, {
@@ -6407,9 +6303,9 @@ var asciiTabOrNewline = /[\t\n\r]/g;
6407
6303
  function stripTabAndNewline(value) {
6408
6304
  return value.replace(asciiTabOrNewline, "");
6409
6305
  }
6410
- function urlHostnameOk(url2, hostname3) {
6411
- hostname3.lastIndex = 0;
6412
- return hostname3.test(url2.hostname);
6306
+ function urlHostnameOk(url2, hostname4) {
6307
+ hostname4.lastIndex = 0;
6308
+ return hostname4.test(url2.hostname);
6413
6309
  }
6414
6310
  function urlProtocolOk(url2, protocol) {
6415
6311
  protocol.lastIndex = 0;
@@ -8620,7 +8516,7 @@ var recursive = /* @__PURE__ */ new WeakMap();
8620
8516
  var NONE = 0;
8621
8517
  var ASSUMED = 1;
8622
8518
  var PROVEN = 2;
8623
- function isRecursive(inst, stack, resolve3) {
8519
+ function isRecursive(inst, stack, resolve4) {
8624
8520
  const cached2 = recursive.get(inst);
8625
8521
  if (cached2 !== void 0)
8626
8522
  return cached2 ? PROVEN : NONE;
@@ -8630,7 +8526,7 @@ function isRecursive(inst, stack, resolve3) {
8630
8526
  let result = NONE;
8631
8527
  const check2 = (child) => {
8632
8528
  if (result !== PROVEN && child?._zod) {
8633
- const answer2 = isRecursive(child, stack, resolve3);
8529
+ const answer2 = isRecursive(child, stack, resolve4);
8634
8530
  if (answer2 > result)
8635
8531
  result = answer2;
8636
8532
  }
@@ -8641,7 +8537,7 @@ function isRecursive(inst, stack, resolve3) {
8641
8537
  const desc = Object.getOwnPropertyDescriptor(sh, key);
8642
8538
  if (spread && !desc.enumerable)
8643
8539
  continue;
8644
- const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve3) : NONE;
8540
+ const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve4) : NONE;
8645
8541
  if (child > answer2)
8646
8542
  answer2 = child;
8647
8543
  }
@@ -8705,7 +8601,7 @@ function isRecursive(inst, stack, resolve3) {
8705
8601
  break;
8706
8602
  // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly
8707
8603
  case "lazy": {
8708
- const inner = def._cachedInner ?? (resolve3 ? inst._zod.innerType : void 0);
8604
+ const inner = def._cachedInner ?? (resolve4 ? inst._zod.innerType : void 0);
8709
8605
  merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
8710
8606
  break;
8711
8607
  }
@@ -23262,66 +23158,292 @@ function date4(params) {
23262
23158
  return _coercedDate(ZodDate, params);
23263
23159
  }
23264
23160
 
23161
+ // ../core/dist/jitless.js
23162
+ external_exports.config({ jitless: true });
23163
+
23164
+ // ../core/dist/types.js
23165
+ var SEVERITIES = ["low", "medium", "high"];
23166
+ var STATUSES = ["open", "resolved", "dismissed", "deleted"];
23167
+ var DELETED_STATUS = "deleted";
23168
+ var REASONS = ["fixed", "duplicate", "wontfix", "noise"];
23169
+ var DUPLICATE_REASON = "duplicate";
23170
+ var MESSAGE_MAX = 4e3;
23171
+ var RESOLUTION_MAX = 500;
23172
+ var TAGS_MAX = 10;
23173
+ var TAG_MAX = 32;
23174
+ var ALLOWED_TAGS_MAX = 50;
23175
+ var OTHER_TAG = "other";
23176
+ var OTHER_TAG_PREFIX = `${OTHER_TAG}:`;
23177
+ var OTHER_TAG_HINT = `${OTHER_TAG_PREFIX}<suggestion>`;
23178
+ var ALLOWED_TAG_RE = /^[a-z0-9][a-z0-9._-]*$/;
23179
+ var AGENT_MAX = 64;
23180
+ var REPORTER_MAX = 128;
23181
+ var EMAIL_MAX = REPORTER_MAX;
23182
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
23183
+ var TOKEN_NAME_MAX = 64;
23184
+ var CWD_MAX = 1024;
23185
+ var CMD_MAX = 1e3;
23186
+ var FINGERPRINT_MAX = 200;
23187
+ var EMPTY_FINGERPRINT = "<empty>";
23188
+ var RECURRENCE_WINDOW_DAYS = 90;
23189
+ var SEARCH_MAX = 200;
23190
+ var BULK_MAX = 100;
23191
+ var ID_MAX = 64;
23192
+ var UNTAGGED_TAG = "-";
23193
+ var STATS_MAX_WINDOW_DAYS = 90;
23194
+ var ROLES = ["filer", "viewer", "maintainer"];
23195
+ var ORG_ROLES = ["member", "admin", "owner"];
23196
+ var ORG_COLOURS = [
23197
+ "slate",
23198
+ "moss",
23199
+ "ember",
23200
+ "rust",
23201
+ "teal",
23202
+ "plum",
23203
+ "snowcat",
23204
+ "bone"
23205
+ ];
23206
+ var DEFAULT_ORG_COLOUR = "slate";
23207
+ var ORG_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,37}[a-z0-9]$/;
23208
+ var RESERVED_SLUGS = [
23209
+ "api",
23210
+ "admin",
23211
+ "login",
23212
+ "logout",
23213
+ "o",
23214
+ "orgs",
23215
+ "new",
23216
+ "platform",
23217
+ "local",
23218
+ "www",
23219
+ "app",
23220
+ "settings",
23221
+ "signup"
23222
+ ];
23223
+ var LOCAL_ORG = "local";
23224
+ var DISPLAY_NAME_MAX = 64;
23225
+ var PROJECT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
23226
+ var DEFAULT_LIMIT = 50;
23227
+ var MAX_LIMIT = 200;
23228
+ var DEFAULT_SEVERITY = "medium";
23229
+ var DEFAULT_STATUS = "open";
23230
+ var DEFAULT_AGENT = "unknown";
23231
+ var DEFAULT_REPORTER = "unknown";
23232
+ var ACT_AS_HEADER = "x-quibble-act-as";
23233
+ var TRUNCATED_HEADER = "x-quibble-truncated";
23234
+ var IDEMPOTENCY_HEADER = "idempotency-key";
23235
+ var IDEMPOTENCY_KEY_MAX = 128;
23236
+
23237
+ // ../core/dist/errors.js
23238
+ var QuibbleError = class extends Error {
23239
+ /** Stable machine-readable code, also used by the API error body. */
23240
+ code;
23241
+ constructor(code, message, options) {
23242
+ super(message, options);
23243
+ this.code = code;
23244
+ this.name = new.target.name;
23245
+ }
23246
+ };
23247
+ var ValidationError = class extends QuibbleError {
23248
+ /** One readable line per problem, such as `message: must be 1-4000 characters`. */
23249
+ issues;
23250
+ constructor(issues, options) {
23251
+ const list = issues.length > 0 ? issues : ["invalid input"];
23252
+ super("validation_error", list.join("; "), options);
23253
+ this.issues = [...list];
23254
+ }
23255
+ };
23256
+ var StorageError = class extends QuibbleError {
23257
+ constructor(message, options) {
23258
+ super("storage_error", message, options);
23259
+ }
23260
+ };
23261
+ var DuplicateIdError = class extends StorageError {
23262
+ /** The id that a record already holds. */
23263
+ id;
23264
+ /**
23265
+ * The record that won, when the store read it back, so the caller answers
23266
+ * it without a read of its own that an index may not see yet.
23267
+ */
23268
+ existing;
23269
+ constructor(id, existing, options) {
23270
+ super(`complaint ${id} already exists`, options);
23271
+ this.id = id;
23272
+ this.existing = existing;
23273
+ }
23274
+ };
23275
+ var ConfigError = class extends QuibbleError {
23276
+ /** The file that holds the bad configuration, when one exists. */
23277
+ path;
23278
+ constructor(message, path, options) {
23279
+ super("config_error", message, options);
23280
+ this.path = path;
23281
+ }
23282
+ };
23283
+ function isNotFound(error62) {
23284
+ return typeof error62 === "object" && error62 !== null && error62.code === "ENOENT";
23285
+ }
23286
+ function describeError(error62) {
23287
+ return error62 instanceof Error ? error62.message : String(error62);
23288
+ }
23289
+
23290
+ // ../core/dist/fingerprint.js
23291
+ function headlineOf(message) {
23292
+ for (const line of message.split("\n")) {
23293
+ const trimmed = line.trim();
23294
+ if (trimmed !== "")
23295
+ return trimmed;
23296
+ }
23297
+ return "";
23298
+ }
23299
+ var WORD_CHAR = "[\\p{L}\\p{N}]";
23300
+ var QUOTED_RE = new RegExp(`"[^"]*"|\`[^\`]*\`|(?<!${WORD_CHAR})'.*?'(?!${WORD_CHAR})`, "gu");
23301
+ var PATH_RE = /[^\s]*[/\\][^\s]*/gu;
23302
+ var UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gu;
23303
+ var HEX_LITERAL_RE = /0x[0-9a-f]+/gu;
23304
+ var HEX_RUN_RE = new RegExp(`(?<!${WORD_CHAR})[0-9a-f]{7,}(?!${WORD_CHAR})`, "gu");
23305
+ var NUMBER_RE = new RegExp("\\p{N}+(?:\\.\\p{N}+)*", "gu");
23306
+ var PUNCTUATION_RE = /[^\p{L}\p{N}\s<>]/gu;
23307
+ function isHexId(run2) {
23308
+ return /[a-f]/.test(run2) && /[0-9]/.test(run2);
23309
+ }
23310
+ function cut(text, max) {
23311
+ if (text.length <= max)
23312
+ return text;
23313
+ const head = text.slice(0, max);
23314
+ const last = head.charCodeAt(head.length - 1);
23315
+ return last >= 55296 && last <= 56319 ? head.slice(0, -1) : head;
23316
+ }
23317
+ function fingerprintOf(message) {
23318
+ let text = headlineOf(message).toLowerCase();
23319
+ text = text.replace(/[<>]/gu, " ");
23320
+ text = text.replace(QUOTED_RE, " <str> ");
23321
+ text = text.replace(PATH_RE, "<path>");
23322
+ text = text.replace(UUID_RE, "<hex>");
23323
+ text = text.replace(HEX_LITERAL_RE, "<hex>");
23324
+ text = text.replace(HEX_RUN_RE, (run2) => isHexId(run2) ? "<hex>" : run2);
23325
+ text = text.replace(NUMBER_RE, "<n>");
23326
+ text = text.replace(PUNCTUATION_RE, " ");
23327
+ text = text.replace(/\s+/gu, " ").trim();
23328
+ text = cut(text, FINGERPRINT_MAX).trim();
23329
+ return text === "" ? EMPTY_FINGERPRINT : text;
23330
+ }
23331
+ function groupComplaints(complaints) {
23332
+ const groups = /* @__PURE__ */ new Map();
23333
+ for (const complaint of complaints) {
23334
+ const key = `${complaint.org}/${complaint.project}
23335
+ ${complaint.fingerprint}`;
23336
+ const group = groups.get(key);
23337
+ const recurred = complaint.status === "open" && complaint.recurred === true;
23338
+ if (group === void 0) {
23339
+ groups.set(key, {
23340
+ project: complaint.project,
23341
+ fingerprint: complaint.fingerprint,
23342
+ head: complaint,
23343
+ members: [complaint],
23344
+ count: 1,
23345
+ recurred
23346
+ });
23347
+ continue;
23348
+ }
23349
+ group.members.push(complaint);
23350
+ group.count = group.members.length;
23351
+ group.recurred ||= recurred;
23352
+ }
23353
+ return [...groups.values()];
23354
+ }
23355
+
23265
23356
  // ../core/dist/validate.js
23266
- var NULLABLE_KEYS = [
23357
+ var NEW_NULLABLE_KEYS = [
23267
23358
  "severity",
23268
23359
  "tags",
23269
23360
  "agent",
23270
23361
  "reporter",
23271
23362
  "reporterName",
23272
23363
  "cwd",
23273
- "status",
23274
- "resolution",
23275
- "updatedAt"
23364
+ "cmd"
23276
23365
  ];
23277
- var BLANKABLE_KEYS = [
23366
+ var NEW_BLANKABLE_KEYS = [
23278
23367
  "severity",
23279
23368
  "agent",
23280
23369
  "reporter",
23281
23370
  "reporterName",
23371
+ "cmd"
23372
+ ];
23373
+ var STORED_STATUS_KEYS = [
23282
23374
  "status",
23283
23375
  "resolution",
23376
+ "reason",
23377
+ "duplicateOf",
23378
+ "statusChangedAt",
23379
+ "statusChangedBy",
23380
+ "statusChangedByName",
23284
23381
  "updatedAt"
23285
23382
  ];
23383
+ var STORED_NULLABLE_KEYS = [
23384
+ ...NEW_NULLABLE_KEYS,
23385
+ ...STORED_STATUS_KEYS,
23386
+ "fingerprint",
23387
+ "recurred"
23388
+ ];
23389
+ var STORED_BLANKABLE_KEYS = [
23390
+ ...NEW_BLANKABLE_KEYS,
23391
+ ...STORED_STATUS_KEYS,
23392
+ "fingerprint"
23393
+ ];
23286
23394
  var FILTER_KEYS = [
23287
23395
  "project",
23288
23396
  "severity",
23289
23397
  "status",
23290
- "tag",
23398
+ "tags",
23291
23399
  "reporter",
23400
+ "q",
23292
23401
  "since",
23293
23402
  "until",
23294
23403
  "limit"
23295
23404
  ];
23296
- var STATUS_CHANGE_KEYS = ["status", "resolution"];
23405
+ var FILTER_NULLABLE_KEYS = ["fingerprint"];
23406
+ var STATUS_CHANGE_KEYS = ["status", "resolution", "reason", "duplicateOf"];
23297
23407
  function isPlainObject2(value) {
23298
23408
  return typeof value === "object" && value !== null && !Array.isArray(value);
23299
23409
  }
23300
23410
  function isBlank(value) {
23301
23411
  return typeof value === "string" && value.trim() === "";
23302
23412
  }
23303
- function normalizeComplaintInput(value) {
23413
+ function complaintInput(nullable2, blankable) {
23414
+ return (value) => {
23415
+ if (!isPlainObject2(value))
23416
+ return value;
23417
+ const out = { ...value };
23418
+ for (const key of nullable2) {
23419
+ if (out[key] === null)
23420
+ delete out[key];
23421
+ }
23422
+ for (const key of blankable) {
23423
+ if (isBlank(out[key]))
23424
+ delete out[key];
23425
+ }
23426
+ if (isBlank(out["cwd"]))
23427
+ out["cwd"] = ".";
23428
+ return out;
23429
+ };
23430
+ }
23431
+ function normalizeKeys(value, keys) {
23304
23432
  if (!isPlainObject2(value))
23305
23433
  return value;
23306
23434
  const out = { ...value };
23307
- for (const key of NULLABLE_KEYS) {
23308
- if (out[key] === null)
23309
- delete out[key];
23310
- }
23311
- for (const key of BLANKABLE_KEYS) {
23312
- if (isBlank(out[key]))
23435
+ for (const key of keys) {
23436
+ if (out[key] === null || isBlank(out[key]))
23313
23437
  delete out[key];
23314
23438
  }
23315
- if (isBlank(out["cwd"]))
23316
- out["cwd"] = ".";
23317
23439
  return out;
23318
23440
  }
23319
- function normalizeKeys(value, keys) {
23441
+ function dropNulls(value, keys) {
23320
23442
  if (!isPlainObject2(value))
23321
23443
  return value;
23322
23444
  const out = { ...value };
23323
23445
  for (const key of keys) {
23324
- if (out[key] === null || isBlank(out[key]))
23446
+ if (out[key] === null)
23325
23447
  delete out[key];
23326
23448
  }
23327
23449
  return out;
@@ -23333,6 +23455,12 @@ function boundedString(max, typeMessage) {
23333
23455
  const message = lengthMessage(1, max);
23334
23456
  return external_exports.string({ message: typeMessage ?? message }).trim().min(1, { message }).max(max, { message });
23335
23457
  }
23458
+ var CONTROL_RE = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/;
23459
+ var CONTROL_MESSAGE = "must not contain control characters";
23460
+ function freeText(max, typeMessage) {
23461
+ const message = lengthMessage(1, max);
23462
+ return external_exports.string({ message: typeMessage ?? message }).overwrite((value) => value.replace(/\r\n?/g, "\n")).trim().min(1, { message }).max(max, { message }).refine((value) => !CONTROL_RE.test(value), { message: CONTROL_MESSAGE });
23463
+ }
23336
23464
  var SEVERITY_MESSAGE = `must be one of ${SEVERITIES.join(", ")}`;
23337
23465
  var STATUS_MESSAGE = `must be one of ${STATUSES.join(", ")}`;
23338
23466
  var LIMIT_MESSAGE = `must be an integer between 1 and ${MAX_LIMIT}`;
@@ -23354,7 +23482,11 @@ var orgSlugSchema = external_exports.string({ message: SLUG_MESSAGE }).refine((v
23354
23482
  });
23355
23483
  var severitySchema = external_exports.enum(SEVERITIES, { message: SEVERITY_MESSAGE });
23356
23484
  var statusSchema = external_exports.enum(STATUSES, { message: STATUS_MESSAGE });
23357
- var resolutionSchema = boundedString(RESOLUTION_MAX);
23485
+ var REASON_MESSAGE = `must be one of ${REASONS.join(", ")}`;
23486
+ var reasonSchema = external_exports.enum(REASONS, { message: REASON_MESSAGE });
23487
+ var idSchema = boundedString(ID_MAX, ID_MESSAGE);
23488
+ var resolutionSchema = freeText(RESOLUTION_MAX);
23489
+ var storedResolutionSchema = boundedString(RESOLUTION_MAX);
23358
23490
  var TAG_LENGTH_MESSAGE = lengthMessage(1, TAG_MAX);
23359
23491
  var SUGGESTION_LENGTH_MESSAGE = `must be at most ${TAG_MAX} characters after "${OTHER_TAG_PREFIX}"`;
23360
23492
  var tagSchema = external_exports.string({ message: "must be a string" }).trim().min(1, { message: TAG_LENGTH_MESSAGE }).superRefine((tag, ctx) => {
@@ -23370,8 +23502,8 @@ var tagSchema = external_exports.string({ message: "must be a string" }).trim().
23370
23502
  }
23371
23503
  });
23372
23504
  var TAGS_TYPE_MESSAGE = "must be an array of strings";
23373
- var UNTAGGED_TAG_MESSAGE = `must not be "${UNTAGGED_TAG}", which selects the complaints with no tag`;
23374
- var RESERVED_TAG_MESSAGE = `must not be "${OTHER_TAG}" or start with "${OTHER_TAG_PREFIX}", which are reserved`;
23505
+ var UNTAGGED_TAG_MESSAGE = `must not be "${UNTAGGED_TAG}" (reserved for untagged)`;
23506
+ var RESERVED_TAG_MESSAGE = `must not be "${OTHER_TAG}" or start with "${OTHER_TAG_PREFIX}" (reserved)`;
23375
23507
  var ALLOWED_TAG_MESSAGE = 'must use only letters, digits, ".", "_" and "-", and start with a letter or digit';
23376
23508
  function normalizeTag(tag) {
23377
23509
  const lower = tag.toLowerCase();
@@ -23386,7 +23518,7 @@ var complaintTagSchema = tagSchema.refine((tag) => tag !== UNTAGGED_TAG, {
23386
23518
  var tagsSchema = external_exports.array(complaintTagSchema, { message: TAGS_TYPE_MESSAGE }).transform(normalizeTags).refine((tags) => tags.length <= TAGS_MAX, {
23387
23519
  message: `must have at most ${TAGS_MAX} tags`
23388
23520
  });
23389
- var tagsChangeObject = external_exports.object({
23521
+ var tagsChangeObject = external_exports.strictObject({
23390
23522
  tags: tagsSchema.refine((tags) => tags.length > 0, {
23391
23523
  message: "must list at least 1 tag"
23392
23524
  })
@@ -23474,17 +23606,21 @@ var limitSchema = external_exports.preprocess((value) => {
23474
23606
  const parsed = Number(trimmed);
23475
23607
  return Number.isNaN(parsed) ? value : parsed;
23476
23608
  }, external_exports.number({ message: LIMIT_MESSAGE }).int({ message: LIMIT_MESSAGE }).min(1, { message: LIMIT_MESSAGE }).max(MAX_LIMIT, { message: LIMIT_MESSAGE }));
23477
- var newComplaintObject = external_exports.object({
23609
+ var agentSchema = freeText(AGENT_MAX);
23610
+ var newComplaintShape = {
23478
23611
  org: orgSlugSchema,
23479
23612
  project: projectNameSchema,
23480
- message: boundedString(MESSAGE_MAX),
23613
+ message: freeText(MESSAGE_MAX),
23481
23614
  severity: severitySchema.optional(),
23482
23615
  tags: tagsSchema.optional(),
23483
- agent: boundedString(AGENT_MAX).optional(),
23484
- reporter: boundedString(REPORTER_MAX).optional(),
23485
- reporterName: boundedString(REPORTER_MAX).optional(),
23486
- cwd: cwdSchema.optional()
23487
- });
23616
+ agent: agentSchema.optional(),
23617
+ reporter: freeText(REPORTER_MAX).optional(),
23618
+ reporterName: freeText(REPORTER_MAX).optional(),
23619
+ cwd: cwdSchema.optional(),
23620
+ // Free text: it is trimmed and may hold newlines, and nothing parses it.
23621
+ cmd: freeText(CMD_MAX).optional()
23622
+ };
23623
+ var newComplaintObject = external_exports.strictObject(newComplaintShape);
23488
23624
  function withDefaults(input2) {
23489
23625
  return {
23490
23626
  org: input2.org,
@@ -23496,74 +23632,207 @@ function withDefaults(input2) {
23496
23632
  reporter: input2.reporter ?? DEFAULT_REPORTER,
23497
23633
  // The server sets the display name, so it stays absent when nobody did.
23498
23634
  ...input2.reporterName === void 0 ? {} : { reporterName: input2.reporterName },
23499
- ...input2.cwd === void 0 ? {} : { cwd: input2.cwd }
23635
+ ...input2.cwd === void 0 ? {} : { cwd: input2.cwd },
23636
+ ...input2.cmd === void 0 ? {} : { cmd: input2.cmd }
23500
23637
  };
23501
23638
  }
23502
- var newComplaintSchema = external_exports.preprocess(normalizeComplaintInput, newComplaintObject).transform(withDefaults);
23503
- var storedComplaintObject = newComplaintObject.extend({
23639
+ var newComplaintSchema = external_exports.preprocess(complaintInput(NEW_NULLABLE_KEYS, NEW_BLANKABLE_KEYS), newComplaintObject).transform(withDefaults);
23640
+ var actorSchema = boundedString(REPORTER_MAX);
23641
+ var storedComplaintObject = external_exports.object({
23642
+ ...newComplaintShape,
23643
+ // The free text of a stored record is read without the control rule, so a
23644
+ // record written before it stays readable.
23645
+ message: boundedString(MESSAGE_MAX),
23646
+ agent: boundedString(AGENT_MAX).optional(),
23647
+ reporter: boundedString(REPORTER_MAX).optional(),
23648
+ reporterName: boundedString(REPORTER_MAX).optional(),
23649
+ cmd: boundedString(CMD_MAX).optional(),
23504
23650
  id: external_exports.string({ message: ID_MESSAGE }).trim().min(1, { message: ID_MESSAGE }),
23651
+ fingerprint: external_exports.string({ message: ID_MESSAGE }).min(1, { message: ID_MESSAGE }).optional(),
23652
+ recurred: external_exports.boolean({ message: "must be a boolean" }).optional(),
23505
23653
  createdAt: timestampSchema,
23506
23654
  status: statusSchema.optional(),
23507
- resolution: resolutionSchema.optional(),
23655
+ resolution: storedResolutionSchema.optional(),
23656
+ reason: reasonSchema.optional(),
23657
+ duplicateOf: idSchema.optional(),
23658
+ statusChangedAt: timestampSchema.optional(),
23659
+ statusChangedBy: actorSchema.optional(),
23660
+ statusChangedByName: actorSchema.optional(),
23508
23661
  updatedAt: timestampSchema.optional()
23509
23662
  });
23510
- var complaintSchema = external_exports.preprocess(normalizeComplaintInput, storedComplaintObject).transform((input2) => ({
23663
+ var complaintSchema = external_exports.preprocess(complaintInput(STORED_NULLABLE_KEYS, STORED_BLANKABLE_KEYS), storedComplaintObject).transform((input2) => ({
23511
23664
  id: input2.id,
23512
23665
  ...withDefaults(input2),
23666
+ fingerprint: input2.fingerprint ?? fingerprintOf(input2.message),
23667
+ // The store writes the flag only when it is true.
23668
+ ...input2.recurred === true ? { recurred: true } : {},
23513
23669
  // Millisecond precision, so a record written without a fraction still
23514
23670
  // compares by time against a `since` and an `until`.
23515
23671
  createdAt: toMilliseconds(input2.createdAt),
23516
23672
  status: input2.status ?? DEFAULT_STATUS,
23517
23673
  ...input2.resolution === void 0 ? {} : { resolution: input2.resolution },
23674
+ ...input2.reason === void 0 ? {} : { reason: input2.reason },
23675
+ ...input2.duplicateOf === void 0 ? {} : { duplicateOf: input2.duplicateOf },
23676
+ ...input2.statusChangedAt === void 0 ? {} : { statusChangedAt: toMilliseconds(input2.statusChangedAt) },
23677
+ ...input2.statusChangedBy === void 0 ? {} : { statusChangedBy: input2.statusChangedBy },
23678
+ ...input2.statusChangedByName === void 0 ? {} : { statusChangedByName: input2.statusChangedByName },
23518
23679
  ...input2.updatedAt === void 0 ? {} : { updatedAt: toMilliseconds(input2.updatedAt) }
23519
23680
  }));
23520
- var statusChangeObject = external_exports.object({
23681
+ var REASON_STATUS_MESSAGE = "only a resolve or a dismiss takes a reason";
23682
+ var DUPLICATE_OF_MESSAGE = `needs reason ${DUPLICATE_REASON}`;
23683
+ var statusChangeShape = {
23521
23684
  status: statusSchema,
23522
- resolution: resolutionSchema.optional()
23523
- });
23524
- var statusChangeSchema = external_exports.preprocess((value) => normalizeKeys(value, STATUS_CHANGE_KEYS), statusChangeObject).transform((input2) => ({
23525
- status: input2.status,
23526
- ...input2.resolution === void 0 ? {} : { resolution: input2.resolution }
23527
- }));
23528
- var BEFORE_UNTIL_MESSAGE = "must be before until";
23529
- function checkWindow(window, ctx) {
23530
- const { since, until } = window;
23685
+ resolution: resolutionSchema.optional(),
23686
+ reason: reasonSchema.optional(),
23687
+ duplicateOf: idSchema.optional()
23688
+ };
23689
+ function checkReason(change, ctx) {
23690
+ if (change.reason !== void 0 && change.status !== "resolved" && change.status !== "dismissed") {
23691
+ ctx.addIssue({ code: "custom", path: ["reason"], message: REASON_STATUS_MESSAGE });
23692
+ }
23693
+ if (change.duplicateOf !== void 0 && change.reason !== DUPLICATE_REASON) {
23694
+ ctx.addIssue({
23695
+ code: "custom",
23696
+ path: ["duplicateOf"],
23697
+ message: DUPLICATE_OF_MESSAGE
23698
+ });
23699
+ }
23700
+ }
23701
+ function toStatusChange(input2) {
23702
+ return {
23703
+ status: input2.status,
23704
+ ...input2.resolution === void 0 ? {} : { resolution: input2.resolution },
23705
+ ...input2.reason === void 0 ? {} : { reason: input2.reason },
23706
+ ...input2.duplicateOf === void 0 ? {} : { duplicateOf: input2.duplicateOf }
23707
+ };
23708
+ }
23709
+ var statusChangeSchema = external_exports.preprocess((value) => normalizeKeys(value, STATUS_CHANGE_KEYS), external_exports.strictObject(statusChangeShape).superRefine(checkReason)).transform(toStatusChange);
23710
+ var BULK_IDS_MESSAGE = `must list 1 to ${BULK_MAX} ids`;
23711
+ var BULK_BOTH_MESSAGE = "must change the status or the tags, not both";
23712
+ var BULK_NEITHER_MESSAGE = "must carry a status, addTags or removeTags";
23713
+ var BULK_NO_TAG_MESSAGE = "must carry at least 1 tag in addTags or removeTags";
23714
+ var BULK_TAG_KEYS = ["addTags", "removeTags"];
23715
+ var bulkIdsSchema = external_exports.array(idSchema, { message: BULK_IDS_MESSAGE }).min(1, { message: BULK_IDS_MESSAGE }).max(BULK_MAX, { message: BULK_IDS_MESSAGE }).transform((ids) => [...new Set(ids)]);
23716
+ var bulkChangeSchema = external_exports.preprocess((value) => dropNulls(normalizeKeys(value, STATUS_CHANGE_KEYS), BULK_TAG_KEYS), external_exports.strictObject({
23717
+ ids: bulkIdsSchema,
23718
+ status: statusChangeShape.status.optional(),
23719
+ resolution: statusChangeShape.resolution,
23720
+ reason: statusChangeShape.reason,
23721
+ duplicateOf: statusChangeShape.duplicateOf,
23722
+ addTags: tagsSchema.optional(),
23723
+ removeTags: tagsSchema.optional()
23724
+ }).superRefine((input2, ctx) => {
23725
+ const status = STATUS_CHANGE_KEYS.some((key) => input2[key] !== void 0);
23726
+ const tags = BULK_TAG_KEYS.some((key) => input2[key] !== void 0);
23727
+ if (status && tags) {
23728
+ ctx.addIssue({ code: "custom", message: BULK_BOTH_MESSAGE });
23729
+ return;
23730
+ }
23731
+ if (!status && !tags) {
23732
+ ctx.addIssue({ code: "custom", message: BULK_NEITHER_MESSAGE });
23733
+ return;
23734
+ }
23735
+ if (status) {
23736
+ if (input2.status === void 0) {
23737
+ ctx.addIssue({ code: "custom", path: ["status"], message: STATUS_MESSAGE });
23738
+ return;
23739
+ }
23740
+ checkReason({ ...input2, status: input2.status }, ctx);
23741
+ return;
23742
+ }
23743
+ const addTags = input2.addTags ?? [];
23744
+ const removeTags = input2.removeTags ?? [];
23745
+ if (addTags.length + removeTags.length === 0) {
23746
+ ctx.addIssue({ code: "custom", message: BULK_NO_TAG_MESSAGE });
23747
+ return;
23748
+ }
23749
+ for (const tag of removeTags) {
23750
+ if (!addTags.includes(tag))
23751
+ continue;
23752
+ ctx.addIssue({
23753
+ code: "custom",
23754
+ path: ["removeTags"],
23755
+ message: `${tag} is also in addTags`
23756
+ });
23757
+ }
23758
+ })).transform((input2) => {
23759
+ if (input2.status !== void 0) {
23760
+ return {
23761
+ ids: input2.ids,
23762
+ kind: "status",
23763
+ change: toStatusChange({ ...input2, status: input2.status })
23764
+ };
23765
+ }
23766
+ return {
23767
+ ids: input2.ids,
23768
+ kind: "tags",
23769
+ addTags: input2.addTags ?? [],
23770
+ removeTags: input2.removeTags ?? []
23771
+ };
23772
+ });
23773
+ var BEFORE_UNTIL_MESSAGE = "must be before until";
23774
+ function checkWindow(window, ctx) {
23775
+ const { since, until } = window;
23531
23776
  if (since === void 0 || until === void 0)
23532
23777
  return;
23533
23778
  if (Date.parse(since) < Date.parse(until))
23534
23779
  return;
23535
23780
  ctx.addIssue({ code: "custom", path: ["since"], message: BEFORE_UNTIL_MESSAGE });
23536
23781
  }
23537
- var listFilterObject = external_exports.object({
23782
+ var filterTagsSchema = external_exports.array(tagSchema, { message: TAGS_TYPE_MESSAGE }).transform(normalizeTags).refine((tags) => tags.length > 0, { message: "must list at least 1 tag" }).refine((tags) => tags.length <= TAGS_MAX, {
23783
+ message: `must have at most ${TAGS_MAX} tags`
23784
+ });
23785
+ var FINGERPRINT_MESSAGE = lengthMessage(1, FINGERPRINT_MAX);
23786
+ var listFilterShape = {
23538
23787
  org: orgSlugSchema,
23539
23788
  project: projectNameSchema.optional(),
23540
23789
  severity: severitySchema.optional(),
23541
23790
  status: statusSchema.optional(),
23542
- // The filter spends `UNTAGGED_TAG` on the complaints with no tag.
23543
- tag: tagSchema.optional(),
23791
+ tags: filterTagsSchema.optional(),
23544
23792
  reporter: boundedString(REPORTER_MAX).optional(),
23793
+ // Exact, so it is never trimmed, and never empty, because no fingerprint is.
23794
+ fingerprint: external_exports.string({ message: FINGERPRINT_MESSAGE }).min(1, { message: FINGERPRINT_MESSAGE }).max(FINGERPRINT_MAX, { message: FINGERPRINT_MESSAGE }).optional(),
23795
+ // The search is case-insensitive in every store.
23796
+ q: boundedString(SEARCH_MAX).transform((value) => value.toLowerCase()).optional(),
23545
23797
  since: timestampSchema.optional(),
23546
23798
  until: timestampSchema.optional(),
23547
23799
  limit: limitSchema.optional()
23548
- }).superRefine(checkWindow);
23549
- var listFilterSchema = external_exports.preprocess((value) => normalizeKeys(value ?? {}, FILTER_KEYS), listFilterObject).transform((input2) => ({
23800
+ };
23801
+ var LIST_FILTER_KEYS = Object.keys(listFilterShape);
23802
+ var listFilterObject = external_exports.strictObject(listFilterShape).superRefine(checkWindow);
23803
+ var listFilterSchema = external_exports.preprocess((value) => dropNulls(normalizeKeys(value ?? {}, FILTER_KEYS), FILTER_NULLABLE_KEYS), listFilterObject).transform((input2) => ({
23550
23804
  org: input2.org,
23551
23805
  ...input2.project === void 0 ? {} : { project: input2.project },
23552
23806
  ...input2.severity === void 0 ? {} : { severity: input2.severity },
23553
23807
  ...input2.status === void 0 ? {} : { status: input2.status },
23554
- // The tag is normalized like a stored one, so `other: network` matches.
23555
- ...input2.tag === void 0 ? {} : { tag: normalizeTag(input2.tag) },
23808
+ ...input2.tags === void 0 ? {} : { tags: input2.tags },
23556
23809
  ...input2.reporter === void 0 ? {} : { reporter: input2.reporter },
23810
+ ...input2.fingerprint === void 0 ? {} : { fingerprint: input2.fingerprint },
23811
+ ...input2.q === void 0 ? {} : { q: input2.q },
23557
23812
  ...input2.since === void 0 ? {} : { since: toMilliseconds(input2.since) },
23558
23813
  ...input2.until === void 0 ? {} : { until: toMilliseconds(input2.until) },
23559
23814
  limit: input2.limit ?? DEFAULT_LIMIT
23560
23815
  }));
23561
23816
  var DAY_MS = 24 * 60 * 60 * 1e3;
23562
23817
  var WINDOW_MESSAGE = `the window must not exceed ${STATS_MAX_WINDOW_DAYS} days`;
23563
- var statsQueryObject = external_exports.object({
23818
+ var ALL_WINDOW = "all";
23819
+ var ALL_WINDOW_MESSAGE = `must be ${ALL_WINDOW}`;
23820
+ var ALL_WINDOW_BOUNDS_MESSAGE = `${ALL_WINDOW} takes no since or until`;
23821
+ var statsQueryObject = external_exports.strictObject({
23564
23822
  since: timestampSchema.optional(),
23565
23823
  until: timestampSchema.optional(),
23566
- status: statusSchema.optional()
23824
+ status: statusSchema.optional(),
23825
+ window: external_exports.literal(ALL_WINDOW, { message: ALL_WINDOW_MESSAGE }).optional()
23826
+ }).superRefine((input2, ctx) => {
23827
+ if (input2.window === void 0)
23828
+ return;
23829
+ if (input2.since === void 0 && input2.until === void 0)
23830
+ return;
23831
+ ctx.addIssue({
23832
+ code: "custom",
23833
+ path: ["window"],
23834
+ message: ALL_WINDOW_BOUNDS_MESSAGE
23835
+ });
23567
23836
  });
23568
23837
  var ROLE_MESSAGE = `must be one of ${ROLES.join(", ")}`;
23569
23838
  var ORG_ROLE_MESSAGE = `must be one of ${ORG_ROLES.join(", ")}`;
@@ -23576,8 +23845,8 @@ var orgRoleSchema = external_exports.enum(ORG_ROLES, { message: ORG_ROLE_MESSAGE
23576
23845
  var colourSchema = external_exports.enum(ORG_COLOURS, { message: COLOUR_MESSAGE });
23577
23846
  var emailSchema = boundedString(EMAIL_MAX, EMAIL_MESSAGE).refine((value) => EMAIL_RE.test(value), { message: EMAIL_MESSAGE });
23578
23847
  var inviteEmailSchema = emailSchema.transform((value) => value.toLowerCase());
23579
- var displayNameSchema = boundedString(DISPLAY_NAME_MAX);
23580
- var newOrgSchema = external_exports.preprocess((value) => normalizeKeys(value, ["colour"]), external_exports.object({
23848
+ var displayNameSchema = freeText(DISPLAY_NAME_MAX);
23849
+ var newOrgSchema = external_exports.preprocess((value) => normalizeKeys(value, ["colour"]), external_exports.strictObject({
23581
23850
  slug: orgSlugSchema.refine((value) => !RESERVED_SLUGS.some((reserved) => reserved === value), { message: RESERVED_SLUG_MESSAGE }),
23582
23851
  name: displayNameSchema,
23583
23852
  colour: colourSchema.optional()
@@ -23586,18 +23855,21 @@ var newOrgSchema = external_exports.preprocess((value) => normalizeKeys(value, [
23586
23855
  name: input2.name,
23587
23856
  colour: input2.colour ?? DEFAULT_ORG_COLOUR
23588
23857
  }));
23589
- var orgPatchSchema = external_exports.preprocess((value) => normalizeKeys(value, ["name", "colour"]), external_exports.object({ name: displayNameSchema.optional(), colour: colourSchema.optional() })).transform((input2) => ({
23858
+ var orgPatchSchema = external_exports.preprocess((value) => normalizeKeys(value, ["name", "colour"]), external_exports.strictObject({
23859
+ name: displayNameSchema.optional(),
23860
+ colour: colourSchema.optional()
23861
+ })).transform((input2) => ({
23590
23862
  ...input2.name === void 0 ? {} : { name: input2.name },
23591
23863
  ...input2.colour === void 0 ? {} : { colour: input2.colour }
23592
23864
  })).refine((patch) => Object.keys(patch).length > 0, { message: ORG_PATCH_MESSAGE });
23593
23865
  var PLATFORM_PATCH_MESSAGE = "must set disabled, caps, or both";
23594
23866
  var CAP_MESSAGE = "must be a positive whole number";
23595
- var orgCapsSchema = external_exports.object({
23867
+ var orgCapsSchema = external_exports.strictObject({
23596
23868
  projectsPerOrg: external_exports.number({ message: CAP_MESSAGE }).int({ message: CAP_MESSAGE }).min(1, { message: CAP_MESSAGE }).optional()
23597
23869
  }).transform((input2) => ({
23598
23870
  ...input2.projectsPerOrg === void 0 ? {} : { projectsPerOrg: input2.projectsPerOrg }
23599
23871
  }));
23600
- var platformOrgPatchSchema = external_exports.object({
23872
+ var platformOrgPatchSchema = external_exports.strictObject({
23601
23873
  disabled: external_exports.boolean({ message: "must be a boolean" }).optional(),
23602
23874
  caps: orgCapsSchema.nullable().optional()
23603
23875
  }).transform((input2) => ({
@@ -23606,30 +23878,41 @@ var platformOrgPatchSchema = external_exports.object({
23606
23878
  })).refine((patch) => Object.keys(patch).length > 0, {
23607
23879
  message: PLATFORM_PATCH_MESSAGE
23608
23880
  });
23609
- var orgInviteSchema = external_exports.object({ email: inviteEmailSchema, role: orgRoleSchema }).transform((input2) => ({ email: input2.email, role: input2.role }));
23610
- var orgRoleChangeSchema = external_exports.object({ role: orgRoleSchema }).transform((input2) => ({ role: input2.role }));
23611
- var profileSchema = external_exports.preprocess((value) => normalizeKeys(value, ["name"]), external_exports.object({ name: displayNameSchema })).transform((input2) => ({ name: input2.name }));
23612
- var newProjectSchema = external_exports.preprocess((value) => normalizeKeys(value, ["tags"]), external_exports.object({
23881
+ var orgInviteSchema = external_exports.strictObject({ email: inviteEmailSchema, role: orgRoleSchema }).transform((input2) => ({ email: input2.email, role: input2.role }));
23882
+ var orgRoleChangeSchema = external_exports.strictObject({ role: orgRoleSchema }).transform((input2) => ({ role: input2.role }));
23883
+ var profileSchema = external_exports.preprocess((value) => normalizeKeys(value, ["name"]), external_exports.strictObject({ name: displayNameSchema })).transform((input2) => ({ name: input2.name }));
23884
+ var newProjectSchema = external_exports.preprocess((value) => normalizeKeys(value, ["tags"]), external_exports.strictObject({
23613
23885
  project: projectNameSchema,
23614
23886
  tags: allowedTagsSchema.optional()
23615
23887
  })).transform((input2) => ({
23616
23888
  project: input2.project,
23617
23889
  ...input2.tags === void 0 ? {} : { tags: input2.tags }
23618
23890
  }));
23619
- var projectUpdateSchema = external_exports.object({ tags: allowedTagsSchema.nullable() }).transform((input2) => ({ tags: input2.tags }));
23620
- var tagPromotionSchema = external_exports.object({ tag: allowedTagSchema }).transform((input2) => ({ tag: normalizeTag(input2.tag) }));
23621
- var roleChangeSchema = external_exports.object({ role: roleSchema }).transform((input2) => ({ role: input2.role }));
23622
- var newTokenSchema = external_exports.preprocess((value) => normalizeKeys(value, ["name", "expiresAt"]), external_exports.object({
23623
- name: boundedString(TOKEN_NAME_MAX),
23624
- expiresAt: timestampSchema.optional()
23625
- })).transform((input2) => ({
23626
- name: input2.name,
23627
- ...input2.expiresAt === void 0 ? {} : { expiresAt: input2.expiresAt }
23628
- }));
23891
+ var projectUpdateSchema = external_exports.strictObject({ tags: allowedTagsSchema.nullable() }).transform((input2) => ({ tags: input2.tags }));
23892
+ var tagPromotionSchema = external_exports.strictObject({ tag: allowedTagSchema }).transform((input2) => ({ tag: normalizeTag(input2.tag) }));
23893
+ var roleChangeSchema = external_exports.strictObject({ role: roleSchema }).transform((input2) => ({ role: input2.role }));
23894
+ var FUTURE_MESSAGE = "must be in the future";
23895
+ function newTokenSchema(now) {
23896
+ return external_exports.preprocess((value) => normalizeKeys(value, ["name", "expiresAt"]), external_exports.strictObject({
23897
+ name: freeText(TOKEN_NAME_MAX),
23898
+ expiresAt: timestampSchema.refine((value) => Date.parse(value) > now.getTime(), {
23899
+ message: FUTURE_MESSAGE
23900
+ }).optional()
23901
+ })).transform((input2) => ({
23902
+ name: input2.name,
23903
+ ...input2.expiresAt === void 0 ? {} : { expiresAt: input2.expiresAt }
23904
+ }));
23905
+ }
23906
+ var UNKNOWN_KEY_MESSAGE = "unknown key";
23907
+ var emptyBodySchema = external_exports.strictObject({});
23629
23908
  function formatIssues(error62) {
23630
- return error62.issues.map((issue2) => {
23631
- const path = issue2.path.map((part) => String(part)).join(".");
23632
- return path === "" ? issue2.message : `${path}: ${issue2.message}`;
23909
+ return error62.issues.flatMap((issue2) => {
23910
+ const path = issue2.path.map((part) => String(part));
23911
+ if (issue2.code === "unrecognized_keys") {
23912
+ return issue2.keys.map((key) => `${[...path, key].join(".")}: ${UNKNOWN_KEY_MESSAGE}`);
23913
+ }
23914
+ const name = path.join(".");
23915
+ return [name === "" ? issue2.message : `${name}: ${issue2.message}`];
23633
23916
  });
23634
23917
  }
23635
23918
  function run(schema, input2) {
@@ -23648,6 +23931,13 @@ function parseComplaint(input2) {
23648
23931
  function parseStatusChange(input2) {
23649
23932
  return run(statusChangeSchema, input2);
23650
23933
  }
23934
+ var statusActorSchema = external_exports.strictObject({ by: actorSchema, byName: actorSchema.optional() }).transform((input2) => ({
23935
+ by: input2.by,
23936
+ ...input2.byName === void 0 ? {} : { byName: input2.byName }
23937
+ }));
23938
+ function parseStatusActor(input2) {
23939
+ return run(statusActorSchema, input2);
23940
+ }
23651
23941
  function parseTagsChange(input2) {
23652
23942
  return run(tagsChangeSchema, input2);
23653
23943
  }
@@ -23663,6 +23953,14 @@ function parseProjectName(input2) {
23663
23953
  function parseTagPromotion(input2) {
23664
23954
  return run(tagPromotionSchema, input2);
23665
23955
  }
23956
+ function parseNewToken(input2, now = /* @__PURE__ */ new Date()) {
23957
+ return run(newTokenSchema(now), input2);
23958
+ }
23959
+ function parseAgentName(input2) {
23960
+ return run(external_exports.object({ agent: agentSchema }), { agent: input2 }).agent;
23961
+ }
23962
+ var IDEMPOTENCY_KEY_RE = new RegExp(`^[A-Za-z0-9_-]{1,${IDEMPOTENCY_KEY_MAX}}$`);
23963
+ var IDEMPOTENCY_KEY_MESSAGE = `Idempotency-Key: must be 1-${IDEMPOTENCY_KEY_MAX} letters, digits, "-" or "_"`;
23666
23964
  function parseOrgSlug(input2) {
23667
23965
  return run(orgSlugSchema, input2);
23668
23966
  }
@@ -23718,8 +24016,8 @@ function summarizeComplaints(complaints) {
23718
24016
  var SUMMARY_WORDS = 20;
23719
24017
  var ELLIPSIS = "\u2026";
23720
24018
  function summarize(message) {
23721
- const line = message.split("\n").map((each) => each.trim()).find((each) => each !== "");
23722
- if (line === void 0)
24019
+ const line = headlineOf(message);
24020
+ if (line === "")
23723
24021
  return "";
23724
24022
  const words = line.split(/\s+/);
23725
24023
  if (words.length <= SUMMARY_WORDS)
@@ -23727,8 +24025,29 @@ function summarize(message) {
23727
24025
  return `${words.slice(0, SUMMARY_WORDS).join(" ")}${ELLIPSIS}`;
23728
24026
  }
23729
24027
 
24028
+ // ../core/dist/query.js
24029
+ var TAG_KEY = "tag";
24030
+ var SINGLE_KEYS = new Set(LIST_FILTER_KEYS.filter((key) => key !== "org" && key !== "tags"));
24031
+ function listQueryOf(filter) {
24032
+ const pairs = [];
24033
+ for (const key of LIST_FILTER_KEYS) {
24034
+ if (key === "org")
24035
+ continue;
24036
+ const value = filter[key];
24037
+ if (value === void 0)
24038
+ continue;
24039
+ if (key === "tags") {
24040
+ for (const tag of filter.tags ?? [])
24041
+ pairs.push([TAG_KEY, tag]);
24042
+ continue;
24043
+ }
24044
+ pairs.push([key, String(value)]);
24045
+ }
24046
+ return pairs;
24047
+ }
24048
+
23730
24049
  // ../core/dist/service.js
23731
- import { randomUUID } from "node:crypto";
24050
+ import { createHash, randomUUID } from "node:crypto";
23732
24051
  function applyTagRules(tags, allowedTags) {
23733
24052
  if (allowedTags === void 0)
23734
24053
  return [...tags];
@@ -23744,23 +24063,57 @@ async function createComplaint(store, input2, options = {}) {
23744
24063
  id: options.id === void 0 ? randomUUID() : options.id(),
23745
24064
  ...normalized,
23746
24065
  tags,
24066
+ fingerprint: fingerprintOf(normalized.message),
23747
24067
  createdAt: stamp(options),
23748
24068
  status: DEFAULT_STATUS
23749
24069
  };
23750
- await store.create(complaint);
23751
- return complaint;
24070
+ if (options.reserve !== void 0)
24071
+ await options.reserve(complaint);
24072
+ const match = options.keyed === true ? await store.create(complaint, { keyed: true }) : await store.create(complaint);
24073
+ return {
24074
+ ...complaint,
24075
+ openMatches: match.open,
24076
+ recurred: match.resolved
24077
+ };
23752
24078
  }
23753
24079
  async function listComplaints(store, filter) {
23754
24080
  const parsed = parseListFilter(filter);
23755
24081
  return store.list(parsed);
23756
24082
  }
24083
+ async function listComplaintPage(store, filter) {
24084
+ const parsed = parseListFilter(filter);
24085
+ if (store.listPage !== void 0)
24086
+ return store.listPage(parsed);
24087
+ return { complaints: await store.list(parsed), truncated: false };
24088
+ }
24089
+ function scanComplaints(store, filter) {
24090
+ return store.scan(parseListFilter(filter));
24091
+ }
23757
24092
  async function getComplaint(store, id) {
23758
24093
  return store.get(id);
23759
24094
  }
23760
- async function updateComplaintStatus(store, id, change, options = {}) {
24095
+ async function updateComplaintStatus(store, id, change, options) {
23761
24096
  const parsed = parseStatusChange(change);
23762
- const update = { ...parsed, updatedAt: stamp(options) };
23763
- return store.update(id, update);
24097
+ const actor = parseStatusActor(options.actor);
24098
+ if (parsed.duplicateOf !== void 0) {
24099
+ const target = options.current ?? await store.get(id);
24100
+ if (target === void 0)
24101
+ return void 0;
24102
+ if (parsed.duplicateOf === id) {
24103
+ throw new ValidationError(["duplicateOf: must name another complaint"]);
24104
+ }
24105
+ const original = await store.get(parsed.duplicateOf);
24106
+ if (original === void 0 || original.org !== target.org) {
24107
+ throw new ValidationError([`duplicateOf: no complaint ${parsed.duplicateOf}`]);
24108
+ }
24109
+ }
24110
+ const update = {
24111
+ ...parsed,
24112
+ updatedAt: stamp(options),
24113
+ changedBy: actor.by,
24114
+ ...actor.byName === void 0 ? {} : { changedByName: actor.byName }
24115
+ };
24116
+ return store.update(id, update, options.current);
23764
24117
  }
23765
24118
  async function updateComplaintTags(store, id, change, options = {}) {
23766
24119
  const parsed = parseTagsChange(change);
@@ -23768,7 +24121,7 @@ async function updateComplaintTags(store, id, change, options = {}) {
23768
24121
  tags: applyTagRules(parsed.tags, options.allowedTags),
23769
24122
  updatedAt: stamp(options)
23770
24123
  };
23771
- return store.updateTags(id, update);
24124
+ return store.updateTags(id, update, options.current);
23772
24125
  }
23773
24126
 
23774
24127
  // ../core/dist/file-store.js
@@ -23776,15 +24129,36 @@ import { appendFile, mkdir, readFile } from "node:fs/promises";
23776
24129
  import { dirname } from "node:path";
23777
24130
  var STATUS_OP = "status";
23778
24131
  var TAGS_OP = "tags";
24132
+ var DAY_MS2 = 24 * 60 * 60 * 1e3;
23779
24133
  var FileStore = class {
23780
24134
  /** Path of the JSONL file. */
23781
24135
  path;
24136
+ /** The creates of this process, one after the other. */
24137
+ #creating = Promise.resolve();
23782
24138
  constructor(path) {
23783
24139
  this.path = path;
23784
24140
  }
23785
- /** Appends one line. Creates the parent directory on the first write. */
23786
- async create(complaint) {
23787
- await this.#append(complaint);
24141
+ /**
24142
+ * Counts what the complaint's project holds under its fingerprint, from the
24143
+ * folded records, then appends the one complaint line, with `recurred: true`
24144
+ * when a resolved match created in the last `RECURRENCE_WINDOW_DAYS` exists.
24145
+ * Creates the parent directory on the first write. A keyed complaint whose
24146
+ * id a record already holds is a `DuplicateIdError` carrying that record,
24147
+ * and nothing is written. The creates of one process run one at a time.
24148
+ */
24149
+ create(complaint, options = {}) {
24150
+ const write = this.#creating.then(async () => {
24151
+ const records = await this.#readAll();
24152
+ const existing = records.find((record2) => record2.id === complaint.id);
24153
+ if (options.keyed === true && existing !== void 0) {
24154
+ throw new DuplicateIdError(complaint.id, existing);
24155
+ }
24156
+ const match = matchOf(records, complaint);
24157
+ await this.#append(match.resolved ? { ...complaint, recurred: true } : complaint);
24158
+ return match;
24159
+ });
24160
+ this.#creating = write.catch(() => void 0);
24161
+ return write;
23788
24162
  }
23789
24163
  /** Returns the matching complaints, newest first. */
23790
24164
  async list(filter) {
@@ -23796,6 +24170,11 @@ var FileStore = class {
23796
24170
  for (const complaint of await this.#matches(filter))
23797
24171
  yield complaint;
23798
24172
  }
24173
+ /** Returns how many complaints match, whatever the limit says. */
24174
+ async count(filter) {
24175
+ const complaints = await this.#readAll();
24176
+ return complaints.filter((complaint) => matchesFilter(complaint, filter)).length;
24177
+ }
23799
24178
  /** Returns the complaint with that id, or `undefined`. */
23800
24179
  async get(id) {
23801
24180
  const complaints = await this.#readAll();
@@ -23804,26 +24183,32 @@ var FileStore = class {
23804
24183
  /**
23805
24184
  * Appends one status line and returns the updated complaint. Returns
23806
24185
  * `undefined`, and writes nothing, when no complaint has that id. A deleted
23807
- * complaint is reached like any other, so a reopen restores it.
24186
+ * complaint is reached like any other, so a reopen restores it. `current` is
24187
+ * ignored, because a fold reads every record anyway.
23808
24188
  */
23809
- async update(id, change) {
24189
+ async update(id, change, _current) {
23810
24190
  const complaint = await this.get(id);
23811
24191
  if (complaint === void 0)
23812
24192
  return void 0;
23813
- await this.#append({
24193
+ const line = {
23814
24194
  op: STATUS_OP,
23815
24195
  id,
23816
24196
  status: change.status,
23817
24197
  ...change.resolution === void 0 ? {} : { resolution: change.resolution },
24198
+ ...change.reason === void 0 ? {} : { reason: change.reason },
24199
+ ...change.duplicateOf === void 0 ? {} : { duplicateOf: change.duplicateOf },
24200
+ changedBy: change.changedBy,
24201
+ ...change.changedByName === void 0 ? {} : { changedByName: change.changedByName },
23818
24202
  updatedAt: change.updatedAt
23819
- });
23820
- return applyStatus(complaint, change);
24203
+ };
24204
+ await this.#append(line);
24205
+ return applyStatus(complaint, line);
23821
24206
  }
23822
24207
  /**
23823
24208
  * Appends one tags line and returns the updated complaint. Returns
23824
24209
  * `undefined`, and writes nothing, when no complaint has that id.
23825
24210
  */
23826
- async updateTags(id, change) {
24211
+ async updateTags(id, change, _current) {
23827
24212
  const complaint = await this.get(id);
23828
24213
  if (complaint === void 0)
23829
24214
  return void 0;
@@ -23946,31 +24331,70 @@ var FileStore = class {
23946
24331
  }
23947
24332
  const change = parseStatusChange({
23948
24333
  status: parsed["status"],
23949
- resolution: parsed["resolution"]
24334
+ resolution: parsed["resolution"],
24335
+ reason: parsed["reason"],
24336
+ duplicateOf: parsed["duplicateOf"]
23950
24337
  });
23951
- return { op, id, ...change, updatedAt };
24338
+ const changedBy = this.#actor(parsed, "changedBy", bad);
24339
+ const changedByName = this.#actor(parsed, "changedByName", bad);
24340
+ return {
24341
+ op,
24342
+ id,
24343
+ ...change,
24344
+ ...changedBy === void 0 ? {} : { changedBy },
24345
+ ...changedByName === void 0 ? {} : { changedByName },
24346
+ updatedAt
24347
+ };
23952
24348
  } catch (error62) {
23953
24349
  if (!(error62 instanceof ValidationError))
23954
24350
  throw error62;
23955
24351
  throw bad(error62.message, error62);
23956
24352
  }
23957
24353
  }
24354
+ /** Reads the optional actor key of a status line. */
24355
+ #actor(parsed, key, bad) {
24356
+ const value = parsed[key];
24357
+ if (value === void 0)
24358
+ return void 0;
24359
+ if (typeof value !== "string" || value.trim() === "" || value.length > REPORTER_MAX) {
24360
+ throw bad(`${key}: must be 1-${REPORTER_MAX} characters`);
24361
+ }
24362
+ return value;
24363
+ }
23958
24364
  /** The prefix that every malformed complaint line shares. */
23959
24365
  #malformed(lineNumber) {
23960
24366
  return `malformed complaint on line ${lineNumber} of ${this.path}`;
23961
24367
  }
23962
24368
  };
23963
24369
  function applyStatus(complaint, change) {
23964
- const updated = {
23965
- ...complaint,
24370
+ const { resolution: _resolution, reason: _reason, duplicateOf: _duplicateOf, statusChangedBy: _statusChangedBy, statusChangedByName: _statusChangedByName, ...rest } = complaint;
24371
+ return {
24372
+ ...rest,
23966
24373
  status: change.status,
24374
+ ...change.resolution === void 0 ? {} : { resolution: change.resolution },
24375
+ ...change.reason === void 0 ? {} : { reason: change.reason },
24376
+ ...change.duplicateOf === void 0 ? {} : { duplicateOf: change.duplicateOf },
24377
+ statusChangedAt: change.updatedAt,
24378
+ ...change.changedBy === void 0 ? {} : { statusChangedBy: change.changedBy },
24379
+ ...change.changedByName === void 0 ? {} : { statusChangedByName: change.changedByName },
23967
24380
  updatedAt: change.updatedAt
23968
24381
  };
23969
- if (change.resolution === void 0)
23970
- delete updated.resolution;
23971
- else
23972
- updated.resolution = change.resolution;
23973
- return updated;
24382
+ }
24383
+ function matchOf(complaints, complaint) {
24384
+ const since = new Date(Date.parse(complaint.createdAt) - RECURRENCE_WINDOW_DAYS * DAY_MS2).toISOString();
24385
+ let open2 = 0;
24386
+ let resolved = false;
24387
+ for (const each of complaints) {
24388
+ if (each.project !== complaint.project)
24389
+ continue;
24390
+ if (each.fingerprint !== complaint.fingerprint)
24391
+ continue;
24392
+ if (each.status === "open")
24393
+ open2 += 1;
24394
+ if (each.status === "resolved" && each.createdAt >= since)
24395
+ resolved = true;
24396
+ }
24397
+ return { open: open2, resolved };
23974
24398
  }
23975
24399
  function applyTags(complaint, change) {
23976
24400
  return { ...complaint, tags: [...change.tags], updatedAt: change.updatedAt };
@@ -23990,11 +24414,17 @@ function matchesFilter(complaint, filter) {
23990
24414
  if (filter.reporter !== void 0 && complaint.reporter !== filter.reporter) {
23991
24415
  return false;
23992
24416
  }
23993
- if (filter.tag !== void 0) {
23994
- const kept = filter.tag === UNTAGGED_TAG ? complaint.tags.length === 0 : complaint.tags.includes(filter.tag);
24417
+ if (filter.tags !== void 0) {
24418
+ const kept = filter.tags.some((tag) => tag === UNTAGGED_TAG ? complaint.tags.length === 0 : complaint.tags.includes(tag));
23995
24419
  if (!kept)
23996
24420
  return false;
23997
24421
  }
24422
+ if (filter.fingerprint !== void 0 && complaint.fingerprint !== filter.fingerprint) {
24423
+ return false;
24424
+ }
24425
+ if (filter.q !== void 0 && !complaint.message.toLowerCase().includes(filter.q)) {
24426
+ return false;
24427
+ }
23998
24428
  if (filter.since !== void 0 && complaint.createdAt < filter.since)
23999
24429
  return false;
24000
24430
  if (filter.until !== void 0 && complaint.createdAt >= filter.until)
@@ -24028,6 +24458,10 @@ function defaultConfigPath(env = process.env) {
24028
24458
  return join(defaultQuibbleHome(env), CONFIG_FILENAME);
24029
24459
  }
24030
24460
 
24461
+ // src/config.ts
24462
+ import { readFileSync as readFileSync2 } from "node:fs";
24463
+ import { dirname as dirname3, isAbsolute, join as join3, relative, resolve, sep } from "node:path";
24464
+
24031
24465
  // src/credentials.ts
24032
24466
  import { randomBytes } from "node:crypto";
24033
24467
  import {
@@ -24041,9 +24475,10 @@ import {
24041
24475
  import { dirname as dirname2, join as join2 } from "node:path";
24042
24476
 
24043
24477
  // src/errors.ts
24478
+ var AMBIGUOUS_ID = "ambiguous_id";
24044
24479
  var UsageError = class extends QuibbleError {
24045
24480
  constructor(message, options) {
24046
- super("usage_error", message, options);
24481
+ super(options?.code ?? "usage_error", message, options);
24047
24482
  }
24048
24483
  };
24049
24484
  var NotFoundError = class extends QuibbleError {
@@ -24058,9 +24493,18 @@ var RemoteError = class extends QuibbleError {
24058
24493
  * other one stays exit 2.
24059
24494
  */
24060
24495
  status;
24496
+ /** The `error.code` of the answer, when it carried one. */
24497
+ serverCode;
24498
+ /** The server's own message, when the answer carried one. */
24499
+ serverMessage;
24500
+ /** True when no server answered, which the offline queue of 5.13 reads. */
24501
+ unanswered;
24061
24502
  constructor(message, options) {
24062
24503
  super("network_error", message, options);
24063
24504
  this.status = options?.status;
24505
+ this.serverCode = options?.serverCode;
24506
+ this.serverMessage = options?.serverMessage;
24507
+ this.unanswered = options?.unanswered === true;
24064
24508
  }
24065
24509
  };
24066
24510
  function exitCodeFor(error62) {
@@ -24117,12 +24561,12 @@ async function send(url2, request) {
24117
24561
  if (isTimeout(error62)) {
24118
24562
  throw new RemoteError(
24119
24563
  `request to ${originOf(url2)} timed out after ${timeoutMs / 1e3}s${detail(site)}`,
24120
- { cause: error62 }
24564
+ { cause: error62, unanswered: true }
24121
24565
  );
24122
24566
  }
24123
24567
  throw new RemoteError(
24124
24568
  `cannot reach ${originOf(url2)}: ${describeError(error62)}${detail(site)}`,
24125
- { cause: error62 }
24569
+ { cause: error62, unanswered: true }
24126
24570
  );
24127
24571
  }
24128
24572
  }
@@ -24132,7 +24576,7 @@ async function readText(response, site) {
24132
24576
  } catch (error62) {
24133
24577
  throw new RemoteError(
24134
24578
  `cannot read the response from ${originOf(site.url)}: ${describeError(error62)}${detail(site, response.status)}`,
24135
- { cause: error62 }
24579
+ { cause: error62, unanswered: true }
24136
24580
  );
24137
24581
  }
24138
24582
  }
@@ -24140,7 +24584,7 @@ function parseJson(text, site, status) {
24140
24584
  try {
24141
24585
  return JSON.parse(text);
24142
24586
  } catch (error62) {
24143
- throw new RemoteError(`the API returned a malformed body${detail(site, status)}`, {
24587
+ throw new RemoteError(`the API returned invalid JSON${detail(site, status)}`, {
24144
24588
  cause: error62
24145
24589
  });
24146
24590
  }
@@ -24176,9 +24620,12 @@ function serverCode(text) {
24176
24620
  return stringOf(asRecord(record2?.["error"]), "code");
24177
24621
  }
24178
24622
  function remoteFailure(site, response, text) {
24179
- const message = serverMessage(text, response) ?? `the API answered ${response.status}`;
24623
+ const own2 = serverMessage(text, response);
24624
+ const message = own2 ?? `the API returned HTTP ${response.status}`;
24180
24625
  return new RemoteError(`${message}${detail(site, response.status)}`, {
24181
- status: response.status
24626
+ status: response.status,
24627
+ serverCode: serverCode(text),
24628
+ serverMessage: own2
24182
24629
  });
24183
24630
  }
24184
24631
 
@@ -24283,12 +24730,12 @@ function removeCredential(env, endpoint) {
24283
24730
  var TARGET_PREFIX = "AWSCognitoIdentityProviderService.";
24284
24731
  var CONTENT_TYPE = "application/x-amz-json-1.1";
24285
24732
  var DEFAULT_EXPIRES_IN = 3600;
24286
- var UNREACHABLE_LINE = "The sign-in service could not be reached.";
24733
+ var UNREACHABLE_LINE = "Couldn't reach the sign-in service.";
24287
24734
  var WRONG_CREDENTIALS_LINE = "Wrong email or password.";
24288
- var TOO_MANY_LINE = "Too many attempts, try again later.";
24735
+ var TOO_MANY_LINE = "Too many attempts. Try again later.";
24289
24736
  var WRONG_CODE_LINE = "Wrong code.";
24290
- var EXPIRED_CODE_LINE = "That code has expired, request a new one.";
24291
- var PASSWORD_POLICY_LINE = "A password needs 12 characters with a lowercase letter, an uppercase letter, a digit and a symbol.";
24737
+ var EXPIRED_CODE_LINE = "That code has expired. Request a new one.";
24738
+ var PASSWORD_POLICY_LINE = "Use at least 12 characters, with an uppercase letter, a lowercase letter, a number and a symbol.";
24292
24739
  var UNREACHABLE_TYPE = "Unreachable";
24293
24740
  var INCORRECT_CREDENTIALS_MESSAGE = "Incorrect username or password.";
24294
24741
  var CognitoError = class extends RemoteError {
@@ -24297,7 +24744,7 @@ var CognitoError = class extends RemoteError {
24297
24744
  /** Cognito's own `message`, or `undefined` when the answer carried none. */
24298
24745
  detail;
24299
24746
  constructor(type, detail2, line, suffix = "") {
24300
- super(`${line}${suffix}`);
24747
+ super(`${line}${suffix}`, { unanswered: type === UNREACHABLE_TYPE });
24301
24748
  this.type = type;
24302
24749
  this.detail = detail2;
24303
24750
  }
@@ -24394,7 +24841,7 @@ function authAnswerOf(body) {
24394
24841
  const shape = result;
24395
24842
  const accessToken = shape["AccessToken"];
24396
24843
  if (typeof accessToken !== "string" || accessToken === "") {
24397
- throw new RemoteError("the sign-in returned no access token");
24844
+ throw new RemoteError("sign-in didn't return an access token");
24398
24845
  }
24399
24846
  const refreshToken = shape["RefreshToken"];
24400
24847
  const expiresIn = shape["ExpiresIn"];
@@ -24409,7 +24856,7 @@ function authAnswerOf(body) {
24409
24856
  }
24410
24857
  const challengeName = body["ChallengeName"];
24411
24858
  if (typeof challengeName !== "string" || challengeName === "") {
24412
- throw new RemoteError("the sign-in returned neither tokens nor a challenge");
24859
+ throw new RemoteError("sign-in returned an unexpected response");
24413
24860
  }
24414
24861
  const session = body["Session"];
24415
24862
  const parameters = body["ChallengeParameters"];
@@ -24496,7 +24943,7 @@ async function fetchAuthConfig(endpoint, options = {}) {
24496
24943
  const webUrl = stringOf(body, "webUrl");
24497
24944
  if (region === void 0 || cliClientId === void 0 || webUrl === void 0) {
24498
24945
  throw new RemoteError(
24499
- `the API returned an auth config without a region${detail(site, response.status)}`
24946
+ `unexpected response from the API: auth config has no region${detail(site, response.status)}`
24500
24947
  );
24501
24948
  }
24502
24949
  return { mode: "cognito", region, cliClientId, webUrl };
@@ -24601,7 +25048,9 @@ var SessionAuthorizer = class {
24601
25048
  throw error62;
24602
25049
  }
24603
25050
  if (answer2.kind === "challenge") {
24604
- throw new RemoteError(`the refresh asked for ${answer2.challenge.challengeName}`);
25051
+ throw new RemoteError(
25052
+ `unexpected sign-in step during refresh (${answer2.challenge.challengeName})`
25053
+ );
24605
25054
  }
24606
25055
  const now = clockOf(this.#options)();
24607
25056
  return saveCredential(this.#options.env, {
@@ -24764,6 +25213,7 @@ function resolveContext(options) {
24764
25213
  return {
24765
25214
  project: name,
24766
25215
  org: mode === "local" ? LOCAL_ORG : slug,
25216
+ slug,
24767
25217
  projectRoot: project?.root,
24768
25218
  configPath: project?.path,
24769
25219
  cwd: project === void 0 ? void 0 : relativeCwd(project.root, options.cwd),
@@ -24786,6 +25236,79 @@ function resolveContext(options) {
24786
25236
  };
24787
25237
  }
24788
25238
 
25239
+ // src/ids.ts
25240
+ var FULL_ID_LENGTH = 36;
25241
+ var SHORT_ID_MIN = 6;
25242
+ var CANDIDATES_SHOWN = 5;
25243
+ var SHORT_ID_RE = /^[0-9a-f-]+$/;
25244
+ function readIdValue(value) {
25245
+ const trimmed = value.trim();
25246
+ if (trimmed.length === FULL_ID_LENGTH) return { id: trimmed, prefix: false };
25247
+ const lower = trimmed.toLowerCase();
25248
+ if (!SHORT_ID_RE.test(lower)) throw new UsageError(`${value} is not an id`);
25249
+ if (lower.length < SHORT_ID_MIN) {
25250
+ throw new UsageError(
25251
+ `${value} is too short: pass at least ${SHORT_ID_MIN} characters of the id`
25252
+ );
25253
+ }
25254
+ return { id: lower, prefix: true };
25255
+ }
25256
+ function ambiguousError(prefix, ids, noun) {
25257
+ const shown = ids.slice(0, CANDIDATES_SHOWN).join(", ");
25258
+ const more = ids.length > CANDIDATES_SHOWN ? ` and ${ids.length - CANDIDATES_SHOWN} more` : "";
25259
+ return new UsageError(`${prefix} matches ${ids.length} ${noun}: ${shown}${more}`, {
25260
+ code: AMBIGUOUS_ID
25261
+ });
25262
+ }
25263
+ async function candidates(source) {
25264
+ const org = requireOrg(source.context);
25265
+ const ids = [];
25266
+ if (source.local !== void 0) {
25267
+ for (const status of [void 0, "deleted"]) {
25268
+ const filter = { org, ...status === void 0 ? {} : { status } };
25269
+ for await (const complaint of scanComplaints(source.local, filter)) {
25270
+ ids.push(complaint.id);
25271
+ }
25272
+ }
25273
+ return ids;
25274
+ }
25275
+ const project = source.context.projectConfig?.project;
25276
+ for (const status of [void 0, "deleted"]) {
25277
+ const filter = {
25278
+ org,
25279
+ limit: MAX_LIMIT,
25280
+ ...project === void 0 ? {} : { project },
25281
+ ...status === void 0 ? {} : { status }
25282
+ };
25283
+ for (const complaint of await listComplaints(source.store, filter)) {
25284
+ ids.push(complaint.id);
25285
+ }
25286
+ }
25287
+ return ids;
25288
+ }
25289
+ async function resolveIds(values, source) {
25290
+ const read = values.map((value) => readIdValue(value));
25291
+ let pool;
25292
+ const resolved = [];
25293
+ for (const value of read) {
25294
+ if (!value.prefix) {
25295
+ resolved.push(value.id);
25296
+ continue;
25297
+ }
25298
+ pool ??= candidates(source);
25299
+ const matches = [...new Set((await pool).filter((id) => id.startsWith(value.id)))];
25300
+ if (matches.length === 1) {
25301
+ resolved.push(matches[0]);
25302
+ continue;
25303
+ }
25304
+ if (matches.length > 1) throw ambiguousError(value.id, matches, "complaints");
25305
+ throw new NotFoundError(
25306
+ source.local === void 0 ? `no complaint among the newest ${MAX_LIMIT} starts with ${value.id}: pass the full id` : `no complaint starts with ${value.id}`
25307
+ );
25308
+ }
25309
+ return resolved;
25310
+ }
25311
+
24789
25312
  // src/io.ts
24790
25313
  async function readStdin(stream) {
24791
25314
  const chunks = [];
@@ -24803,9 +25326,10 @@ function writeJson(stream, value) {
24803
25326
  }
24804
25327
 
24805
25328
  // src/client.ts
24806
- var NOT_A_COMPLAINT = "the API returned a body that is not a complaint";
25329
+ import { randomUUID as randomUUID2 } from "node:crypto";
25330
+ var NOT_A_COMPLAINT = "unexpected response from the API: expected a complaint";
24807
25331
  var ORG_NOT_FOUND = "org_not_found";
24808
- var NOT_A_PROMOTION = "the API returned a body that is not a tag promotion";
25332
+ var NOT_A_PROMOTION = "unexpected response from the API: expected a tag promotion";
24809
25333
  function readRoles(value) {
24810
25334
  if (!Array.isArray(value)) return [];
24811
25335
  const roles = [];
@@ -24838,7 +25362,7 @@ function parseMe(body) {
24838
25362
  const record2 = asRecord(body);
24839
25363
  const reporter = stringOf(record2, "reporter");
24840
25364
  if (record2 === void 0 || reporter === void 0) {
24841
- throw new RemoteError("the API returned an identity without a reporter");
25365
+ throw new RemoteError("unexpected response from the API: identity has no reporter");
24842
25366
  }
24843
25367
  return {
24844
25368
  kind: stringOf(record2, "kind") ?? "user",
@@ -24857,7 +25381,7 @@ function parseMe(body) {
24857
25381
  function parseOrgAnswer(body, slug) {
24858
25382
  const record2 = asRecord(body);
24859
25383
  if (record2 === void 0) {
24860
- throw new RemoteError("the API returned a body that is not an organization");
25384
+ throw new RemoteError("unexpected response from the API: expected an organization");
24861
25385
  }
24862
25386
  return {
24863
25387
  org: stringOf(record2, "org") ?? slug,
@@ -24867,6 +25391,35 @@ function parseOrgAnswer(body, slug) {
24867
25391
  body: record2
24868
25392
  };
24869
25393
  }
25394
+ function readToken(value) {
25395
+ const record2 = asRecord(value);
25396
+ const id = stringOf(record2, "id");
25397
+ const name = stringOf(record2, "name");
25398
+ const createdAt = stringOf(record2, "createdAt");
25399
+ if (id === void 0 || name === void 0 || createdAt === void 0) {
25400
+ throw new RemoteError("unexpected response from the API: expected a token");
25401
+ }
25402
+ return {
25403
+ id,
25404
+ name,
25405
+ createdAt,
25406
+ expiresAt: stringOf(record2, "expiresAt"),
25407
+ lastUsedAt: stringOf(record2, "lastUsedAt"),
25408
+ revokedAt: stringOf(record2, "revokedAt")
25409
+ };
25410
+ }
25411
+ function readProject(body, name) {
25412
+ const record2 = asRecord(body);
25413
+ if (record2 === void 0) {
25414
+ throw new RemoteError("unexpected response from the API: expected a project");
25415
+ }
25416
+ const tags = record2["tags"];
25417
+ return {
25418
+ project: stringOf(record2, "project") ?? name,
25419
+ tags: Array.isArray(tags) ? tags.map((tag) => String(tag)) : void 0,
25420
+ body: record2
25421
+ };
25422
+ }
24870
25423
  function readComplaint(body) {
24871
25424
  try {
24872
25425
  return parseComplaint(body);
@@ -24881,24 +25434,37 @@ var ApiClient = class {
24881
25434
  #base;
24882
25435
  #auth;
24883
25436
  #http;
25437
+ #beforeFirst;
24884
25438
  constructor(options) {
24885
25439
  this.endpoint = options.endpoint;
24886
25440
  this.org = options.org;
24887
25441
  this.#base = trimSlash(options.endpoint);
24888
25442
  this.#auth = options.authorizer;
25443
+ this.#beforeFirst = options.beforeFirst;
24889
25444
  this.#http = {
24890
25445
  fetch: options.fetch,
24891
25446
  timeoutMs: options.timeoutMs,
24892
25447
  debug: options.debug
24893
25448
  };
24894
25449
  }
24895
- /** Sends one request and returns its body. A non-2xx answer is an error. */
24896
- async request(method, path, body) {
24897
- return this.#call(method, path, body, false);
25450
+ /**
25451
+ * Sends one request and returns its body. A non-2xx answer is an error.
25452
+ * `headers` are sent beside the auth headers, such as the idempotency key
25453
+ * of a filing.
25454
+ */
25455
+ async request(method, path, body, headers) {
25456
+ return (await this.#call(method, path, body, false, headers)).body;
25457
+ }
25458
+ /**
25459
+ * The same, and the headers of the answer, which carry what the body does
25460
+ * not, such as the truncation mark of a list.
25461
+ */
25462
+ async requestWithHeaders(method, path) {
25463
+ return this.#call(method, path, void 0, false);
24898
25464
  }
24899
25465
  /** The same, except that a `404` answers `undefined` instead of failing. */
24900
25466
  async requestOptional(method, path, body) {
24901
- return this.#call(method, path, body, true);
25467
+ return (await this.#call(method, path, body, true)).body;
24902
25468
  }
24903
25469
  /**
24904
25470
  * The prefix of every org-scoped path. Every command that reaches one
@@ -24917,7 +25483,9 @@ var ApiClient = class {
24917
25483
  async orgs() {
24918
25484
  const body = asRecord(await this.request("GET", "/v1/orgs"));
24919
25485
  if (body === void 0) {
24920
- throw new RemoteError("the API returned a body that is not an organization list");
25486
+ throw new RemoteError(
25487
+ "unexpected response from the API: expected an organization list"
25488
+ );
24921
25489
  }
24922
25490
  return { orgs: readOrgs(body["orgs"]), body };
24923
25491
  }
@@ -24926,16 +25494,55 @@ var ApiClient = class {
24926
25494
  const slug = this.org ?? "";
24927
25495
  return parseOrgAnswer(await this.request("GET", this.orgPath()), slug);
24928
25496
  }
25497
+ /** The path of one project of the organization. */
25498
+ projectPath(name) {
25499
+ return `${this.orgPath()}/projects/${encodeURIComponent(name)}`;
25500
+ }
24929
25501
  /** Reads the project record, which owns the allowed tag list in remote mode. */
24930
25502
  async project(name) {
24931
- const body = asRecord(
24932
- await this.request("GET", `${this.orgPath()}/projects/${encodeURIComponent(name)}`)
25503
+ return readProject(await this.request("GET", this.projectPath(name)), name);
25504
+ }
25505
+ /**
25506
+ * Replaces the allowed tag list of a project, or clears it with `null`. The
25507
+ * route retags the stored complaints and answers how many it rewrote.
25508
+ */
25509
+ async setProjectTags(name, tags) {
25510
+ const body = await this.request(
25511
+ "PATCH",
25512
+ this.projectPath(name),
25513
+ JSON.stringify({ tags })
25514
+ );
25515
+ const answer2 = readProject(body, name);
25516
+ const retagged = answer2.body["retagged"];
25517
+ return { ...answer2, retagged: typeof retagged === "number" ? retagged : 0 };
25518
+ }
25519
+ /** Lists the tokens of a project, newest first, as the route sorts them. */
25520
+ async tokens(project) {
25521
+ const body = await this.request("GET", `${this.projectPath(project)}/tokens`);
25522
+ if (!Array.isArray(body)) {
25523
+ throw new RemoteError("unexpected response from the API: expected a token list");
25524
+ }
25525
+ return { tokens: body.map((entry) => readToken(entry)), body };
25526
+ }
25527
+ /** Mints a token. The answer carries the plaintext, once. */
25528
+ async createToken(project, input2) {
25529
+ const body = await this.request(
25530
+ "POST",
25531
+ `${this.projectPath(project)}/tokens`,
25532
+ JSON.stringify(input2)
25533
+ );
25534
+ const plaintext = stringOf(asRecord(body), "plaintext");
25535
+ if (plaintext === void 0) {
25536
+ throw new RemoteError("unexpected response from the API: token has no plaintext");
25537
+ }
25538
+ return { token: readToken(body), plaintext, body };
25539
+ }
25540
+ /** Revokes one token. A repeat answers `204` again. */
25541
+ async revokeToken(project, id) {
25542
+ await this.request(
25543
+ "DELETE",
25544
+ `${this.projectPath(project)}/tokens/${encodeURIComponent(id)}`
24933
25545
  );
24934
- const tags = body?.["tags"];
24935
- return {
24936
- project: stringOf(body, "project") ?? name,
24937
- tags: Array.isArray(tags) ? tags.map((tag) => String(tag)) : void 0
24938
- };
24939
25546
  }
24940
25547
  /**
24941
25548
  * Promotes one suggestion to a real tag. The route needs `maintainer`, so a
@@ -24956,15 +25563,36 @@ var ApiClient = class {
24956
25563
  }
24957
25564
  return { tags: tags.map((entry) => String(entry)), retagged };
24958
25565
  }
24959
- async #call(method, path, body, notFoundIsUndefined) {
25566
+ /**
25567
+ * Sends one request with the auth rules of section 5.7 and returns the
25568
+ * answer whatever its status. It never runs `beforeFirst`, so the offline
25569
+ * queue sends through it. A request that gets no answer still throws.
25570
+ */
25571
+ async exchange(method, path, body, headers) {
25572
+ const { response, text } = await this.#exchange(method, path, body, headers);
25573
+ return { status: response.status, text };
25574
+ }
25575
+ async #call(method, path, body, notFoundIsUndefined, headers) {
25576
+ const first = this.#beforeFirst;
25577
+ this.#beforeFirst = void 0;
25578
+ if (first !== void 0) await first(this);
25579
+ const { response, text, site } = await this.#exchange(method, path, body, headers);
25580
+ if (response.status === 404 && notFoundIsUndefined && serverCode(text) !== ORG_NOT_FOUND) {
25581
+ return { body: void 0, headers: response.headers };
25582
+ }
25583
+ if (!response.ok) throw remoteFailure(site, response, text);
25584
+ if (response.status === 204) return { body: void 0, headers: response.headers };
25585
+ return { body: parseJson(text, site, response.status), headers: response.headers };
25586
+ }
25587
+ async #exchange(method, path, body, extra) {
24960
25588
  const url2 = `${this.#base}${path}`;
24961
25589
  const site = { method, url: url2, debug: this.#http.debug };
24962
- let response = await this.#send(url2, method, await this.#auth.token(), body);
25590
+ let response = await this.#send(url2, method, await this.#auth.token(), body, extra);
24963
25591
  if (response.status === 401) {
24964
25592
  const fresh = await this.#auth.retry();
24965
25593
  if (fresh !== void 0) {
24966
25594
  await readText(response, site);
24967
- response = await this.#send(url2, method, fresh, body);
25595
+ response = await this.#send(url2, method, fresh, body, extra);
24968
25596
  if (response.status === 401) throw sessionExpired(this.endpoint);
24969
25597
  } else if (this.#auth.actAs !== void 0) {
24970
25598
  await readText(response, site);
@@ -24972,14 +25600,13 @@ var ApiClient = class {
24972
25600
  }
24973
25601
  }
24974
25602
  const text = await readText(response, site);
24975
- if (response.status === 404 && notFoundIsUndefined && serverCode(text) !== ORG_NOT_FOUND) {
24976
- return void 0;
24977
- }
24978
- if (!response.ok) throw remoteFailure(site, response, text);
24979
- return parseJson(text, site, response.status);
25603
+ return { response, text, site };
24980
25604
  }
24981
- #send(url2, method, bearer, body) {
24982
- const headers = { authorization: `Bearer ${bearer}` };
25605
+ #send(url2, method, bearer, body, extra = {}) {
25606
+ const headers = {
25607
+ ...extra,
25608
+ authorization: `Bearer ${bearer}`
25609
+ };
24983
25610
  const actAs = this.#auth.actAs;
24984
25611
  if (actAs !== void 0) headers[ACT_AS_HEADER] = actAs;
24985
25612
  if (body !== void 0) headers["content-type"] = "application/json";
@@ -24991,6 +25618,67 @@ var ApiClient = class {
24991
25618
  });
24992
25619
  }
24993
25620
  };
25621
+ var UndeliveredError = class extends RemoteError {
25622
+ body;
25623
+ reason;
25624
+ /**
25625
+ * The idempotency key the request carried, which the queued entry keeps,
25626
+ * so a request that did reach the server files nothing new when it is sent
25627
+ * again.
25628
+ */
25629
+ key;
25630
+ constructor(body, reason, key, cause) {
25631
+ super(cause.message, { cause, status: cause.status, unanswered: cause.unanswered });
25632
+ this.body = body;
25633
+ this.reason = reason;
25634
+ this.key = key;
25635
+ }
25636
+ };
25637
+ function retryableReason(error62) {
25638
+ if (!(error62 instanceof RemoteError)) return void 0;
25639
+ if (error62.unanswered) return "the API is unreachable";
25640
+ const status = error62.status;
25641
+ if (status === void 0) return void 0;
25642
+ if (status === 429) return error62.serverMessage ?? `the API answered ${status}`;
25643
+ if (status >= 500) return `the API answered ${status}`;
25644
+ return void 0;
25645
+ }
25646
+ function complaintBody(complaint) {
25647
+ return {
25648
+ project: complaint.project,
25649
+ message: complaint.message,
25650
+ severity: complaint.severity,
25651
+ tags: complaint.tags,
25652
+ agent: complaint.agent,
25653
+ ...complaint.cwd === void 0 ? {} : { cwd: complaint.cwd },
25654
+ ...complaint.cmd === void 0 ? {} : { cmd: complaint.cmd }
25655
+ };
25656
+ }
25657
+ function readBulkResults(body) {
25658
+ const results = asRecord(body)?.["results"];
25659
+ if (!Array.isArray(results)) {
25660
+ throw new RemoteError("unexpected response from the API: expected bulk results");
25661
+ }
25662
+ return results.map((entry) => {
25663
+ const record2 = asRecord(entry);
25664
+ const id = stringOf(record2, "id");
25665
+ if (record2 === void 0 || id === void 0) {
25666
+ throw new RemoteError("unexpected response from the API: expected a bulk result");
25667
+ }
25668
+ if (record2["ok"] === true) {
25669
+ return { id, ok: true, complaint: readComplaint(record2["complaint"]) };
25670
+ }
25671
+ const error62 = asRecord(record2["error"]);
25672
+ return {
25673
+ id,
25674
+ ok: false,
25675
+ error: {
25676
+ code: stringOf(error62, "code") ?? "error",
25677
+ message: stringOf(error62, "message") ?? "the change failed"
25678
+ }
25679
+ };
25680
+ });
25681
+ }
24994
25682
  function adopt(target, source) {
24995
25683
  for (const key of Object.keys(target)) {
24996
25684
  delete target[key];
@@ -25007,39 +25695,55 @@ var RemoteStore = class {
25007
25695
  return `${this.#client.orgPath()}/complaints${suffix}`;
25008
25696
  }
25009
25697
  /**
25010
- * Posts the complaint. The API assigns the stored `id` and `createdAt`, so
25011
- * the validated record of the response replaces the record passed in.
25698
+ * Posts the complaint. The API assigns the stored `id`, `createdAt`,
25699
+ * `fingerprint` and `recurred`, so the validated record of the response
25700
+ * replaces the record passed in, and the answer's match is returned.
25012
25701
  */
25013
25702
  async create(complaint) {
25014
- const {
25015
- id: _id,
25016
- createdAt: _createdAt,
25017
- org: _org,
25018
- reporter: _reporter,
25019
- reporterName: _reporterName,
25020
- ...input2
25021
- } = complaint;
25022
- const body = await this.#client.request("POST", this.#path(), JSON.stringify(input2));
25703
+ const input2 = complaintBody(complaint);
25704
+ const key = randomUUID2();
25705
+ let body;
25706
+ try {
25707
+ body = await this.#client.request("POST", this.#path(), JSON.stringify(input2), {
25708
+ [IDEMPOTENCY_HEADER]: key
25709
+ });
25710
+ } catch (error62) {
25711
+ const reason = retryableReason(error62);
25712
+ if (reason !== void 0 && error62 instanceof RemoteError) {
25713
+ throw new UndeliveredError(input2, reason, key, error62);
25714
+ }
25715
+ throw error62;
25716
+ }
25023
25717
  adopt(complaint, readComplaint(body));
25718
+ const answer2 = asRecord(body);
25719
+ const openMatches = answer2?.["openMatches"];
25720
+ return {
25721
+ open: typeof openMatches === "number" ? openMatches : 0,
25722
+ resolved: answer2?.["recurred"] === true
25723
+ };
25024
25724
  }
25025
25725
  /** Returns the matching complaints, newest first. */
25026
25726
  async list(filter) {
25027
- const query = new URLSearchParams();
25028
- if (filter.project !== void 0) query.set("project", filter.project);
25029
- if (filter.severity !== void 0) query.set("severity", filter.severity);
25030
- if (filter.status !== void 0) query.set("status", filter.status);
25031
- if (filter.tag !== void 0) query.set("tag", filter.tag);
25032
- if (filter.reporter !== void 0) query.set("reporter", filter.reporter);
25033
- if (filter.since !== void 0) query.set("since", filter.since);
25034
- if (filter.until !== void 0) query.set("until", filter.until);
25035
- if (filter.limit !== void 0) query.set("limit", String(filter.limit));
25036
- const search = query.toString();
25727
+ return (await this.listPage(filter)).complaints;
25728
+ }
25729
+ /**
25730
+ * Returns the matching complaints, newest first, and whether the server
25731
+ * stopped at its read budget, which the answer marks with a header.
25732
+ */
25733
+ async listPage(filter) {
25734
+ const search = new URLSearchParams(listQueryOf(filter)).toString();
25037
25735
  const suffix = search === "" ? "" : `?${search}`;
25038
- const body = await this.#client.request("GET", this.#path(suffix));
25736
+ const { body, headers } = await this.#client.requestWithHeaders(
25737
+ "GET",
25738
+ this.#path(suffix)
25739
+ );
25039
25740
  if (!Array.isArray(body)) {
25040
- throw new RemoteError("the API returned a body that is not an array");
25741
+ throw new RemoteError("unexpected response from the API: expected a list");
25041
25742
  }
25042
- return body.map((item) => readComplaint(item));
25743
+ return {
25744
+ complaints: body.map((item) => readComplaint(item)),
25745
+ truncated: headers.get(TRUNCATED_HEADER) === "1"
25746
+ };
25043
25747
  }
25044
25748
  /** Returns the complaint with that id, or `undefined` when the API says 404. */
25045
25749
  async get(id) {
@@ -25059,11 +25763,26 @@ var RemoteStore = class {
25059
25763
  this.#path(`/${encodeURIComponent(id)}`),
25060
25764
  JSON.stringify({
25061
25765
  status: change.status,
25062
- ...change.resolution === void 0 ? {} : { resolution: change.resolution }
25766
+ ...change.resolution === void 0 ? {} : { resolution: change.resolution },
25767
+ ...change.reason === void 0 ? {} : { reason: change.reason },
25768
+ ...change.duplicateOf === void 0 ? {} : { duplicateOf: change.duplicateOf }
25063
25769
  })
25064
25770
  );
25065
25771
  return body === void 0 ? void 0 : readComplaint(body);
25066
25772
  }
25773
+ /**
25774
+ * Applies one status change to many complaints with the bulk route, in one
25775
+ * request. A per-id `not_found` reads as an absent record, exactly like the
25776
+ * `404` of one id, so its result carries no complaint and the code.
25777
+ */
25778
+ async bulkStatus(ids, change) {
25779
+ const body = await this.#client.request(
25780
+ "POST",
25781
+ this.#path("/bulk"),
25782
+ JSON.stringify({ ids, ...change })
25783
+ );
25784
+ return readBulkResults(body);
25785
+ }
25067
25786
  /**
25068
25787
  * Replaces the tags. The API stamps the change with its own time, so the
25069
25788
  * request carries the list only.
@@ -25078,40 +25797,411 @@ var RemoteStore = class {
25078
25797
  }
25079
25798
  };
25080
25799
 
25081
- // src/store.ts
25082
- function actAsProblem(context) {
25083
- if (context.actAs === void 0) return void 0;
25084
- const endpoint = context.endpoint;
25085
- if (context.mode === "local" || endpoint === void 0) {
25086
- return "local mode is in use";
25087
- }
25088
- if (context.token !== void 0) return "QUIBBLE_TOKEN is set";
25089
- if (context.credential !== void 0) {
25090
- return `a stored session for ${endpoint} is used`;
25800
+ // src/queue.ts
25801
+ import { createHash as createHash2, randomBytes as randomBytes2, randomUUID as randomUUID3 } from "node:crypto";
25802
+ import {
25803
+ appendFileSync,
25804
+ closeSync,
25805
+ fstatSync,
25806
+ linkSync,
25807
+ mkdirSync as mkdirSync2,
25808
+ openSync,
25809
+ readFileSync as readFileSync3,
25810
+ readSync,
25811
+ renameSync as renameSync2,
25812
+ rmSync as rmSync2,
25813
+ statSync,
25814
+ writeSync
25815
+ } from "node:fs";
25816
+ import { hostname as hostname3 } from "node:os";
25817
+ import { dirname as dirname4, join as join4 } from "node:path";
25818
+
25819
+ // src/text.ts
25820
+ var CONTROL_RE2 = /[\u0000-\u001F\u007F-\u009F]/g;
25821
+ function escape(char) {
25822
+ return `\\x${(char.codePointAt(0) ?? 0).toString(16).padStart(2, "0")}`;
25823
+ }
25824
+ function printable(text) {
25825
+ return text.replace(/\s+/g, " ").replace(CONTROL_RE2, escape);
25826
+ }
25827
+ function printableLines(text) {
25828
+ return text.replace(/\r\n?/g, "\n").replace(
25829
+ CONTROL_RE2,
25830
+ (char) => char === "\n" || char === " " ? char : escape(char)
25831
+ );
25832
+ }
25833
+
25834
+ // src/queue.ts
25835
+ var QUEUE_FILENAME = "unsent.jsonl";
25836
+ var LOCK_FILENAME = "unsent.lock";
25837
+ var LOCK_STALE_MS = 5 * 6e4;
25838
+ var QUEUE_MODE = CREDENTIALS_MODE;
25839
+ function queuePath(env) {
25840
+ return join4(defaultQuibbleHome(env), QUEUE_FILENAME);
25841
+ }
25842
+ function lockPathOf(queue) {
25843
+ return join4(dirname4(queue), LOCK_FILENAME);
25844
+ }
25845
+ function authLabel(context) {
25846
+ if (context.token !== void 0) {
25847
+ const hash2 = createHash2("sha256").update(context.token).digest("hex");
25848
+ return `token:${hash2.slice(0, 16)}`;
25849
+ }
25850
+ if (context.credential !== void 0) return `session:${context.credential.sub}`;
25851
+ return `dev:${context.actAs ?? ""}`;
25852
+ }
25853
+ function readOptional(path) {
25854
+ try {
25855
+ return readFileSync3(path, "utf8");
25856
+ } catch (error62) {
25857
+ if (isNotFound(error62)) return "";
25858
+ throw new StorageError(`cannot read ${path}`, { cause: error62 });
25091
25859
  }
25092
- if (!isLoopback(endpoint)) return `${endpoint} is not a loopback endpoint`;
25093
- return void 0;
25094
25860
  }
25095
- function checkActAs(context) {
25096
- const problem = actAsProblem(context);
25097
- if (problem !== void 0) throw actAsNotApplicable(problem);
25861
+ function endsInsideLine(path) {
25862
+ let fd;
25863
+ try {
25864
+ fd = openSync(path, "r");
25865
+ } catch (error62) {
25866
+ if (isNotFound(error62)) return false;
25867
+ throw error62;
25868
+ }
25869
+ try {
25870
+ const size = fstatSync(fd).size;
25871
+ if (size === 0) return false;
25872
+ const last = Buffer.alloc(1);
25873
+ readSync(fd, last, 0, 1, size - 1);
25874
+ return last[0] !== 10;
25875
+ } finally {
25876
+ closeSync(fd);
25877
+ }
25098
25878
  }
25099
- function openClient(context, io) {
25100
- checkActAs(context);
25101
- const endpoint = requireEndpoint(context);
25102
- const http = { debug: context.debug, fetch: io.fetch };
25103
- const org = context.org === void 0 ? {} : { org: context.org };
25104
- if (context.token === void 0 && context.credential === void 0) {
25105
- if (!isLoopback(endpoint)) throw notLoggedIn(endpoint);
25106
- return new ApiClient({
25107
- endpoint,
25108
- authorizer: devAuthorizer(endpoint, context.actAs, http),
25109
- ...org,
25110
- ...http
25879
+ function appendLine(path, value) {
25880
+ try {
25881
+ mkdirSync2(dirname4(path), { recursive: true, mode: HOME_MODE });
25882
+ const lead = endsInsideLine(path) ? "\n" : "";
25883
+ appendFileSync(path, `${lead}${JSON.stringify(value)}
25884
+ `, {
25885
+ encoding: "utf8",
25886
+ mode: QUEUE_MODE
25111
25887
  });
25888
+ } catch (error62) {
25889
+ throw new StorageError(`cannot write ${path}`, { cause: error62 });
25112
25890
  }
25113
- return new ApiClient({
25114
- endpoint,
25891
+ }
25892
+ function badLine(path, line, why) {
25893
+ return new StorageError(`${path}: line ${line}: ${why}`);
25894
+ }
25895
+ function readQueuedLine(record2, path, line) {
25896
+ const body = asRecord(record2["body"]);
25897
+ const fields = ["key", "endpoint", "org", "auth", "queuedAt"];
25898
+ for (const field of fields) {
25899
+ if (typeof record2[field] !== "string") {
25900
+ throw badLine(path, line, `${field} must be a string`);
25901
+ }
25902
+ }
25903
+ if (body === void 0) throw badLine(path, line, "body must be an object");
25904
+ return {
25905
+ key: record2["key"],
25906
+ endpoint: record2["endpoint"],
25907
+ org: record2["org"],
25908
+ auth: record2["auth"],
25909
+ body,
25910
+ queuedAt: record2["queuedAt"]
25911
+ };
25912
+ }
25913
+ function readQueue(path) {
25914
+ const pending = /* @__PURE__ */ new Map();
25915
+ const known = /* @__PURE__ */ new Set();
25916
+ const lines = readOptional(path).split("\n");
25917
+ for (const [index, text] of lines.entries()) {
25918
+ const line = index + 1;
25919
+ if (text.trim() === "") continue;
25920
+ let record2;
25921
+ try {
25922
+ record2 = asRecord(JSON.parse(text));
25923
+ } catch {
25924
+ throw badLine(path, line, "not JSON");
25925
+ }
25926
+ if (record2 === void 0) throw badLine(path, line, "not a JSON object");
25927
+ const op = record2["op"];
25928
+ if (op === "queued") {
25929
+ const entry = readQueuedLine(record2, path, line);
25930
+ known.add(entry.key);
25931
+ pending.set(entry.key, entry);
25932
+ continue;
25933
+ }
25934
+ if (op === "sent" || op === "dropped") {
25935
+ const key = record2["key"];
25936
+ if (typeof key !== "string" || !known.has(key)) {
25937
+ throw badLine(path, line, `${op} names no queued complaint`);
25938
+ }
25939
+ pending.delete(key);
25940
+ continue;
25941
+ }
25942
+ throw badLine(path, line, `unknown op ${JSON.stringify(op)}`);
25943
+ }
25944
+ return [...pending.values()];
25945
+ }
25946
+ function pendingFor(path, endpoint) {
25947
+ const key = normalizeEndpoint(endpoint);
25948
+ return readQueue(path).filter((entry) => entry.endpoint === key);
25949
+ }
25950
+ function enqueue(path, input2, now = /* @__PURE__ */ new Date()) {
25951
+ const entry = {
25952
+ key: input2.key ?? randomUUID3(),
25953
+ endpoint: normalizeEndpoint(input2.endpoint),
25954
+ org: input2.org,
25955
+ auth: input2.auth,
25956
+ body: input2.body,
25957
+ queuedAt: now.toISOString()
25958
+ };
25959
+ appendLine(path, { op: "queued", ...entry });
25960
+ return entry;
25961
+ }
25962
+ function processRuns(pid) {
25963
+ try {
25964
+ process.kill(pid, 0);
25965
+ return true;
25966
+ } catch (error62) {
25967
+ return error62.code === "EPERM";
25968
+ }
25969
+ }
25970
+ function judgeLock(lock, now = /* @__PURE__ */ new Date()) {
25971
+ let text;
25972
+ try {
25973
+ text = readFileSync3(lock, "utf8");
25974
+ } catch (error62) {
25975
+ if (isNotFound(error62)) return void 0;
25976
+ throw new StorageError(`cannot read ${lock}`, { cause: error62 });
25977
+ }
25978
+ const record2 = asRecord(parseQuietly(text));
25979
+ const pid = record2?.["pid"];
25980
+ const host = stringOf(record2, "host");
25981
+ const at = Date.parse(stringOf(record2, "at") ?? "");
25982
+ if (typeof pid !== "number" || host === void 0 || Number.isNaN(at)) {
25983
+ let written;
25984
+ try {
25985
+ written = statSync(lock).mtimeMs;
25986
+ } catch (error62) {
25987
+ if (isNotFound(error62)) return void 0;
25988
+ throw new StorageError(`cannot read ${lock}`, { cause: error62 });
25989
+ }
25990
+ return { text, stale: now.getTime() - written > LOCK_STALE_MS };
25991
+ }
25992
+ const dead = host === hostname3() && !processRuns(pid);
25993
+ return { text, stale: dead || now.getTime() - at > LOCK_STALE_MS };
25994
+ }
25995
+ function createLock(lock, owner = { pid: process.pid, host: hostname3(), at: (/* @__PURE__ */ new Date()).toISOString() }) {
25996
+ const text = JSON.stringify(owner);
25997
+ let fd;
25998
+ try {
25999
+ mkdirSync2(dirname4(lock), { recursive: true, mode: HOME_MODE });
26000
+ fd = openSync(lock, "wx", QUEUE_MODE);
26001
+ } catch (error62) {
26002
+ if (error62.code === "EEXIST") return void 0;
26003
+ throw new StorageError(`cannot create ${lock}`, { cause: error62 });
26004
+ }
26005
+ try {
26006
+ writeSync(fd, text);
26007
+ } finally {
26008
+ closeSync(fd);
26009
+ }
26010
+ return text;
26011
+ }
26012
+ function takeOverLock(lock, judged, owner) {
26013
+ const moved = `${lock}.${process.pid}.${randomBytes2(6).toString("hex")}`;
26014
+ try {
26015
+ renameSync2(lock, moved);
26016
+ } catch (error62) {
26017
+ if (isNotFound(error62)) return void 0;
26018
+ throw new StorageError(`cannot move ${lock}`, { cause: error62 });
26019
+ }
26020
+ const text = readOptional(moved);
26021
+ if (text === judged.text) {
26022
+ rmSync2(moved, { force: true });
26023
+ return createLock(lock, owner);
26024
+ }
26025
+ try {
26026
+ linkSync(moved, lock);
26027
+ } catch (error62) {
26028
+ if (error62.code !== "EEXIST") {
26029
+ throw new StorageError(`cannot restore ${lock}`, { cause: error62 });
26030
+ }
26031
+ } finally {
26032
+ rmSync2(moved, { force: true });
26033
+ }
26034
+ return void 0;
26035
+ }
26036
+ function acquireLock(lock) {
26037
+ const created = createLock(lock);
26038
+ if (created !== void 0) return created;
26039
+ const judged = judgeLock(lock);
26040
+ if (judged === void 0) return createLock(lock);
26041
+ if (!judged.stale) return void 0;
26042
+ return takeOverLock(lock, judged);
26043
+ }
26044
+ function releaseLock(lock, text) {
26045
+ if (readOptional(lock) === text) rmSync2(lock, { force: true });
26046
+ }
26047
+ function stopReason(status, text) {
26048
+ if (status >= 500) return `the API answered ${status}`;
26049
+ const code = serverCode(text);
26050
+ const stops = status === 429 || status === 401 || status === 403 || status === 404 && code === ORG_NOT_FOUND;
26051
+ if (!stops) return void 0;
26052
+ const message = stringOf(asRecord(asRecord(parseQuietly(text))?.["error"]), "message");
26053
+ return message ?? `the API answered ${status}`;
26054
+ }
26055
+ function projectOf(entry) {
26056
+ const project = entry.body["project"];
26057
+ return typeof project === "string" ? project : "?";
26058
+ }
26059
+ function headlineOfEntry(entry) {
26060
+ const message = entry.body["message"];
26061
+ return typeof message === "string" ? printable(summarize(message)) : "?";
26062
+ }
26063
+ function queuedCount(n) {
26064
+ return `${n} queued ${n === 1 ? "complaint" : "complaints"}`;
26065
+ }
26066
+ async function flushQueue(options) {
26067
+ const endpoint = normalizeEndpoint(options.endpoint);
26068
+ const mine = () => readQueue(options.path).filter(
26069
+ (entry) => entry.endpoint === endpoint && entry.auth === options.auth
26070
+ );
26071
+ const result = {
26072
+ sent: [],
26073
+ dropped: 0,
26074
+ left: 0,
26075
+ locked: false,
26076
+ stopped: void 0
26077
+ };
26078
+ if (mine().length === 0) return result;
26079
+ const lock = lockPathOf(options.path);
26080
+ const held = acquireLock(lock);
26081
+ if (held === void 0) {
26082
+ return { ...result, locked: true, left: mine().length };
26083
+ }
26084
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
26085
+ try {
26086
+ const entries = mine();
26087
+ for (const [index, entry] of entries.entries()) {
26088
+ let status;
26089
+ let text;
26090
+ try {
26091
+ ({ status, text } = await options.client.exchange(
26092
+ "POST",
26093
+ `/v1/orgs/${encodeURIComponent(entry.org)}/complaints`,
26094
+ JSON.stringify(entry.body),
26095
+ { [IDEMPOTENCY_HEADER]: entry.key }
26096
+ ));
26097
+ } catch (error62) {
26098
+ result.stopped = errorMessage(error62);
26099
+ result.left = entries.length - index;
26100
+ return result;
26101
+ }
26102
+ if (status >= 200 && status < 300) {
26103
+ const id = stringOf(asRecord(parseQuietly(text)), "id") ?? "";
26104
+ appendLine(options.path, {
26105
+ op: "sent",
26106
+ key: entry.key,
26107
+ id,
26108
+ sentAt: now().toISOString()
26109
+ });
26110
+ result.sent.push(id);
26111
+ continue;
26112
+ }
26113
+ const reason = stopReason(status, text);
26114
+ if (reason !== void 0) {
26115
+ result.stopped = reason;
26116
+ result.left = entries.length - index;
26117
+ return result;
26118
+ }
26119
+ const record2 = asRecord(asRecord(parseQuietly(text))?.["error"]);
26120
+ const code = stringOf(record2, "code") ?? `http_${status}`;
26121
+ const message = stringOf(record2, "message") ?? `the API answered ${status}`;
26122
+ appendLine(options.path, {
26123
+ op: "dropped",
26124
+ key: entry.key,
26125
+ code,
26126
+ message,
26127
+ droppedAt: now().toISOString()
26128
+ });
26129
+ result.dropped += 1;
26130
+ writeLine(
26131
+ options.stderr,
26132
+ `quibble: dropped a queued complaint for ${entry.org}/${projectOf(entry)}, "${headlineOfEntry(entry)}": ${printable(message)}`
26133
+ );
26134
+ }
26135
+ return result;
26136
+ } finally {
26137
+ releaseLock(lock, held);
26138
+ }
26139
+ }
26140
+
26141
+ // src/store.ts
26142
+ function queueSender(context, endpoint, io) {
26143
+ return async (client) => {
26144
+ let result;
26145
+ try {
26146
+ result = await flushQueue({
26147
+ path: queuePath(io.env),
26148
+ endpoint,
26149
+ auth: authLabel(context),
26150
+ client,
26151
+ stderr: io.stderr
26152
+ });
26153
+ } catch (error62) {
26154
+ if (!(error62 instanceof StorageError)) throw error62;
26155
+ writeLine(
26156
+ io.stderr,
26157
+ `quibble: warning: cannot send the queue: ${errorMessage(error62)}`
26158
+ );
26159
+ return;
26160
+ }
26161
+ if (result.stopped !== void 0) {
26162
+ writeLine(
26163
+ io.stderr,
26164
+ `quibble: ${queuedCount(result.left)} still unsent: ${result.stopped}`
26165
+ );
26166
+ }
26167
+ };
26168
+ }
26169
+ function actAsProblem(context) {
26170
+ if (context.actAs === void 0) return void 0;
26171
+ const endpoint = context.endpoint;
26172
+ if (context.mode === "local" || endpoint === void 0) {
26173
+ return "local mode is in use";
26174
+ }
26175
+ if (context.token !== void 0) return "QUIBBLE_TOKEN is set";
26176
+ if (context.credential !== void 0) {
26177
+ return `you are logged in to ${endpoint}`;
26178
+ }
26179
+ if (!isLoopback(endpoint)) return `${endpoint} is not a loopback endpoint`;
26180
+ return void 0;
26181
+ }
26182
+ function checkActAs(context) {
26183
+ const problem = actAsProblem(context);
26184
+ if (problem !== void 0) throw actAsNotApplicable(problem);
26185
+ }
26186
+ function openClient(context, io, options = {}) {
26187
+ checkActAs(context);
26188
+ const endpoint = requireEndpoint(context);
26189
+ const http = { debug: context.debug, fetch: io.fetch };
26190
+ const org = {
26191
+ ...context.org === void 0 ? {} : { org: context.org },
26192
+ ...options.queue === false ? {} : { beforeFirst: queueSender(context, endpoint, io) }
26193
+ };
26194
+ if (context.token === void 0 && context.credential === void 0) {
26195
+ if (!isLoopback(endpoint)) throw notLoggedIn(endpoint);
26196
+ return new ApiClient({
26197
+ endpoint,
26198
+ authorizer: devAuthorizer(endpoint, context.actAs, http),
26199
+ ...org,
26200
+ ...http
26201
+ });
26202
+ }
26203
+ return new ApiClient({
26204
+ endpoint,
25115
26205
  authorizer: createAuthorizer(endpoint, { env: io.env, ...http }),
25116
26206
  ...org,
25117
26207
  ...http
@@ -25125,17 +26215,26 @@ function openOrgClient(context, io) {
25125
26215
  function openBackend(context, io) {
25126
26216
  if (context.mode === "local") {
25127
26217
  checkActAs(context);
25128
- return { store: new FileStore(context.storePath), client: void 0 };
26218
+ const local = new FileStore(context.storePath);
26219
+ return { store: local, client: void 0, local };
25129
26220
  }
25130
26221
  const client = openOrgClient(context, io);
25131
- return { store: new RemoteStore(client), client };
26222
+ return { store: new RemoteStore(client), client, local: void 0 };
25132
26223
  }
25133
26224
  function openStore(context, io) {
25134
26225
  return openBackend(context, io).store;
25135
26226
  }
26227
+ function needsServer(command, context) {
26228
+ return new ConfigError(
26229
+ context.endpoint === void 0 ? `${command} needs a server: pass --endpoint <url>, set QUIBBLE_ENDPOINT, or add one to .quibblerc` : `${command} needs a server: drop --local`
26230
+ );
26231
+ }
26232
+ function requireServer(command, context) {
26233
+ if (context.mode !== "remote") throw needsServer(command, context);
26234
+ }
25136
26235
 
25137
26236
  // src/commands/complain.ts
25138
- var REMOTE_REPORTER_MESSAGE = "--reporter is not allowed in remote mode: the server records the signed-in user";
26237
+ var REMOTE_REPORTER_MESSAGE = "--reporter only works in local mode: the server sets the reporter";
25139
26238
  async function runComplain(message, options, context, io) {
25140
26239
  const project = requireProject(context);
25141
26240
  const org = requireOrg(context);
@@ -25153,11 +26252,18 @@ async function runComplain(message, options, context, io) {
25153
26252
  ...options.tag === void 0 || options.tag.length === 0 ? {} : { tags: options.tag },
25154
26253
  agent: context.agent,
25155
26254
  reporter: context.reporter,
25156
- ...context.cwd === void 0 ? {} : { cwd: context.cwd }
26255
+ ...context.cwd === void 0 ? {} : { cwd: context.cwd },
26256
+ ...options.cmd === void 0 ? {} : { cmd: options.cmd }
25157
26257
  };
25158
- const complaint = await createComplaint(store, input2, {
25159
- allowedTags: context.allowedTags
25160
- });
26258
+ let complaint;
26259
+ try {
26260
+ complaint = await createComplaint(store, input2, {
26261
+ allowedTags: context.allowedTags
26262
+ });
26263
+ } catch (error62) {
26264
+ if (!(error62 instanceof UndeliveredError)) throw error62;
26265
+ return queueComplaint(error62, org, project, context, io, options.json === true);
26266
+ }
25161
26267
  if (options.json === true) {
25162
26268
  writeJson(io.stdout, complaint);
25163
26269
  return 0;
@@ -25167,6 +26273,35 @@ async function runComplain(message, options, context, io) {
25167
26273
  io.stdout,
25168
26274
  `Filed complaint ${complaint.id} for ${scope} (${complaint.severity})`
25169
26275
  );
26276
+ for (const line of matchLines(complaint)) writeLine(io.stdout, line);
26277
+ return 0;
26278
+ }
26279
+ function matchLines(complaint) {
26280
+ const lines = [];
26281
+ if (complaint.openMatches === 1) {
26282
+ lines.push("1 open complaint already shares this headline.");
26283
+ } else if (complaint.openMatches > 1) {
26284
+ lines.push(`${complaint.openMatches} open complaints already share this headline.`);
26285
+ }
26286
+ if (complaint.recurred) {
26287
+ lines.push("A resolved complaint shares this headline: this one is marked recurred.");
26288
+ }
26289
+ return lines;
26290
+ }
26291
+ function queueComplaint(error62, org, project, context, io, json2) {
26292
+ const path = queuePath(io.env);
26293
+ enqueue(path, {
26294
+ endpoint: requireEndpoint(context),
26295
+ org,
26296
+ auth: authLabel(context),
26297
+ body: error62.body,
26298
+ key: error62.key
26299
+ });
26300
+ writeLine(
26301
+ io.stderr,
26302
+ `quibble: queued the complaint for ${org}/${project} in ${path}: ${error62.reason}. It is sent with the next remote command under the same credential, or run \`quibble retry\`.`
26303
+ );
26304
+ if (json2) writeJson(io.stdout, { queued: true, org, project, reason: error62.reason });
25170
26305
  return 0;
25171
26306
  }
25172
26307
  async function readMessage(message, io) {
@@ -25208,7 +26343,9 @@ function runConfig(options, context, io) {
25208
26343
  agent: context.agent,
25209
26344
  reporter: context.reporter,
25210
26345
  actAs: context.actAs ?? null,
25211
- auth: describeAuth(context)
26346
+ auth: describeAuth(context),
26347
+ // Every credential counts, and nothing is sent.
26348
+ unsent: context.mode === "remote" && context.endpoint !== void 0 ? pendingFor(queuePath(io.env), context.endpoint).length : 0
25212
26349
  };
25213
26350
  if (options.json === true) {
25214
26351
  writeJson(io.stdout, report);
@@ -25222,31 +26359,50 @@ function runConfig(options, context, io) {
25222
26359
  }
25223
26360
 
25224
26361
  // src/commands/init.ts
25225
- import { existsSync, writeFileSync as writeFileSync2 } from "node:fs";
25226
- import { basename, join as join4, resolve as resolve2 } from "node:path";
25227
- var SNIPPET_INTRO = `## Filing complaints
26362
+ import { existsSync as existsSync2, writeFileSync as writeFileSync3 } from "node:fs";
26363
+ import { basename as basename2, join as join6, resolve as resolve3 } from "node:path";
25228
26364
 
25229
- This project uses Quibble. When the build, the tests, the documentation or the
25230
- tooling waste your time, file a complaint instead of working around the problem
25231
- in silence.`;
25232
- var SNIPPET_USAGE = `Use \`-s low|medium|high\` for severity and repeat \`-t <tag>\` for tags. The first
25233
- line of the message is the headline every surface shows, so put the summary on
25234
- the first line and the detail below it. Pipe a long message on stdin with
25235
- \`quibble complain -\`. File one complaint per problem, and file it when you hit
25236
- the problem rather than at the end of the task.`;
25237
- function exampleCommand(tag) {
25238
- return ` quibble complain "what went wrong, and what you expected" -s medium -t ${tag}`;
25239
- }
25240
- var AGENT_SNIPPET = [SNIPPET_INTRO, exampleCommand("build"), SNIPPET_USAGE].join(
25241
- "\n\n"
25242
- );
26365
+ // src/snippet.ts
26366
+ import { basename } from "node:path";
26367
+ var SNIPPET_HEADING = "## Filing complaints";
26368
+ var SKILL_HEADING = "# Filing complaints";
26369
+ var SKILL_PATH = ".claude/skills/quibble/SKILL.md";
26370
+ var DEFAULT_SNIPPET_AGENT = "claude";
26371
+ var AGENTS_FILE_AGENT = "codex";
26372
+ var SNIPPET_INTRO = `This project uses Quibble. When the build, tests, docs or tooling waste your
26373
+ time, file a complaint instead of quietly working around the problem.`;
26374
+ var SNIPPET_USAGE = `- Set severity with \`-s low|medium|high\`. Add tags with \`-t <tag>\` (repeatable).
26375
+ - Put a one-line summary first; it is the headline everywhere. Add details below.
26376
+ - Name the command that failed with \`--cmd "<command>"\`.
26377
+ - Pipe a long message on stdin with \`quibble complain -\`.
26378
+ - File one complaint per problem, as soon as you hit it, not at the end of the task.`;
26379
+ var SKILL_FRONT_MATTER = `---
26380
+ name: quibble
26381
+ description: File a Quibble complaint when the build, tests, docs or tooling of this project waste your time. Use it as soon as you hit the problem, not at the end of the task.
26382
+ ---`;
25243
26383
  var SNIPPET_LOGIN = "Run `quibble login` once before the first complaint.";
25244
26384
  function orgSentence(org) {
25245
26385
  return `Complaints go to the \`${org}\` organization.`;
25246
26386
  }
26387
+ function exampleCommand(tag, agent) {
26388
+ return ` quibble complain "what went wrong, and what you expected" -s medium -t ${tag} --agent ${agent}`;
26389
+ }
26390
+ function tagRules(tags) {
26391
+ const list = tags.map((tag) => `\`${tag}\``).join(", ");
26392
+ return `Every complaint needs at least one of these tags: ${list}.
26393
+ If none fits, pass \`-t ${OTHER_TAG_PREFIX}<name>\` naming the tag you wanted, such as
26394
+ \`-t ${OTHER_TAG_PREFIX}network\`. Run \`quibble tags\` to see the list.`;
26395
+ }
25247
26396
  function agentSnippet(tags, scope = {}) {
25248
26397
  const first = tags?.[0];
25249
- const blocks = tags === void 0 || first === void 0 ? [SNIPPET_INTRO, exampleCommand("build"), SNIPPET_USAGE] : [SNIPPET_INTRO, exampleCommand(first), tagRules(tags), SNIPPET_USAGE];
26398
+ const agent = scope.agent ?? DEFAULT_SNIPPET_AGENT;
26399
+ const blocks = [
26400
+ SNIPPET_HEADING,
26401
+ SNIPPET_INTRO,
26402
+ exampleCommand(first ?? "build", agent)
26403
+ ];
26404
+ if (tags !== void 0 && first !== void 0) blocks.push(tagRules(tags));
26405
+ blocks.push(SNIPPET_USAGE);
25250
26406
  const sentences = [];
25251
26407
  const { endpoint, org } = scope;
25252
26408
  if (endpoint !== void 0 && endpoint !== "") sentences.push(SNIPPET_LOGIN);
@@ -25254,54 +26410,207 @@ function agentSnippet(tags, scope = {}) {
25254
26410
  if (sentences.length > 0) blocks.push(sentences.join(" "));
25255
26411
  return blocks.join("\n\n");
25256
26412
  }
25257
- function tagRules(tags) {
25258
- const list = tags.map((tag) => `\`${tag}\``).join(", ");
25259
- return `Every complaint needs at least one tag. The allowed tags are ${list}.
25260
- When no tag fits, pass \`-t ${OTHER_TAG_HINT}\` and name the tag you wanted, such
25261
- as \`-t ${OTHER_TAG_PREFIX}network\`. Run \`quibble tags\` to print the list.`;
25262
- }
25263
- function runInit(options, io) {
25264
- const directory = resolve2(io.cwd);
25265
- const path = join4(directory, PROJECT_CONFIG_FILENAME);
25266
- if (existsSync(path) && options.force !== true) {
26413
+ function skillFile(snippet) {
26414
+ const body = snippet.startsWith(SNIPPET_HEADING) ? `${SKILL_HEADING}${snippet.slice(SNIPPET_HEADING.length)}` : snippet;
26415
+ return `${SKILL_FRONT_MATTER}
26416
+
26417
+ ${body}
26418
+ `;
26419
+ }
26420
+ function agentForFile(path) {
26421
+ return basename(path).toLowerCase() === "agents.md" ? AGENTS_FILE_AGENT : DEFAULT_SNIPPET_AGENT;
26422
+ }
26423
+ function hasSnippet(text) {
26424
+ return text.split(/\r?\n/).some((line) => line.trimEnd() === SNIPPET_HEADING);
26425
+ }
26426
+
26427
+ // src/commands/snippet.ts
26428
+ import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
26429
+ import { dirname as dirname5, join as join5, resolve as resolve2 } from "node:path";
26430
+ function existsError(path) {
26431
+ return new UsageError(`${path} already exists: pass --force to overwrite it`);
26432
+ }
26433
+ function readOptional2(path) {
26434
+ try {
26435
+ return readFileSync4(path, "utf8");
26436
+ } catch (error62) {
26437
+ if (isNotFound(error62)) return "";
26438
+ throw error62;
26439
+ }
26440
+ }
26441
+ function writeSkill(path, snippet) {
26442
+ mkdirSync3(dirname5(path), { recursive: true });
26443
+ writeFileSync2(path, skillFile(snippet), "utf8");
26444
+ }
26445
+ async function runSnippet(options, context, io) {
26446
+ const project = requireProject(context);
26447
+ const override = options.agent === void 0 ? void 0 : parseAgentName(options.agent);
26448
+ const appendPath = options.append === void 0 ? void 0 : resolve2(io.cwd, options.append);
26449
+ const skillPath = options.skill === true ? join5(context.projectRoot ?? io.cwd, SKILL_PATH) : void 0;
26450
+ const existing = appendPath === void 0 ? "" : readOptional2(appendPath);
26451
+ if (options.append !== void 0 && hasSnippet(existing)) {
26452
+ throw new UsageError(
26453
+ `${options.append} already has a "${SNIPPET_HEADING}" section: edit it by hand`
26454
+ );
26455
+ }
26456
+ if (skillPath !== void 0 && existsSync(skillPath) && options.force !== true) {
26457
+ throw existsError(skillPath);
26458
+ }
26459
+ let tags;
26460
+ if (context.mode === "remote") {
26461
+ tags = (await openOrgClient(context, io).project(project)).tags;
26462
+ } else {
26463
+ checkActAs(context);
26464
+ tags = context.allowedTags;
26465
+ }
26466
+ const scope = {
26467
+ ...context.mode === "remote" ? { endpoint: context.endpoint } : {},
26468
+ ...context.slug === void 0 ? {} : { org: context.slug }
26469
+ };
26470
+ let shown = { agent: override ?? DEFAULT_SNIPPET_AGENT, snippet: "" };
26471
+ shown.snippet = agentSnippet(tags, { ...scope, agent: shown.agent });
26472
+ if (appendPath !== void 0) {
26473
+ const agent = override ?? agentForFile(appendPath);
26474
+ const snippet = agentSnippet(tags, { ...scope, agent });
26475
+ const kept = existing.replace(/\s+$/, "");
26476
+ writeFileSync2(appendPath, kept === "" ? `${snippet}
26477
+ ` : `${kept}
26478
+
26479
+ ${snippet}
26480
+ `);
26481
+ shown = { agent, snippet };
26482
+ }
26483
+ if (skillPath !== void 0) {
26484
+ const agent = override ?? DEFAULT_SNIPPET_AGENT;
26485
+ const snippet = agentSnippet(tags, { ...scope, agent });
26486
+ writeSkill(skillPath, snippet);
26487
+ if (appendPath === void 0) shown = { agent, snippet };
26488
+ }
26489
+ if (options.json === true) {
26490
+ writeJson(io.stdout, {
26491
+ snippet: shown.snippet,
26492
+ agent: shown.agent,
26493
+ appended: appendPath ?? null,
26494
+ skill: skillPath ?? null
26495
+ });
26496
+ return 0;
26497
+ }
26498
+ if (appendPath === void 0 && skillPath === void 0) {
26499
+ writeLine(io.stdout, shown.snippet);
26500
+ return 0;
26501
+ }
26502
+ if (appendPath !== void 0) {
26503
+ writeLine(io.stdout, `Appended the Quibble snippet to ${appendPath}`);
26504
+ }
26505
+ if (skillPath !== void 0) writeLine(io.stdout, `Wrote ${skillPath}`);
26506
+ return 0;
26507
+ }
26508
+
26509
+ // src/commands/init.ts
26510
+ var CODEX_LINE = "For Codex, run `quibble snippet --append AGENTS.md`, which names the agent codex.";
26511
+ function agentLead(agent) {
26512
+ return agent === DEFAULT_SNIPPET_AGENT ? "Add this to your CLAUDE.md:" : "Add this to your agent's instructions file:";
26513
+ }
26514
+ async function runInit(options, io) {
26515
+ const directory = resolve3(io.cwd);
26516
+ const path = join6(directory, PROJECT_CONFIG_FILENAME);
26517
+ if (existsSync2(path) && options.force !== true) {
25267
26518
  throw new ConfigError(`${path} already exists: pass --force to overwrite it`, path);
25268
26519
  }
26520
+ const skillPath = options.skill === true ? join6(directory, SKILL_PATH) : void 0;
26521
+ if (skillPath !== void 0 && existsSync2(skillPath) && options.force !== true) {
26522
+ throw existsError(skillPath);
26523
+ }
25269
26524
  const project = resolveProjectName(options.project, directory);
25270
26525
  const org = options.org === void 0 || options.org.trim() === "" ? void 0 : parseOrgSlug(options.org);
25271
26526
  const endpoint = options.endpoint?.trim();
25272
26527
  const tags = options.tag === void 0 ? void 0 : parseAllowedTags(options.tag);
26528
+ const agent = options.agent === void 0 ? DEFAULT_SNIPPET_AGENT : parseAgentName(options.agent);
25273
26529
  const config2 = {
25274
26530
  project,
25275
26531
  ...org === void 0 ? {} : { org },
25276
26532
  ...endpoint === void 0 || endpoint === "" ? {} : { endpoint },
25277
26533
  ...tags === void 0 ? {} : { tags }
25278
26534
  };
25279
- writeFileSync2(path, `${JSON.stringify(config2, null, 2)}
26535
+ writeFileSync3(path, `${JSON.stringify(config2, null, 2)}
25280
26536
  `, "utf8");
26537
+ const snippet = agentSnippet(tags, {
26538
+ agent,
26539
+ ...endpoint === void 0 ? {} : { endpoint },
26540
+ ...org === void 0 ? {} : { org }
26541
+ });
26542
+ if (skillPath !== void 0) writeSkill(skillPath, snippet);
26543
+ const check2 = await checkServer(options, project, io);
25281
26544
  if (options.json === true) {
25282
- writeJson(io.stdout, { path, project, org: org ?? null, tags: tags ?? null });
26545
+ writeJson(io.stdout, {
26546
+ path,
26547
+ project,
26548
+ org: org ?? null,
26549
+ tags: tags ?? null,
26550
+ skill: skillPath ?? null,
26551
+ server: check2.server
26552
+ });
25283
26553
  return 0;
25284
26554
  }
25285
26555
  writeLine(io.stdout, `Wrote ${path}`);
26556
+ if (skillPath !== void 0) writeLine(io.stdout, `Wrote ${skillPath}`);
25286
26557
  writeLine(io.stdout, `Project: ${project}`);
25287
26558
  if (org !== void 0) writeLine(io.stdout, `Organization: ${org}`);
25288
26559
  if (tags !== void 0) writeLine(io.stdout, `Tags: ${tags.join(", ")}`);
25289
26560
  writeLine(io.stdout);
25290
- writeLine(io.stdout, "Add this to your CLAUDE.md or AGENTS.md:");
26561
+ writeLine(io.stdout, agentLead(agent));
25291
26562
  writeLine(io.stdout);
25292
- writeLine(
25293
- io.stdout,
25294
- agentSnippet(tags, {
25295
- ...endpoint === void 0 ? {} : { endpoint },
25296
- ...org === void 0 ? {} : { org }
25297
- })
25298
- );
26563
+ writeLine(io.stdout, snippet);
26564
+ if (agent === DEFAULT_SNIPPET_AGENT) {
26565
+ writeLine(io.stdout);
26566
+ writeLine(io.stdout, CODEX_LINE);
26567
+ }
26568
+ if (check2.line !== void 0) {
26569
+ writeLine(io.stdout);
26570
+ writeLine(io.stdout, check2.line);
26571
+ }
25299
26572
  return 0;
25300
26573
  }
26574
+ async function checkServer(options, project, io) {
26575
+ const context = {
26576
+ ...resolveContext({
26577
+ cwd: io.cwd,
26578
+ env: io.env,
26579
+ endpoint: options.endpoint,
26580
+ org: options.org,
26581
+ local: options.local
26582
+ }),
26583
+ actAs: void 0
26584
+ };
26585
+ const { endpoint, org } = context;
26586
+ if (context.mode !== "remote" || endpoint === void 0 || org === void 0 || context.token === void 0 && context.credential === void 0 && !isLoopback(endpoint)) {
26587
+ return { server: "unchecked" };
26588
+ }
26589
+ const warn = (text) => {
26590
+ writeLine(io.stderr, `quibble: warning: ${text}`);
26591
+ };
26592
+ try {
26593
+ await openClient(context, io, { queue: false }).project(project);
26594
+ return { server: "found", line: `Project ${org}/${project} exists on ${endpoint}.` };
26595
+ } catch (error62) {
26596
+ if (error62 instanceof RemoteError && error62.status === 404) {
26597
+ if (error62.serverCode === ORG_NOT_FOUND) {
26598
+ warn(`no organization ${org} on ${endpoint} for this account`);
26599
+ return { server: "no_org" };
26600
+ }
26601
+ warn(
26602
+ `${org} has no project ${project} on ${endpoint}: an admin creates it in the organization settings`
26603
+ );
26604
+ return { server: "no_project" };
26605
+ }
26606
+ warn(`could not check the project on ${endpoint}: ${describeError(error62)}`);
26607
+ return { server: "unreachable" };
26608
+ }
26609
+ }
25301
26610
  function resolveProjectName(given, directory) {
25302
26611
  const candidate = given?.trim();
25303
26612
  if (candidate !== void 0 && candidate !== "") return parseProjectName(candidate);
25304
- const name = basename(directory);
26613
+ const name = basename2(directory);
25305
26614
  try {
25306
26615
  return parseProjectName(name);
25307
26616
  } catch (error62) {
@@ -25332,25 +26641,30 @@ var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
25332
26641
  var TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
25333
26642
  var MINUTE_MS = 6e4;
25334
26643
  var HOUR_MS = 60 * MINUTE_MS;
25335
- var DAY_MS2 = 24 * HOUR_MS;
26644
+ var DAY_MS3 = 24 * HOUR_MS;
25336
26645
  var UNIT_MS = {
25337
26646
  m: MINUTE_MS,
25338
26647
  h: HOUR_MS,
25339
- d: DAY_MS2,
25340
- w: 7 * DAY_MS2
26648
+ d: DAY_MS3,
26649
+ w: 7 * DAY_MS3
25341
26650
  };
25342
- function reject() {
25343
- throw new UsageError(SINCE_MESSAGE);
26651
+ var EXPIRES_MESSAGE = "--expires must be a duration like 30d, a date like 2026-12-01, or an ISO 8601 UTC timestamp";
26652
+ function reject(message = SINCE_MESSAGE) {
26653
+ throw new UsageError(message);
25344
26654
  }
25345
26655
  function iso(at) {
25346
26656
  if (Number.isNaN(at.getTime())) reject();
25347
26657
  return at.toISOString();
25348
26658
  }
25349
- function fromDuration(digits, unit, now) {
25350
- if (digits.startsWith("0")) reject();
26659
+ function durationMs(digits, unit) {
26660
+ if (digits.startsWith("0")) return void 0;
25351
26661
  const step = UNIT_MS[unit];
25352
- if (step === void 0) reject();
25353
- return iso(new Date(now.getTime() - Number(digits) * step));
26662
+ return step === void 0 ? void 0 : Number(digits) * step;
26663
+ }
26664
+ function fromDuration(digits, unit, now) {
26665
+ const span = durationMs(digits, unit);
26666
+ if (span === void 0) reject();
26667
+ return iso(new Date(now.getTime() - span));
25354
26668
  }
25355
26669
  function fromDate(value) {
25356
26670
  const at = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
@@ -25368,6 +26682,27 @@ function parseSince(text, now) {
25368
26682
  if (TIMESTAMP_RE.test(value)) return iso(new Date(value));
25369
26683
  return reject();
25370
26684
  }
26685
+ function parseExpires(text, now) {
26686
+ const value = text.trim();
26687
+ const duration3 = DURATION_RE.exec(value);
26688
+ let stamp2;
26689
+ try {
26690
+ if (duration3 !== null) {
26691
+ const span = durationMs(duration3[1] ?? "", duration3[2] ?? "");
26692
+ if (span === void 0) reject(EXPIRES_MESSAGE);
26693
+ stamp2 = iso(new Date(now.getTime() + span));
26694
+ } else if (DATE_RE.test(value)) {
26695
+ stamp2 = fromDate(value);
26696
+ } else if (TIMESTAMP_RE.test(value)) {
26697
+ stamp2 = iso(new Date(value));
26698
+ } else {
26699
+ return reject(EXPIRES_MESSAGE);
26700
+ }
26701
+ } catch {
26702
+ return reject(EXPIRES_MESSAGE);
26703
+ }
26704
+ return stamp2;
26705
+ }
25371
26706
 
25372
26707
  // src/scope.ts
25373
26708
  function resolveScope(options, context, now = /* @__PURE__ */ new Date()) {
@@ -25387,6 +26722,9 @@ function resolveScope(options, context, now = /* @__PURE__ */ new Date()) {
25387
26722
  ...options.since === void 0 ? {} : { since: parseSince(options.since, now) }
25388
26723
  };
25389
26724
  }
26725
+ function tagFilter(tags) {
26726
+ return tags === void 0 || tags.length === 0 ? {} : { tags: [...tags] };
26727
+ }
25390
26728
 
25391
26729
  // src/commands/list.ts
25392
26730
  var COLUMNS = [
@@ -25396,57 +26734,104 @@ var COLUMNS = [
25396
26734
  "status",
25397
26735
  "project",
25398
26736
  "reporter",
26737
+ "tags",
26738
+ "group",
25399
26739
  "summary"
25400
26740
  ];
25401
26741
  var REPORTER_WIDTH = 20;
26742
+ var TAGS_WIDTH = 24;
25402
26743
  var SUMMARY_MAX = 60;
26744
+ var RECURRED_MARK = "\u21BB";
26745
+ function cut2(text, width) {
26746
+ if (text.length <= width) return text;
26747
+ return `${text.slice(0, width - 3)}...`;
26748
+ }
25403
26749
  function summaryOf(complaint) {
25404
- const summary = summarize(complaint.message);
25405
- if (summary.length <= SUMMARY_MAX) return summary;
25406
- return `${summary.slice(0, SUMMARY_MAX - 3)}...`;
26750
+ return cut2(printable(summarize(complaint.message)), SUMMARY_MAX);
25407
26751
  }
26752
+ function limitNote(count, limit, more) {
26753
+ if (limit >= MAX_LIMIT) {
26754
+ return count >= MAX_LIMIT ? `Showing the newest ${MAX_LIMIT} complaints: older ones may exist, narrow the filters to see them.` : void 0;
26755
+ }
26756
+ return more ? `Showing the newest ${limit} complaints: pass --limit to see more (up to ${MAX_LIMIT}).` : void 0;
26757
+ }
26758
+ var BUDGET_NOTE = "The server stopped searching early, so older matches may exist: narrow the filters or pass --since.";
25408
26759
  async function runList(options, context, io) {
26760
+ if (options.ids === true && options.json === true) {
26761
+ throw new UsageError("--ids and --json cannot be used together");
26762
+ }
25409
26763
  const { store, client } = openBackend(context, io);
25410
26764
  const reporter = options.mine === true ? await resolveMine(context, client, io.env) : void 0;
25411
26765
  const filter = parseListFilter({
25412
26766
  ...resolveScope(options, context),
25413
26767
  ...options.severity === void 0 ? {} : { severity: options.severity },
25414
- ...options.tag === void 0 ? {} : { tag: options.tag },
26768
+ ...tagFilter(options.tag),
26769
+ ...options.search === void 0 ? {} : { q: options.search },
25415
26770
  ...reporter === void 0 ? {} : { reporter },
25416
26771
  ...options.limit === void 0 ? {} : { limit: options.limit }
25417
26772
  });
25418
- const complaints = await listComplaints(store, filter);
26773
+ const limit = filter.limit ?? DEFAULT_LIMIT;
26774
+ const page = await listComplaintPage(store, {
26775
+ ...filter,
26776
+ limit: Math.min(limit + 1, MAX_LIMIT)
26777
+ });
26778
+ const complaints = page.complaints.slice(0, limit);
25419
26779
  if (options.json === true) {
25420
26780
  writeJson(io.stdout, complaints);
25421
26781
  return 0;
25422
26782
  }
26783
+ const note = limitNote(complaints.length, limit, page.complaints.length > limit);
26784
+ if (note !== void 0) writeLine(io.stderr, note);
26785
+ if (page.truncated) writeLine(io.stderr, BUDGET_NOTE);
26786
+ if (options.ids === true) {
26787
+ for (const complaint of complaints) writeLine(io.stdout, complaint.id);
26788
+ return 0;
26789
+ }
25423
26790
  if (complaints.length === 0) {
25424
26791
  writeLine(io.stdout, "No complaints found.");
25425
26792
  return 0;
25426
26793
  }
25427
- for (const line of formatTable(complaints)) writeLine(io.stdout, line);
26794
+ const lines = options.ungroup === true ? formatTable(complaints.map((complaint) => ({ complaint }))) : formatTable(
26795
+ groupComplaints(complaints).map((group) => ({
26796
+ complaint: group.head,
26797
+ group
26798
+ }))
26799
+ );
26800
+ for (const line of lines) writeLine(io.stdout, line);
25428
26801
  return 0;
25429
26802
  }
25430
26803
  function reporterOf(complaint) {
25431
- const name = complaint.reporterName ?? complaint.reporter;
25432
- if (name.length <= REPORTER_WIDTH) return name;
25433
- return `${name.slice(0, REPORTER_WIDTH - 3)}...`;
25434
- }
25435
- function formatTable(complaints) {
25436
- const rows2 = complaints.map((complaint) => [
25437
- complaint.id.slice(0, 8),
25438
- complaint.createdAt.slice(0, 16),
25439
- complaint.severity,
25440
- complaint.status,
25441
- complaint.project,
25442
- reporterOf(complaint),
25443
- summaryOf(complaint)
25444
- ]);
25445
- const widths = COLUMNS.map(
25446
- (name, column) => rows2.reduce((width, row) => Math.max(width, (row[column] ?? "").length), name.length)
26804
+ return cut2(printable(complaint.reporterName ?? complaint.reporter), REPORTER_WIDTH);
26805
+ }
26806
+ function groupCell(group) {
26807
+ const count = group.count > 1 ? `\xD7${group.count}` : "";
26808
+ return `${count}${group.recurred ? RECURRED_MARK : ""}`;
26809
+ }
26810
+ function formatTable(rows2) {
26811
+ const grouped = rows2.some((row) => row.group !== void 0);
26812
+ const columns = COLUMNS.filter((name) => grouped || name !== "group");
26813
+ const cells = rows2.map(({ complaint, group }) => {
26814
+ const values = {
26815
+ id: complaint.id.slice(0, 8),
26816
+ created: complaint.createdAt.slice(0, 16),
26817
+ severity: complaint.severity,
26818
+ status: complaint.status,
26819
+ project: complaint.project,
26820
+ reporter: reporterOf(complaint),
26821
+ tags: cut2(printable(complaint.tags.join(",")), TAGS_WIDTH),
26822
+ group: group === void 0 ? "" : groupCell(group),
26823
+ summary: summaryOf(complaint)
26824
+ };
26825
+ return columns.map((name) => values[name]);
26826
+ });
26827
+ const widths = columns.map(
26828
+ (name, column) => cells.reduce(
26829
+ (width, row) => Math.max(width, (row[column] ?? "").length),
26830
+ name.length
26831
+ )
25447
26832
  );
25448
- const format = (cells) => cells.map((cell2, column) => cell2.padEnd(widths[column] ?? 0)).join(" ").trimEnd();
25449
- return [format(COLUMNS), ...rows2.map(format)];
26833
+ const format = (line) => line.map((cell2, column) => cell2.padEnd(widths[column] ?? 0)).join(" ").trimEnd();
26834
+ return [format(columns), ...cells.map(format)];
25450
26835
  }
25451
26836
 
25452
26837
  // src/commands/orgs.ts
@@ -25571,8 +26956,8 @@ var NodeTerminal = class {
25571
26956
  const queued = this.#lines.shift();
25572
26957
  if (queued !== void 0) return Promise.resolve(queued);
25573
26958
  if (this.#closed) return Promise.resolve("");
25574
- return new Promise((resolve3) => {
25575
- this.#waiting = resolve3;
26959
+ return new Promise((resolve4) => {
26960
+ this.#waiting = resolve4;
25576
26961
  });
25577
26962
  }
25578
26963
  async #read(label, hidden) {
@@ -25610,7 +26995,7 @@ function nodeTerminal(input2, output2) {
25610
26995
 
25611
26996
  // src/commands/login.ts
25612
26997
  var LOCAL_IDENTITY_MESSAGE = "local mode: no identity.";
25613
- var NEEDS_TERMINAL_MESSAGE = "quibble login needs a terminal: a CI job sets QUIBBLE_TOKEN instead";
26998
+ var NEEDS_TERMINAL_MESSAGE = "quibble login needs a terminal: in CI, set QUIBBLE_TOKEN instead";
25614
26999
  var MFA_CHALLENGE = "SOFTWARE_TOKEN_MFA";
25615
27000
  var NEW_PASSWORD_CHALLENGE = "NEW_PASSWORD_REQUIRED";
25616
27001
  var LOGIN_PAGE = "/login";
@@ -25654,7 +27039,7 @@ async function signIn(terminal, identity, config2) {
25654
27039
  );
25655
27040
  }
25656
27041
  throw new RemoteError(
25657
- `the sign-in asked for ${name}, which quibble does not support`
27042
+ `this account needs a sign-in step that quibble doesn't support (${name})`
25658
27043
  );
25659
27044
  }
25660
27045
  return result.tokens;
@@ -25678,7 +27063,7 @@ async function runLogin(options, context, io) {
25678
27063
  const http = { debug: context.debug, fetch: io.fetch };
25679
27064
  const config2 = await fetchAuthConfig(endpoint, http);
25680
27065
  if (config2.mode === "local") {
25681
- writeLine(io.stdout, `${endpoint} needs no login.`);
27066
+ writeLine(io.stdout, `${endpoint} is in local mode: no login needed.`);
25682
27067
  return 0;
25683
27068
  }
25684
27069
  let tokens;
@@ -25688,7 +27073,7 @@ async function runLogin(options, context, io) {
25688
27073
  terminal.close();
25689
27074
  }
25690
27075
  if (tokens.refreshToken === void 0) {
25691
- throw new RemoteError("the sign-in returned no refresh token");
27076
+ throw new RemoteError("sign-in didn't return a refresh token");
25692
27077
  }
25693
27078
  const now = /* @__PURE__ */ new Date();
25694
27079
  const me = await readProfile(
@@ -25700,7 +27085,9 @@ async function runLogin(options, context, io) {
25700
27085
  config2.webUrl
25701
27086
  );
25702
27087
  if (me.sub === void 0 || me.email === void 0) {
25703
- throw new RemoteError("the API returned an identity without a sub or an email");
27088
+ throw new RemoteError(
27089
+ "unexpected response from the API: identity has no sub or email"
27090
+ );
25704
27091
  }
25705
27092
  saveCredential(io.env, {
25706
27093
  endpoint,
@@ -25824,7 +27211,7 @@ function whoamiLines(endpoint, me, org) {
25824
27211
 
25825
27212
  // src/commands/promote.ts
25826
27213
  var PROMOTE_NEEDS_SERVER = "promote needs a server: pass --endpoint <url>, set QUIBBLE_ENDPOINT, or add one to .quibblerc";
25827
- var PROMOTE_NEEDS_SERVER_LOCAL = "promote needs a server: drop --local, because the project record of the server owns the allowed tag list";
27214
+ var PROMOTE_NEEDS_SERVER_LOCAL = "promote needs a server: drop --local";
25828
27215
  async function runPromote(tag, options, context, io) {
25829
27216
  if (context.mode !== "remote") {
25830
27217
  throw new ConfigError(
@@ -25855,10 +27242,12 @@ async function runPromote(tag, options, context, io) {
25855
27242
 
25856
27243
  // src/commands/retag.ts
25857
27244
  var NO_TAG_MESSAGE = "retag needs at least one --tag";
25858
- async function runRetag(id, options, context, io) {
27245
+ async function runRetag(value, options, context, io) {
25859
27246
  const tags = options.tag ?? [];
25860
27247
  if (tags.length === 0) throw new UsageError(NO_TAG_MESSAGE);
25861
- const { store, client } = openBackend(context, io);
27248
+ const { store, client, local } = openBackend(context, io);
27249
+ const [id] = await resolveIds([value], { store, local, context });
27250
+ if (id === void 0) throw new NotFoundError(`no complaint with id ${value}`);
25862
27251
  const allowedTags = client === void 0 ? await localAllowedTags(store, id, context) : void 0;
25863
27252
  const complaint = await updateComplaintTags(
25864
27253
  store,
@@ -25882,35 +27271,228 @@ async function localAllowedTags(store, id, context) {
25882
27271
  return complaint.project === config2.project ? config2.tags : void 0;
25883
27272
  }
25884
27273
 
27274
+ // src/commands/retry.ts
27275
+ async function runRetry(options, context, io) {
27276
+ requireServer("retry", context);
27277
+ const endpoint = requireEndpoint(context);
27278
+ const path = queuePath(io.env);
27279
+ const client = openClient(context, io, { queue: false });
27280
+ const result = await flushQueue({
27281
+ path,
27282
+ endpoint,
27283
+ auth: authLabel(context),
27284
+ client,
27285
+ stderr: io.stderr
27286
+ });
27287
+ if (result.stopped !== void 0) {
27288
+ throw new RemoteError(`${queuedCount(result.left)} still unsent: ${result.stopped}`);
27289
+ }
27290
+ const auth = authLabel(context);
27291
+ const left = pendingFor(path, endpoint).filter((entry) => entry.auth === auth).length;
27292
+ if (options.json === true) {
27293
+ writeJson(io.stdout, { sent: result.sent, dropped: result.dropped, left });
27294
+ return 0;
27295
+ }
27296
+ if (result.locked) {
27297
+ writeLine(io.stdout, "Another quibble process is sending the queue.");
27298
+ return 0;
27299
+ }
27300
+ if (result.sent.length === 0 && result.dropped === 0) {
27301
+ writeLine(io.stdout, "No queued complaints.");
27302
+ return 0;
27303
+ }
27304
+ const sent = result.sent.length;
27305
+ writeLine(
27306
+ io.stdout,
27307
+ `Sent ${sent} queued ${sent === 1 ? "complaint" : "complaints"}; ${left} left.`
27308
+ );
27309
+ return 0;
27310
+ }
27311
+
27312
+ // src/commands/show.ts
27313
+ var LABEL_WIDTH = "duplicate".length;
27314
+ function groupLine(count) {
27315
+ if (count >= MAX_LIMIT) return `${MAX_LIMIT}+ complaints share this headline`;
27316
+ return count === 1 ? "1 complaint shares this headline" : `${count} complaints share this headline`;
27317
+ }
27318
+ function groupCountOf(complaint, members2) {
27319
+ const others = members2.filter((member) => member.id !== complaint.id).length;
27320
+ return Math.min(others + 1, MAX_LIMIT);
27321
+ }
27322
+ function statusLine(complaint) {
27323
+ const reason = complaint.reason === void 0 ? "" : ` (${complaint.reason})`;
27324
+ const by = complaint.statusChangedByName ?? complaint.statusChangedBy;
27325
+ const when = by === void 0 || complaint.statusChangedAt === void 0 ? "" : ` by ${by} at ${complaint.statusChangedAt}`;
27326
+ return `${complaint.status}${reason}${when}`;
27327
+ }
27328
+ function showLines(complaint, groupCount, remote) {
27329
+ const reporter = complaint.reporterName === void 0 ? complaint.reporter : `${complaint.reporterName} (${complaint.reporter})`;
27330
+ const rows2 = [
27331
+ ["id", complaint.id],
27332
+ ["project", remote ? `${complaint.org}/${complaint.project}` : complaint.project],
27333
+ ["status", statusLine(complaint)],
27334
+ ["note", complaint.resolution],
27335
+ ["duplicate", complaint.duplicateOf],
27336
+ ["severity", complaint.severity],
27337
+ ["tags", complaint.tags.length === 0 ? void 0 : complaint.tags.join(", ")],
27338
+ ["agent", complaint.agent],
27339
+ ["reporter", reporter],
27340
+ ["created", complaint.createdAt],
27341
+ ["updated", complaint.updatedAt],
27342
+ ["cwd", complaint.cwd],
27343
+ ["cmd", complaint.cmd],
27344
+ ["group", groupLine(groupCount)],
27345
+ ["recurred", complaint.recurred === true ? "yes" : void 0]
27346
+ ];
27347
+ const lines = rows2.filter((row) => row[1] !== void 0).map(([label, value]) => `${label.padEnd(LABEL_WIDTH)} ${printable(value)}`);
27348
+ return [...lines, "", printableLines(complaint.message)];
27349
+ }
27350
+ async function runShow(value, options, context, io) {
27351
+ const { store, local } = openBackend(context, io);
27352
+ const [id] = await resolveIds([value], { store, local, context });
27353
+ const complaint = id === void 0 ? void 0 : await getComplaint(store, id);
27354
+ if (id === void 0 || complaint === void 0) {
27355
+ throw new NotFoundError(`no complaint with id ${id ?? value}`);
27356
+ }
27357
+ const group = await listComplaints(store, {
27358
+ org: complaint.org,
27359
+ project: complaint.project,
27360
+ fingerprint: complaint.fingerprint,
27361
+ limit: MAX_LIMIT
27362
+ });
27363
+ const groupCount = groupCountOf(complaint, group);
27364
+ if (options.json === true) {
27365
+ writeJson(io.stdout, { ...complaint, groupCount });
27366
+ return 0;
27367
+ }
27368
+ for (const line of showLines(complaint, groupCount, context.mode === "remote")) {
27369
+ writeLine(io.stdout, line);
27370
+ }
27371
+ return 0;
27372
+ }
27373
+
25885
27374
  // src/commands/status.ts
27375
+ var REMOTE_ACTOR = "server";
25886
27376
  var VERBS = {
25887
27377
  open: "Reopened",
25888
27378
  resolved: "Resolved",
25889
27379
  dismissed: "Dismissed",
25890
27380
  deleted: "Deleted"
25891
27381
  };
25892
- async function runStatusChange(status, id, options, context, io) {
25893
- const store = openStore(context, io);
25894
- const complaint = await updateComplaintStatus(store, id, {
27382
+ var NOT_FOUND = "not_found";
27383
+ function noComplaint(id) {
27384
+ return `no complaint with id ${id}`;
27385
+ }
27386
+ async function readIdArguments(values, io) {
27387
+ if (!values.includes("-")) return [...values];
27388
+ if (values.length > 1) {
27389
+ throw new UsageError("- reads the ids from stdin, so it takes no other id");
27390
+ }
27391
+ if (io.stdin.isTTY === true) throw new UsageError("no ids: pipe them on stdin");
27392
+ const ids = (await readStdin(io.stdin)).split(/\s+/).filter((id) => id !== "");
27393
+ if (ids.length === 0) throw new UsageError("no ids on stdin");
27394
+ return ids;
27395
+ }
27396
+ async function runStatusChange(status, values, options, context, io) {
27397
+ const reason = options.reason ?? (options.duplicateOf === void 0 ? void 0 : DUPLICATE_REASON);
27398
+ const draft = {
25895
27399
  status,
25896
- ...options.note === void 0 ? {} : { resolution: options.note }
27400
+ ...options.note === void 0 ? {} : { resolution: options.note },
27401
+ ...reason === void 0 ? {} : { reason },
27402
+ ...options.duplicateOf === void 0 ? {} : { duplicateOf: options.duplicateOf }
27403
+ };
27404
+ parseStatusChange(draft);
27405
+ const given = await readIdArguments(values, io);
27406
+ const backend = openBackend(context, io);
27407
+ const extra = options.duplicateOf === void 0 ? [] : [options.duplicateOf];
27408
+ const resolved = await resolveIds([...given, ...extra], {
27409
+ store: backend.store,
27410
+ local: backend.local,
27411
+ context
25897
27412
  });
25898
- if (complaint === void 0) throw new NotFoundError(`no complaint with id ${id}`);
25899
- if (options.json === true) {
25900
- writeJson(io.stdout, complaint);
27413
+ const ids = [...new Set(resolved.slice(0, given.length))];
27414
+ const duplicateOf = options.duplicateOf === void 0 ? void 0 : resolved.at(-1);
27415
+ const change = parseStatusChange({
27416
+ ...draft,
27417
+ ...duplicateOf === void 0 ? {} : { duplicateOf }
27418
+ });
27419
+ const actor = { by: context.mode === "remote" ? REMOTE_ACTOR : context.reporter };
27420
+ if (ids.length === 1) {
27421
+ const id = ids[0];
27422
+ const complaint = await updateComplaintStatus(backend.store, id, change, { actor });
27423
+ if (complaint === void 0) throw new NotFoundError(noComplaint(id));
27424
+ if (options.json === true) {
27425
+ writeJson(io.stdout, complaint);
27426
+ return 0;
27427
+ }
27428
+ writeLine(io.stdout, successLine(status, complaint));
25901
27429
  return 0;
25902
27430
  }
25903
- writeLine(
25904
- io.stdout,
25905
- `${VERBS[status]} complaint ${complaint.id} for ${complaint.project}`
25906
- );
25907
- return 0;
27431
+ const { results, error: error62 } = backend.store instanceof RemoteStore ? await remoteResults(backend.store, ids, change) : {
27432
+ results: await localResults(
27433
+ ids,
27434
+ async (id) => updateComplaintStatus(backend.store, id, change, { actor })
27435
+ ),
27436
+ error: void 0
27437
+ };
27438
+ const failures = [];
27439
+ for (const result of results) {
27440
+ if (result.ok && result.complaint !== void 0) {
27441
+ if (options.json !== true)
27442
+ writeLine(io.stdout, successLine(status, result.complaint));
27443
+ continue;
27444
+ }
27445
+ const code = result.error?.code ?? NOT_FOUND;
27446
+ const message = code === NOT_FOUND ? noComplaint(result.id) : result.error?.message;
27447
+ writeLine(io.stderr, `quibble: ${result.id}: ${message ?? "the change failed"}`);
27448
+ failures.push({ remote: backend.store instanceof RemoteStore && code !== NOT_FOUND });
27449
+ }
27450
+ if (options.json === true) writeJson(io.stdout, results);
27451
+ if (error62 !== void 0) throw error62;
27452
+ if (failures.length === 0) return 0;
27453
+ return failures.some((failure2) => failure2.remote) ? 2 : 1;
27454
+ }
27455
+ function successLine(status, complaint) {
27456
+ return `${VERBS[status]} complaint ${complaint.id} for ${complaint.project}`;
27457
+ }
27458
+ async function localResults(ids, apply) {
27459
+ const results = [];
27460
+ for (const id of ids) {
27461
+ try {
27462
+ const complaint = await apply(id);
27463
+ results.push(
27464
+ complaint === void 0 ? { id, ok: false, error: { code: NOT_FOUND, message: noComplaint(id) } } : { id, ok: true, complaint }
27465
+ );
27466
+ } catch (error62) {
27467
+ if (!(error62 instanceof ValidationError)) throw error62;
27468
+ results.push({
27469
+ id,
27470
+ ok: false,
27471
+ error: { code: error62.code, message: errorMessage(error62) }
27472
+ });
27473
+ }
27474
+ }
27475
+ return results;
27476
+ }
27477
+ async function remoteResults(store, ids, change) {
27478
+ const results = [];
27479
+ for (let start = 0; start < ids.length; start += BULK_MAX) {
27480
+ try {
27481
+ results.push(
27482
+ ...await store.bulkStatus(ids.slice(start, start + BULK_MAX), change)
27483
+ );
27484
+ } catch (error62) {
27485
+ return { results, error: error62 };
27486
+ }
27487
+ }
27488
+ return { results, error: void 0 };
25908
27489
  }
25909
27490
 
25910
27491
  // src/commands/summary.ts
25911
27492
  var FORMATS = ["text", "markdown"];
25912
27493
  var SEVERITY_ORDER = [...SEVERITIES].reverse();
25913
27494
  var TRUNCATED_MESSAGE = `Counting the newest ${MAX_LIMIT} complaints only.`;
27495
+ var BUDGET_MESSAGE = "The server stopped searching early, so the counts are a floor: narrow the filters or pass --since.";
25914
27496
  var EMPTY_MESSAGE = "No complaints found.";
25915
27497
  async function runSummary(options, context, io) {
25916
27498
  if (options.format !== void 0 && options.json === true) {
@@ -25921,17 +27503,23 @@ async function runSummary(options, context, io) {
25921
27503
  const reporter = options.mine === true ? await resolveMine(context, client, io.env) : void 0;
25922
27504
  const filter = parseListFilter({
25923
27505
  ...resolveScope(options, context),
27506
+ ...options.severity === void 0 ? {} : { severity: options.severity },
27507
+ ...tagFilter(options.tag),
25924
27508
  ...reporter === void 0 ? {} : { reporter },
25925
27509
  limit: MAX_LIMIT
25926
27510
  });
25927
- const complaints = await listComplaints(store, filter);
25928
- const truncated = complaints.length === MAX_LIMIT;
27511
+ const page = await listComplaintPage(store, filter);
27512
+ const complaints = page.complaints;
27513
+ const capped = complaints.length === MAX_LIMIT;
27514
+ const truncated = capped || page.truncated;
25929
27515
  const summary = summarizeComplaints(complaints);
25930
27516
  if (options.json === true) {
25931
27517
  writeJson(io.stdout, {
25932
27518
  scope: {
25933
27519
  project: filter.project ?? null,
25934
27520
  status: filter.status ?? null,
27521
+ severity: filter.severity ?? null,
27522
+ tags: filter.tags ?? null,
25935
27523
  since: filter.since ?? null
25936
27524
  },
25937
27525
  truncated,
@@ -25943,7 +27531,8 @@ async function runSummary(options, context, io) {
25943
27531
  writeLine(io.stdout, EMPTY_MESSAGE);
25944
27532
  return 0;
25945
27533
  }
25946
- const lines = format === "markdown" ? markdownLines(filter, summary, truncated) : textLines(filter, summary, truncated);
27534
+ const note = capped ? TRUNCATED_MESSAGE : page.truncated ? BUDGET_MESSAGE : void 0;
27535
+ const lines = format === "markdown" ? markdownLines(filter, summary, note) : textLines(filter, summary, note);
25947
27536
  for (const line of lines) writeLine(io.stdout, line);
25948
27537
  return 0;
25949
27538
  }
@@ -25958,7 +27547,9 @@ function scopeLabel(filter) {
25958
27547
  const name = filter.project ?? "all projects";
25959
27548
  const since = filter.since === void 0 ? "" : ` since ${filter.since}`;
25960
27549
  const status = filter.status === void 0 ? "" : ` with status ${filter.status}`;
25961
- return `Complaints for ${name}${since}${status}`;
27550
+ const severity = filter.severity === void 0 ? "" : ` with severity ${filter.severity}`;
27551
+ const tags = filter.tags === void 0 ? "" : ` tagged ${filter.tags.map((tag) => tag === UNTAGGED_TAG ? "untagged" : oneLine(tag)).join(" or ")}`;
27552
+ return `Complaints for ${name}${since}${status}${severity}${tags}`;
25962
27553
  }
25963
27554
  function blocksOf(filter, summary) {
25964
27555
  const severity = SEVERITY_ORDER.filter((name) => summary.bySeverity[name] > 0).map(
@@ -25982,13 +27573,11 @@ function statusCounts(summary) {
25982
27573
  const { open: open2, resolved, dismissed, deleted } = summary.byStatus;
25983
27574
  return `open ${open2}, resolved ${resolved}, dismissed ${dismissed}, deleted ${deleted}`;
25984
27575
  }
25985
- function oneLine(name) {
25986
- return name.replace(/\s+/g, " ");
25987
- }
27576
+ var oneLine = printable;
25988
27577
  function cell(name) {
25989
27578
  return oneLine(name).replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
25990
27579
  }
25991
- function textLines(filter, summary, truncated) {
27580
+ function textLines(filter, summary, note) {
25992
27581
  const lines = [`${scopeLabel(filter)}: ${summary.total} (${statusCounts(summary)})`];
25993
27582
  for (const block2 of blocksOf(filter, summary)) {
25994
27583
  const names = block2.rows.map((row) => oneLine(row.name));
@@ -25998,10 +27587,10 @@ function textLines(filter, summary, truncated) {
25998
27587
  lines.push(` ${(names[index] ?? "").padEnd(width)} ${row.count}`);
25999
27588
  }
26000
27589
  }
26001
- if (truncated) lines.push("", TRUNCATED_MESSAGE);
27590
+ if (note !== void 0) lines.push("", note);
26002
27591
  return lines;
26003
27592
  }
26004
- function markdownLines(filter, summary, truncated) {
27593
+ function markdownLines(filter, summary, note) {
26005
27594
  const { open: open2, resolved, dismissed, deleted } = summary.byStatus;
26006
27595
  const noun = summary.total === 1 ? "complaint" : "complaints";
26007
27596
  const counts = `${open2} open, ${resolved} resolved, ${dismissed} dismissed, ${deleted} deleted`;
@@ -26016,7 +27605,7 @@ function markdownLines(filter, summary, truncated) {
26016
27605
  );
26017
27606
  for (const row of block2.rows) lines.push(`| ${cell(row.name)} | ${row.count} |`);
26018
27607
  }
26019
- if (truncated) lines.push("", TRUNCATED_MESSAGE);
27608
+ if (note !== void 0) lines.push("", note);
26020
27609
  return lines;
26021
27610
  }
26022
27611
 
@@ -26029,12 +27618,146 @@ async function runTags(options, context, io) {
26029
27618
  writeJson(io.stdout, { project, tags: tags ?? null });
26030
27619
  return 0;
26031
27620
  }
26032
- if (tags === void 0) {
27621
+ printList(project, tags, io);
27622
+ return 0;
27623
+ }
27624
+ function printList(project, tags, io) {
27625
+ if (tags === void 0 || tags.length === 0) {
26033
27626
  writeLine(io.stdout, `No tags configured for ${project}: any tag is allowed.`);
26034
- return 0;
27627
+ return;
26035
27628
  }
26036
27629
  for (const tag of tags) writeLine(io.stdout, tag);
26037
27630
  writeLine(io.stdout, OTHER_TAG_HINT);
27631
+ }
27632
+ async function runTagsEdit(edit, given, options, context, io) {
27633
+ requireServer("tags", context);
27634
+ const project = requireProject(context);
27635
+ if (given.length === 0) throw new UsageError(`tags ${edit} needs at least one tag`);
27636
+ const named = parseAllowedTags(given);
27637
+ const client = openOrgClient(context, io);
27638
+ let next;
27639
+ if (edit === "set") {
27640
+ next = named;
27641
+ } else {
27642
+ const current = (await client.project(project)).tags ?? [];
27643
+ const combined = edit === "add" ? [...current, ...named.filter((tag) => !current.includes(tag))] : current.filter((tag) => !named.includes(tag));
27644
+ next = combined.length === 0 ? null : parseAllowedTags(combined);
27645
+ }
27646
+ const answer2 = await client.setProjectTags(project, next);
27647
+ if (options.json === true) {
27648
+ writeJson(io.stdout, answer2.body);
27649
+ return 0;
27650
+ }
27651
+ const noun = answer2.retagged === 1 ? "complaint" : "complaints";
27652
+ writeLine(
27653
+ io.stdout,
27654
+ `Updated the tags of ${project}: ${answer2.retagged} ${noun} retagged`
27655
+ );
27656
+ printList(project, answer2.tags, io);
27657
+ return 0;
27658
+ }
27659
+
27660
+ // src/commands/token.ts
27661
+ var COLUMNS3 = ["ID", "NAME", "CREATED", "LAST USED", "EXPIRES", "STATE"];
27662
+ function minute(stamp2) {
27663
+ return stamp2.slice(0, 16).replace("T", " ");
27664
+ }
27665
+ function stateOf(token, now) {
27666
+ if (token.revokedAt !== void 0) return "revoked";
27667
+ if (token.expiresAt !== void 0 && Date.parse(token.expiresAt) <= now.getTime()) {
27668
+ return "expired";
27669
+ }
27670
+ return "active";
27671
+ }
27672
+ function formatTokens(tokens, now) {
27673
+ const rows2 = tokens.map((token) => [
27674
+ token.id.slice(0, 8),
27675
+ token.name,
27676
+ minute(token.createdAt),
27677
+ token.lastUsedAt === void 0 ? "\u2014" : minute(token.lastUsedAt),
27678
+ token.expiresAt === void 0 ? "never" : minute(token.expiresAt),
27679
+ stateOf(token, now)
27680
+ ]);
27681
+ const widths = COLUMNS3.map(
27682
+ (name, column) => rows2.reduce((width, row) => Math.max(width, (row[column] ?? "").length), name.length)
27683
+ );
27684
+ const format = (cells) => cells.map((cell2, column) => cell2.padEnd(widths[column] ?? 0)).join(" ").trimEnd();
27685
+ return [format(COLUMNS3), ...rows2.map(format)];
27686
+ }
27687
+ function scopeOf(context) {
27688
+ requireServer("token", context);
27689
+ return { project: requireProject(context), org: requireOrg(context) };
27690
+ }
27691
+ async function runTokenCreate(name, options, context, io, now = /* @__PURE__ */ new Date()) {
27692
+ const { project, org } = scopeOf(context);
27693
+ const expiresAt = options.expires === void 0 ? void 0 : parseExpires(options.expires, now);
27694
+ const body = parseNewToken(
27695
+ { name, ...expiresAt === void 0 ? {} : { expiresAt } },
27696
+ now
27697
+ );
27698
+ const client = openOrgClient(context, io);
27699
+ const minted = await client.createToken(project, body);
27700
+ if (options.json === true) {
27701
+ writeJson(io.stdout, minted.body);
27702
+ return 0;
27703
+ }
27704
+ writeLine(
27705
+ io.stdout,
27706
+ `Created token ${minted.token.name} for ${org}/${project}. It is shown once:`
27707
+ );
27708
+ writeLine(io.stdout, `QUIBBLE_ENDPOINT=${trimSlash(client.endpoint)}`);
27709
+ writeLine(io.stdout, `QUIBBLE_TOKEN=${minted.plaintext}`);
27710
+ return 0;
27711
+ }
27712
+ async function runTokenList(options, context, io, now = /* @__PURE__ */ new Date()) {
27713
+ const { project } = scopeOf(context);
27714
+ const answer2 = await openOrgClient(context, io).tokens(project);
27715
+ if (options.json === true) {
27716
+ writeJson(io.stdout, answer2.body);
27717
+ return 0;
27718
+ }
27719
+ if (answer2.tokens.length === 0) {
27720
+ writeLine(io.stdout, `No tokens for ${project}.`);
27721
+ return 0;
27722
+ }
27723
+ for (const line of formatTokens(answer2.tokens, now)) writeLine(io.stdout, line);
27724
+ return 0;
27725
+ }
27726
+ function findToken(tokens, value) {
27727
+ const named = tokens.find((token) => token.name === value.trim());
27728
+ if (named !== void 0) return named;
27729
+ let read;
27730
+ try {
27731
+ read = readIdValue(value);
27732
+ } catch (error62) {
27733
+ if (error62 instanceof UsageError)
27734
+ throw new NotFoundError(`no token is named ${value}`);
27735
+ throw error62;
27736
+ }
27737
+ const { id, prefix } = read;
27738
+ const matches = tokens.filter(
27739
+ (token) => prefix ? token.id.startsWith(id) : token.id === id
27740
+ );
27741
+ if (matches.length === 1) return matches[0];
27742
+ if (matches.length > 1) {
27743
+ throw ambiguousError(
27744
+ id,
27745
+ matches.map((token) => token.id),
27746
+ "tokens"
27747
+ );
27748
+ }
27749
+ throw new NotFoundError(`no token is named or starts with ${id}`);
27750
+ }
27751
+ async function runTokenRevoke(value, options, context, io) {
27752
+ const { project, org } = scopeOf(context);
27753
+ const client = openOrgClient(context, io);
27754
+ const token = findToken((await client.tokens(project)).tokens, value);
27755
+ await client.revokeToken(project, token.id);
27756
+ if (options.json === true) {
27757
+ writeJson(io.stdout, { id: token.id, name: token.name, revoked: true });
27758
+ return 0;
27759
+ }
27760
+ writeLine(io.stdout, `Revoked token ${token.name} of ${org}/${project}.`);
26038
27761
  return 0;
26039
27762
  }
26040
27763
 
@@ -26049,11 +27772,18 @@ var NON_EMPTY_OPTIONS = [
26049
27772
  "note"
26050
27773
  ];
26051
27774
  var STATUS_COMMANDS = [
26052
- ["resolve", "resolved", "mark a complaint resolved"],
26053
- ["dismiss", "dismissed", "dismiss a complaint as a duplicate or as noise"],
26054
- ["delete", "deleted", "hide a complaint from every default view"],
26055
- ["reopen", "open", "reopen a complaint, restore a deleted one and remove its note"]
27775
+ ["resolve", "resolved", "mark complaints resolved"],
27776
+ ["dismiss", "dismissed", "dismiss duplicates, won't-fixes or noise"],
27777
+ ["delete", "deleted", "hide complaints from the default views"],
27778
+ ["reopen", "open", "reopen complaints and clear their notes"]
27779
+ ];
27780
+ var TAGS_EDITS = [
27781
+ ["set", "replace the project's allowed tag list"],
27782
+ ["add", "add tags to the allowed tag list"],
27783
+ ["remove", "remove tags from the allowed tag list"]
26056
27784
  ];
27785
+ var ID_HELP = `complaint id, or a unique prefix of at least ${SHORT_ID_MIN} characters`;
27786
+ var REASON_HELP = `${REASONS.slice(0, -1).join(", ")} or ${REASONS.at(-1) ?? ""}`;
26057
27787
  function packageVersion() {
26058
27788
  const require2 = createRequire(import.meta.url);
26059
27789
  const manifest = require2("../package.json");
@@ -26068,7 +27798,7 @@ function rejectEmptyOptions(options) {
26068
27798
  }
26069
27799
  }
26070
27800
  function withGlobalOptions(command) {
26071
- return command.option("--local", "use the local store, even when an endpoint is configured").option("--endpoint <url>", "base URL of the Quibble API").option("--org <slug>", "organization of every remote request").option("--json", "print JSON instead of text");
27801
+ return command.option("--local", "use the local store even if an endpoint is set").option("--endpoint <url>", "base URL of the Quibble API").option("--org <slug>", "organization to use in remote mode").option("--json", "print JSON instead of text");
26072
27802
  }
26073
27803
  function globals(program2, command) {
26074
27804
  const outer = program2.opts();
@@ -26106,88 +27836,151 @@ function buildProgram(io, setCode) {
26106
27836
  }).exitOverride();
26107
27837
  withGlobalOptions(program2);
26108
27838
  withGlobalOptions(
26109
- program2.command("init").description("write a .quibblerc in the current directory").option("--project <name>", "project name, defaults to the directory name").option("-t, --tag <tag>", "allowed tag of the project, repeatable", collect).option("--force", "overwrite an existing .quibblerc")
26110
- ).action(function() {
27839
+ program2.command("init").description("write a .quibblerc in the current directory").option("--project <name>", "project name (default: the directory name)").option("-t, --tag <tag>", "allowed tag (repeatable)", collect).option("--agent <name>", "agent named in the snippet (default: claude)").option("--skill", "also write .claude/skills/quibble/SKILL.md").option("--force", "overwrite an existing .quibblerc or skill file")
27840
+ ).action(async function() {
27841
+ const options = { ...this.opts(), ...globals(program2, this) };
27842
+ rejectEmptyOptions(options);
27843
+ setCode(await runInit(options, io));
27844
+ });
27845
+ withGlobalOptions(
27846
+ program2.command("snippet").description("print or write the agent snippet of this project").option("--append <file>", "append the snippet to a file such as CLAUDE.md").option("--skill", "write .claude/skills/quibble/SKILL.md").option("--agent <name>", "agent named in the snippet").option("--project <name>", "project name (overrides .quibblerc)").option("--force", "overwrite an existing skill file")
27847
+ ).action(async function() {
26111
27848
  const options = { ...this.opts(), ...globals(program2, this) };
26112
27849
  rejectEmptyOptions(options);
26113
- setCode(runInit(options, io));
27850
+ setCode(await runSnippet(options, contextFor(options, io), io));
26114
27851
  });
26115
27852
  withGlobalOptions(
26116
- program2.command("complain").description("file a complaint, reading the message from stdin when omitted").argument("[message]", "the complaint, or `-` to read stdin").option("-s, --severity <level>", "low, medium or high").option("-t, --tag <tag>", "tag, repeatable", collect).option("--agent <name>", "agent that files the complaint").option("--reporter <name>", "person on whose behalf it is filed").option("--project <name>", "project name, overrides the .quibblerc")
27853
+ program2.command("complain").description("file a complaint").argument("[message]", "what went wrong; omit it or pass `-` to read stdin").option("-s, --severity <level>", "low, medium or high").option("-t, --tag <tag>", "tag (repeatable)", collect).option("--agent <name>", "name of the agent filing it").option("--reporter <name>", "person you are filing for (local mode only)").option("--project <name>", "project name (overrides .quibblerc)").option("--cmd <command>", "the command that failed")
26117
27854
  ).action(async function(message) {
26118
27855
  const options = { ...this.opts(), ...globals(program2, this) };
26119
27856
  rejectEmptyOptions(options);
26120
27857
  setCode(await runComplain(message, options, contextFor(options, io), io));
26121
27858
  });
26122
27859
  withGlobalOptions(
26123
- program2.command("list").description("list complaints, newest first").option("--project <name>", "list one project").option("--all", "list every project").option("-s, --severity <level>", "low, medium or high").option("--status <status>", "open, resolved, dismissed or deleted").option("--open", "only open complaints, short for --status open").option("-t, --tag <tag>", "only complaints with this tag, or `-` for none").option("--mine", "only the complaints of the calling reporter").option("--since <when>", "a duration like 7d, a date or a UTC timestamp").option("--limit <n>", "how many complaints to return")
27860
+ program2.command("list").description("list complaints, newest first").option("--project <name>", "list one project").option("--all", "list all projects").option("-s, --severity <level>", "low, medium or high").option("--status <status>", "open, resolved, dismissed or deleted").option("--open", "same as --status open").option(
27861
+ "-t, --tag <tag>",
27862
+ "complaints with any of these tags (repeatable, `-` for untagged)",
27863
+ collect
27864
+ ).option("--mine", "only your own complaints").option("--since <when>", "a duration like 7d, a date or a UTC timestamp").option("--search <text>", "complaints whose message contains the text").option("--limit <n>", "maximum number of complaints").option("--ungroup", "one row per complaint instead of one per group").option("--ids", "print the full id of every complaint, one per line")
26124
27865
  ).action(async function() {
26125
27866
  const options = { ...this.opts(), ...globals(program2, this) };
26126
27867
  rejectEmptyOptions(options);
26127
27868
  setCode(await runList(options, contextFor(options, io), io));
26128
27869
  });
27870
+ withGlobalOptions(
27871
+ program2.command("show").description("print one whole complaint").argument("<id>", ID_HELP)
27872
+ ).action(async function(id) {
27873
+ const options = { ...this.opts(), ...globals(program2, this) };
27874
+ rejectEmptyOptions(options);
27875
+ setCode(await runShow(id, options, contextFor(options, io), io));
27876
+ });
26129
27877
  for (const [name, status, description] of STATUS_COMMANDS) {
26130
- const command = program2.command(name).description(description).argument("<id>", "full id of the complaint");
27878
+ const command = program2.command(name).description(description).argument("<id...>", `${ID_HELP}; \`-\` reads the ids from stdin`);
26131
27879
  if (status !== "open") {
26132
- command.option("--note <text>", "note that explains the change");
27880
+ command.option("--note <text>", "note explaining the change");
26133
27881
  }
26134
- withGlobalOptions(command).action(async function(id) {
27882
+ if (status === "resolved" || status === "dismissed") {
27883
+ command.option("--reason <reason>", REASON_HELP).option("--duplicate-of <id>", "the complaint these repeat (implies duplicate)");
27884
+ }
27885
+ withGlobalOptions(command).action(async function(ids) {
26135
27886
  const options = { ...this.opts(), ...globals(program2, this) };
26136
27887
  rejectEmptyOptions(options);
26137
- setCode(await runStatusChange(status, id, options, contextFor(options, io), io));
27888
+ setCode(await runStatusChange(status, ids, options, contextFor(options, io), io));
26138
27889
  });
26139
27890
  }
26140
27891
  withGlobalOptions(
26141
- program2.command("retag").description("replace the tags of a complaint").argument("<id>", "full id of the complaint").option("-t, --tag <tag>", "tag, repeatable and needed at least once", collect)
27892
+ program2.command("retag").description("replace a complaint's tags").argument("<id>", ID_HELP).option("-t, --tag <tag>", "tag (repeatable, at least one)", collect)
26142
27893
  ).action(async function(id) {
26143
27894
  const options = { ...this.opts(), ...globals(program2, this) };
26144
27895
  rejectEmptyOptions(options);
26145
27896
  setCode(await runRetag(id, options, contextFor(options, io), io));
26146
27897
  });
26147
27898
  withGlobalOptions(
26148
- program2.command("summary").description("count the matching complaints by severity, project and tag").option("--project <name>", "summarize one project").option("--all", "summarize every project").option("--status <status>", "open, resolved, dismissed or deleted").option("--open", "only open complaints, short for --status open").option("--mine", "only the complaints of the calling reporter").option("--since <when>", "a duration like 7d, a date or a UTC timestamp").option("--format <format>", "text or markdown")
27899
+ program2.command("summary").description("count complaints by status, severity, project and tag").option("--project <name>", "summarize one project").option("--all", "summarize all projects").option("--status <status>", "open, resolved, dismissed or deleted").option("--open", "same as --status open").option("-s, --severity <level>", "low, medium or high").option(
27900
+ "-t, --tag <tag>",
27901
+ "complaints with any of these tags (repeatable, `-` for untagged)",
27902
+ collect
27903
+ ).option("--mine", "only your own complaints").option("--since <when>", "a duration like 7d, a date or a UTC timestamp").option("--format <format>", "text or markdown")
26149
27904
  ).action(async function() {
26150
27905
  const options = { ...this.opts(), ...globals(program2, this) };
26151
27906
  rejectEmptyOptions(options);
26152
27907
  setCode(await runSummary(options, contextFor(options, io), io));
26153
27908
  });
26154
- withGlobalOptions(
26155
- program2.command("tags").description("print the allowed tags of the project").option("--project <name>", "project name, overrides the .quibblerc")
27909
+ const tags = withGlobalOptions(
27910
+ program2.command("tags").description("print or edit the project's allowed tags").option("--project <name>", "project name (overrides .quibblerc)")
26156
27911
  ).action(async function() {
26157
27912
  const options = { ...this.opts(), ...globals(program2, this) };
26158
27913
  rejectEmptyOptions(options);
26159
27914
  setCode(await runTags(options, contextFor(options, io), io));
26160
27915
  });
27916
+ for (const [edit, description] of TAGS_EDITS) {
27917
+ withGlobalOptions(
27918
+ tags.command(edit).description(`${description} (remote only)`).argument("[tag...]", "tags").option("--project <name>", "project name (overrides .quibblerc)")
27919
+ ).action(async function(given) {
27920
+ const options = { ...tags.opts(), ...this.opts(), ...globals(program2, this) };
27921
+ rejectEmptyOptions(options);
27922
+ setCode(await runTagsEdit(edit, given, options, contextFor(options, io), io));
27923
+ });
27924
+ }
27925
+ const token = program2.command("token").description("manage the project's tokens (remote only)");
27926
+ withGlobalOptions(
27927
+ token.command("create").description("mint a project token and print it once").argument("<name>", "token name, unique in the project").option("--expires <when>", "a duration like 30d, a date or a UTC timestamp").option("--project <name>", "project name (overrides .quibblerc)")
27928
+ ).action(async function(name) {
27929
+ const options = { ...this.opts(), ...globals(program2, this) };
27930
+ rejectEmptyOptions(options);
27931
+ setCode(await runTokenCreate(name, options, contextFor(options, io), io));
27932
+ });
27933
+ withGlobalOptions(
27934
+ token.command("list").description("list the project's tokens").option("--project <name>", "project name (overrides .quibblerc)")
27935
+ ).action(async function() {
27936
+ const options = { ...this.opts(), ...globals(program2, this) };
27937
+ rejectEmptyOptions(options);
27938
+ setCode(await runTokenList(options, contextFor(options, io), io));
27939
+ });
27940
+ withGlobalOptions(
27941
+ token.command("revoke").description("revoke a token").argument("<token>", "token name, or an id prefix of at least 6 characters").option("--project <name>", "project name (overrides .quibblerc)")
27942
+ ).action(async function(value) {
27943
+ const options = { ...this.opts(), ...globals(program2, this) };
27944
+ rejectEmptyOptions(options);
27945
+ setCode(await runTokenRevoke(value, options, contextFor(options, io), io));
27946
+ });
27947
+ withGlobalOptions(
27948
+ program2.command("retry").description("send the complaints queued while offline")
27949
+ ).action(async function() {
27950
+ const options = { ...this.opts(), ...globals(program2, this) };
27951
+ rejectEmptyOptions(options);
27952
+ setCode(await runRetry(options, contextFor(options, io), io));
27953
+ });
26161
27954
  withGlobalOptions(
26162
- program2.command("promote").description("add a suggestion to the allowed tags and retag the complaints").argument("<tag>", "the tag to promote, without the `other:` prefix").option("--project <name>", "project name, overrides the .quibblerc")
27955
+ program2.command("promote").description("turn an `other:` suggestion into an allowed tag and retag complaints").argument("<tag>", "tag to promote, without `other:`").option("--project <name>", "project name (overrides .quibblerc)")
26163
27956
  ).action(async function(tag) {
26164
27957
  const options = { ...this.opts(), ...globals(program2, this) };
26165
27958
  rejectEmptyOptions(options);
26166
27959
  setCode(await runPromote(tag, options, contextFor(options, io), io));
26167
27960
  });
26168
27961
  withGlobalOptions(
26169
- program2.command("orgs").description("list the organizations the caller belongs to")
27962
+ program2.command("orgs").description("list your organizations")
26170
27963
  ).action(async function() {
26171
27964
  const options = { ...this.opts(), ...globals(program2, this) };
26172
27965
  rejectEmptyOptions(options);
26173
27966
  setCode(await runOrgs(options, contextFor(options, io), io));
26174
27967
  });
26175
27968
  withGlobalOptions(
26176
- program2.command("login").description("sign in to the endpoint on the terminal and store the session")
27969
+ program2.command("login").description("log in to the endpoint")
26177
27970
  ).action(async function() {
26178
27971
  const options = { ...this.opts(), ...globals(program2, this) };
26179
27972
  rejectEmptyOptions(options);
26180
27973
  setCode(await runLogin(options, contextFor(options, io), io));
26181
27974
  });
26182
27975
  withGlobalOptions(
26183
- program2.command("logout").description("revoke the refresh token and remove the stored session")
27976
+ program2.command("logout").description("log out of the endpoint")
26184
27977
  ).action(async function() {
26185
27978
  const options = { ...this.opts(), ...globals(program2, this) };
26186
27979
  rejectEmptyOptions(options);
26187
27980
  setCode(await runLogout(contextFor(options, io), io));
26188
27981
  });
26189
27982
  withGlobalOptions(
26190
- program2.command("whoami").description("print the identity that the API resolves")
27983
+ program2.command("whoami").description("show the current identity")
26191
27984
  ).action(async function() {
26192
27985
  const options = { ...this.opts(), ...globals(program2, this) };
26193
27986
  rejectEmptyOptions(options);