@indigoai-us/hq-cli 5.345.168 → 5.345.170
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/dist/command-catalog.generated.d.ts +49 -4
- package/dist/command-catalog.generated.js +60 -4
- package/dist/commands/access.js +7 -2
- package/dist/commands/bot.js +4 -0
- package/dist/commands/cloud.d.ts +20 -0
- package/dist/commands/cloud.js +55 -4
- package/dist/commands/core-checkpoint-native.js +30 -10
- package/dist/commands/feedback-triage.d.ts +43 -1
- package/dist/commands/feedback-triage.js +123 -23
- package/dist/commands/feedback.d.ts +40 -1
- package/dist/commands/feedback.js +195 -7
- package/dist/commands/files.d.ts +42 -0
- package/dist/commands/files.js +223 -30
- package/dist/commands/mesh.js +6 -0
- package/dist/commands/sync-manifest.d.ts +16 -0
- package/dist/commands/sync-manifest.js +17 -1
- package/dist/lib/bot/decisions.d.ts +62 -0
- package/dist/lib/bot/decisions.js +228 -0
- package/dist/lib/bot/index.d.ts +1 -0
- package/dist/lib/bot/index.js +1 -0
- package/dist/lib/bot/run.d.ts +7 -0
- package/dist/lib/bot/run.js +104 -1
- package/dist/lib/daemon/catalog.d.ts +4 -0
- package/dist/lib/daemon/catalog.js +60 -29
- package/dist/lib/daemon/config.d.ts +7 -0
- package/dist/lib/daemon/config.js +32 -0
- package/dist/lib/daemon/run.d.ts +3 -2
- package/dist/lib/daemon/run.js +23 -3
- package/dist/lib/daemon/supervisor.d.ts +1 -0
- package/dist/lib/daemon/supervisor.js +4 -0
- package/dist/lib/daemon/sync-core-canary.d.ts +9 -0
- package/dist/lib/daemon/sync-core-canary.js +56 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +39 -1
- package/dist/lib/doctor/checks/sync-health.js +121 -3
- package/dist/lib/doctor/fix/apply.d.ts +66 -8
- package/dist/lib/doctor/fix/apply.js +278 -80
- package/dist/lib/doctor/fix/process-identity.d.ts +13 -0
- package/dist/lib/doctor/fix/process-identity.js +34 -0
- package/dist/lib/doctor/fix/refresh-sync-handoff.d.ts +201 -0
- package/dist/lib/doctor/fix/refresh-sync-handoff.js +752 -0
- package/dist/lib/doctor/fix/refresh-sync-outcome.d.ts +1 -1
- package/dist/lib/doctor/fix/refresh-sync-outcome.js +13 -0
- package/dist/lib/doctor/types.d.ts +1 -1
- package/dist/lib/doctor/types.js +3 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.js +31 -0
- package/dist/lib/work-context/organize.d.ts +7 -0
- package/dist/lib/work-context/organize.js +21 -1
- package/dist/sentry-before-send.js +14 -0
- package/dist/utils/breadcrumb-buffer.js +15 -4
- package/dist/utils/feedback-diagnostics.js +5 -2
- package/dist/utils/feedback-logs.d.ts +7 -0
- package/dist/utils/feedback-logs.js +37 -3
- package/dist/utils/scrub-diagnostics.d.ts +4 -0
- package/dist/utils/scrub-diagnostics.js +95 -0
- package/package.json +7 -7
|
@@ -11,6 +11,29 @@ import { assertFeedbackId, normalizeFeedbackAssignee, readFeedbackError, resolve
|
|
|
11
11
|
* send them.
|
|
12
12
|
*/
|
|
13
13
|
export const TRIAGE_KINDS = ["bug", "feature", "question"];
|
|
14
|
+
/**
|
|
15
|
+
* Internal feedback row statuses `hq feedback list --status` accepts.
|
|
16
|
+
* `stalled` is open work that started and then stopped: a row is in_progress
|
|
17
|
+
* only while it has a linked open PR or a live lane, and moves to stalled after
|
|
18
|
+
* 48h with no activity. It is listed apart from `new` (not started).
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Statuses shown in `list --status` help. Help text only: the CLI does not
|
|
22
|
+
* validate against this list, because hq-pro owns the set and returns a 400
|
|
23
|
+
* for an unknown value.
|
|
24
|
+
*/
|
|
25
|
+
export const FEEDBACK_LIST_STATUSES = [
|
|
26
|
+
"new",
|
|
27
|
+
"triaging",
|
|
28
|
+
"accepted",
|
|
29
|
+
"wont_fix",
|
|
30
|
+
"in_progress",
|
|
31
|
+
"resolved",
|
|
32
|
+
"waiting_on_customer",
|
|
33
|
+
"reopened",
|
|
34
|
+
"stalled",
|
|
35
|
+
"needs_info",
|
|
36
|
+
];
|
|
14
37
|
export const MAX_TRIAGE_SUMMARY = 2000;
|
|
15
38
|
export const DEFAULT_LIST_LIMIT = 50;
|
|
16
39
|
/** Safety stop for --all so a server that never ends its cursor can't loop forever. */
|
|
@@ -64,49 +87,114 @@ export function cursorFrom(data) {
|
|
|
64
87
|
}
|
|
65
88
|
return null;
|
|
66
89
|
}
|
|
90
|
+
function isTruncated(data) {
|
|
91
|
+
return (!!data &&
|
|
92
|
+
typeof data === "object" &&
|
|
93
|
+
!Array.isArray(data) &&
|
|
94
|
+
data.truncated === true);
|
|
95
|
+
}
|
|
67
96
|
/** Server page cap (FEEDBACK_LIST_PAGE_MAX). The plain list path rejects a cursor without a limit. */
|
|
68
97
|
const LIST_PAGE_SIZE = 200;
|
|
98
|
+
/**
|
|
99
|
+
* Lifecycle states hq-pro treats as closed (`TERMINAL_STATUSES` in
|
|
100
|
+
* feedback-status.ts). `--open` lists every row outside this set; a row with
|
|
101
|
+
* no status counts as open.
|
|
102
|
+
*/
|
|
103
|
+
export const TERMINAL_FEEDBACK_STATUSES = ["resolved", "wont_fix", "accepted"];
|
|
104
|
+
function isOpenRow(row) {
|
|
105
|
+
const status = row.status;
|
|
106
|
+
return typeof status !== "string" || !TERMINAL_FEEDBACK_STATUSES.includes(status);
|
|
107
|
+
}
|
|
69
108
|
/** Page through `GET /v1/feedback` until `limit` rows (or every row with `limit: null`). */
|
|
70
109
|
export async function listFeedback(opts) {
|
|
71
110
|
const base = opts.selector.kind === "untriaged"
|
|
72
111
|
? { triage: "untriaged" }
|
|
73
|
-
:
|
|
112
|
+
: opts.selector.kind === "assignee"
|
|
113
|
+
? { assignee: opts.selector.assignee }
|
|
114
|
+
: {};
|
|
74
115
|
if (opts.status)
|
|
75
116
|
base.status = opts.status;
|
|
117
|
+
const keep = opts.selector.kind === "open" ? isOpenRow : () => true;
|
|
76
118
|
const rows = [];
|
|
77
119
|
let cursor = null;
|
|
120
|
+
let serverTruncated = false;
|
|
78
121
|
for (let page = 0; page < MAX_LIST_PAGES; page++) {
|
|
79
|
-
|
|
122
|
+
// The untriaged queue applies `limit` after filtering, so ask for exactly
|
|
123
|
+
// what is still needed. The plain list path applies `limit` to the
|
|
124
|
+
// DynamoDB read BEFORE its filters, so a small page returns few or no
|
|
125
|
+
// matches per request and burns the page budget: always ask for a full
|
|
126
|
+
// page there and trim to the limit here.
|
|
127
|
+
const pageSize = opts.limit === null || opts.selector.kind !== "untriaged"
|
|
128
|
+
? LIST_PAGE_SIZE
|
|
129
|
+
: Math.min(LIST_PAGE_SIZE, opts.limit - rows.length);
|
|
80
130
|
const query = { ...base, limit: String(pageSize) };
|
|
81
131
|
if (cursor)
|
|
82
132
|
query.cursor = cursor;
|
|
83
133
|
const res = await vaultApiFetch({ token: opts.token, path: "/v1/feedback", query });
|
|
84
134
|
if (!res.ok) {
|
|
85
|
-
throw new Error(
|
|
135
|
+
throw new Error("Failed to list feedback: " + (await readFeedbackError(res)));
|
|
86
136
|
}
|
|
87
137
|
const data = (await res.json());
|
|
88
|
-
rows.push(...rowsFrom(data));
|
|
89
|
-
if (
|
|
90
|
-
|
|
138
|
+
rows.push(...rowsFrom(data).filter(keep));
|
|
139
|
+
if (isTruncated(data))
|
|
140
|
+
serverTruncated = true;
|
|
91
141
|
cursor = cursorFrom(data);
|
|
92
|
-
if (
|
|
93
|
-
|
|
142
|
+
if (opts.limit !== null && rows.length >= opts.limit) {
|
|
143
|
+
// More rows exist when this page overflowed the limit or the server
|
|
144
|
+
// has another page.
|
|
145
|
+
const more = rows.length > opts.limit || cursor !== null || serverTruncated;
|
|
146
|
+
return {
|
|
147
|
+
items: rows.slice(0, opts.limit),
|
|
148
|
+
...(more ? { truncated: serverTruncated ? "server" : "limit" } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (!cursor) {
|
|
152
|
+
return { items: rows, ...(serverTruncated ? { truncated: "server" } : {}) };
|
|
153
|
+
}
|
|
94
154
|
}
|
|
95
|
-
return rows;
|
|
155
|
+
return { items: rows, truncated: "page-cap" };
|
|
156
|
+
}
|
|
157
|
+
/** One-line stderr notice for a list that stopped before the end. */
|
|
158
|
+
export function feedbackTruncationNotice(reason, shown) {
|
|
159
|
+
if (reason === "limit") {
|
|
160
|
+
return "Showing the first " + String(shown) + " items; more may match. Use --all or a larger --limit.";
|
|
161
|
+
}
|
|
162
|
+
if (reason === "page-cap") {
|
|
163
|
+
return ("Stopped after " + String(MAX_LIST_PAGES) + " pages (" + String(shown) +
|
|
164
|
+
" items); more may match. Narrow the query, for example with --status or --assignee instead of --open.");
|
|
165
|
+
}
|
|
166
|
+
return "The server cut this read short; some matching items may be missing.";
|
|
96
167
|
}
|
|
97
168
|
export function formatFeedbackTable(rows, now = Date.now()) {
|
|
98
|
-
const header = ["ID", "AGE", "PLAN", "TITLE"];
|
|
169
|
+
const header = ["ID", "AGE", "STATUS", "PLAN", "TITLE"];
|
|
99
170
|
const body = rows.map((row) => [
|
|
100
171
|
row.id,
|
|
101
172
|
formatFeedbackAge(row.submittedAt ?? row.createdAt, now),
|
|
173
|
+
formatFeedbackStatus(row.status),
|
|
102
174
|
typeof row.plan === "string" && row.plan.length > 0 ? row.plan : "-",
|
|
103
175
|
typeof row.title === "string" ? row.title : "",
|
|
104
176
|
]);
|
|
105
|
-
const widths = [0, 1, 2].map((col) => Math.max(header[col].length, ...body.map((cells) => cells[col].length)));
|
|
177
|
+
const widths = [0, 1, 2, 3].map((col) => Math.max(header[col].length, ...body.map((cells) => cells[col].length)));
|
|
106
178
|
return [header, ...body]
|
|
107
|
-
.map((cells) => cells.map((cell, col) => (col <
|
|
179
|
+
.map((cells) => cells.map((cell, col) => (col < 4 ? cell.padEnd(widths[col]) : cell)).join(" ").trimEnd())
|
|
108
180
|
.join("\n");
|
|
109
181
|
}
|
|
182
|
+
/** Status cell for the list table. A stalled row is flagged so it does not read as not started. */
|
|
183
|
+
export function formatFeedbackStatus(status) {
|
|
184
|
+
if (typeof status !== "string" || status.length === 0)
|
|
185
|
+
return "-";
|
|
186
|
+
return status === "stalled" ? "stalled!" : status;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Normalize `list --status`; accepts `in-progress` as an alias of `in_progress`.
|
|
190
|
+
* Any non-empty value goes to the server, which validates it.
|
|
191
|
+
*/
|
|
192
|
+
export function parseListStatus(raw) {
|
|
193
|
+
const value = raw.trim().toLowerCase().replace(/-/g, "_");
|
|
194
|
+
if (!value)
|
|
195
|
+
throw new Error("--status must not be empty.");
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
110
198
|
export function parseListLimit(raw) {
|
|
111
199
|
const n = Number(raw);
|
|
112
200
|
if (!Number.isInteger(n) || n < 1) {
|
|
@@ -167,31 +255,40 @@ function registerListSubcommand(feedbackCmd) {
|
|
|
167
255
|
.command("list")
|
|
168
256
|
.description("List feedback: untriaged (default), yours, or one person's")
|
|
169
257
|
.option("--untriaged", "Items nobody has triaged yet (the default)")
|
|
258
|
+
.option("--open", "Every item not resolved, won't-fix or accepted, triaged or not")
|
|
170
259
|
.option("--mine", "Items assigned to you")
|
|
171
260
|
.option("--assignee <person>", "Items assigned to this email address or prs_ uid")
|
|
172
|
-
.option("--status <status>",
|
|
261
|
+
.option("--status <status>", `Only items with this status (${FEEDBACK_LIST_STATUSES.join(", ")}). Without --untriaged, --mine or --assignee it lists every item in that status`)
|
|
173
262
|
.option("--limit <n>", `Stop after this many items (default ${DEFAULT_LIST_LIMIT})`)
|
|
174
263
|
.option("--all", "Fetch every page")
|
|
175
|
-
.option("--json", "Print { items, count } as JSON")
|
|
264
|
+
.option("--json", "Print { items, count } as JSON, plus truncated: true when more items match")
|
|
176
265
|
.action(async (opts) => {
|
|
177
266
|
try {
|
|
178
|
-
const picked = [opts.untriaged, opts.mine, opts.assignee !== undefined].filter(Boolean);
|
|
267
|
+
const picked = [opts.untriaged, opts.open, opts.mine, opts.assignee !== undefined].filter(Boolean);
|
|
179
268
|
if (picked.length > 1) {
|
|
180
|
-
throw new Error("Use only one of --untriaged, --mine and --assignee.");
|
|
269
|
+
throw new Error("Use only one of --untriaged, --open, --mine and --assignee.");
|
|
270
|
+
}
|
|
271
|
+
if (opts.open && opts.status !== undefined) {
|
|
272
|
+
throw new Error("--open already selects every open status; use --status on its own instead.");
|
|
181
273
|
}
|
|
182
274
|
if (opts.all && opts.limit !== undefined) {
|
|
183
275
|
throw new Error("Use --limit or --all, not both.");
|
|
184
276
|
}
|
|
185
|
-
|
|
186
|
-
throw new Error("--status must not be empty.");
|
|
187
|
-
}
|
|
277
|
+
const status = opts.status === undefined ? undefined : parseListStatus(opts.status);
|
|
188
278
|
const limit = opts.all
|
|
189
279
|
? null
|
|
190
280
|
: opts.limit === undefined
|
|
191
281
|
? DEFAULT_LIST_LIMIT
|
|
192
282
|
: parseListLimit(opts.limit);
|
|
193
283
|
const token = await resolveStaffToken();
|
|
194
|
-
|
|
284
|
+
// No selector means the untriaged queue, except with --status: a
|
|
285
|
+
// status filter alone lists every row in that status. The queue only
|
|
286
|
+
// holds untriaged rows, so `--status accepted` there was nearly empty.
|
|
287
|
+
let selector = opts.open
|
|
288
|
+
? { kind: "open" }
|
|
289
|
+
: opts.status !== undefined && !opts.untriaged
|
|
290
|
+
? { kind: "any" }
|
|
291
|
+
: { kind: "untriaged" };
|
|
195
292
|
if (opts.mine) {
|
|
196
293
|
const email = emailFromToken(token);
|
|
197
294
|
if (!email) {
|
|
@@ -206,14 +303,17 @@ function registerListSubcommand(feedbackCmd) {
|
|
|
206
303
|
};
|
|
207
304
|
}
|
|
208
305
|
assertVaultApiRequestSupported({ token, path: "/v1/feedback" });
|
|
209
|
-
const items = await listFeedback({
|
|
306
|
+
const { items, truncated } = await listFeedback({
|
|
210
307
|
token,
|
|
211
308
|
selector,
|
|
212
|
-
status
|
|
309
|
+
status,
|
|
213
310
|
limit,
|
|
214
311
|
});
|
|
312
|
+
if (truncated) {
|
|
313
|
+
console.error(chalk.yellow("Note:"), feedbackTruncationNotice(truncated, items.length));
|
|
314
|
+
}
|
|
215
315
|
if (opts.json) {
|
|
216
|
-
console.log(JSON.stringify({ items, count: items.length }));
|
|
316
|
+
console.log(JSON.stringify({ items, count: items.length, ...(truncated ? { truncated: true } : {}) }));
|
|
217
317
|
}
|
|
218
318
|
else if (items.length === 0) {
|
|
219
319
|
console.log("No feedback items match.");
|
|
@@ -73,7 +73,7 @@ export declare function attachDebugLogs(requestBody: Record<string, unknown>, di
|
|
|
73
73
|
}): void;
|
|
74
74
|
export declare function submitFeedback(opts: FeedbackSubmitOptions): Promise<FeedbackResult>;
|
|
75
75
|
/** Customer-visible states accepted by `hq feedback update --state`. */
|
|
76
|
-
export declare const PUBLIC_TICKET_STATES: readonly ["triaged", "in-progress", "shipped", "verified", "resolved"];
|
|
76
|
+
export declare const PUBLIC_TICKET_STATES: readonly ["triaged", "in-progress", "stalled", "shipped", "verified", "resolved"];
|
|
77
77
|
export type PublicTicketState = (typeof PUBLIC_TICKET_STATES)[number];
|
|
78
78
|
export declare function assertFeedbackId(id: string): void;
|
|
79
79
|
/**
|
|
@@ -88,6 +88,45 @@ export declare function fetchFeedbackLink(opts: {
|
|
|
88
88
|
id: string;
|
|
89
89
|
token: string;
|
|
90
90
|
}): Promise<string>;
|
|
91
|
+
/**
|
|
92
|
+
* Work links for an in-progress update. A feedback row is in_progress only
|
|
93
|
+
* while it has a linked open PR or a live lane, so `--state in-progress`
|
|
94
|
+
* requires one of them. Field names match the hq-pro PATCH body.
|
|
95
|
+
*/
|
|
96
|
+
export interface FeedbackWorkLink {
|
|
97
|
+
prUrl?: string;
|
|
98
|
+
laneId?: string;
|
|
99
|
+
}
|
|
100
|
+
/** Accept `in_progress` as an alias of the public `in-progress` state. */
|
|
101
|
+
export declare function normalizePublicState(raw: string): string;
|
|
102
|
+
/** Validate --pr/--lane for a state; in-progress needs at least one, other states take none. */
|
|
103
|
+
export declare function buildWorkLink(state: string, opts: {
|
|
104
|
+
pr?: string;
|
|
105
|
+
lane?: string;
|
|
106
|
+
}): FeedbackWorkLink | null;
|
|
107
|
+
/** Read a feedback row with the staff GET. Returns null when it cannot be read. */
|
|
108
|
+
export declare function fetchFeedbackRow(opts: {
|
|
109
|
+
id: string;
|
|
110
|
+
token: string;
|
|
111
|
+
}): Promise<Record<string, unknown> | null>;
|
|
112
|
+
/** Delays between read-back attempts: 3 reads over about 1s. */
|
|
113
|
+
export declare const LINK_READBACK_DELAYS_MS: readonly number[];
|
|
114
|
+
/** Overridable in tests so retries do not wait on real timers. */
|
|
115
|
+
export declare const linkReadbackTimer: {
|
|
116
|
+
sleep: (ms: number) => Promise<void>;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Store the PR or lane link before any state change, then confirm it was
|
|
120
|
+
* saved by reading the row back. The PATCH response does not echo prUrl or
|
|
121
|
+
* laneId, and an older server ignores unknown fields, so only the GET shows
|
|
122
|
+
* the link is stored. A link-only PATCH can itself change the row (hq-pro
|
|
123
|
+
* resumes a stalled row), so errors report the row's actual state.
|
|
124
|
+
*/
|
|
125
|
+
export declare function storeFeedbackWorkLink(opts: {
|
|
126
|
+
id: string;
|
|
127
|
+
token: string;
|
|
128
|
+
link: FeedbackWorkLink;
|
|
129
|
+
}): Promise<void>;
|
|
91
130
|
/** Post a customer-visible update; the server also mirrors it into #hq-feedback. */
|
|
92
131
|
export declare function postFeedbackUpdate(opts: {
|
|
93
132
|
id: string;
|
|
@@ -3,11 +3,13 @@ import chalk from "chalk";
|
|
|
3
3
|
import { ensureCognitoToken, loadCachedTokens } from "../utils/cognito-session.js";
|
|
4
4
|
import { assertVaultApiRequestSupported, vaultApiFetch, } from "../utils/vault-api.js";
|
|
5
5
|
import { collectDiagnostics } from "../utils/feedback-diagnostics.js";
|
|
6
|
+
import { scrubDiagnosticsString, scrubDiagnosticsValue } from "../utils/scrub-diagnostics.js";
|
|
6
7
|
import { MAX_SCREENSHOTS, uploadScreenshots } from "../utils/feedback-screenshots.js";
|
|
7
8
|
import { FEEDBACK_LOGS_MAX_TOTAL_BYTES, FEEDBACK_LOGS_MIN_USEFUL_BYTES, collectFeedbackLogs, feedbackLogsEnabled, } from "../utils/feedback-logs.js";
|
|
8
9
|
import { uploadLogBundle } from "../utils/feedback-log-bundle.js";
|
|
9
10
|
import { resolveProcessFlagGate } from "../lib/flag-registry.js";
|
|
10
11
|
import { cachedLatestVersion } from "../utils/version-check.js";
|
|
12
|
+
import { decodeJwtPayload } from "../utils/person-token-cache.js";
|
|
11
13
|
import { registerFeedbackShowCommand } from "./feedback-show.js";
|
|
12
14
|
import { registerFeedbackTriageCommands } from "./feedback-triage.js";
|
|
13
15
|
export const FEEDBACK_LATEST_VERSION_FLAG = "feedback.latest-version";
|
|
@@ -79,8 +81,10 @@ export function resolveFeedbackReporter(env = process.env, isTTY = process.stdin
|
|
|
79
81
|
function buildFeedbackRequestBody(opts) {
|
|
80
82
|
const requestBody = {
|
|
81
83
|
type: opts.type,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
// Title and body are free text, and /hq-bug puts tool output in the body,
|
|
85
|
+
// so they get the same credential scrub as diagnostics.
|
|
86
|
+
title: scrubDiagnosticsString(opts.title),
|
|
87
|
+
body: scrubDiagnosticsString(opts.body),
|
|
84
88
|
diagnostics: opts.diagnostics,
|
|
85
89
|
};
|
|
86
90
|
if (opts.reporterMark) {
|
|
@@ -157,7 +161,12 @@ export async function submitFeedback(opts) {
|
|
|
157
161
|
// Keep submitFeedback safe for direct callers as well as the command path,
|
|
158
162
|
// which invokes the same validator before uploading any attachments.
|
|
159
163
|
validateFeedbackContent(opts.title, opts.body);
|
|
160
|
-
|
|
164
|
+
// Scrub here, at the one point every submission passes through. Callers may
|
|
165
|
+
// hand in their own diagnostics (the command path collects them before
|
|
166
|
+
// uploads, and adds latestVersion afterwards), and a caller-supplied blob
|
|
167
|
+
// previously skipped redaction entirely. The scrubbed copy is the object
|
|
168
|
+
// attachDebugLogs later mutates, so inline logs still land in the request.
|
|
169
|
+
const diagnostics = scrubDiagnosticsValue(opts.diagnostics ?? collectDiagnostics());
|
|
161
170
|
const reporterMark = opts.type === "bug" && resolveProcessFlagGate(FEEDBACK_REPORTER_KIND_FLAG, { fallback: FEEDBACK_REPORTER_KIND_FLAG_FALLBACK }, () => FEEDBACK_REPORTER_KIND_FLAG_FALLBACK)
|
|
162
171
|
? resolveFeedbackReporter(process.env, process.stdin.isTTY === true, opts.reporterOverride)
|
|
163
172
|
: undefined;
|
|
@@ -215,6 +224,7 @@ export async function submitFeedback(opts) {
|
|
|
215
224
|
export const PUBLIC_TICKET_STATES = [
|
|
216
225
|
"triaged",
|
|
217
226
|
"in-progress",
|
|
227
|
+
"stalled",
|
|
218
228
|
"shipped",
|
|
219
229
|
"verified",
|
|
220
230
|
"resolved",
|
|
@@ -269,6 +279,129 @@ export async function fetchFeedbackLink(opts) {
|
|
|
269
279
|
}
|
|
270
280
|
return url;
|
|
271
281
|
}
|
|
282
|
+
const PR_URL_RE = /^https:\/\/[^\s/]+\/[^\s]+$/;
|
|
283
|
+
/** Max `--lane` length; matches hq-pro LANE_ID_MAX_CHARS. */
|
|
284
|
+
const LANE_ID_MAX_CHARS = 200;
|
|
285
|
+
/** Same rule as hq-pro normalizeLaneId: non-empty, at most 200 chars, no control characters. */
|
|
286
|
+
function isValidLaneId(lane) {
|
|
287
|
+
if (lane.length === 0 || lane.length > LANE_ID_MAX_CHARS)
|
|
288
|
+
return false;
|
|
289
|
+
for (let i = 0; i < lane.length; i += 1) {
|
|
290
|
+
const code = lane.charCodeAt(i);
|
|
291
|
+
if (code < 0x20 || code === 0x7f)
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
/** Accept `in_progress` as an alias of the public `in-progress` state. */
|
|
297
|
+
export function normalizePublicState(raw) {
|
|
298
|
+
const value = raw.trim().toLowerCase();
|
|
299
|
+
return value === "in_progress" ? "in-progress" : value;
|
|
300
|
+
}
|
|
301
|
+
/** Validate --pr/--lane for a state; in-progress needs at least one, other states take none. */
|
|
302
|
+
export function buildWorkLink(state, opts) {
|
|
303
|
+
const pr = opts.pr?.trim();
|
|
304
|
+
const lane = opts.lane?.trim();
|
|
305
|
+
if (state !== "in-progress") {
|
|
306
|
+
if (pr !== undefined || lane !== undefined) {
|
|
307
|
+
throw new Error("--pr and --lane only apply to --state in-progress.");
|
|
308
|
+
}
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
if (!pr && !lane) {
|
|
312
|
+
throw new Error("--state in-progress needs --pr <url> or --lane <id>: a ticket is in progress only while it has an open PR or a live lane. Claiming a ticket does not need a state; use hq feedback claim.");
|
|
313
|
+
}
|
|
314
|
+
const link = {};
|
|
315
|
+
if (pr) {
|
|
316
|
+
if (!PR_URL_RE.test(pr))
|
|
317
|
+
throw new Error(`Invalid --pr '${opts.pr}': expected an https:// pull request URL.`);
|
|
318
|
+
link.prUrl = pr;
|
|
319
|
+
}
|
|
320
|
+
if (lane) {
|
|
321
|
+
if (!isValidLaneId(lane)) {
|
|
322
|
+
throw new Error(`Invalid --lane '${opts.lane}': expected a lane id of at most ${LANE_ID_MAX_CHARS} characters with no control characters.`);
|
|
323
|
+
}
|
|
324
|
+
link.laneId = lane;
|
|
325
|
+
}
|
|
326
|
+
return link;
|
|
327
|
+
}
|
|
328
|
+
function asRecord(value) {
|
|
329
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
330
|
+
}
|
|
331
|
+
function storedLinkMatches(row, link) {
|
|
332
|
+
if (!row)
|
|
333
|
+
return false;
|
|
334
|
+
return ((link.prUrl === undefined || row.prUrl === link.prUrl) &&
|
|
335
|
+
(link.laneId === undefined || row.laneId === link.laneId));
|
|
336
|
+
}
|
|
337
|
+
/** Read a feedback row with the staff GET. Returns null when it cannot be read. */
|
|
338
|
+
export async function fetchFeedbackRow(opts) {
|
|
339
|
+
try {
|
|
340
|
+
const res = await vaultApiFetch({ token: opts.token, path: `/v1/feedback/${opts.id}`, method: "GET" });
|
|
341
|
+
if (!res.ok)
|
|
342
|
+
return null;
|
|
343
|
+
const data = asRecord(await res.json().catch(() => null));
|
|
344
|
+
return asRecord(data?.item);
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function describeRowState(row) {
|
|
351
|
+
return typeof row?.status === "string"
|
|
352
|
+
? `The ticket's current state is '${row.status}'.`
|
|
353
|
+
: "The ticket's current state could not be read; check it with hq feedback list.";
|
|
354
|
+
}
|
|
355
|
+
/** Re-read the row and describe its actual state, for error messages. */
|
|
356
|
+
async function currentStateNote(id, token) {
|
|
357
|
+
return describeRowState(await fetchFeedbackRow({ id, token }));
|
|
358
|
+
}
|
|
359
|
+
const OLD_SERVER_HINT = "The server may not support work links yet.";
|
|
360
|
+
/** Delays between read-back attempts: 3 reads over about 1s. */
|
|
361
|
+
export const LINK_READBACK_DELAYS_MS = [300, 700];
|
|
362
|
+
/** Overridable in tests so retries do not wait on real timers. */
|
|
363
|
+
export const linkReadbackTimer = {
|
|
364
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
365
|
+
};
|
|
366
|
+
function sleep(ms) {
|
|
367
|
+
return linkReadbackTimer.sleep(ms);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Store the PR or lane link before any state change, then confirm it was
|
|
371
|
+
* saved by reading the row back. The PATCH response does not echo prUrl or
|
|
372
|
+
* laneId, and an older server ignores unknown fields, so only the GET shows
|
|
373
|
+
* the link is stored. A link-only PATCH can itself change the row (hq-pro
|
|
374
|
+
* resumes a stalled row), so errors report the row's actual state.
|
|
375
|
+
*/
|
|
376
|
+
export async function storeFeedbackWorkLink(opts) {
|
|
377
|
+
const res = await vaultApiFetch({
|
|
378
|
+
token: opts.token,
|
|
379
|
+
path: `/v1/feedback/${opts.id}`,
|
|
380
|
+
method: "PATCH",
|
|
381
|
+
body: { ...opts.link },
|
|
382
|
+
});
|
|
383
|
+
if (!res.ok) {
|
|
384
|
+
const message = await readFeedbackError(res);
|
|
385
|
+
throw new Error(`The server rejected the work link for ${opts.id}: ${message}. ${OLD_SERVER_HINT} ${await currentStateNote(opts.id, opts.token)}`);
|
|
386
|
+
}
|
|
387
|
+
// The GET resolves through an eventually consistent index, so a read right
|
|
388
|
+
// after the PATCH can return the old row. Retry briefly before deciding.
|
|
389
|
+
let row = null;
|
|
390
|
+
let readOk = false;
|
|
391
|
+
for (let attempt = 0; attempt < LINK_READBACK_DELAYS_MS.length + 1; attempt += 1) {
|
|
392
|
+
if (attempt > 0)
|
|
393
|
+
await sleep(LINK_READBACK_DELAYS_MS[attempt - 1]);
|
|
394
|
+
row = await fetchFeedbackRow({ id: opts.id, token: opts.token });
|
|
395
|
+
if (row)
|
|
396
|
+
readOk = true;
|
|
397
|
+
if (storedLinkMatches(row, opts.link))
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (!readOk) {
|
|
401
|
+
throw new Error(`Could not read ${opts.id} back to confirm the work link; the link may have been saved. The state was not posted; rerun the same command to retry.`);
|
|
402
|
+
}
|
|
403
|
+
throw new Error(`The server did not store the work link for ${opts.id}. ${OLD_SERVER_HINT} ${describeRowState(row)}`);
|
|
404
|
+
}
|
|
272
405
|
/** Post a customer-visible update; the server also mirrors it into #hq-feedback. */
|
|
273
406
|
export async function postFeedbackUpdate(opts) {
|
|
274
407
|
const res = await vaultApiFetch({
|
|
@@ -278,7 +411,11 @@ export async function postFeedbackUpdate(opts) {
|
|
|
278
411
|
body: { publicUpdate: { state: opts.state, note: opts.note } },
|
|
279
412
|
});
|
|
280
413
|
if (!res.ok) {
|
|
281
|
-
|
|
414
|
+
const message = await readFeedbackError(res);
|
|
415
|
+
const hint = opts.state === "stalled" && (res.status === 400 || res.status === 422)
|
|
416
|
+
? ` The server may not support the stalled state yet. ${await currentStateNote(opts.id, opts.token)}`
|
|
417
|
+
: "";
|
|
418
|
+
throw new Error(`Failed to update ${opts.id}: ${message}${hint}`);
|
|
282
419
|
}
|
|
283
420
|
const data = (await res.json());
|
|
284
421
|
return {
|
|
@@ -359,6 +496,41 @@ function registerAssignSubcommand(feedbackCmd) {
|
|
|
359
496
|
}
|
|
360
497
|
});
|
|
361
498
|
}
|
|
499
|
+
function registerClaimSubcommand(feedbackCmd) {
|
|
500
|
+
feedbackCmd
|
|
501
|
+
.command("claim")
|
|
502
|
+
.description("Assign a ticket to yourself (sets the assignee only, never the status)")
|
|
503
|
+
.argument("<id>", "Feedback id (feedback_<uuid>)")
|
|
504
|
+
.option("--json", "Print { id, assignee } as JSON")
|
|
505
|
+
.action(async (id, opts) => {
|
|
506
|
+
try {
|
|
507
|
+
assertFeedbackId(id);
|
|
508
|
+
const token = await resolveStaffToken();
|
|
509
|
+
const email = emailFromStaffToken(token);
|
|
510
|
+
if (!email) {
|
|
511
|
+
throw new Error("hq feedback claim needs a person login with an email (hq login). Use hq feedback assign <id> <person> instead.");
|
|
512
|
+
}
|
|
513
|
+
assertVaultApiRequestSupported({ token, path: `/v1/feedback/${id}` });
|
|
514
|
+
await assignFeedback({ id, token, assignee: email });
|
|
515
|
+
if (opts.json) {
|
|
516
|
+
console.log(JSON.stringify({ id, assignee: email }));
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
console.log(`Claimed: ${id} -> ${email}`);
|
|
520
|
+
console.log("Set --state in-progress with --pr <url> or --lane <id> once work is linked.");
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
catch (err) {
|
|
524
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
525
|
+
process.exit(1);
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
function emailFromStaffToken(token) {
|
|
530
|
+
const claims = decodeJwtPayload(token);
|
|
531
|
+
const email = claims?.email;
|
|
532
|
+
return typeof email === "string" && email.includes("@") ? email.toLowerCase() : null;
|
|
533
|
+
}
|
|
362
534
|
function registerLinkSubcommand(feedbackCmd) {
|
|
363
535
|
feedbackCmd
|
|
364
536
|
.command("link")
|
|
@@ -385,12 +557,16 @@ function registerUpdateSubcommand(feedbackCmd) {
|
|
|
385
557
|
.argument("<id>", "Feedback id (feedback_<uuid>)")
|
|
386
558
|
.requiredOption("--state <state>", `One of: ${PUBLIC_TICKET_STATES.join(", ")}`)
|
|
387
559
|
.requiredOption("--note <text>", "What the customer should read (plain text, up to 2000 characters)")
|
|
560
|
+
.option("--pr <url>", "Open pull request for this ticket (required with --state in-progress unless --lane is given)")
|
|
561
|
+
.option("--lane <id>", "Live lane working this ticket (required with --state in-progress unless --pr is given)")
|
|
388
562
|
.action(async (id, opts) => {
|
|
389
563
|
try {
|
|
390
564
|
assertFeedbackId(id);
|
|
391
|
-
|
|
565
|
+
const state = normalizePublicState(opts.state);
|
|
566
|
+
if (!PUBLIC_TICKET_STATES.includes(state)) {
|
|
392
567
|
throw new Error(`Invalid --state '${opts.state}': expected one of ${PUBLIC_TICKET_STATES.join(", ")}.`);
|
|
393
568
|
}
|
|
569
|
+
const link = buildWorkLink(state, opts);
|
|
394
570
|
const note = opts.note.trim();
|
|
395
571
|
if (note.length === 0)
|
|
396
572
|
throw new Error("--note must not be empty.");
|
|
@@ -398,13 +574,24 @@ function registerUpdateSubcommand(feedbackCmd) {
|
|
|
398
574
|
throw new Error("--note must be at most 2000 characters.");
|
|
399
575
|
const token = await resolveStaffToken();
|
|
400
576
|
assertVaultApiRequestSupported({ token, path: `/v1/feedback/${id}` });
|
|
577
|
+
if (link)
|
|
578
|
+
await storeFeedbackWorkLink({ id, token, link });
|
|
401
579
|
const result = await postFeedbackUpdate({
|
|
402
580
|
id,
|
|
403
581
|
token,
|
|
404
|
-
state:
|
|
582
|
+
state: state,
|
|
405
583
|
note,
|
|
584
|
+
}).catch((err) => {
|
|
585
|
+
if (!link)
|
|
586
|
+
throw err;
|
|
587
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
588
|
+
throw new Error(`${message} The work link was saved; rerun the same command to set the state.`);
|
|
406
589
|
});
|
|
407
|
-
console.log(`Updated: ${id} -> ${
|
|
590
|
+
console.log(`Updated: ${id} -> ${state}`);
|
|
591
|
+
if (link?.prUrl)
|
|
592
|
+
console.log(`PR: ${link.prUrl}`);
|
|
593
|
+
if (link?.laneId)
|
|
594
|
+
console.log(`Lane: ${link.laneId}`);
|
|
408
595
|
if (result.publicUrl)
|
|
409
596
|
console.log(`Track: ${result.publicUrl}`);
|
|
410
597
|
if (result.slack === "failed") {
|
|
@@ -522,6 +709,7 @@ export function registerFeedbackCommand(program) {
|
|
|
522
709
|
registerUpdateSubcommand(feedbackCmd);
|
|
523
710
|
registerFeedbackShowCommand(feedbackCmd);
|
|
524
711
|
registerAssignSubcommand(feedbackCmd);
|
|
712
|
+
registerClaimSubcommand(feedbackCmd);
|
|
525
713
|
registerFeedbackTriageCommands(feedbackCmd);
|
|
526
714
|
}
|
|
527
715
|
//# sourceMappingURL=feedback.js.map
|
package/dist/commands/files.d.ts
CHANGED
|
@@ -66,6 +66,48 @@ export declare class ShareSessionHttpError extends Error {
|
|
|
66
66
|
*/
|
|
67
67
|
export declare function formatShareSessionError(err: ShareSessionHttpError): string;
|
|
68
68
|
export declare function registerFilesCommand(program: Command): Command;
|
|
69
|
+
/**
|
|
70
|
+
* True for a prefix that might name a folder without saying so: no trailing
|
|
71
|
+
* `/` (private folder), no `/*` (shared folder glob), and not the `*`
|
|
72
|
+
* wildcard, and a last segment with no extension (a name with an extension is
|
|
73
|
+
* taken as the file it names). The vault ACL treats such a path as an exact
|
|
74
|
+
* object key, so
|
|
75
|
+
* `hq files share knowledge --with x` used to grant an object named
|
|
76
|
+
* "knowledge" and nothing under `knowledge/` (DEV-5173: members were blocked
|
|
77
|
+
* under a folder their admin had "shared"). The name alone does not decide
|
|
78
|
+
* whether it is a folder (`Makefile`, `bin/hq`); resolveBareSharePrefix asks
|
|
79
|
+
* the vault.
|
|
80
|
+
*/
|
|
81
|
+
export declare function looksLikeBareFolderPrefix(prefix: string): boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Refuse a bare prefix that would become a malformed grant if it were
|
|
84
|
+
* rewritten to a folder glob: a `*` inside a segment (`**`, `know*`) or an
|
|
85
|
+
* empty segment (`/knowledge`, `a//b`). Returns the error text, or null.
|
|
86
|
+
*/
|
|
87
|
+
export declare function malformedBareSharePrefix(prefix: string): string | null;
|
|
88
|
+
export type BareSharePrefixResolution = {
|
|
89
|
+
kind: "unchanged";
|
|
90
|
+
prefix: string;
|
|
91
|
+
} | {
|
|
92
|
+
kind: "folder";
|
|
93
|
+
prefix: string;
|
|
94
|
+
folderGlob: string;
|
|
95
|
+
} | {
|
|
96
|
+
kind: "refused";
|
|
97
|
+
prefix: string;
|
|
98
|
+
message: string;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Decide what a bare prefix means by looking it up in the vault. Rewrite `X`
|
|
102
|
+
* to `X/*` only when `X/` has children and no object `X` exists. Keep `X` when
|
|
103
|
+
* only the object exists. Refuse when both exist, when neither does, or when
|
|
104
|
+
* the lookup fails; the caller exits non-zero and points to --exact or `X/*`.
|
|
105
|
+
*/
|
|
106
|
+
export declare function resolveBareSharePrefix(params: {
|
|
107
|
+
token: string;
|
|
108
|
+
companyUid: string;
|
|
109
|
+
prefix: string;
|
|
110
|
+
}): Promise<BareSharePrefixResolution>;
|
|
69
111
|
/** Server response shape for POST /v1/files/delete. */
|
|
70
112
|
export interface FilesDeleteResponse {
|
|
71
113
|
prefix: string;
|