@beryl-so/cli 0.54.1 → 0.60.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.
- package/README.md +13 -5
- package/dist/commands/environments.js +35 -2
- package/dist/commands/health.js +380 -19
- package/dist/commands/runs.js +2 -2
- package/dist/commands/tests.js +71 -8
- package/dist/commands/tracker.js +295 -0
- package/dist/commands/workspaces.js +10 -4
- package/dist/http.js +2 -2
- package/dist/registry/index.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -191,7 +191,7 @@ Manage a project's environments: the URLs and auth Beryl runs tests against.
|
|
|
191
191
|
| `beryl envs list` | List a project's environments | `envs_list` |
|
|
192
192
|
| `beryl envs get <env-id>` | Show one environment | `envs_get` |
|
|
193
193
|
| `beryl envs create <name> <url>` | Add an environment (e.g. staging) to a project | `envs_create` |
|
|
194
|
-
| `beryl envs update <env-id>` | Update an environment's name, URL, or
|
|
194
|
+
| `beryl envs update <env-id>` | Update an environment's name, URL, auth settings, Site Health screen sizes, schedule, CI and pages, or issue tracker settings | `envs_update` |
|
|
195
195
|
| `beryl envs delete <env-id>` | Delete an environment | `envs_delete` |
|
|
196
196
|
|
|
197
197
|
### schedule
|
|
@@ -223,7 +223,8 @@ Author, inspect, version, and heal a project's tests: the checks Beryl runs on e
|
|
|
223
223
|
| `beryl tests set-groups <test-id>` | Replace the groups a test belongs to | `tests_set_groups` |
|
|
224
224
|
| `beryl tests set-access <test-id> <access>` | Mark a test read or write | `tests_set_access` |
|
|
225
225
|
| `beryl tests add-groups <test-ids...>` | Add groups to one or more tests, keeping the groups they already have | `tests_add_groups` |
|
|
226
|
-
| `beryl tests quarantine <
|
|
226
|
+
| `beryl tests quarantine <state> <test-ids...>` | Mute flaky tests: they keep running, but their failures stop failing the run | `tests_quarantine` |
|
|
227
|
+
| `beryl tests events <test-id>` | Who quarantined, grouped or renamed a test, and why | `tests_events` |
|
|
227
228
|
| `beryl tests delete <test-ids...>` | Delete tests, their version history, and their results | `tests_delete` |
|
|
228
229
|
| `beryl tests recompile <test-id>` | Validate + verify an edited plan against the live site before persisting | `tests_recompile` |
|
|
229
230
|
| `beryl tests versions <test-id>` | List a test's version history | `tests_versions` |
|
|
@@ -269,14 +270,21 @@ Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and downlo
|
|
|
269
270
|
|
|
270
271
|
### health
|
|
271
272
|
|
|
272
|
-
Site Health, or how your site reads to search engines and visitors: content, speed,
|
|
273
|
+
Site Health, or how your site reads to search engines and visitors: content, speed, links, security, and a visual check of layout at phone, tablet and desktop sizes, graded from a real check of your live pages.
|
|
273
274
|
|
|
274
275
|
`beryl health` with no subcommand runs `health get`.
|
|
275
276
|
|
|
276
277
|
| Command | Summary | MCP tool |
|
|
277
278
|
| --- | --- | --- |
|
|
278
|
-
| `beryl health get` | Show the latest Site Health report
|
|
279
|
-
| `beryl health run` | Run a fresh Site Health check
|
|
279
|
+
| `beryl health get` | Show the latest Site Health report, or one report by id | `health_get` |
|
|
280
|
+
| `beryl health run` | Run a fresh Site Health check, or a visual check of one page | `health_run` |
|
|
281
|
+
| `beryl health intended <key>` | Mark a visual finding as intended, so it stops counting against the grade | `health_intended` |
|
|
282
|
+
| `beryl health tracker status` | Show how Site Health files findings as issue tracker tickets for an environment | `health_tracker_status` |
|
|
283
|
+
| `beryl health tracker tickets` | List the findings Site Health tracks in the issue tracker, with their tickets | `health_tracker_tickets` |
|
|
284
|
+
| `beryl health tracker connect` | Get the link that connects an issue tracker to the workspace | `health_tracker_connect` |
|
|
285
|
+
| `beryl health tracker disconnect` | Remove the workspace's issue tracker connection | `health_tracker_disconnect` |
|
|
286
|
+
| `beryl health tracker teams` | List the teams of the connected issue tracker | `health_tracker_teams` |
|
|
287
|
+
| `beryl health tracker projects` | List a tracker team's projects | `health_tracker_projects` |
|
|
280
288
|
|
|
281
289
|
### explorations
|
|
282
290
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { UsageError } from "../errors.js";
|
|
2
|
-
import {
|
|
2
|
+
import { HEALTH_RUN_SETTING_FLAGS, VIEWPORTS, runSettingsFromFlags } from "./health.js";
|
|
3
|
+
import { TRACKER_SETTING_FLAGS, trackerSettingsFromFlags } from "./tracker.js";
|
|
4
|
+
import { arg, findGroup, flagBool, flagNum, flagStr, flagStrings, projectPath } from "./util.js";
|
|
3
5
|
const SCHEDULE_GROUPS_FLAG = "Comma-separated group names or ids the schedule targets (the run covers the union of their active tests at fire time); omit to run every active test";
|
|
4
6
|
const SCHEDULE_CADENCE_FLAGS = [
|
|
5
7
|
{
|
|
@@ -65,13 +67,30 @@ export const environmentCommands = [
|
|
|
65
67
|
},
|
|
66
68
|
{
|
|
67
69
|
name: "envs update",
|
|
68
|
-
summary: "Update an environment's name, URL, or
|
|
70
|
+
summary: "Update an environment's name, URL, auth settings, Site Health screen sizes, schedule, CI and pages, or issue tracker settings",
|
|
69
71
|
scope: "project",
|
|
70
72
|
args: [{ name: "env-id", description: "Environment id", required: true }],
|
|
71
73
|
flags: [
|
|
72
74
|
{ name: "name", type: "string", description: "New name" },
|
|
73
75
|
{ name: "url", type: "string", description: "New root URL" },
|
|
74
76
|
{ name: "auth", type: "string", enum: ["public", "gated"], description: "New auth choice" },
|
|
77
|
+
{
|
|
78
|
+
name: "viewport",
|
|
79
|
+
type: "strings",
|
|
80
|
+
enum: ["all", ...VIEWPORTS],
|
|
81
|
+
description: "Screen size the Site Health visual checks run at, repeatable; replaces the set. " +
|
|
82
|
+
"Pass `--viewport all` to reset to all five.",
|
|
83
|
+
},
|
|
84
|
+
...HEALTH_RUN_SETTING_FLAGS,
|
|
85
|
+
...TRACKER_SETTING_FLAGS,
|
|
86
|
+
],
|
|
87
|
+
examples: [
|
|
88
|
+
"beryl envs update <env-id> --tracker linear --tracker-team <team-id>",
|
|
89
|
+
"beryl envs update <env-id> --tracker-threshold medium --tracker-max-tickets 5",
|
|
90
|
+
"beryl envs update <env-id> --tracker-priority high=urgent --tracker-pause on",
|
|
91
|
+
"beryl envs update <env-id> --health-schedule daily --health-time 06:00 --health-timezone Europe/Berlin",
|
|
92
|
+
"beryl envs update <env-id> --health-pages crawl --health-max-pages 25 --health-exclude '/blog/*'",
|
|
93
|
+
"beryl envs update <env-id> --health-ci main_only --health-ci-fail-on medium",
|
|
75
94
|
],
|
|
76
95
|
async run(ctx, input) {
|
|
77
96
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
@@ -82,6 +101,20 @@ export const environmentCommands = [
|
|
|
82
101
|
body.root_url = flagStr(input, "url");
|
|
83
102
|
if (flagStr(input, "auth"))
|
|
84
103
|
body.requires_auth_choice = flagStr(input, "auth");
|
|
104
|
+
const viewports = flagStrings(input, "viewport");
|
|
105
|
+
if (viewports) {
|
|
106
|
+
const bad = viewports.find((v) => v !== "all" && !VIEWPORTS.includes(v));
|
|
107
|
+
if (bad) {
|
|
108
|
+
throw new UsageError(`--viewport must be one of: all, ${VIEWPORTS.join(", ")}`);
|
|
109
|
+
}
|
|
110
|
+
body.site_health_viewports = viewports.includes("all") ? [] : viewports;
|
|
111
|
+
}
|
|
112
|
+
const tracker = trackerSettingsFromFlags(input);
|
|
113
|
+
if (tracker)
|
|
114
|
+
body.site_health_tracker = tracker;
|
|
115
|
+
const runs = runSettingsFromFlags(input);
|
|
116
|
+
if (runs)
|
|
117
|
+
body.site_health_runs = runs;
|
|
85
118
|
if (Object.keys(body).length === 0)
|
|
86
119
|
throw new UsageError("Nothing to update");
|
|
87
120
|
return {
|
package/dist/commands/health.js
CHANGED
|
@@ -1,43 +1,404 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { UsageError } from "../errors.js";
|
|
2
|
+
import { arg, flagBool, flagNum, flagStr, flagStrings, projectPath } from "./util.js";
|
|
2
3
|
const ENV_FLAG = {
|
|
3
4
|
name: "env",
|
|
4
5
|
type: "string",
|
|
5
6
|
description: "Environment id (defaults to the project's default environment)",
|
|
6
7
|
};
|
|
8
|
+
export const VIEWPORTS = [
|
|
9
|
+
"phone",
|
|
10
|
+
"tablet_portrait",
|
|
11
|
+
"tablet_landscape",
|
|
12
|
+
"desktop",
|
|
13
|
+
"desktop_wide",
|
|
14
|
+
];
|
|
15
|
+
const SCREENSHOTS_FLAG = {
|
|
16
|
+
name: "screenshots",
|
|
17
|
+
type: "boolean",
|
|
18
|
+
description: "Include the annotated screenshots (as images over MCP, data URIs in --json)",
|
|
19
|
+
};
|
|
20
|
+
// Annotated evidence is spent from the agent's context window; the top few carry it.
|
|
21
|
+
const MAX_MCP_IMAGES = 3;
|
|
22
|
+
const WAIT_POLL_MS = 5000;
|
|
23
|
+
const WAIT_LIMIT_MS = 6 * 60 * 1000;
|
|
24
|
+
function toImage(dataUri) {
|
|
25
|
+
const [, mimeType, data] = /^data:([^;]+);base64,(.*)$/.exec(dataUri) ?? [];
|
|
26
|
+
return mimeType && data ? { data, mimeType } : undefined;
|
|
27
|
+
}
|
|
28
|
+
// Over MCP the screenshots go out as image blocks and never as inline base64 text.
|
|
29
|
+
function present(ctx, report, withShots) {
|
|
30
|
+
if (!withShots || !ctx.mcp)
|
|
31
|
+
return { data: report };
|
|
32
|
+
const images = [];
|
|
33
|
+
for (const cat of report.categories ?? []) {
|
|
34
|
+
for (const f of cat.findings ?? []) {
|
|
35
|
+
if (f.screenshot && !f.intended && f.status !== "pass" && images.length < MAX_MCP_IMAGES) {
|
|
36
|
+
const img = toImage(f.screenshot);
|
|
37
|
+
if (img)
|
|
38
|
+
images.push(img);
|
|
39
|
+
}
|
|
40
|
+
delete f.screenshot;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const stripped = { ...report, screenshots: null };
|
|
44
|
+
return { data: stripped, images: images.length ? images : undefined };
|
|
45
|
+
}
|
|
46
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
47
|
+
// A run fans out to up to 100 pages, each on its own worker.
|
|
48
|
+
const CI_WAIT_LIMIT_MS = 30 * 60 * 1000;
|
|
49
|
+
const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
|
50
|
+
const ON_OFF = ["on", "off"];
|
|
51
|
+
/** `envs update` flags for when Site Health runs and which pages it checks. */
|
|
52
|
+
export const HEALTH_RUN_SETTING_FLAGS = [
|
|
53
|
+
{
|
|
54
|
+
name: "health-schedule",
|
|
55
|
+
type: "string",
|
|
56
|
+
enum: ["off", "daily", "weekly"],
|
|
57
|
+
description: "Run Site Health on a schedule (default off)",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "health-time",
|
|
61
|
+
type: "string",
|
|
62
|
+
placeholder: "HH:MM",
|
|
63
|
+
description: "Time of day scheduled runs start, 24h, in --health-timezone (default 09:00)",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "health-day",
|
|
67
|
+
type: "string",
|
|
68
|
+
enum: DAYS,
|
|
69
|
+
description: "Day weekly runs start (default mon)",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "health-timezone",
|
|
73
|
+
type: "string",
|
|
74
|
+
description: "IANA timezone for the schedule, e.g. Europe/Berlin (default UTC)",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "health-ci",
|
|
78
|
+
type: "string",
|
|
79
|
+
enum: ["off", "every_build", "main_only", "deploy_only"],
|
|
80
|
+
description: "Which CI builds `health run --ci` checks; other builds skip and pass (default off)",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "health-ci-fail-on",
|
|
84
|
+
type: "string",
|
|
85
|
+
enum: ["high", "medium", "never"],
|
|
86
|
+
description: "Lowest finding severity that fails the CI build; never reports only (default high)",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "health-ci-tickets",
|
|
90
|
+
type: "string",
|
|
91
|
+
enum: ON_OFF,
|
|
92
|
+
description: "CI runs file tracker tickets; off leaves the findings to the PR (default off)",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "health-pages",
|
|
96
|
+
type: "string",
|
|
97
|
+
enum: ["homepage", "paths", "sitemap", "crawl"],
|
|
98
|
+
description: "Pages a run checks: the homepage, --health-path list, sitemap.xml, or a crawl from " +
|
|
99
|
+
"the homepage (default homepage)",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "health-path",
|
|
103
|
+
type: "strings",
|
|
104
|
+
description: "Page path for the paths mode, repeatable; replaces the list. `none` clears it",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "health-crawl-depth",
|
|
108
|
+
type: "number",
|
|
109
|
+
description: "Link hops from the homepage in crawl mode, 1-5 (default 2)",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "health-max-pages",
|
|
113
|
+
type: "number",
|
|
114
|
+
description: "Most pages one run checks, homepage included, 1-100 (default 25)",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "health-include",
|
|
118
|
+
type: "strings",
|
|
119
|
+
description: "Path pattern a page must match, e.g. '/blog/*', repeatable; replaces the list. `none` clears it",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "health-exclude",
|
|
123
|
+
type: "strings",
|
|
124
|
+
description: "Path pattern to skip, repeatable; replaces the list. `none` clears it",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "health-logged-in",
|
|
128
|
+
type: "strings",
|
|
129
|
+
description: "Path pattern checked signed in with the environment's saved test sign-in, repeatable; " +
|
|
130
|
+
"the rest run logged out. `none` clears it",
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "health-retries",
|
|
134
|
+
type: "number",
|
|
135
|
+
description: "Extra attempts when a page check fails or times out, 0-3 (default 1)",
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
function list(input, name) {
|
|
139
|
+
const values = flagStrings(input, name);
|
|
140
|
+
if (!values)
|
|
141
|
+
return undefined;
|
|
142
|
+
return values.includes("none") ? [] : values;
|
|
143
|
+
}
|
|
144
|
+
/** The `site_health_runs` PATCH body from `envs update` flags; undefined when none were given. */
|
|
145
|
+
export function runSettingsFromFlags(input) {
|
|
146
|
+
const body = {};
|
|
147
|
+
const strings = [
|
|
148
|
+
["health-schedule", "schedule"],
|
|
149
|
+
["health-timezone", "schedule_timezone"],
|
|
150
|
+
["health-ci", "ci_mode"],
|
|
151
|
+
["health-ci-fail-on", "ci_fail_on"],
|
|
152
|
+
["health-pages", "pages"],
|
|
153
|
+
];
|
|
154
|
+
for (const [flag, field] of strings) {
|
|
155
|
+
const v = flagStr(input, flag);
|
|
156
|
+
if (v !== undefined)
|
|
157
|
+
body[field] = v;
|
|
158
|
+
}
|
|
159
|
+
const time = flagStr(input, "health-time");
|
|
160
|
+
if (time !== undefined) {
|
|
161
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(time);
|
|
162
|
+
if (!m || Number(m[1]) > 23 || Number(m[2]) > 59) {
|
|
163
|
+
throw new UsageError("--health-time must be HH:MM, 24h, e.g. 09:30");
|
|
164
|
+
}
|
|
165
|
+
body.schedule_hour = Number(m[1]);
|
|
166
|
+
body.schedule_minute = Number(m[2]);
|
|
167
|
+
}
|
|
168
|
+
const day = flagStr(input, "health-day");
|
|
169
|
+
if (day !== undefined) {
|
|
170
|
+
const idx = DAYS.indexOf(day.toLowerCase());
|
|
171
|
+
if (idx < 0)
|
|
172
|
+
throw new UsageError(`--health-day must be one of: ${DAYS.join(", ")}`);
|
|
173
|
+
body.schedule_day_of_week = idx;
|
|
174
|
+
}
|
|
175
|
+
const tickets = flagStr(input, "health-ci-tickets");
|
|
176
|
+
if (tickets !== undefined) {
|
|
177
|
+
if (!ON_OFF.includes(tickets))
|
|
178
|
+
throw new UsageError("--health-ci-tickets must be on or off");
|
|
179
|
+
body.ci_files_tickets = tickets === "on";
|
|
180
|
+
}
|
|
181
|
+
const lists = [
|
|
182
|
+
["health-path", "paths"],
|
|
183
|
+
["health-include", "include"],
|
|
184
|
+
["health-exclude", "exclude"],
|
|
185
|
+
["health-logged-in", "logged_in"],
|
|
186
|
+
];
|
|
187
|
+
for (const [flag, field] of lists) {
|
|
188
|
+
const v = list(input, flag);
|
|
189
|
+
if (v !== undefined)
|
|
190
|
+
body[field] = v;
|
|
191
|
+
}
|
|
192
|
+
const numbers = [
|
|
193
|
+
["health-crawl-depth", "crawl_depth"],
|
|
194
|
+
["health-max-pages", "max_pages"],
|
|
195
|
+
["health-retries", "retries"],
|
|
196
|
+
];
|
|
197
|
+
for (const [flag, field] of numbers) {
|
|
198
|
+
const v = flagNum(input, flag);
|
|
199
|
+
if (v !== undefined)
|
|
200
|
+
body[field] = v;
|
|
201
|
+
}
|
|
202
|
+
return Object.keys(body).length ? body : undefined;
|
|
203
|
+
}
|
|
204
|
+
function ciBranch() {
|
|
205
|
+
const env = process.env;
|
|
206
|
+
return (env.GITHUB_HEAD_REF ||
|
|
207
|
+
env.GITHUB_REF_NAME ||
|
|
208
|
+
env.CI_COMMIT_REF_NAME ||
|
|
209
|
+
env.CIRCLE_BRANCH ||
|
|
210
|
+
env.BITBUCKET_BRANCH ||
|
|
211
|
+
env.BRANCH_NAME ||
|
|
212
|
+
undefined);
|
|
213
|
+
}
|
|
214
|
+
// Markdown, so a CI step can post it as the PR comment as is.
|
|
215
|
+
export function ciMarkdown(run) {
|
|
216
|
+
if (run.skipped)
|
|
217
|
+
return `Beryl Site Health: skipped (${run.reason ?? "CI mode"}).`;
|
|
218
|
+
const f = run.findings ?? {};
|
|
219
|
+
const verdict = run.status === "failed"
|
|
220
|
+
? "failed to complete"
|
|
221
|
+
: run.gate?.passed
|
|
222
|
+
? "passed"
|
|
223
|
+
: `failed (findings at or above ${run.gate?.fail_on})`;
|
|
224
|
+
const lines = [
|
|
225
|
+
`**Beryl Site Health ${verdict}**`,
|
|
226
|
+
"",
|
|
227
|
+
`${run.pages?.length ?? 0} page(s) checked. Open findings: ${f.high ?? 0} high, ` +
|
|
228
|
+
`${f.medium ?? 0} medium, ${f.low ?? 0} low.`,
|
|
229
|
+
];
|
|
230
|
+
if (run.failing?.length) {
|
|
231
|
+
lines.push("", "| Page | Severity | Finding |", "| --- | --- | --- |");
|
|
232
|
+
for (const x of run.failing)
|
|
233
|
+
lines.push(`| ${x.page_path} | ${x.severity} | ${x.title} |`);
|
|
234
|
+
}
|
|
235
|
+
const broken = (run.pages ?? []).filter((p) => p.status === "failed" || p.problem);
|
|
236
|
+
if (broken.length) {
|
|
237
|
+
lines.push("", "Pages that could not be checked:");
|
|
238
|
+
for (const p of broken) {
|
|
239
|
+
lines.push(`- ${p.page_path}: ${p.problem ?? p.error ?? "check failed"}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
async function runCi(ctx, input, base) {
|
|
245
|
+
let run = (await ctx.client.post(`${base}/runs`, {
|
|
246
|
+
trigger: "ci",
|
|
247
|
+
branch: flagStr(input, "branch") ?? ciBranch(),
|
|
248
|
+
deploy: flagBool(input, "deploy"),
|
|
249
|
+
}, { environment_id: flagStr(input, "env") }));
|
|
250
|
+
if (run.skipped)
|
|
251
|
+
return { data: run, human: ciMarkdown(run) };
|
|
252
|
+
const deadline = Date.now() + CI_WAIT_LIMIT_MS;
|
|
253
|
+
while (run.status === "queued" || run.status === "running") {
|
|
254
|
+
if (Date.now() > deadline) {
|
|
255
|
+
return { data: run, human: "Beryl Site Health: timed out waiting for the run.", exitCode: 1 };
|
|
256
|
+
}
|
|
257
|
+
await sleep(WAIT_POLL_MS);
|
|
258
|
+
run = (await ctx.client.get(`${base}/runs/${run.run_id}`));
|
|
259
|
+
}
|
|
260
|
+
return { data: run, human: ciMarkdown(run), exitCode: run.gate?.passed ? 0 : 1 };
|
|
261
|
+
}
|
|
7
262
|
export const healthCommands = [
|
|
8
263
|
{
|
|
9
264
|
name: "health get",
|
|
10
265
|
groupDefault: true,
|
|
11
266
|
groupSummary: "Site Health, or how your site reads to search engines and visitors: content, " +
|
|
12
|
-
"speed,
|
|
13
|
-
|
|
267
|
+
"speed, links, security, and a visual check of layout at phone, tablet and desktop " +
|
|
268
|
+
"sizes, graded from a real check of your live pages.",
|
|
269
|
+
summary: "Show the latest Site Health report, or one report by id",
|
|
14
270
|
description: "A check runs automatically when a project or environment gets its URL. While one " +
|
|
15
|
-
"is in flight the report comes back with status queued/running and no grades yet
|
|
16
|
-
"call again to pick up the finished result."
|
|
271
|
+
"is in flight the report comes back with status queued/running and no grades yet, " +
|
|
272
|
+
"plus the `stage` a running check is on; call again to pick up the finished result. Visual findings sit in the `layout` " +
|
|
273
|
+
"category, which also grades them per screen size (`screens`: phone, tablet, " +
|
|
274
|
+
"desktop) and as `mobile` (phone and tablet only). Each carries a `key`, the " +
|
|
275
|
+
"`selector`, measured values, the `viewports` it shows at and a fix hint in " +
|
|
276
|
+
"`recommendation`. The `performance` category grades Google PageSpeed on mobile " +
|
|
277
|
+
"and on desktop separately (`speeds`, each with its own Core Web Vitals) and takes " +
|
|
278
|
+
"the worse of the two; one that couldn't be measured is left out. Pass --report for a single-page check started with " +
|
|
279
|
+
"`health run --path`.",
|
|
17
280
|
scope: "project",
|
|
18
|
-
flags: [
|
|
281
|
+
flags: [
|
|
282
|
+
ENV_FLAG,
|
|
283
|
+
{ name: "report", type: "string", description: "Report id (from `health run`)" },
|
|
284
|
+
{
|
|
285
|
+
name: "run",
|
|
286
|
+
type: "string",
|
|
287
|
+
description: "Run id: every page of one run, finding counts and the CI gate verdict",
|
|
288
|
+
},
|
|
289
|
+
SCREENSHOTS_FLAG,
|
|
290
|
+
],
|
|
19
291
|
async run(ctx, input) {
|
|
20
292
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
293
|
+
const runId = flagStr(input, "run");
|
|
294
|
+
if (runId) {
|
|
295
|
+
return {
|
|
296
|
+
data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/seo-report/runs/${runId}`),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
const withShots = flagBool(input, "screenshots");
|
|
300
|
+
const reportId = flagStr(input, "report");
|
|
301
|
+
const base = `${projectPath(workspaceId, projectId)}/seo-report`;
|
|
302
|
+
const report = (await ctx.client.get(reportId ? `${base}/${reportId}` : base, {
|
|
303
|
+
environment_id: reportId ? undefined : flagStr(input, "env"),
|
|
304
|
+
screenshots: withShots ? "true" : "false",
|
|
305
|
+
}));
|
|
306
|
+
return present(ctx, report, withShots);
|
|
26
307
|
},
|
|
27
308
|
},
|
|
28
309
|
{
|
|
29
310
|
name: "health run",
|
|
30
|
-
summary: "Run a fresh Site Health check
|
|
31
|
-
description: "Queues a
|
|
32
|
-
"
|
|
311
|
+
summary: "Run a fresh Site Health check, or a visual check of one page",
|
|
312
|
+
description: "Queues a check and returns immediately (or, with --wait, once it finishes; a check " +
|
|
313
|
+
"takes one to three minutes). Without --path it is the full Site Health run of the " +
|
|
314
|
+
"environment URL. With --path it runs only the visual checks (clipping, overlap, tap " +
|
|
315
|
+
"targets, scroll traps, content stability, scroll-through) on that page, logged out " +
|
|
316
|
+
"unless it matches the environment's --health-logged-in patterns, " +
|
|
317
|
+
"at the chosen --viewport sizes (default: the environment's configured set). Returns " +
|
|
318
|
+
"the in-flight report instead of stacking a second one of the same scope. A full run " +
|
|
319
|
+
"also checks the environment's configured pages (see `envs update --health-pages`); " +
|
|
320
|
+
"each page gets its own report, grouped under the homepage report's `run_id`.\n\n" +
|
|
321
|
+
"With --ci it starts a CI run, waits for every page, prints a Markdown summary you can " +
|
|
322
|
+
"post as the PR comment, and exits 1 when findings at or above the environment's " +
|
|
323
|
+
"fail-on severity exist, a page couldn't be checked, or the run failed. Builds that the " +
|
|
324
|
+
"environment's CI mode doesn't cover skip and exit 0. CI runs file tracker tickets only when --health-ci-tickets is on.",
|
|
325
|
+
scope: "project",
|
|
326
|
+
flags: [
|
|
327
|
+
ENV_FLAG,
|
|
328
|
+
{ name: "path", type: "string", description: "Page path to check, e.g. /pricing" },
|
|
329
|
+
{
|
|
330
|
+
name: "viewport",
|
|
331
|
+
type: "strings",
|
|
332
|
+
enum: VIEWPORTS,
|
|
333
|
+
description: "Screen size, repeatable: phone (390x844), tablet_portrait (768x1024), " +
|
|
334
|
+
"tablet_landscape (1024x768), desktop (1440x900), desktop_wide (1920x1080)",
|
|
335
|
+
},
|
|
336
|
+
{ name: "wait", type: "boolean", description: "Wait for the check to finish" },
|
|
337
|
+
SCREENSHOTS_FLAG,
|
|
338
|
+
{
|
|
339
|
+
name: "ci",
|
|
340
|
+
type: "boolean",
|
|
341
|
+
description: "CI run: wait, print a PR-ready summary, exit 1 at or above the fail-on severity",
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
name: "branch",
|
|
345
|
+
type: "string",
|
|
346
|
+
description: "Branch of the CI build (default: read from the CI environment)",
|
|
347
|
+
},
|
|
348
|
+
{ name: "deploy", type: "boolean", description: "The CI build is a deploy (for --health-ci deploy_only)" },
|
|
349
|
+
],
|
|
350
|
+
examples: [
|
|
351
|
+
"beryl health run",
|
|
352
|
+
"beryl health run --path /pricing --viewport phone --wait",
|
|
353
|
+
"beryl health run --ci",
|
|
354
|
+
"beryl health run --ci --deploy --env <env-id>",
|
|
355
|
+
],
|
|
356
|
+
async run(ctx, input) {
|
|
357
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
358
|
+
const base = `${projectPath(workspaceId, projectId)}/seo-report`;
|
|
359
|
+
const path = flagStr(input, "path");
|
|
360
|
+
const viewports = flagStrings(input, "viewport");
|
|
361
|
+
if (flagBool(input, "ci")) {
|
|
362
|
+
if (path || viewports)
|
|
363
|
+
throw new UsageError("--ci runs the configured pages; drop --path and --viewport");
|
|
364
|
+
return runCi(ctx, input, base);
|
|
365
|
+
}
|
|
366
|
+
const bad = viewports?.find((v) => !VIEWPORTS.includes(v));
|
|
367
|
+
if (bad)
|
|
368
|
+
throw new UsageError(`--viewport must be one of: ${VIEWPORTS.join(", ")}`);
|
|
369
|
+
const body = path || viewports ? { path, viewports } : undefined;
|
|
370
|
+
let report = (await ctx.client.post(base, body, {
|
|
371
|
+
environment_id: flagStr(input, "env"),
|
|
372
|
+
}));
|
|
373
|
+
const withShots = flagBool(input, "screenshots");
|
|
374
|
+
if (!flagBool(input, "wait"))
|
|
375
|
+
return { data: report };
|
|
376
|
+
const deadline = Date.now() + WAIT_LIMIT_MS;
|
|
377
|
+
while (report.status === "queued" || report.status === "running") {
|
|
378
|
+
if (Date.now() > deadline)
|
|
379
|
+
return { data: report, exitCode: 1 };
|
|
380
|
+
await sleep(WAIT_POLL_MS);
|
|
381
|
+
report = (await ctx.client.get(`${base}/${report.id}`, {
|
|
382
|
+
screenshots: withShots ? "true" : "false",
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
const result = present(ctx, report, withShots);
|
|
386
|
+
return report.status === "failed" ? { ...result, exitCode: 1 } : result;
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
name: "health intended",
|
|
391
|
+
summary: "Mark a visual finding as intended, so it stops counting against the grade",
|
|
392
|
+
description: "Takes the finding's `key` from `health get`. The finding stays in the report, flagged " +
|
|
393
|
+
"intended, and is left out of the grade on this report and every later run of the " +
|
|
394
|
+
"environment. --clear removes the mark.",
|
|
33
395
|
scope: "project",
|
|
34
|
-
|
|
35
|
-
|
|
396
|
+
args: [{ name: "key", description: "Finding key", required: true }],
|
|
397
|
+
flags: [ENV_FLAG, { name: "clear", type: "boolean", description: "Remove the mark" }],
|
|
36
398
|
async run(ctx, input) {
|
|
37
399
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
};
|
|
400
|
+
const report = (await ctx.client.put(`${projectPath(workspaceId, projectId)}/seo-report/intended`, { key: arg(input, "key"), intended: !flagBool(input, "clear") }, { environment_id: flagStr(input, "env") }));
|
|
401
|
+
return present(ctx, report, false);
|
|
41
402
|
},
|
|
42
403
|
},
|
|
43
404
|
];
|
package/dist/commands/runs.js
CHANGED
|
@@ -585,8 +585,8 @@ export const runCommands = [
|
|
|
585
585
|
{
|
|
586
586
|
name: "status",
|
|
587
587
|
type: "string",
|
|
588
|
-
description: "With --page, show only this bucket: passed, failed, errored, cancelled,
|
|
589
|
-
"quarantined",
|
|
588
|
+
description: "With --page, show only this bucket: passed, failed, errored, cancelled, " +
|
|
589
|
+
"quarantined, or unstable (currently unstable in the run's environment)",
|
|
590
590
|
},
|
|
591
591
|
{
|
|
592
592
|
name: "group",
|
package/dist/commands/tests.js
CHANGED
|
@@ -9,6 +9,29 @@ import { confirmInstall, installCommandsFor, installPlaywright, playwrightGaps,
|
|
|
9
9
|
import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
|
|
10
10
|
import { arg, argList, flagBool, flagNum, flagStr, flagStrings, inGroup, projectPath, readJsonFlag, } from "./util.js";
|
|
11
11
|
const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
|
|
12
|
+
const EVENT_VERBS = {
|
|
13
|
+
QUARANTINED: "quarantined",
|
|
14
|
+
UNQUARANTINED: "released from quarantine",
|
|
15
|
+
GROUPS_ADDED: "added to",
|
|
16
|
+
GROUPS_REMOVED: "removed from",
|
|
17
|
+
RENAMED: "renamed",
|
|
18
|
+
};
|
|
19
|
+
function eventLine(e) {
|
|
20
|
+
const when = e.created_at.slice(0, 19).replace("T", " ");
|
|
21
|
+
const verb = EVENT_VERBS[e.action] ?? e.action.toLowerCase();
|
|
22
|
+
const who = e.actor_name ? `by ${e.actor_name}` : "automatically";
|
|
23
|
+
const d = e.detail ?? {};
|
|
24
|
+
const groups = Array.isArray(d.groups) ? ` ${d.groups.join(", ")}` : "";
|
|
25
|
+
// The reason is the point: an automatic mute has to say what triggered it.
|
|
26
|
+
const why = d.failures !== undefined
|
|
27
|
+
? ` (${d.failures} failures in ${d.days} days, run ${d.run_id})`
|
|
28
|
+
: d.passes !== undefined
|
|
29
|
+
? ` (${d.passes} clean passes)`
|
|
30
|
+
: d.from !== undefined
|
|
31
|
+
? ` ("${d.from}" to "${d.to}")`
|
|
32
|
+
: "";
|
|
33
|
+
return `${when} ${verb}${groups} ${who}${why}`;
|
|
34
|
+
}
|
|
12
35
|
// A duplicate-title 409 after a green replay is idempotent when the existing row
|
|
13
36
|
// holds the same plan (a retry after a lost response) — success, not an error.
|
|
14
37
|
// A different plan under the same title is a real conflict.
|
|
@@ -165,7 +188,8 @@ export const testCommands = [
|
|
|
165
188
|
{
|
|
166
189
|
name: "status",
|
|
167
190
|
type: "string",
|
|
168
|
-
description: "With --page, show only this bucket: passed, failed,
|
|
191
|
+
description: "With --page, show only this bucket: passed, failed, errored, cancelled, " +
|
|
192
|
+
"quarantined, or unstable (failed no longer includes errored)",
|
|
169
193
|
},
|
|
170
194
|
{
|
|
171
195
|
name: "ungrouped",
|
|
@@ -637,7 +661,7 @@ export const testCommands = [
|
|
|
637
661
|
},
|
|
638
662
|
{
|
|
639
663
|
name: "tests quarantine",
|
|
640
|
-
summary: "Mute
|
|
664
|
+
summary: "Mute flaky tests: they keep running, but their failures stop failing the run",
|
|
641
665
|
description: "A quarantined test still executes and its result is still recorded and visible. Its " +
|
|
642
666
|
"red lands in the run's quarantined_count and gates neither the run's verdict nor exit " +
|
|
643
667
|
"codes, so it can't red-light a deploy. Use " +
|
|
@@ -645,24 +669,63 @@ export const testCommands = [
|
|
|
645
669
|
"asking support to deactivate it (which stops it running at all). After the project's " +
|
|
646
670
|
"release threshold of consecutive clean passes (default 5) the test reports " +
|
|
647
671
|
"rehab_ready: a suggestion while auto quarantine is off, the actual release once " +
|
|
648
|
-
"`projects quarantine on` has turned it on. `off` un-quarantines."
|
|
672
|
+
"`projects quarantine on` has turned it on. `off` un-quarantines. Several ids are one " +
|
|
673
|
+
"call; tests already in the requested state are left alone rather than rejected. Who " +
|
|
674
|
+
"quarantined what is recorded, readable with `beryl tests events`.",
|
|
649
675
|
scope: "project",
|
|
650
676
|
args: [
|
|
651
|
-
{ name: "test-id", description: "Test id", required: true },
|
|
652
677
|
{ name: "state", description: "on | off", required: true },
|
|
678
|
+
{ name: "test-ids", description: "One or more test ids", required: true, variadic: true },
|
|
653
679
|
],
|
|
654
|
-
examples: ["beryl tests quarantine 4f… on", "beryl tests quarantine 4f…
|
|
680
|
+
examples: ["beryl tests quarantine on 4f…", "beryl tests quarantine on 4f… 9a… 1c…", "beryl tests quarantine off 4f…"],
|
|
655
681
|
async run(ctx, input) {
|
|
656
682
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
657
|
-
|
|
683
|
+
let state = arg(input, "state").toLowerCase();
|
|
684
|
+
let ids = argList(input, "test-ids");
|
|
685
|
+
// Older scripts wrote `tests quarantine <id> on`; keep them working.
|
|
658
686
|
if (state !== "on" && state !== "off") {
|
|
659
|
-
|
|
687
|
+
const trailing = ids[ids.length - 1]?.toLowerCase();
|
|
688
|
+
if (trailing === "on" || trailing === "off") {
|
|
689
|
+
ids = [arg(input, "state"), ...ids.slice(0, -1)];
|
|
690
|
+
state = trailing;
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
throw new UsageError(`Expected "on" or "off", got "${arg(input, "state")}"`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const quarantined = state === "on";
|
|
697
|
+
const only = ids.length === 1 ? ids[0] : undefined;
|
|
698
|
+
if (only !== undefined) {
|
|
699
|
+
return {
|
|
700
|
+
data: await ctx.client.patch(`${testPath(workspaceId, projectId, only)}/quarantine`, { quarantined }),
|
|
701
|
+
};
|
|
660
702
|
}
|
|
703
|
+
const data = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/tests/bulk-quarantine`, { test_case_ids: ids, quarantined }));
|
|
661
704
|
return {
|
|
662
|
-
data
|
|
705
|
+
data,
|
|
706
|
+
human: `${quarantined ? "Quarantined" : "Released"} ${data.updated} of ${ids.length} tests (the rest were already ${quarantined ? "quarantined" : "not"}).`,
|
|
663
707
|
};
|
|
664
708
|
},
|
|
665
709
|
},
|
|
710
|
+
{
|
|
711
|
+
name: "tests events",
|
|
712
|
+
summary: "Who quarantined, grouped or renamed a test, and why",
|
|
713
|
+
description: "Lifecycle history for one test, newest first: quarantine and release (by a person or " +
|
|
714
|
+
"by auto-quarantine, with the run and failure streak behind it), group changes, and " +
|
|
715
|
+
"renames. Plan edits are NOT here: those are versions, and `beryl tests versions` can " +
|
|
716
|
+
"restore them.",
|
|
717
|
+
scope: "project",
|
|
718
|
+
args: [{ name: "test-id", description: "Test id", required: true }],
|
|
719
|
+
flags: [{ name: "limit", type: "number", description: "How many events (default 100, max 500)" }],
|
|
720
|
+
examples: ["beryl tests events 4f…", "beryl tests events 4f… --limit 20"],
|
|
721
|
+
async run(ctx, input) {
|
|
722
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
723
|
+
const limit = flagNum(input, "limit");
|
|
724
|
+
const path = `${testPath(workspaceId, projectId, arg(input, "test-id"))}/events`;
|
|
725
|
+
const data = (await ctx.client.get(limit === undefined ? path : `${path}?limit=${limit}`));
|
|
726
|
+
return { data, human: data.length ? data.map(eventLine).join("\n") : "No changes recorded." };
|
|
727
|
+
},
|
|
728
|
+
},
|
|
666
729
|
{
|
|
667
730
|
name: "tests delete",
|
|
668
731
|
summary: "Delete tests, their version history, and their results",
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { UsageError } from "../errors.js";
|
|
2
|
+
import { flagNum, flagStr, flagStrings, projectPath } from "./util.js";
|
|
3
|
+
const PROVIDERS = ["linear"];
|
|
4
|
+
const SEVERITIES = ["high", "medium", "low"];
|
|
5
|
+
const PRIORITIES = ["urgent", "high", "medium", "low", "none"];
|
|
6
|
+
const ON_OFF = ["on", "off"];
|
|
7
|
+
const ENV_FLAG = {
|
|
8
|
+
name: "env",
|
|
9
|
+
type: "string",
|
|
10
|
+
description: "Environment id (defaults to the project's default environment)",
|
|
11
|
+
};
|
|
12
|
+
const PROVIDER_FLAG = {
|
|
13
|
+
name: "tracker",
|
|
14
|
+
type: "string",
|
|
15
|
+
enum: PROVIDERS,
|
|
16
|
+
description: "Issue tracker (default linear)",
|
|
17
|
+
};
|
|
18
|
+
/** `envs update` flags for the Site Health issue tracker settings. */
|
|
19
|
+
export const TRACKER_SETTING_FLAGS = [
|
|
20
|
+
{
|
|
21
|
+
name: "tracker",
|
|
22
|
+
type: "string",
|
|
23
|
+
enum: [...PROVIDERS, "off"],
|
|
24
|
+
description: "Tracker Site Health files tickets in; `off` stops filing for this environment " +
|
|
25
|
+
"(connect it first with `health tracker connect`)",
|
|
26
|
+
},
|
|
27
|
+
{ name: "tracker-team", type: "string", description: "Tracker team id (see `health tracker teams`)" },
|
|
28
|
+
{
|
|
29
|
+
name: "tracker-project",
|
|
30
|
+
type: "string",
|
|
31
|
+
description: "Tracker project id inside the team (see `health tracker projects`); `none` clears it",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "tracker-label",
|
|
35
|
+
type: "strings",
|
|
36
|
+
description: "Label put on every ticket, repeatable; replaces the set (default beryl)",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "tracker-priority",
|
|
40
|
+
type: "strings",
|
|
41
|
+
description: "Severity to tracker priority, repeatable, e.g. high=urgent. Priorities: " +
|
|
42
|
+
PRIORITIES.join(", ") +
|
|
43
|
+
" (default high=high, medium=medium, low=low)",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "tracker-threshold",
|
|
47
|
+
type: "string",
|
|
48
|
+
enum: SEVERITIES,
|
|
49
|
+
description: "Lowest severity filed automatically; the rest go to the digest (default high)",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "tracker-digest",
|
|
53
|
+
type: "string",
|
|
54
|
+
enum: ["off", "daily", "weekly"],
|
|
55
|
+
description: "Digest of findings that were not filed (default weekly)",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "tracker-digest-to",
|
|
59
|
+
type: "string",
|
|
60
|
+
enum: ["ticket", "slack"],
|
|
61
|
+
description: "Where the digest goes: one digest ticket, or the Slack alert channel (default ticket)",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "tracker-auto-close",
|
|
65
|
+
type: "string",
|
|
66
|
+
enum: ON_OFF,
|
|
67
|
+
description: "Close a ticket once a run no longer sees its finding (default on)",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "tracker-reopen",
|
|
71
|
+
type: "string",
|
|
72
|
+
enum: ON_OFF,
|
|
73
|
+
description: "Reopen the same ticket when a fixed finding comes back; off files a new one (default on)",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "tracker-reopen-closed",
|
|
77
|
+
type: "string",
|
|
78
|
+
enum: ON_OFF,
|
|
79
|
+
description: "Reopen a ticket closed in the tracker while the check still fails (default on)",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "tracker-max-tickets",
|
|
83
|
+
type: "number",
|
|
84
|
+
description: "New tickets one run may file, 0-100; the rest go to the digest (default 10)",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "tracker-assignee",
|
|
88
|
+
type: "string",
|
|
89
|
+
description: "Tracker user id (a person or an agent user) new tickets go to; `none` clears it",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "tracker-min-runs",
|
|
93
|
+
type: "number",
|
|
94
|
+
description: "Runs in a row a finding must show in before a manual run files it (default 1)",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "tracker-min-runs-scheduled",
|
|
98
|
+
type: "number",
|
|
99
|
+
description: "Same, for scheduled and CI runs (default 2)",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "tracker-pause",
|
|
103
|
+
type: "string",
|
|
104
|
+
enum: ON_OFF,
|
|
105
|
+
description: "Pause all ticket creation and updates without disconnecting",
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
function onOff(input, name) {
|
|
109
|
+
const v = flagStr(input, name);
|
|
110
|
+
if (v === undefined)
|
|
111
|
+
return undefined;
|
|
112
|
+
if (!ON_OFF.includes(v))
|
|
113
|
+
throw new UsageError(`--${name} must be on or off`);
|
|
114
|
+
return v === "on";
|
|
115
|
+
}
|
|
116
|
+
function orNull(value) {
|
|
117
|
+
return value === "none" ? null : value;
|
|
118
|
+
}
|
|
119
|
+
function priorityMap(entries) {
|
|
120
|
+
const map = {};
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const [severity, priority] = entry.split("=").map((s) => s.trim().toLowerCase());
|
|
123
|
+
if (!severity || !priority || !SEVERITIES.includes(severity) || !PRIORITIES.includes(priority)) {
|
|
124
|
+
throw new UsageError(`--tracker-priority takes severity=priority, e.g. high=urgent (severities: ${SEVERITIES.join(", ")}; priorities: ${PRIORITIES.join(", ")})`);
|
|
125
|
+
}
|
|
126
|
+
map[severity] = priority;
|
|
127
|
+
}
|
|
128
|
+
return map;
|
|
129
|
+
}
|
|
130
|
+
/** The `site_health_tracker` PATCH body from `envs update` flags; undefined when none were given. */
|
|
131
|
+
export function trackerSettingsFromFlags(input) {
|
|
132
|
+
const body = {};
|
|
133
|
+
const provider = flagStr(input, "tracker");
|
|
134
|
+
if (provider !== undefined)
|
|
135
|
+
body.provider = provider === "off" ? null : provider;
|
|
136
|
+
const team = flagStr(input, "tracker-team");
|
|
137
|
+
if (team !== undefined) {
|
|
138
|
+
body.team_id = team;
|
|
139
|
+
if (provider === undefined)
|
|
140
|
+
body.provider = "linear";
|
|
141
|
+
}
|
|
142
|
+
const project = orNull(flagStr(input, "tracker-project"));
|
|
143
|
+
if (project !== undefined)
|
|
144
|
+
body.project_id = project;
|
|
145
|
+
const labels = flagStrings(input, "tracker-label");
|
|
146
|
+
if (labels)
|
|
147
|
+
body.labels = labels;
|
|
148
|
+
const priorities = flagStrings(input, "tracker-priority");
|
|
149
|
+
if (priorities)
|
|
150
|
+
body.priority_map = priorityMap(priorities);
|
|
151
|
+
const threshold = flagStr(input, "tracker-threshold");
|
|
152
|
+
if (threshold !== undefined)
|
|
153
|
+
body.auto_file_threshold = threshold;
|
|
154
|
+
const digest = flagStr(input, "tracker-digest");
|
|
155
|
+
if (digest !== undefined)
|
|
156
|
+
body.digest = digest;
|
|
157
|
+
const digestTo = flagStr(input, "tracker-digest-to");
|
|
158
|
+
if (digestTo !== undefined)
|
|
159
|
+
body.digest_target = digestTo;
|
|
160
|
+
const toggles = [
|
|
161
|
+
["tracker-auto-close", "auto_close"],
|
|
162
|
+
["tracker-reopen", "reopen_on_regression"],
|
|
163
|
+
["tracker-reopen-closed", "reopen_if_closed_while_failing"],
|
|
164
|
+
["tracker-pause", "paused"],
|
|
165
|
+
];
|
|
166
|
+
for (const [flag, field] of toggles) {
|
|
167
|
+
const v = onOff(input, flag);
|
|
168
|
+
if (v !== undefined)
|
|
169
|
+
body[field] = v;
|
|
170
|
+
}
|
|
171
|
+
const numbers = [
|
|
172
|
+
["tracker-max-tickets", "max_tickets_per_run"],
|
|
173
|
+
["tracker-min-runs", "min_consecutive_runs_manual"],
|
|
174
|
+
["tracker-min-runs-scheduled", "min_consecutive_runs_scheduled"],
|
|
175
|
+
];
|
|
176
|
+
for (const [flag, field] of numbers) {
|
|
177
|
+
const v = flagNum(input, flag);
|
|
178
|
+
if (v !== undefined)
|
|
179
|
+
body[field] = v;
|
|
180
|
+
}
|
|
181
|
+
const assignee = orNull(flagStr(input, "tracker-assignee"));
|
|
182
|
+
if (assignee !== undefined)
|
|
183
|
+
body.assignee_id = assignee;
|
|
184
|
+
return Object.keys(body).length ? body : undefined;
|
|
185
|
+
}
|
|
186
|
+
function provider(input) {
|
|
187
|
+
return flagStr(input, "tracker") ?? "linear";
|
|
188
|
+
}
|
|
189
|
+
export const trackerCommands = [
|
|
190
|
+
{
|
|
191
|
+
name: "health tracker status",
|
|
192
|
+
summary: "Show how Site Health files findings as issue tracker tickets for an environment",
|
|
193
|
+
description: "Returns the environment's tracker settings with every default filled in, the " +
|
|
194
|
+
"workspace's connection (`status: disconnected` means the token expired or was " +
|
|
195
|
+
"revoked; ticket changes queue until you reconnect), the number of queued changes, " +
|
|
196
|
+
"open tickets (filed in the tracker), `unfiled_tickets` the tracker hasn't accepted " +
|
|
197
|
+
"yet with its `last_sync_error`, digest findings, and `last_sync`: what the latest run filed, " +
|
|
198
|
+
"updated, closed or skipped. Change settings with `envs update --tracker-*`.",
|
|
199
|
+
scope: "project",
|
|
200
|
+
flags: [ENV_FLAG],
|
|
201
|
+
async run(ctx, input) {
|
|
202
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
203
|
+
return {
|
|
204
|
+
data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/site-health/tracker`, {
|
|
205
|
+
environment_id: flagStr(input, "env"),
|
|
206
|
+
}),
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: "health tracker tickets",
|
|
212
|
+
summary: "List the findings Site Health tracks in the issue tracker, with their tickets",
|
|
213
|
+
description: "One row per finding: `status` watching (seen, waiting for enough runs in a row), " +
|
|
214
|
+
"digest (not filed), open, closed (with `close_reason` fixed, intended or tracker) " +
|
|
215
|
+
"or stale (its page is gone), plus the ticket key and URL once filed and `queued` " +
|
|
216
|
+
"when a change is waiting to reach the tracker.",
|
|
217
|
+
scope: "project",
|
|
218
|
+
flags: [
|
|
219
|
+
ENV_FLAG,
|
|
220
|
+
{
|
|
221
|
+
name: "status",
|
|
222
|
+
type: "string",
|
|
223
|
+
enum: ["watching", "digest", "open", "closed", "stale"],
|
|
224
|
+
description: "Only rows in this status",
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
async run(ctx, input) {
|
|
228
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
229
|
+
return {
|
|
230
|
+
data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/site-health/tickets`, {
|
|
231
|
+
environment_id: flagStr(input, "env"),
|
|
232
|
+
status: flagStr(input, "status"),
|
|
233
|
+
}),
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: "health tracker connect",
|
|
239
|
+
summary: "Get the link that connects an issue tracker to the workspace",
|
|
240
|
+
description: "Returns an authorize URL for a workspace admin to open in a browser. Once approved, " +
|
|
241
|
+
"Beryl files tickets as its own app user, and any changes queued while disconnected " +
|
|
242
|
+
"are applied. Then pick a team with `envs update --tracker-team`.",
|
|
243
|
+
scope: "workspace",
|
|
244
|
+
flags: [PROVIDER_FLAG],
|
|
245
|
+
async run(ctx, input) {
|
|
246
|
+
const ws = await ctx.requireWorkspace(input);
|
|
247
|
+
const res = (await ctx.client.get(`/workspaces/${ws}/trackers/${provider(input)}/connect`));
|
|
248
|
+
return { data: res, human: `Open this link to connect:\n${res.url}` };
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: "health tracker disconnect",
|
|
253
|
+
summary: "Remove the workspace's issue tracker connection",
|
|
254
|
+
description: "Deletes the stored tokens. Tickets already filed stay in the tracker; to stop " +
|
|
255
|
+
"filing for one environment only, use `envs update --tracker off` or `--tracker-pause on`.",
|
|
256
|
+
scope: "workspace",
|
|
257
|
+
flags: [PROVIDER_FLAG, { name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
|
|
258
|
+
async run(ctx, input) {
|
|
259
|
+
const ws = await ctx.requireWorkspace(input);
|
|
260
|
+
await ctx.confirm(`Disconnect ${provider(input)}?`, input.flags.force === true);
|
|
261
|
+
await ctx.client.del(`/workspaces/${ws}/trackers/${provider(input)}`);
|
|
262
|
+
return { human: "Disconnected." };
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: "health tracker teams",
|
|
267
|
+
summary: "List the teams of the connected issue tracker",
|
|
268
|
+
scope: "workspace",
|
|
269
|
+
flags: [PROVIDER_FLAG],
|
|
270
|
+
async run(ctx, input) {
|
|
271
|
+
const ws = await ctx.requireWorkspace(input);
|
|
272
|
+
return { data: await ctx.client.get(`/workspaces/${ws}/trackers/${provider(input)}/teams`) };
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
name: "health tracker projects",
|
|
277
|
+
summary: "List a tracker team's projects",
|
|
278
|
+
scope: "workspace",
|
|
279
|
+
flags: [
|
|
280
|
+
PROVIDER_FLAG,
|
|
281
|
+
{ name: "team", type: "string", description: "Team id (see `health tracker teams`)", required: true },
|
|
282
|
+
],
|
|
283
|
+
async run(ctx, input) {
|
|
284
|
+
const ws = await ctx.requireWorkspace(input);
|
|
285
|
+
const team = flagStr(input, "team");
|
|
286
|
+
if (!team)
|
|
287
|
+
throw new UsageError("--team is required");
|
|
288
|
+
return {
|
|
289
|
+
data: await ctx.client.get(`/workspaces/${ws}/trackers/${provider(input)}/projects`, {
|
|
290
|
+
team_id: team,
|
|
291
|
+
}),
|
|
292
|
+
};
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { autoFormat } from "../output.js";
|
|
1
2
|
import { arg, flagBool, flagStr } from "./util.js";
|
|
2
3
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3
4
|
export const workspaceCommands = [
|
|
@@ -145,11 +146,16 @@ export const workspaceCommands = [
|
|
|
145
146
|
],
|
|
146
147
|
async run(ctx, input) {
|
|
147
148
|
const ws = await ctx.requireWorkspace(input);
|
|
149
|
+
const email = arg(input, "email");
|
|
150
|
+
const invitation = (await ctx.client.post(`/workspaces/${ws}/invitations`, {
|
|
151
|
+
email,
|
|
152
|
+
role: (flagStr(input, "role") ?? "USER").toUpperCase(),
|
|
153
|
+
}));
|
|
154
|
+
if (!invitation.email_delivery_failed)
|
|
155
|
+
return { data: invitation };
|
|
148
156
|
return {
|
|
149
|
-
data:
|
|
150
|
-
|
|
151
|
-
role: (flagStr(input, "role") ?? "USER").toUpperCase(),
|
|
152
|
-
}),
|
|
157
|
+
data: invitation,
|
|
158
|
+
human: `${autoFormat(invitation)}\nWarning: the invitation email to ${email} was not delivered. Check the address, or revoke and re-invite later.`,
|
|
153
159
|
};
|
|
154
160
|
},
|
|
155
161
|
},
|
package/dist/http.js
CHANGED
|
@@ -97,8 +97,8 @@ export class ApiClient {
|
|
|
97
97
|
patch(path, body) {
|
|
98
98
|
return this.request("PATCH", path, { body });
|
|
99
99
|
}
|
|
100
|
-
put(path, body) {
|
|
101
|
-
return this.request("PUT", path, { body });
|
|
100
|
+
put(path, body, query) {
|
|
101
|
+
return this.request("PUT", path, { body, query });
|
|
102
102
|
}
|
|
103
103
|
del(path, body) {
|
|
104
104
|
return this.request("DELETE", path, { body });
|
package/dist/registry/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { mcpCommands } from "../commands/mcp.js";
|
|
|
12
12
|
import { projectCommands } from "../commands/projects.js";
|
|
13
13
|
import { runCommands } from "../commands/runs.js";
|
|
14
14
|
import { testCommands } from "../commands/tests.js";
|
|
15
|
+
import { trackerCommands } from "../commands/tracker.js";
|
|
15
16
|
import { workspaceCommands } from "../commands/workspaces.js";
|
|
16
17
|
export const WORKSPACE_FLAG = {
|
|
17
18
|
name: "workspace",
|
|
@@ -65,6 +66,7 @@ export const commands = [
|
|
|
65
66
|
...groupCommands,
|
|
66
67
|
...runCommands,
|
|
67
68
|
...healthCommands,
|
|
69
|
+
...trackerCommands,
|
|
68
70
|
...explorationCommands,
|
|
69
71
|
...configCommands,
|
|
70
72
|
...mailboxCommands,
|
package/package.json
CHANGED