@catalyst-cloud/cli 0.14.6 → 0.14.8
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 +14 -0
- package/README.md +7 -5
- package/dist/args.js +68 -18
- package/dist/browser.js +37 -3
- package/dist/cli.js +58 -12
- package/dist/config.js +17 -5
- package/dist/consent-browser.js +58 -0
- package/dist/daemon-authority-lifetime.js +171 -0
- package/dist/daemon-authority.js +216 -0
- package/dist/daemon-cache.js +348 -0
- package/dist/daemon-store.js +370 -0
- package/dist/daemon-supervisor.js +460 -0
- package/dist/env/onboard-draft-store.js +250 -0
- package/dist/env/onboard-draft.js +33 -0
- package/dist/env/selected-import-writer.js +163 -0
- package/dist/env/settings-authority.js +205 -0
- package/dist/events-cloud-tail.js +146 -0
- package/dist/events-cloud.js +619 -0
- package/dist/events.js +186 -31
- package/dist/oauth.js +129 -34
- package/dist/onboard-accounts.js +259 -0
- package/dist/onboard-automation-management.js +15 -0
- package/dist/onboard-bootstrap.js +162 -0
- package/dist/onboard-capabilities.js +15 -7
- package/dist/onboard-capacity.js +226 -0
- package/dist/onboard-existing.js +106 -32
- package/dist/onboard-file-snapshot.js +45 -0
- package/dist/onboard-github.js +14 -3
- package/dist/onboard-login-candidate.js +261 -0
- package/dist/onboard-next.js +139 -0
- package/dist/onboard-personal.js +14 -3
- package/dist/onboard-projects.js +350 -0
- package/dist/onboard-repositories.js +146 -37
- package/dist/onboard-runtime.js +108 -13
- package/dist/onboard-settings-context.js +141 -0
- package/dist/onboard-settings-import.js +127 -0
- package/dist/onboard-settings.js +234 -0
- package/dist/onboard-ui.js +85 -59
- package/dist/onboard-workflow.js +316 -0
- package/dist/onboard-workspace.js +14 -3
- package/dist/onboard.js +422 -39
- package/dist/owned-fetch.js +182 -0
- package/dist/pagination.js +13 -1
- package/dist/query.js +104 -31
- package/dist/ready.js +143 -35
- package/dist/replica.js +11 -4
- package/dist/repository-id.js +5 -0
- package/dist/sdk.js +33 -0
- package/dist/watch.js +2 -2
- package/package.json +3 -2
- package/skills/catalyst-github/SKILL.md +4 -2
- package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
- package/skills/catalyst-github/references/local-replica.md +78 -0
- package/skills/catalyst-github/references/reading-from-the-cloud.md +24 -0
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
- package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
- package/skills/catalyst-linear/SKILL.md +5 -3
- package/skills/catalyst-linear/references/local-replica.md +91 -0
- package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
- package/skills/catalyst-linear/references/reading-from-the-cloud.md +38 -0
- package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
- package/skills/catalyst-onboard/SKILL.md +6 -4
- package/skills/catalyst-onboard/references/local-replica.md +40 -0
- package/skills/catalyst-onboard/references/local-sync.md +8 -4
- package/skills/catalyst-onboard/scripts/local-sync.mjs +2 -2
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -1
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +4 -2
- package/skills/whats-happening/references/local-replica.md +51 -0
- package/skills/whats-happening/references/reading-from-the-cloud.md +21 -0
- package/skills/whats-happening/references/status-reply.md +1 -1
- package/skills/whats-happening/scripts/snapshot.mjs +104 -26
- package/vendor/settings/index.d.ts +4 -0
- package/vendor/settings/index.js +2937 -0
- package/vendor/settings/provenance.json +148 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
export function ownedFetch(fetchImpl, parent, options = {}) {
|
|
2
|
+
const limit = options.maxBodyBytes;
|
|
3
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1))
|
|
4
|
+
throw new Error("owned_fetch_body_limit_invalid");
|
|
5
|
+
const owned = new AbortController();
|
|
6
|
+
const requests = new Set();
|
|
7
|
+
const bodies = new Set();
|
|
8
|
+
const cleanupErrors = [];
|
|
9
|
+
let settling = false;
|
|
10
|
+
const stopped = () => owned.abort(parent.reason);
|
|
11
|
+
parent.addEventListener("abort", stopped, { once: true });
|
|
12
|
+
if (parent.aborted)
|
|
13
|
+
stopped();
|
|
14
|
+
const trackedFetch = (input, init) => {
|
|
15
|
+
if (owned.signal.aborted)
|
|
16
|
+
return Promise.reject(owned.signal.reason);
|
|
17
|
+
if (settling)
|
|
18
|
+
return Promise.reject(new Error("owned_fetch_stopped"));
|
|
19
|
+
const requestSignal = input instanceof Request ? input.signal : undefined;
|
|
20
|
+
const signal = AbortSignal.any([
|
|
21
|
+
owned.signal,
|
|
22
|
+
...(requestSignal ? [requestSignal] : []),
|
|
23
|
+
...(init?.signal ? [init.signal] : []),
|
|
24
|
+
]);
|
|
25
|
+
if (signal.aborted)
|
|
26
|
+
return Promise.reject(signal.reason);
|
|
27
|
+
const network = new AbortController();
|
|
28
|
+
const stopHeaders = () => network.abort(signal.reason);
|
|
29
|
+
signal.addEventListener("abort", stopHeaders, { once: true });
|
|
30
|
+
const pending = (async () => {
|
|
31
|
+
let response;
|
|
32
|
+
try {
|
|
33
|
+
response = await fetchImpl(input, {
|
|
34
|
+
...init,
|
|
35
|
+
signal: network.signal,
|
|
36
|
+
redirect: "error",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
signal.removeEventListener("abort", stopHeaders);
|
|
41
|
+
}
|
|
42
|
+
if (!response.body) {
|
|
43
|
+
if (signal.aborted)
|
|
44
|
+
throw signal.reason;
|
|
45
|
+
return response;
|
|
46
|
+
}
|
|
47
|
+
const reader = response.body.getReader();
|
|
48
|
+
let bodyController;
|
|
49
|
+
let terminal = false;
|
|
50
|
+
let consumed = 0;
|
|
51
|
+
let completed = false;
|
|
52
|
+
let enteredRead;
|
|
53
|
+
let firstCancel;
|
|
54
|
+
let complete;
|
|
55
|
+
const joined = new Promise((resolve) => {
|
|
56
|
+
complete = resolve;
|
|
57
|
+
});
|
|
58
|
+
bodies.add(joined);
|
|
59
|
+
const finish = () => {
|
|
60
|
+
if (completed)
|
|
61
|
+
return;
|
|
62
|
+
completed = true;
|
|
63
|
+
signal.removeEventListener("abort", aborted);
|
|
64
|
+
try {
|
|
65
|
+
reader.releaseLock();
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
cleanupErrors.push(error);
|
|
69
|
+
}
|
|
70
|
+
complete();
|
|
71
|
+
};
|
|
72
|
+
const cancel = (reason) => {
|
|
73
|
+
if (firstCancel)
|
|
74
|
+
return firstCancel;
|
|
75
|
+
if (completed)
|
|
76
|
+
return Promise.resolve();
|
|
77
|
+
terminal = true;
|
|
78
|
+
// Retain this FIRST promise: a second cancel can resolve before underlying IO acknowledges it.
|
|
79
|
+
firstCancel = reader.cancel(reason);
|
|
80
|
+
// Close/error the owned reader FIRST. Aborting native fetch first can instead error its
|
|
81
|
+
// upstream stream and turn an otherwise acknowledged cancellation into a rejection.
|
|
82
|
+
network.abort(reason);
|
|
83
|
+
const read = enteredRead;
|
|
84
|
+
void Promise.allSettled([firstCancel, ...(read ? [read] : [])]).then((results) => {
|
|
85
|
+
const cancellation = results[0];
|
|
86
|
+
if (cancellation?.status === "rejected")
|
|
87
|
+
cleanupErrors.push(cancellation.reason);
|
|
88
|
+
finish();
|
|
89
|
+
});
|
|
90
|
+
return firstCancel;
|
|
91
|
+
};
|
|
92
|
+
const aborted = () => {
|
|
93
|
+
if (completed)
|
|
94
|
+
return;
|
|
95
|
+
terminal = true;
|
|
96
|
+
// An abort is an error, never successful EOF that could publish a truncated page.
|
|
97
|
+
try {
|
|
98
|
+
bodyController?.error(signal.reason);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* already terminal */
|
|
102
|
+
}
|
|
103
|
+
void cancel(signal.reason).catch(() => { });
|
|
104
|
+
};
|
|
105
|
+
const body = new ReadableStream({
|
|
106
|
+
start(controller) {
|
|
107
|
+
bodyController = controller;
|
|
108
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
109
|
+
if (signal.aborted)
|
|
110
|
+
aborted();
|
|
111
|
+
},
|
|
112
|
+
async pull(controller) {
|
|
113
|
+
if (terminal)
|
|
114
|
+
return;
|
|
115
|
+
const read = reader.read();
|
|
116
|
+
enteredRead = read;
|
|
117
|
+
try {
|
|
118
|
+
const result = await read;
|
|
119
|
+
if (terminal || signal.aborted)
|
|
120
|
+
return;
|
|
121
|
+
if (result.done) {
|
|
122
|
+
terminal = true;
|
|
123
|
+
controller.close();
|
|
124
|
+
finish();
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
if (limit !== undefined &&
|
|
128
|
+
result.value.byteLength > limit - consumed)
|
|
129
|
+
throw new Error("owned_fetch_body_limit");
|
|
130
|
+
consumed += result.value.byteLength;
|
|
131
|
+
controller.enqueue(result.value);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (!terminal) {
|
|
136
|
+
terminal = true;
|
|
137
|
+
controller.error(error);
|
|
138
|
+
void cancel(error).catch(() => { });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
if (enteredRead === read)
|
|
143
|
+
enteredRead = undefined;
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
cancel(reason) {
|
|
147
|
+
return cancel(reason);
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
if (signal.aborted) {
|
|
151
|
+
aborted();
|
|
152
|
+
await joined;
|
|
153
|
+
throw signal.reason;
|
|
154
|
+
}
|
|
155
|
+
return new Response(body, {
|
|
156
|
+
status: response.status,
|
|
157
|
+
statusText: response.statusText,
|
|
158
|
+
headers: response.headers,
|
|
159
|
+
});
|
|
160
|
+
})();
|
|
161
|
+
requests.add(pending);
|
|
162
|
+
void pending.then(() => requests.delete(pending), () => requests.delete(pending));
|
|
163
|
+
return pending;
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
fetch: trackedFetch,
|
|
167
|
+
abort(reason) {
|
|
168
|
+
owned.abort(reason);
|
|
169
|
+
},
|
|
170
|
+
async settle() {
|
|
171
|
+
settling = true;
|
|
172
|
+
owned.abort(new Error("owned_fetch_settling"));
|
|
173
|
+
// A fetch that ignores its signal stays owned. Its eventual body is cancelled before return.
|
|
174
|
+
// This join does not pretend a deadline proves that non-cooperative IO has stopped.
|
|
175
|
+
await Promise.allSettled([...requests]);
|
|
176
|
+
await Promise.all([...bodies]);
|
|
177
|
+
parent.removeEventListener("abort", stopped);
|
|
178
|
+
if (cleanupErrors.length > 0)
|
|
179
|
+
throw new AggregateError(cleanupErrors, "owned_fetch_cleanup_failed");
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
package/dist/pagination.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import { CliError } from "./errors.js";
|
|
13
13
|
export const NEXT_CURSOR_HEADER = "x-mirror-next-cursor";
|
|
14
14
|
export const TOTAL_HEADER = "x-mirror-total";
|
|
15
|
+
/** CTC-4556 — the `/issues` params that narrowed the page, comma-separated; absent on older mirrors. */
|
|
16
|
+
export const SCOPE_HEADER = "x-mirror-scope";
|
|
15
17
|
/** The server clamps every page to this regardless of the requested `limit` (MAX_VIEW_LIMIT). */
|
|
16
18
|
export const SERVER_PAGE_CAP = 500;
|
|
17
19
|
/** 100 pages × 500 rows = 50,000. Reaching it is a refusal, not an ending. */
|
|
@@ -29,6 +31,12 @@ export function rowsOf(body) {
|
|
|
29
31
|
}
|
|
30
32
|
return [];
|
|
31
33
|
}
|
|
34
|
+
function readScope(headers) {
|
|
35
|
+
const raw = headers.get(SCOPE_HEADER);
|
|
36
|
+
if (raw === null)
|
|
37
|
+
return null;
|
|
38
|
+
return raw.split(",").map((p) => p.trim()).filter(Boolean);
|
|
39
|
+
}
|
|
32
40
|
function readTotal(headers) {
|
|
33
41
|
const raw = headers.get(TOTAL_HEADER);
|
|
34
42
|
if (raw === null)
|
|
@@ -43,6 +51,7 @@ export async function fetchPage(api, path, query) {
|
|
|
43
51
|
rows: rowsOf(res.body),
|
|
44
52
|
nextCursor: next === null || next === "" ? null : next,
|
|
45
53
|
total: readTotal(res.headers),
|
|
54
|
+
scope: readScope(res.headers),
|
|
46
55
|
};
|
|
47
56
|
}
|
|
48
57
|
export async function fetchAllPages(api, path, query) {
|
|
@@ -50,13 +59,16 @@ export async function fetchAllPages(api, path, query) {
|
|
|
50
59
|
const seen = new Set();
|
|
51
60
|
let after;
|
|
52
61
|
let total = null;
|
|
62
|
+
let scope = null;
|
|
53
63
|
for (let pages = 1; pages <= MAX_PAGES; pages++) {
|
|
54
64
|
const page = await fetchPage(api, path, { ...query, limit: SERVER_PAGE_CAP, after });
|
|
55
65
|
rows.push(...page.rows);
|
|
56
66
|
if (page.total !== null)
|
|
57
67
|
total = page.total;
|
|
68
|
+
// A param counts as applied only if every page says so.
|
|
69
|
+
scope = pages === 1 ? page.scope : scope && page.scope && scope.filter((p) => page.scope?.includes(p));
|
|
58
70
|
if (page.nextCursor === null)
|
|
59
|
-
return { rows, total, pages };
|
|
71
|
+
return { rows, total, pages, scope };
|
|
60
72
|
// ⛔ A cursor that does not advance is an infinite loop, not a long read.
|
|
61
73
|
if (seen.has(page.nextCursor)) {
|
|
62
74
|
throw new CliError(`GET ${path} repeated a page cursor after ${rows.length} rows — the cloud is not advancing the keyset, so --all cannot finish`, "pagination-stalled", 1);
|
package/dist/query.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// query.ts — `query issues|issue|pulls|pull|projects|cycles|search|changes`: the replica when it is
|
|
2
|
-
//
|
|
2
|
+
// explicitly selected, otherwise the API, with the source named on stderr every time. Output shape is the read-model
|
|
3
3
|
// view either way. `pull`, `cycles`, `search` and `changes` are API-only (the SDK wraps no view for
|
|
4
4
|
// them and the read-model package is not a declared dependency of this bundle).
|
|
5
|
-
import { flagBool, flagInt, flagString, positionals } from "./args.js";
|
|
6
|
-
import { requireConfig, replicaDbPath } from "./config.js";
|
|
5
|
+
import { flagBool, flagInt, flagString, positionals, } from "./args.js";
|
|
6
|
+
import { requireConfig, replicaDbPath, } from "./config.js";
|
|
7
7
|
import { CliError, UsageError } from "./errors.js";
|
|
8
8
|
import { apiClient } from "./transport.js";
|
|
9
|
-
import { fetchAllPages, fetchPage, rowsOf, truncationNotice } from "./pagination.js";
|
|
9
|
+
import { fetchAllPages, fetchPage, rowsOf, truncationNotice, } from "./pagination.js";
|
|
10
10
|
import { engineFor, replicaStatus } from "./replica.js";
|
|
11
11
|
import { loadSdk } from "./sdk.js";
|
|
12
12
|
export { rowsOf };
|
|
@@ -20,9 +20,10 @@ export async function cmdQuery(args, ctx, deps = {}) {
|
|
|
20
20
|
throw new UsageError("query needs a subcommand: issues | issue <id> | pulls | pull <id> | projects | cycles | search <terms> | changes --since <n>");
|
|
21
21
|
const cfg = requireConfig(ctx);
|
|
22
22
|
const filters = {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
// CTC-4556 — trimmed here, so the value sent and the value compared are the one the mirror reads.
|
|
24
|
+
team: trimmedFlag(args, "team"),
|
|
25
|
+
project: trimmedFlag(args, "project"),
|
|
26
|
+
state: trimmedFlag(args, "state"),
|
|
26
27
|
limit: flagInt(args, "limit", 50),
|
|
27
28
|
all: flagBool(args, "all"),
|
|
28
29
|
};
|
|
@@ -38,7 +39,9 @@ export async function cmdQuery(args, ctx, deps = {}) {
|
|
|
38
39
|
if (filters.all && flagString(args, "limit") !== undefined) {
|
|
39
40
|
ctx.stderr("--all reads the whole scope, so --limit is ignored");
|
|
40
41
|
}
|
|
41
|
-
const status =
|
|
42
|
+
const status = forced === "replica" && REPLICA_CAPABLE.has(sub)
|
|
43
|
+
? replicaStatus(ctx, cfg)
|
|
44
|
+
: null;
|
|
42
45
|
let source;
|
|
43
46
|
let why;
|
|
44
47
|
if (!REPLICA_CAPABLE.has(sub)) {
|
|
@@ -53,19 +56,17 @@ export async function cmdQuery(args, ctx, deps = {}) {
|
|
|
53
56
|
source = "api";
|
|
54
57
|
why = "--all follows the cloud's pages";
|
|
55
58
|
}
|
|
56
|
-
else if (status.verdict === "fresh") {
|
|
57
|
-
source = "replica";
|
|
58
|
-
why = `cursor ${status.cursor}`;
|
|
59
|
-
}
|
|
60
59
|
else {
|
|
61
60
|
source = "api";
|
|
62
|
-
why =
|
|
61
|
+
why = "cloud reads by default";
|
|
63
62
|
}
|
|
64
63
|
ctx.stderr(source === "replica" ? `source: replica (${why})` : `source: api (${why})`);
|
|
65
64
|
let result;
|
|
66
65
|
if (source === "replica") {
|
|
67
|
-
if (status
|
|
68
|
-
|
|
66
|
+
if (!status ||
|
|
67
|
+
status.verdict === "absent" ||
|
|
68
|
+
status.verdict === "not-configured") {
|
|
69
|
+
throw new UsageError(`--source replica but the replica is ${status?.verdict ?? "unavailable"}`);
|
|
69
70
|
}
|
|
70
71
|
result = await fromReplica(ctx, cfg, sub, rest, filters, deps);
|
|
71
72
|
}
|
|
@@ -82,8 +83,15 @@ export async function cmdQuery(args, ctx, deps = {}) {
|
|
|
82
83
|
async function fromReplica(ctx, cfg, sub, rest, f, deps) {
|
|
83
84
|
const sdk = await loadSdk();
|
|
84
85
|
const dbPath = replicaDbPath(cfg, ctx.home, ctx.env);
|
|
85
|
-
const engine = await engineFor(sdk, dbPath, ctx, {
|
|
86
|
-
|
|
86
|
+
const engine = await engineFor(sdk, dbPath, ctx, {
|
|
87
|
+
...deps.engineDeps,
|
|
88
|
+
readonly: true,
|
|
89
|
+
});
|
|
90
|
+
const replica = await sdk.CatalystReplica.openReadOnly({
|
|
91
|
+
dbPath,
|
|
92
|
+
engine,
|
|
93
|
+
log: () => { },
|
|
94
|
+
});
|
|
87
95
|
try {
|
|
88
96
|
switch (sub) {
|
|
89
97
|
case "issues": {
|
|
@@ -95,7 +103,9 @@ async function fromReplica(ctx, cfg, sub, rest, f, deps) {
|
|
|
95
103
|
case "pulls":
|
|
96
104
|
return replica.pulls({ limit: f.limit });
|
|
97
105
|
case "projects":
|
|
98
|
-
return applyFilters(replica.projects({
|
|
106
|
+
return applyFilters(replica.projects({
|
|
107
|
+
limit: Math.max(f.limit, 200),
|
|
108
|
+
}), { limit: f.limit, state: f.state }).slice(0, f.limit);
|
|
99
109
|
default:
|
|
100
110
|
throw new UsageError(`${sub} is not readable from the replica`);
|
|
101
111
|
}
|
|
@@ -106,14 +116,25 @@ async function fromReplica(ctx, cfg, sub, rest, f, deps) {
|
|
|
106
116
|
}
|
|
107
117
|
async function fromApi(ctx, cfg, sub, rest, f, args) {
|
|
108
118
|
const api = apiClient(cfg, ctx);
|
|
109
|
-
|
|
119
|
+
// CTC-4556 — the mirror narrows `/issues` by `team_key` and `state_name`; `team` and a state name
|
|
120
|
+
// in `state` narrow nothing there.
|
|
121
|
+
const issueScope = {
|
|
122
|
+
team_key: f.team,
|
|
123
|
+
project: f.project,
|
|
124
|
+
state_name: f.state,
|
|
125
|
+
};
|
|
110
126
|
switch (sub) {
|
|
111
127
|
case "issues": {
|
|
112
128
|
if (f.all) {
|
|
113
|
-
const { rows } = await fetchAllPages(api, "/api/v1/issues",
|
|
129
|
+
const { rows, scope } = await fetchAllPages(api, "/api/v1/issues", issueScope);
|
|
130
|
+
requireIssueScope(scope, f);
|
|
114
131
|
return applyFilters(rows, f); // no .slice — --all means the whole scope
|
|
115
132
|
}
|
|
116
|
-
const page = await fetchPage(api, "/api/v1/issues",
|
|
133
|
+
const page = await fetchPage(api, "/api/v1/issues", {
|
|
134
|
+
...issueScope,
|
|
135
|
+
limit: f.limit,
|
|
136
|
+
});
|
|
137
|
+
requireIssueScope(page.scope, f);
|
|
117
138
|
const rows = applyFilters(page.rows, f).slice(0, f.limit);
|
|
118
139
|
const notice = truncationNotice(page);
|
|
119
140
|
if (notice)
|
|
@@ -131,7 +152,10 @@ async function fromApi(ctx, cfg, sub, rest, f, args) {
|
|
|
131
152
|
const { rows } = await fetchAllPages(api, "/api/v1/pulls", { ticket });
|
|
132
153
|
return rows;
|
|
133
154
|
}
|
|
134
|
-
const page = await fetchPage(api, "/api/v1/pulls", {
|
|
155
|
+
const page = await fetchPage(api, "/api/v1/pulls", {
|
|
156
|
+
limit: f.limit,
|
|
157
|
+
ticket,
|
|
158
|
+
});
|
|
135
159
|
const rows = page.rows.slice(0, f.limit);
|
|
136
160
|
const notice = truncationNotice(page);
|
|
137
161
|
if (notice)
|
|
@@ -144,18 +168,24 @@ async function fromApi(ctx, cfg, sub, rest, f, args) {
|
|
|
144
168
|
return res.status === 404 ? null : res.body;
|
|
145
169
|
}
|
|
146
170
|
case "projects": {
|
|
147
|
-
const res = await api.getJson("/api/v1/projects", {
|
|
171
|
+
const res = await api.getJson("/api/v1/projects", {
|
|
172
|
+
query: { limit: f.limit },
|
|
173
|
+
});
|
|
148
174
|
return rowsOf(res.body).slice(0, f.limit);
|
|
149
175
|
}
|
|
150
176
|
case "cycles": {
|
|
151
|
-
const res = await api.getJson("/api/v1/cycles", {
|
|
177
|
+
const res = await api.getJson("/api/v1/cycles", {
|
|
178
|
+
query: { team: f.team, limit: f.limit },
|
|
179
|
+
});
|
|
152
180
|
return rowsOf(res.body);
|
|
153
181
|
}
|
|
154
182
|
case "search": {
|
|
155
183
|
const q = rest.join(" ").trim();
|
|
156
184
|
if (!q)
|
|
157
185
|
throw new UsageError("search needs terms: query search <terms>");
|
|
158
|
-
const res = await api.getJson("/api/v1/search", {
|
|
186
|
+
const res = await api.getJson("/api/v1/search", {
|
|
187
|
+
query: { q, limit: f.limit },
|
|
188
|
+
});
|
|
159
189
|
return searchRows(res.body);
|
|
160
190
|
}
|
|
161
191
|
case "changes": {
|
|
@@ -191,7 +221,11 @@ async function readChanges(api, since, limit) {
|
|
|
191
221
|
});
|
|
192
222
|
const head = res.headers.get(HEAD_SEQ_HEADER);
|
|
193
223
|
if (res.status !== 409) {
|
|
194
|
-
return {
|
|
224
|
+
return {
|
|
225
|
+
since: Number(resolved),
|
|
226
|
+
head: head === null ? null : Number(head),
|
|
227
|
+
changes: res.body,
|
|
228
|
+
};
|
|
195
229
|
}
|
|
196
230
|
const where = head === null ? "" : ` The feed's live cursor is ${head}.`;
|
|
197
231
|
throw new CliError(`cursor ${resolved} is not usable: it is either past the feed's head or no longer in the change log, which keeps only recent changes.${where} Re-run with \`--since head\` to start from now, or with a cursor the feed still holds.`, "changefeed-resync", 1, 409);
|
|
@@ -199,7 +233,10 @@ async function readChanges(api, since, limit) {
|
|
|
199
233
|
/** The live head, read off any `/changes` response's own header — a 409 carries it too, which is
|
|
200
234
|
* what makes this work on a tenant whose log has rotated past every cursor the caller could guess. */
|
|
201
235
|
async function headCursor(api) {
|
|
202
|
-
const probe = await api.getNdjson("/api/v1/changes", {
|
|
236
|
+
const probe = await api.getNdjson("/api/v1/changes", {
|
|
237
|
+
query: { since: "0", limit: 1 },
|
|
238
|
+
accept: [409],
|
|
239
|
+
});
|
|
203
240
|
const raw = probe.headers.get(HEAD_SEQ_HEADER);
|
|
204
241
|
const head = raw === null ? Number.NaN : Number(raw);
|
|
205
242
|
if (!Number.isFinite(head)) {
|
|
@@ -213,13 +250,39 @@ function needArg(rest, usage) {
|
|
|
213
250
|
throw new UsageError(`query ${usage}`);
|
|
214
251
|
return v;
|
|
215
252
|
}
|
|
253
|
+
/** CTC-4556 — refuse a page the cloud did not narrow by a filter the user asked for. Filtering it
|
|
254
|
+
* here would print the matches among the newest rows and call that the answer. */
|
|
255
|
+
function requireIssueScope(scope, f) {
|
|
256
|
+
const asked = [
|
|
257
|
+
["--team", "team_key", f.team],
|
|
258
|
+
["--state", "state_name", f.state],
|
|
259
|
+
["--project", "project", f.project],
|
|
260
|
+
];
|
|
261
|
+
for (const [flag, param, value] of asked) {
|
|
262
|
+
if (!value || scope?.includes(param))
|
|
263
|
+
continue;
|
|
264
|
+
throw new CliError(`the cloud did not apply ${flag} (${scope === null ? "it sent no x-mirror-scope header" : `it applied: ${scope.join(", ") || "nothing"}`}), so the page is not that scope; nothing printed. Read it with --source replica, or drop ${flag}`, "filter-not-applied", 1);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function trimmedFlag(args, name) {
|
|
268
|
+
const value = flagString(args, name)?.trim();
|
|
269
|
+
return value === "" ? undefined : value;
|
|
270
|
+
}
|
|
271
|
+
/** The mirror's comparison for team keys and state names: trimmed and lowercased, nothing more.
|
|
272
|
+
* "In Progress" and "In_Progress" stay different names. */
|
|
273
|
+
const sameName = (a, b) => String(a ?? "").trim().toLowerCase() === b.trim().toLowerCase();
|
|
216
274
|
export function applyFilters(rows, f) {
|
|
217
275
|
return rows.filter((r) => {
|
|
218
|
-
if (f.team &&
|
|
276
|
+
if (f.team &&
|
|
277
|
+
!(String(r.identifier ?? "")
|
|
278
|
+
.toUpperCase()
|
|
279
|
+
.startsWith(`${f.team.toUpperCase()}-`) ||
|
|
280
|
+
r.team_id === f.team ||
|
|
281
|
+
sameName(r.team_key, f.team)))
|
|
219
282
|
return false;
|
|
220
283
|
if (f.project && r.project_id !== f.project)
|
|
221
284
|
return false;
|
|
222
|
-
if (f.state &&
|
|
285
|
+
if (f.state && !sameName(r.state, f.state))
|
|
223
286
|
return false;
|
|
224
287
|
return true;
|
|
225
288
|
});
|
|
@@ -227,7 +290,12 @@ export function applyFilters(rows, f) {
|
|
|
227
290
|
/** The hub answers search in four groups (`issues`, `pulls`, `projects`, `initiatives`, the read
|
|
228
291
|
* model's SearchView). `rowsOf` returns only the first array it finds, so it dropped every match that
|
|
229
292
|
* was not a ticket. Every group comes back here, flattened, each row tagged with its `kind`. */
|
|
230
|
-
const SEARCH_GROUPS = [
|
|
293
|
+
const SEARCH_GROUPS = [
|
|
294
|
+
["issues", "issue"],
|
|
295
|
+
["pulls", "pull"],
|
|
296
|
+
["projects", "project"],
|
|
297
|
+
["initiatives", "initiative"],
|
|
298
|
+
];
|
|
231
299
|
export function searchRows(body) {
|
|
232
300
|
if (Array.isArray(body))
|
|
233
301
|
return body;
|
|
@@ -235,7 +303,12 @@ export function searchRows(body) {
|
|
|
235
303
|
return [];
|
|
236
304
|
const b = body;
|
|
237
305
|
if (SEARCH_GROUPS.some(([group]) => Array.isArray(b[group]))) {
|
|
238
|
-
return SEARCH_GROUPS.flatMap(([group, kind]) => Array.isArray(b[group])
|
|
306
|
+
return SEARCH_GROUPS.flatMap(([group, kind]) => Array.isArray(b[group])
|
|
307
|
+
? b[group].map((row) => ({
|
|
308
|
+
kind,
|
|
309
|
+
...row,
|
|
310
|
+
}))
|
|
311
|
+
: []);
|
|
239
312
|
}
|
|
240
313
|
return rowsOf(body);
|
|
241
314
|
}
|