@impeccable/reviews 0.1.28 → 0.1.29
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 +1 -1
- package/dist/cloudflare.js +49 -8
- package/dist/fence.js +27 -2
- package/dist/hosted.js +16 -5
- package/dist/log.js +2 -1
- package/dist/playbook/cases/cloudflare-previews.md +3 -3
- package/dist/playbook/cases/live-app-address.md +1 -1
- package/dist/playbook/cases/pull-requests.md +2 -2
- package/dist/playbook/cases/render-previews.md +1 -0
- package/dist/playbook.js +2 -2
- package/dist/tools.js +127 -25
- package/dist/ui/run-state.js +19 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ The command stops when the sandbox does not start: on macOS inside another tool'
|
|
|
66
66
|
|
|
67
67
|
The fence stays the layer inside the sandbox: it keeps the setup to its own steps and out of `.env` files, and the agent's commands get your environment without the tokens, keys and passwords in it, whether their names say so or their values are shaped like a secret (a GitHub token, a private key, a service account's key, a password in a URL), and what runs as you (`git commit` and `push`, `gh`, the host CLIs) gets it without `GH_REPO` and `GH_HOST`, so `gh` works on this checkout's repository on github.com, whatever repository or host they name. The agent's own file tools (read, write, edit, grep, find and ls) open files in the command's process, as you, so for them the fence judges the path pi opens, not the string the agent typed: spelled the way pi spells it (a curly apostrophe for a straight one, among others) and with its symlinks followed, and a path that starts with `@` or `~` or a URL scheme, which pi would open elsewhere, is refused outright; read, write, edit, grep and ls judge each file once more as they open it (ls each entry too, so a symlink out of the checkout is not listed), find judges the folder it searches before fd walks it and every path fd reports after, and a symlinked folder out of the checkout is neither followed nor listed; the names of `.env` files are listed, as `ls -a` shows them, and only a read of one is refused; a grep glob that names `.env` files is refused, and no line of a `.env` file is in what grep reports, whatever glob it was given. It also reads what goes past it: text shaped like a secret (a cloud, GitHub, Google or SendGrid key, a private key, a token, a Slack webhook's address, a password in an address or on a curl command line) in a file the agent writes, a setting, a commit, a pull request or a message to you is refused, read as written and as the screen would show it once an escape sequence is taken out, a command that names a credential file (`~/.ssh`, `.aws/credentials`, `.netrc`, `.env`) is refused, and what the agent reads gets a line saying it is data when it carries text shaped like instructions; the debug log and `--scan-report` list each match. A commit message, a template and a pull request body come inline (`-m`, `--body`), never from a file: the file the fence read is not surely the file git or gh, outside the sandbox, read later, since a command still running in the sandbox can swap it in between. For the same reason the messages of the commits a push carries are read again as the push runs, all of them (past what the remote has: its branch of that name, the default branch, and one the pushed branch is stacked on, such as an earlier run's setup branch under a new test drive), and so is every line they add to a `.env` file's example, which a command may have written, with the same check as a file the agent writes; a push whose commits cannot be read whole is refused, as is one whose remote cannot be reached, since what it carries cannot be checked then (the push can be tried again once the network is back; the remote is asked with no prompt for a password or a passphrase, which would hold it), and one in the same command as the `git commit` or `git checkout -b` that makes what it carries, which is not there yet when it is read: the push runs as a command of its own, after the commit. A commit carries only files the agent wrote, or named in a `git add`: a file a script staged on its own is refused by name, and `git restore --staged` takes it out; a push whose new commits carry such a file (a script can commit on its own, past the index) is refused the same way. Once `impeccable_sync_workflow` wrote the preview workflow, or removed the retired one, a commit carries that file only as the tool left it: the setup state's bytes, or no file. A status line the agent starts a message with shows in the terminal and on the web page only when its progress tool would take it: in the product voice, short, and holding nothing shaped like a secret.
|
|
68
68
|
|
|
69
|
-
Without asking: `git status|diff|log|show|add|restore --staged|commit|checkout -b|push -u origin <branch>`, `git branch` and `git remote` in their listing forms, `git ls-remote origin` (in the sandbox, over https, and never held on a prompt for a sign-in), `gh pr create`, `gh pr view` for a pull request of this repository, named with `--repo`, by number or by this run's branch name (with `--json` or `--comments`, never `--web`) and `gh pr ready` for the test drive's branch by name (a Netlify draft, marked ready once its preview is proven), `npm|pnpm|yarn|bun install|ci`, the scripts of the package.json where the command runs (`npm run`, `pnpm run`, `bun run`, `yarn <script>`), `npx`, `pnpm exec`, `yarn` and `bunx` for `tsc`, `eslint`, `prettier`, `next`, `vite`, `astro`, `nuxt`, `prisma`, `playwright`, `drizzle-kit`, `sequelize` and `knex`, and `ls`, `cat`, `head`, `grep` and `find` inside the checkout (by relative or absolute path) and the run's scratch folder. A package manager or tool runner may have `KEY=value` settings in front; any of these may start with a `cd` into a folder of the checkout and be chained with `&&`, `||`, `;` or `|` to others on the list.
|
|
69
|
+
Without asking: `git status|diff|log|show|add|restore --staged|commit|checkout -b|push -u origin <branch>`, `git branch` and `git remote` in their listing forms, `git ls-remote origin` (in the sandbox, over https, and never held on a prompt for a sign-in), `gh pr create`, `gh pr view` for a pull request of this repository, named with `--repo`, by number or by this run's branch name (with `--json` or `--comments`, never `--web`), `gh pr list` for this repository's pull requests, named with `--repo` (with `--state`, `--limit`, at most 100, and `--json`, never `--web` or a search) and `gh pr ready` for the test drive's branch by name (a Netlify draft, marked ready once its preview is proven), `npm|pnpm|yarn|bun install|ci`, the scripts of the package.json where the command runs (`npm run`, `pnpm run`, `bun run`, `yarn <script>`), `npx`, `pnpm exec`, `yarn` and `bunx` for `tsc`, `eslint`, `prettier`, `next`, `vite`, `astro`, `nuxt`, `prisma`, `playwright`, `drizzle-kit`, `sequelize` and `knex`, and `ls`, `cat`, `head`, `grep` and `find` inside the checkout (by relative or absolute path) and the run's scratch folder. A package manager or tool runner may have `KEY=value` settings in front; any of these may start with a `cd` into a folder of the checkout and be chained with `&&`, `||`, `;` or `|` to others on the list.
|
|
70
70
|
|
|
71
71
|
`vercel whoami` and `vercel --version`. Once you said yes to the Vercel setup: `vercel link --yes` in the checkout's root alone (never in a folder of it, whose name would pick one of your projects), `vercel project add` and `vercel deploy` for a preview, on the project named after the folder and in the one team the sign-in settled on (the CLI's current scope, your only team, or the one you pick when it asks): no other `--scope`, `--team` or `--project`, never `vercel project ls`, which names your projects, and a global install of the Vercel CLI itself when it is missing, with npm and pinned to the version this release names (`npm install -g vercel@<version>`; another package manager, version or tag is refused). The sign-in (`vercel login`) and the repository's connection (`vercel git connect`, for a project the run created or one connected to the repository already) are the run's own tools, never the agent's commands. Never a production deploy, a project's removal, its environment variables or protection settings (`vercel project protection`, `api` and `curl` print a project's bypass secrets, so the agent's shell never runs them; the lock before a new project's first deployment and the bypass secret are the run's own tools), or a token on the command line.
|
|
72
72
|
|
package/dist/cloudflare.js
CHANGED
|
@@ -14,8 +14,11 @@ import { ownFolder } from './sandbox.js';
|
|
|
14
14
|
* check run on each commit it built and a comment on the pull request, both
|
|
15
15
|
* naming the deployment's address (https://<id>.<project>.pages.dev) and the
|
|
16
16
|
* branch's alias (https://<branch>.<project>.pages.dev), which follows the
|
|
17
|
-
* branch's latest build.
|
|
18
|
-
*
|
|
17
|
+
* branch's latest build. A Worker that Workers Builds builds from the repository
|
|
18
|
+
* gets previews of its branches where its branch builds make them, on workers.dev
|
|
19
|
+
* (https://<version or branch>-<worker>.<subdomain>.workers.dev): its check run
|
|
20
|
+
* names both addresses, or, for a Worker Preview, its comment names the preview's.
|
|
21
|
+
* Nothing here reads a token, and nothing comes back beyond the fields below.
|
|
19
22
|
*/
|
|
20
23
|
/** How many pull requests, the most recently updated, the evidence reads. */
|
|
21
24
|
const NEWEST = 10;
|
|
@@ -23,6 +26,8 @@ const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
|
|
|
23
26
|
/** A Pages deployment's address, or a branch's alias: one label, then the project, under pages.dev. */
|
|
24
27
|
export const PAGES_ADDRESS = new RegExp(`^https://(${LABEL})\\.(${LABEL})\\.pages\\.dev$`);
|
|
25
28
|
const LINKS = new RegExp(`https://${LABEL}\\.${LABEL}\\.pages\\.dev`, 'g');
|
|
29
|
+
/** A Worker's preview address: one label, the version's id or the branch's alias joined to the Worker's name, then the account's subdomain, under workers.dev. */
|
|
30
|
+
export const WORKERS_ADDRESS = new RegExp(`^https://(${LABEL})\\.(${LABEL})\\.workers\\.dev$`);
|
|
26
31
|
/** Cloudflare's GitHub app, by the slug its check runs carry and the login its comments carry; the earlier Pages-only app posted the same. */
|
|
27
32
|
const APPS = new Set(['cloudflare-workers-and-pages', 'cloudflare-pages']);
|
|
28
33
|
const isApp = (login) => typeof login === 'string' && APPS.has(login.replace(/\[bot\]$/, ''));
|
|
@@ -47,14 +52,27 @@ function addresses(html) {
|
|
|
47
52
|
const links = [...new Set(html.match(LINKS) ?? [])];
|
|
48
53
|
return { url: fits(row('Preview URL')) ?? links[0], alias: fits(row('Branch Preview URL')) ?? (links[1] !== links[0] ? links[1] : undefined) };
|
|
49
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* A preview of the Worker `script` by its alias on workers.dev, `<name>-<script>.<subdomain>` (`name` the preview's where
|
|
57
|
+
* Cloudflare names it, else any branch's), with the pattern that alias stands for, and the version's address,
|
|
58
|
+
* `<version id>-<script>.<subdomain>`, where it is the same Worker's on the same subdomain; none for an alias of another Worker, or none.
|
|
59
|
+
*/
|
|
60
|
+
function workerPreview(script, url, alias, name) {
|
|
61
|
+
const [, label, subdomain] = (alias && WORKERS_ADDRESS.exec(alias)) || [];
|
|
62
|
+
if (!alias || !(name !== undefined ? label === `${name}-${script}` : label?.endsWith(`-${script}`)))
|
|
63
|
+
return undefined;
|
|
64
|
+
const [, version, on] = (url && WORKERS_ADDRESS.exec(url)) || [];
|
|
65
|
+
return { url: url && version?.slice(9) === script && /^[0-9a-f]{8}-/.test(version) && on === subdomain ? url : alias, alias, pattern: `https://{branch}-${script}.${subdomain}.workers.dev` };
|
|
66
|
+
}
|
|
50
67
|
/** What a check run's conclusion, or the words the app's table says, tell of the build. */
|
|
51
68
|
const stateOf = (value) => (typeof value !== 'string' ? 'building' : /succe/i.test(value) ? 'success' : /fail|timed_out|error/i.test(value) ? 'failure' : /cancel/i.test(value) ? 'canceled' : 'building');
|
|
52
69
|
/**
|
|
53
70
|
* GitHub's record: the Cloudflare check run on each head (or, with `only`, that pull request's; with `branch`, the
|
|
54
71
|
* pushed branch's head alone, before its pull request exists), and the app's comment on each pull request when it
|
|
55
|
-
* describes the head.
|
|
72
|
+
* describes the head. A Worker Preview's check run names no address, so its alias is made from `pattern`, the one an
|
|
73
|
+
* earlier read's Worker preview stood for. `failed` when a read did not answer.
|
|
56
74
|
*/
|
|
57
|
-
async function fromGitHub(owner, repo, gh, only, branch) {
|
|
75
|
+
async function fromGitHub(owner, repo, gh, only, branch, pattern) {
|
|
58
76
|
const base = `/repos/${owner}/${repo}`;
|
|
59
77
|
const get = async (route) => JSON.parse(await gh(['api', '--hostname', 'github.com', '--method', 'GET', route]));
|
|
60
78
|
let failed = false;
|
|
@@ -87,8 +105,16 @@ async function fromGitHub(owner, repo, gh, only, branch) {
|
|
|
87
105
|
continue;
|
|
88
106
|
const script = typeof run.name === 'string' ? /^Workers Builds: (\S+)$/.exec(run.name)?.[1] : undefined;
|
|
89
107
|
if (script) {
|
|
108
|
+
const build = run.status === 'completed' ? stateOf(run.conclusion) : 'building';
|
|
90
109
|
if (!worker || head.at > worker.at)
|
|
91
|
-
worker = { script, build
|
|
110
|
+
worker = { script, build, at: head.at };
|
|
111
|
+
// A branch build's preview: its summary names the version's address and the branch's alias; a Worker Preview's names neither, and its build page's address names the preview.
|
|
112
|
+
const said = Object.fromEntries([...String(record(run.output)?.summary ?? '').matchAll(/^Preview (URL|Alias URL): (\S+)$/gm)].map((m) => [m[1], m[2]]));
|
|
113
|
+
const name = typeof run.details_url === 'string' ? new RegExp(`/previews/(${LABEL})/builds/`).exec(run.details_url)?.[1] : undefined;
|
|
114
|
+
// An earlier read's pattern makes the alias only where it is this Worker's own, never another Worker's whose name ends in this one's.
|
|
115
|
+
const preview = workerPreview(script, said.URL, said['Alias URL'] ?? (name && pattern?.startsWith(`https://{branch}-${script}.`) ? pattern.replace('{branch}', name) : undefined));
|
|
116
|
+
if (preview)
|
|
117
|
+
found.push({ ...(head.number ? { pullRequest: head.number } : {}), ...(head.branch ? { branch: head.branch } : {}), sha: head.sha, ...preview, state: build, from: 'check run', at: when(run.completed_at) || head.at });
|
|
92
118
|
continue;
|
|
93
119
|
}
|
|
94
120
|
const { url, alias } = addresses(String(record(run.output)?.summary ?? ''));
|
|
@@ -105,6 +131,15 @@ async function fromGitHub(owner, repo, gh, only, branch) {
|
|
|
105
131
|
const head = heads.find((h) => h.number === issue);
|
|
106
132
|
if (!head || !isApp(record(comment.user)?.login) || typeof comment.body !== 'string')
|
|
107
133
|
continue;
|
|
134
|
+
// A Worker Preview's comment names the preview's address and the commit it last deployed, `### Preview URL: <address> (commit <sha>)`, and its dashboard link names the Worker.
|
|
135
|
+
const latest = /^### Preview URL: (\S+) \(commit ([0-9a-f]{7,40})\)$/m.exec(comment.body);
|
|
136
|
+
const [, script, name] = new RegExp(`/workers/services/view/(${LABEL})/production/previews/(${LABEL})`).exec(comment.body) ?? [];
|
|
137
|
+
if (latest && script) {
|
|
138
|
+
const preview = head.sha.startsWith(latest[2]) ? workerPreview(script, undefined, latest[1], name) : undefined;
|
|
139
|
+
if (preview)
|
|
140
|
+
found.push({ pullRequest: issue, ...(head.branch ? { branch: head.branch } : {}), sha: head.sha, ...preview, state: 'success', from: 'bot comment', at: when(comment.updated_at) || head.at });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
108
143
|
// The app edits its one comment as the pull request moves on: it describes the head when the commit it names is the head's.
|
|
109
144
|
const commit = /Latest commit:<\/strong>[\s\S]{0,80}?<code>([0-9a-f]{7,40})<\/code>/.exec(comment.body)?.[1];
|
|
110
145
|
if (!commit || !head.sha.startsWith(commit))
|
|
@@ -424,7 +459,8 @@ async function listCloudflareProjects(cf, accounts, signal) {
|
|
|
424
459
|
}
|
|
425
460
|
/**
|
|
426
461
|
* Whether this repository's pull requests get Cloudflare Pages previews: the newest, as GitHub and cf have them;
|
|
427
|
-
* with `pull`, that pull request's only; with `branch`, the pushed branch's head's only, before its pull request exists
|
|
462
|
+
* with `pull`, that pull request's only; with `branch`, the pushed branch's head's only, before its pull request exists;
|
|
463
|
+
* with `pattern`, the one an earlier read's Worker preview stood for, a Worker Preview's build listed too.
|
|
428
464
|
*/
|
|
429
465
|
export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
430
466
|
// cf is not asked here, so whether it is installed is unknown.
|
|
@@ -435,7 +471,7 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
|
435
471
|
return unread();
|
|
436
472
|
const gh = deps.gh ?? commandRunner('gh', cwd, { signal: deps.signal });
|
|
437
473
|
const plain = deps.pull === undefined && deps.branch === undefined;
|
|
438
|
-
const github = await fromGitHub(owner, repo, gh, deps.pull, deps.branch);
|
|
474
|
+
const github = await fromGitHub(owner, repo, gh, deps.pull, deps.branch, deps.pattern);
|
|
439
475
|
const config = await wranglerConfig(cwd);
|
|
440
476
|
const readme = plain ? await readmeAddresses(cwd) : { pages: [] };
|
|
441
477
|
// For one app's folder, an address or the root's config names no app's project: only the project's settings do.
|
|
@@ -458,6 +494,9 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
|
458
494
|
else if (app)
|
|
459
495
|
github.previews = github.previews.filter((p) => pagesProject(p.url) === (cf.subdomain ? pagesProject(`https://${cf.subdomain}`) : cf.project));
|
|
460
496
|
const project = cf.project ?? named;
|
|
497
|
+
// Where a Pages project builds the repository, its previews stay the method, and a Worker's beside it are left out.
|
|
498
|
+
if (project)
|
|
499
|
+
github.previews = github.previews.filter((p) => !p.pattern);
|
|
461
500
|
// A Worker built from the repository: its check runs, the config or the README say so, and the check runs say how its newest build went.
|
|
462
501
|
const script = github.worker?.script ?? (config?.kind === 'worker' ? config.name : undefined);
|
|
463
502
|
const worker = github.worker || config?.kind === 'worker' || readme.workers
|
|
@@ -487,8 +526,10 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
|
487
526
|
}
|
|
488
527
|
// The most recently updated pull request's first; a branch with no pull request among them last, as cf lists it.
|
|
489
528
|
previews.sort((a, b) => (b.pullRequest ? 1 : 0) - (a.pullRequest ? 1 : 0) || b.at - a.at);
|
|
529
|
+
// No Pages project, and a Worker's previews listed: the Worker's own address, on the subdomain they name, is the live app.
|
|
530
|
+
const live = previews.find((p) => p.pattern)?.pattern?.replace('{branch}-', '');
|
|
490
531
|
return {
|
|
491
|
-
...(project ? { project, production: `https://${cf.subdomain ?? `${project}.pages.dev`}` } : {}),
|
|
532
|
+
...(project ? { project, production: `https://${cf.subdomain ?? `${project}.pages.dev`}` } : live ? { production: live } : {}),
|
|
492
533
|
previews: previews.slice(0, deps.branch !== undefined ? previews.length : NEWEST).map(({ at, ...preview }) => preview),
|
|
493
534
|
cli: cf.cli,
|
|
494
535
|
...(github.failed || cf.failed
|
package/dist/fence.js
CHANGED
|
@@ -918,6 +918,9 @@ function simpleCommand(seg, where, opts) {
|
|
|
918
918
|
return ghView(args.slice(2)) ? allow() : deny("gh pr view takes a pull request's number or this run's branch, --repo for this repository, --json and --comments");
|
|
919
919
|
if (args[0] === 'pr' && args[1] === 'checks')
|
|
920
920
|
return ghView(args.slice(2), '--required') ? allow() : deny("gh pr checks takes a pull request's number or this run's branch, --repo for this repository, --json and --required");
|
|
921
|
+
// The repository's own pull requests, listed: the earlier ones whose previews are its host's evidence (REN-1108).
|
|
922
|
+
if (args[0] === 'pr' && args[1] === 'list')
|
|
923
|
+
return ghList(args.slice(2)) ? allow() : deny('gh pr list takes --repo for this repository, --state, --limit (100 at most) and --json');
|
|
921
924
|
// The test drive opens as a draft where its preview needs the pull request first, and is marked ready once that preview is proven: the test drive's branch by name, nothing else.
|
|
922
925
|
if (args[0] === 'pr' && args[1] === 'ready')
|
|
923
926
|
return ghReady(args.slice(2)) !== null ? allow() : deny('gh pr ready takes the test drive branch by name, and --repo');
|
|
@@ -1705,6 +1708,28 @@ function ghView(args, flag = '--comments') {
|
|
|
1705
1708
|
const repository = seen.get('--repo');
|
|
1706
1709
|
return repository === undefined ? {} : REPOSITORY.test(repository) ? { repository } : null;
|
|
1707
1710
|
}
|
|
1711
|
+
/** `gh pr list [--repo <owner>/<name>] [--state open|closed|merged|all] [--limit <n>] [--json <fields>]`, each once and nothing else (no --web, no search): the repository named, or null. */
|
|
1712
|
+
function ghList(args) {
|
|
1713
|
+
const seen = new Map();
|
|
1714
|
+
for (let i = 0; i < args.length; i++) {
|
|
1715
|
+
const arg = args[i];
|
|
1716
|
+
const at = arg.startsWith('--') ? arg.indexOf('=') : -1;
|
|
1717
|
+
const given = at < 0 ? arg : arg.slice(0, at);
|
|
1718
|
+
const name = { '-R': '--repo', '-s': '--state', '-L': '--limit' }[given] ?? given;
|
|
1719
|
+
if (!['--repo', '--state', '--limit', '--json'].includes(name) || seen.has(name))
|
|
1720
|
+
return null;
|
|
1721
|
+
const value = (at < 0 ? args[++i] : arg.slice(at + 1)) ?? '';
|
|
1722
|
+
if (!value || value.startsWith('-'))
|
|
1723
|
+
return null;
|
|
1724
|
+
if (name === '--state' && !['open', 'closed', 'merged', 'all'].includes(value))
|
|
1725
|
+
return null;
|
|
1726
|
+
if (name === '--limit' && !(/^[1-9]\d{0,2}$/.test(value) && Number(value) <= 100))
|
|
1727
|
+
return null;
|
|
1728
|
+
seen.set(name, value);
|
|
1729
|
+
}
|
|
1730
|
+
const repository = seen.get('--repo');
|
|
1731
|
+
return repository === undefined ? {} : REPOSITORY.test(repository) ? { repository } : null;
|
|
1732
|
+
}
|
|
1708
1733
|
/** The branch of `push -u origin <branch>`, git's words from its command on, when `literal` takes it. */
|
|
1709
1734
|
const pushBranch = (args, literal = setupBranch) => args.length === 4 && args[0] === 'push'
|
|
1710
1735
|
&& ['-u', '--set-upstream'].includes(args[1]) && args[2] === 'origin' && literal(args[3]) ? args[3] : undefined;
|
|
@@ -1835,8 +1860,8 @@ export function reservePreviewCommand(state, command, root, scratch) {
|
|
|
1835
1860
|
if (!drives.get(parsed.branch)?.created)
|
|
1836
1861
|
return deny('mark ready the test drive this run opened');
|
|
1837
1862
|
}
|
|
1838
|
-
if (words[0] === 'gh' && words[1] === 'pr' && (words[2] === 'view' || words[2] === 'checks')) {
|
|
1839
|
-
const parsed = ghView(words.slice(3), words[2] === 'checks' ? '--required' : '--comments');
|
|
1863
|
+
if (words[0] === 'gh' && words[1] === 'pr' && (words[2] === 'view' || words[2] === 'checks' || words[2] === 'list')) {
|
|
1864
|
+
const parsed = words[2] === 'list' ? ghList(words.slice(3)) : ghView(words.slice(3), words[2] === 'checks' ? '--required' : '--comments');
|
|
1840
1865
|
if (!parsed)
|
|
1841
1866
|
return deny("view only this run's pull requests, by number or branch name");
|
|
1842
1867
|
// Without --repo, gh picks a repository from the checkout's remotes, which may name another one.
|
package/dist/hosted.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { promisify } from 'node:util';
|
|
3
4
|
import { ACCESS_ID_HEADER, ACCESS_SECRET_HEADER } from './cloudflare-access.js';
|
|
4
5
|
import { gitEnvironment } from './sandbox.js';
|
|
@@ -94,15 +95,17 @@ const NETLIFY = new RegExp(`^(?=[^.]{1,63}\\.netlify\\.app$)deploy-preview-[1-9]
|
|
|
94
95
|
const PAGES = new RegExp(`^${LABEL}\\.(${LABEL})\\.pages\\.dev$`);
|
|
95
96
|
/** A Render pull request's preview: a site of its own, the service's name and the pull request's number, under onrender.com. */
|
|
96
97
|
const RENDER = new RegExp(`^(${LABEL})-pr-([1-9]\\d{0,6})\\.onrender\\.com$`);
|
|
98
|
+
/** A Worker's preview: one label (the version's id, or the branch's alias, joined to the Worker's name), then the account's subdomain, under workers.dev. */
|
|
99
|
+
const WORKERS = new RegExp(`^${LABEL}\\.${LABEL}\\.workers\\.dev$`);
|
|
97
100
|
/** A live app's own address on its host: a Vercel alias (one name under vercel.app, the shape its deployments have too), a Netlify site's netlify.app address (a deploy or branch subdomain carries `--`), a Pages project's pages.dev address, a Render service's onrender.com address (a pull request's preview ends in `-pr-<n>`). */
|
|
98
101
|
const LIVE = { vercel: new RegExp(`^${LABEL}\\.vercel\\.app$`), netlify: new RegExp(`^(?!.*--)(${LABEL})\\.netlify\\.app$`), cloudflare: new RegExp(`^(${LABEL})\\.pages\\.dev$`), render: new RegExp(`^(?!.*-pr-[1-9]\\d{0,6}\\.)(${LABEL})\\.onrender\\.com$`) };
|
|
99
102
|
/** https on one name, with no credentials, port, query or fragment. */
|
|
100
103
|
const plainHttps = (url) => url.protocol === 'https:' && !url.username && !url.password && !url.port && !url.search && !url.hash;
|
|
101
|
-
/** A deployment's own address: https on one name, and nothing else. Vercel's is one name under vercel.app, Netlify's a pull request's deploy preview, Cloudflare's a Pages preview, Render's a pull request's preview site. */
|
|
104
|
+
/** A deployment's own address: https on one name, and nothing else. Vercel's is one name under vercel.app, Netlify's a pull request's deploy preview, Cloudflare's a Pages preview or a Worker's, Render's a pull request's preview site. */
|
|
102
105
|
const hostOf = (url) => {
|
|
103
106
|
if (!plainHttps(url))
|
|
104
107
|
return null;
|
|
105
|
-
return LIVE.vercel.test(url.hostname) ? 'vercel' : NETLIFY.test(url.hostname) ? 'netlify' : PAGES.test(url.hostname) ? 'cloudflare' : RENDER.test(url.hostname) ? 'render' : null;
|
|
108
|
+
return LIVE.vercel.test(url.hostname) ? 'vercel' : NETLIFY.test(url.hostname) ? 'netlify' : PAGES.test(url.hostname) || WORKERS.test(url.hostname) ? 'cloudflare' : RENDER.test(url.hostname) ? 'render' : null;
|
|
106
109
|
};
|
|
107
110
|
/** The host a live app's address is on, by its domain, so its lock is known; `other` for any other https address, whose lock no key opens. */
|
|
108
111
|
const liveHostOf = (url) => {
|
|
@@ -136,7 +139,7 @@ export function liveShaped(url) {
|
|
|
136
139
|
/** The host a preview address belongs to, by name; null for an address of none. */
|
|
137
140
|
export function previewHostName(url) {
|
|
138
141
|
const host = URL.canParse(url) ? hostOf(new URL(url)) : null;
|
|
139
|
-
return host ? HOSTS[host].name : null;
|
|
142
|
+
return host === 'cloudflare' && WORKERS.test(new URL(url).hostname) ? 'Cloudflare Workers' : host ? HOSTS[host].name : null;
|
|
140
143
|
}
|
|
141
144
|
/** The Netlify site a deploy preview address, or the site's own netlify.app address, belongs to, by name; null for any other address. */
|
|
142
145
|
export function netlifySiteOf(url) {
|
|
@@ -348,7 +351,7 @@ export async function provePreview(url, words, root, deps = {}) {
|
|
|
348
351
|
const parsed = URL.canParse(url) ? new URL(url) : null;
|
|
349
352
|
const host = !parsed ? null : !deps.live ? hostOf(parsed) : !plainHttps(parsed) ? null : typeof deps.live === 'string' ? deps.live : liveHostOf(parsed);
|
|
350
353
|
if (!host)
|
|
351
|
-
return unproven(deps.live ? 'Only an https address with no query is opened.' : 'Only a Vercel preview address, https://<name>.vercel.app, a Netlify deploy preview, https://deploy-preview-<n>--<name>.netlify.app, a Cloudflare Pages preview, https://<id or branch>.<project>.pages.dev, or a Render preview, https://<site>-pr-<n>.onrender.com, with no query, is opened.');
|
|
354
|
+
return unproven(deps.live ? 'Only an https address with no query is opened.' : 'Only a Vercel preview address, https://<name>.vercel.app, a Netlify deploy preview, https://deploy-preview-<n>--<name>.netlify.app, a Cloudflare Pages preview, https://<id or branch>.<project>.pages.dev, a Worker\'s, https://<id or branch>-<worker>.<subdomain>.workers.dev, or a Render preview, https://<site>-pr-<n>.onrender.com, with no query, is opened.');
|
|
352
355
|
// Another host's live address has a lock no key opens, so none is looked for: its login is another site, and its 401 the app's own sign-in.
|
|
353
356
|
const { gate, login, lock } = host === 'other' ? { gate: undefined, login: /$^/, lock: 'the app' } : HOSTS[host];
|
|
354
357
|
const phrase = words.trim();
|
|
@@ -372,9 +375,17 @@ export async function provePreview(url, words, root, deps = {}) {
|
|
|
372
375
|
// Where the login stands in front all the same, the bypass secret, the password or the service token did not get past it.
|
|
373
376
|
const still = opened ? ' even with the bypass secret' : withPassword ? ' even with the password' : withToken ? ' even with the service token' : '';
|
|
374
377
|
let target = new URL(url);
|
|
378
|
+
// Render's edge keeps a page it served for five minutes (`s-maxage=300`), and after a deploy goes on serving the old one for a while (REN-1117): each request carries a query value of its own beside any a redirect gave, which the edge caches apart, so the proof reads the build Render serves now. A request's `Cache-Control: no-cache` the edge ignores.
|
|
379
|
+
const fresh = (at) => {
|
|
380
|
+
if (host !== 'render')
|
|
381
|
+
return at;
|
|
382
|
+
const asked = new URL(at);
|
|
383
|
+
asked.searchParams.set('impeccable', randomUUID());
|
|
384
|
+
return asked;
|
|
385
|
+
};
|
|
375
386
|
try {
|
|
376
387
|
for (let hop = 0; hop < 4; hop++) {
|
|
377
|
-
const res = await open(target, { redirect: 'manual', headers, credentials: 'omit', signal });
|
|
388
|
+
const res = await open(fresh(target), { redirect: 'manual', headers, credentials: 'omit', signal });
|
|
378
389
|
if (res.status >= 300 && res.status < 400) {
|
|
379
390
|
await res.body?.cancel().catch(() => { });
|
|
380
391
|
const location = res.headers.get('location') ?? '';
|
package/dist/log.js
CHANGED
|
@@ -31,7 +31,8 @@ export function createLog(debug, dir = tmpdir()) {
|
|
|
31
31
|
const secret = (value) => void secrets.add(value);
|
|
32
32
|
if (!debug)
|
|
33
33
|
return { secret, write: () => { } };
|
|
34
|
-
|
|
34
|
+
// The process id keeps two runs started in the same millisecond out of one file.
|
|
35
|
+
const path = join(dir, `impeccable-reviews-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}.log`);
|
|
35
36
|
let tightened = false;
|
|
36
37
|
return {
|
|
37
38
|
path,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
### When this applies
|
|
2
2
|
|
|
3
|
-
Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_previews lists a built preview of a pull request (`state` success), with its address and the branch's alias. Ask it only where the repository is set up for Cloudflare (`wrangler.toml`, `wrangler.json` or `wrangler.jsonc`, a `wrangler` or `@cloudflare/*` package, a `.pages.dev` or `.workers.dev` address in the README or the workflows), but its record decides, not the checkout: Cloudflare's GitHub app posts a check run on each commit it built and a comment on the pull request, and cf, Cloudflare's CLI, lists the project's deployments when it is signed in here. It applies as well where the checkout is set up for Cloudflare and no pull request got a preview: the answer then says why (`pullRequestPreviews`, `worker`, `githubApp`), and "No pull-request previews" below says what the run does about each cause, in that order, before any other preview is set up. Cloudflare builds and serves every preview, so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request.
|
|
3
|
+
Cloudflare Pages deploys previews of the pull requests, or a Worker Cloudflare builds (Workers Builds) does where its branch builds make previews: impeccable_cloudflare_previews lists a built preview of a pull request (`state` success), with its address and the branch's alias (a Worker's, on workers.dev, with the `pattern` it stands for). Ask it only where the repository is set up for Cloudflare (`wrangler.toml`, `wrangler.json` or `wrangler.jsonc`, a `wrangler` or `@cloudflare/*` package, a `.pages.dev` or `.workers.dev` address in the README or the workflows), but its record decides, not the checkout: Cloudflare's GitHub app posts a check run on each commit it built and a comment on the pull request, and cf, Cloudflare's CLI, lists the project's deployments when it is signed in here. It applies as well where the checkout is set up for Cloudflare and no pull request got a preview: the answer then says why (`pullRequestPreviews`, `worker`, `githubApp`), and "No pull-request previews" below says what the run does about each cause, in that order, before any other preview is set up. Cloudflare builds and serves every preview, so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request.
|
|
4
4
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
@@ -9,13 +9,13 @@ Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_pr
|
|
|
9
9
|
|
|
10
10
|
### Reference
|
|
11
11
|
|
|
12
|
-
- **The live app**: `production` in the evidence is the project's own address, `https://<project>.pages.dev`; live-app-address says how it becomes the before, in step 2's write.
|
|
12
|
+
- **The live app**: `production` in the evidence is the project's own address, `https://<project>.pages.dev`, or, with no Pages project and a Worker's previews listed, the Worker's own, `https://<worker>.<subdomain>.workers.dev`; live-app-address says how it becomes the before, in step 2's write. A workers.dev address the README names is not the before on its own: its shape cannot tell the Worker's own address from a preview's.
|
|
13
13
|
- **Behind Cloudflare Access** (`gate: "cloudflare-access"`): Cloudflare's login stands in front of the previews. Get past it as preview-protection says, which proves the preview, and go on with step 2; when nothing got past it, that case says how the run ends.
|
|
14
14
|
- **Another app, or no answer from any of the three**: Cloudflare's previews do not show this app. Set the preview up as server-previews says instead, and say under "What is not verified" what the Cloudflare address answered.
|
|
15
15
|
- **No pull-request previews**: the checkout is set up for Cloudflare and nothing is listed, or a project's settings build none. The aim holds: find out why from the answer, in this order, and for each cause ask once with the tool that fixes it, before anything is pushed and never a second time; with a preview listed, after step 1's proof and preview-protection's steps, so the person sees the lock's question and this one back to back, at most one each. Each tool shows its one screen and does the change as the person, or opens the page where it cannot. After `enabled` or `connected`, nothing is listed yet, so the test drive's build is the first preview: take pull-requests' step 4 the Cloudflare way and save the method on its alias as step 2 says, no setup pull request.
|
|
16
16
|
1. **Previews off** (`pullRequestPreviews` with `enabled: false` and `cause` `previews` or `deployments`, with its `reason`): cf, signed in, found the Pages project built from this repository, and its settings build no previews of pull requests. impeccable_enable_previews (`host: "cloudflare"`) turns the preview branch control on; deployments off altogether is the person's change, on the page it opens.
|
|
17
17
|
2. **Not connected** (`cause: "source"`): the Pages project the checkout names (its wrangler config, or the README's pages.dev address) exists with no Git repository connected, so no push builds. impeccable_cloudflare_connect connects it to this repository.
|
|
18
|
-
3. **A Worker, not a Pages project** (`worker` in the answer, and no
|
|
18
|
+
3. **A Worker, not a Pages project** (`worker` in the answer, no `pullRequestPreviews`, and no Worker preview listed): Cloudflare builds the repository as a Worker (Workers Builds: its check runs name the script, or the wrangler config or the README's workers.dev address does), whose branch builds made no preview on record, and a Worker with static assets no branch build at all. impeccable_cloudflare_connect, with the build command and output folder the checkout runs, creates a Pages project for the repository and connects it; the Worker stays as it is. The same tool creates the project where the config names a Pages project cf finds nowhere.
|
|
19
19
|
4. **Cloudflare's GitHub app cannot see the repository** (`githubApp` in the answer: the app's installation covers selected repositories, or `installed: false`): a project connected with previews on still gets no build, since the app builds only the repositories its installation names. Nothing in the account shows it, so the wait for the test drive's build tells: when none comes, the wait's own screen opens the installation page (`githubApp.page`) and waits again on the person's yes. Its `reason` says when they skipped it or no build came after the retries.
|
|
20
20
|
- **`skipped` or `refused` from any of these**: push no test drive on Cloudflare (pull-requests' "No preview, no test drive" where a preview was proven; else set the preview up as server-previews says, never without saying so) and say under "What was skipped, and why" which cause stopped it, with the `reason`, and name the `page` where there is one; the person was asked in this run, so `left` stays empty. `skipped`, or the GitHub app's access skipped on the wait's screen, with no method saved, is a no that ends the setup, as the playbook says: no other preview is set up.
|
|
21
21
|
- **No `pullRequestPreviews`, no `worker`** (`cli` signed out or unknown, or no Pages project built from this repository among the accounts cf reaches): the check could not be made. Say so in one line under "What is not verified", naming why, and go on as the aim says: nothing listed, as "Nothing listed" says; a preview listed, as the steps say, where the wait for the test drive's build then tells.
|
|
@@ -4,7 +4,7 @@ Every run that saves a preview method, for the state's `before` item: the addres
|
|
|
4
4
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
7
|
-
1. **Find the address**: the `production` address a host tool answered (impeccable_netlify_previews, the Netlify site's own; impeccable_cloudflare_previews, the Pages project's, `https://<project>.pages.dev`; impeccable_render_previews, the Render site's, `https://<site>.onrender.com`; impeccable_hosted_previews, the linked Vercel project's own domain where GitHub records a production deployment of it; impeccable_vercel_connect's `productionUrl` where production exists), else the live-site line the item's `proposal` read in the README, checked against the file, when it is the site's or project's own address on one of the hosts (an address elsewhere is only the host tools' to name). A project or site created in this run has no live app yet. Done when you have one address, or know there is none.
|
|
7
|
+
1. **Find the address**: the `production` address a host tool answered (impeccable_netlify_previews, the Netlify site's own; impeccable_cloudflare_previews, the Pages project's, `https://<project>.pages.dev`, or with no Pages project a Worker's own, `https://<worker>.<subdomain>.workers.dev`; impeccable_render_previews, the Render site's, `https://<site>.onrender.com`; impeccable_hosted_previews, the linked Vercel project's own domain where GitHub records a production deployment of it; impeccable_vercel_connect's `productionUrl` where production exists), else the live-site line the item's `proposal` read in the README, checked against the file, when it is the site's or project's own address on one of the hosts (an address elsewhere is only the host tools' to name). A project or site created in this run has no live app yet. Done when you have one address, or know there is none.
|
|
8
8
|
2. **Prove it** with impeccable_prove_preview, under the status line *"Opening the live app"*: that address, or a page on it a visitor opens without signing in, and words the page shows that the checkout holds. A key the run already got in place (Vercel's bypass secret, Netlify's site password, Cloudflare's service token) goes with it where the lock is the same project's or site's. Done when it answers `proven: true`. **An address no key opens is not set**: a `gate`, the app's own sign-in on every page, or no answer leaves the item as it is, and the report says what the address answered.
|
|
9
9
|
3. **Save it** with impeccable_write to `preview`: `beforeUrl` the proven address's root, `https://<host>` with no path, whatever page the proof opened (a login page is one role's, and the review signs in as another; impeccable_write saves the root either way), in the same write as the method where the proof came before it (`{"intendedMethod": "preview-deploy", "beforeUrl": "https://…"}`), else in a write of its own; `appKey` the app's on a repository of several apps, one address per app. Done when the answer carries `beforeUrl`; its `beforeCheck.access` says how Impeccable's own check opened it: `ok`; `auth-wall`, the app's sign-in, which the saved test accounts get past; anything else goes under "What is not verified".
|
|
10
10
|
|
|
@@ -5,11 +5,11 @@ Every run, once the settings are saved. The person merges the setup pull request
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
7
7
|
1. **The setup commit**, when the repository needs a change to keep previews working: the workflow and any fix the build needed, nothing else. With the workflow current on the default branch and no fix, there is none. Check access as workflow-as-person says, branch `impeccable/setup` from `origin/<defaultBranch>`, and bring in the workflow and commit as that case says. Push it in step 7, after the test drive. Done when that case's steps are done.
|
|
8
|
-
2. **The test drive page**, always. The test drive shows the person a whole review: pages to capture, a finding for each measure, and a new page, which is what starts the review's visitor sessions. Branch `impeccable/test-drive` from the setup commit, so it carries the same workflow and its preview runs from its own head (from the default branch when there is no setup commit). Add one small, self-contained page in the repository's own framework, such as `/impeccable-test-drive`: a file where the routing finds pages, its styles in the page's own `<style>` element or imported the way the framework imports them, never a stylesheet linked by a relative path (a host may serve the page at its address without the trailing slash, where `./page.css` misses and the review sees it unstyled), with a heading, a paragraph and a few elements in the app's own words (so the review judges it as one of the app's pages), and the flaws below. Every word a visitor sees, the page's title, heading and copy and the link to it, reads as a plausible real page of the site, such as a short feature or about page in the product's voice: none says it is a test, a demo or a test drive, names Impeccable or a review, or calls its content placeholder or not real, since a visitor who reads that marks the page down. The flaws' comments stay in the code, never on the page. Link it from a page **a visitor opens without signing in**, the landing page when it is one, and keep the new page open without signing in too: no guard, on the server or in the browser, sends it to the sign-in. Done when the new page carries every flaw below with its comment, and the page you picked links to it.
|
|
8
|
+
2. **The test drive page**, always. The test drive shows the person a whole review: pages to capture, a finding for each measure, and a new page, which is what starts the review's visitor sessions. Branch `impeccable/test-drive` from the setup commit, so it carries the same workflow and its preview runs from its own head (from the default branch when there is no setup commit). Add one small, self-contained page in the repository's own framework, such as `/impeccable-test-drive`: a file where the routing finds pages, its styles in the page's own `<style>` element or imported the way the framework imports them, never a stylesheet linked by a relative path (a host may serve the page at its address without the trailing slash, where `./page.css` misses and the review sees it unstyled), with a heading, a paragraph and a few elements in the app's own words (so the review judges it as one of the app's pages), and the flaws below. Every element holds real content, a table its rows, a card its figures, a list its items: no empty container and no note standing in for data, which the review marks down ahead of the flaws. Every word a visitor sees, the page's title, heading and copy and the link to it, reads as a plausible real page of the site, such as a short feature or about page in the product's voice: none says it is a test, a demo or a test drive, names Impeccable or a review, or calls its content placeholder or not real, since a visitor who reads that marks the page down. The flaws' comments stay in the code, never on the page. Link it from a page **a visitor opens without signing in**, the landing page when it is one, and keep the new page open without signing in too: no guard, on the server or in the browser, sends it to the sign-in. Done when the new page carries every flaw below with its comment, and the page you picked links to it.
|
|
9
9
|
3. **Build the test drive branch here**, as the host's case says in place of this step (vercel-previews, netlify-previews, cloudflare-previews, render-previews), so a page that does not build is never pushed; its preview is proven on the host's own build in step 4. Done when the build passed.
|
|
10
10
|
4. **Push the test drive and prove its preview**: a host whose evidence said `pullRequestPreviews.enabled: false` is asked first, once, as its case says (impeccable_enable_previews; skipped or refused, push nothing: "No preview, no test drive" below); on Vercel, impeccable_vercel_bypass with `beforePush: true` (and each app's `appKey`, one call per app) asks for the bypass secret first where Vercel's login will stand in front of the preview, as preview-protection's "Before the test drive's push" says (`skipped`, push nothing); then commit the new page **and** the page that links to it, each staged by name, `git push -u origin impeccable/test-drive`, then the way its preview comes:
|
|
11
11
|
- **Vercel** (vercel-previews, vercel-setup) builds every push: impeccable_hosted_previews with `branch: "impeccable/test-drive"` waits up to five minutes for its deployment of the pushed head, under a status line with its timer. Prove the test drive's page on that address with impeccable_prove_preview (past Vercel's login as preview-protection says), and save the method as the case says. Then step 5.
|
|
12
|
-
- **Netlify** (netlify-previews, netlify-setup) and **Render** (render-previews) build a preview only for a pull request: open the test drive now as a **draft**, step 5's `gh pr create` with `--draft`, which Impeccable leaves unreviewed. impeccable_netlify_previews (on Render, impeccable_render_previews) with `pull` the number it printed waits up to five minutes for the deploy preview, under a status line with its timer. Prove the test drive's page on it, save the method as the case says, then `gh pr ready impeccable/test-drive --repo <owner>/<repo>` marks it ready, which starts the review. Then step 5's recording.
|
|
12
|
+
- **Netlify** (netlify-previews, netlify-setup) and **Render** (render-previews) build a preview only for a pull request: open the test drive now as a **draft**, step 5's `gh pr create` with `--draft`, which Impeccable leaves unreviewed. impeccable_netlify_previews (on Render, impeccable_render_previews) with `pull` the number it printed waits up to five minutes for the deploy preview, under a status line with its timer. Prove the test drive's page on it (on Render, a 404 is render-previews' to handle, never a guessed push), save the method as the case says, then `gh pr ready impeccable/test-drive --repo <owner>/<repo>` marks it ready, which starts the review. Then step 5's recording.
|
|
13
13
|
|
|
14
14
|
A host's wait that ran out shows its own screen, which opens the host's page where its builds are turned on and waits again on the person's yes. Done when the test drive's page is proven on its preview, and a host's method saved; when nothing proved it, the reference below says how the run goes on.
|
|
15
15
|
5. **Open the test drive and record it**: `gh pr create --repo <owner>/<repo> --head impeccable/test-drive --base <defaultBranch>` with the title "Impeccable test drive (safe to close)" and a body that starts "This pull request only shows Impeccable reviewing a change. Close it when you are done; nothing here needs to be merged." and then names the pages its diff holds, no more, and lists the flaws planted for the review to find, one line each (a Netlify draft opened this way in step 4, and was marked ready there). Record it right away with impeccable_test_pr, so the web page follows its review; it refuses a draft, and a branch that changes no existing page. Done when impeccable_test_pr answered "Recorded".
|
|
@@ -15,6 +15,7 @@ Render deploys the app: impeccable_render_previews (with the app's `appKey` on a
|
|
|
15
15
|
|
|
16
16
|
- **The live app**: `production` is the site's own address; live-app-address says how it becomes the before, in step 3's write.
|
|
17
17
|
- **The pull requests**: no setup pull request; the test drive branches from the default branch. Render builds a preview only for a pull request, at once for a draft, so the test drive opens as a draft, impeccable_render_previews with `pull` waits for its preview, the test drive's page is proven on it, the method saved, and then the draft is marked ready: pull-requests' step 4. In place of pull-requests' step 3, build the test drive branch once with the build the service runs (`render.yaml`'s `buildCommand`, else the package's `build` script), so a page that does not build is never pushed.
|
|
18
|
+
- **A 404 on the test drive's page**: Render reports the deploy before its edge serves it, so impeccable_prove_preview asks again itself, for up to 90 seconds. Never push a guessed change to the test drive (a link, an address, a route) to explain a 404, and never wait with a command. Where the proof still answers 404, the page is not in the preview's build: pull-requests' "No preview, no test drive".
|
|
18
19
|
- `pullRequestPreviews` with `enabled: 'unknown'` (no answer from the CLI): say so in one line under "What is not verified" and go on; the wait for the test drive's preview opens Render's page when none comes.
|
|
19
20
|
- The evidence is read only: change nothing on Render beyond what impeccable_render_previews does on the person's yes. Save the pattern the proof answered, and no other.
|
|
20
21
|
- The report names the address that answered with this app under "What we found", and the pattern saved under "What was configured, and by whom". The test drive's own preview, which Render builds after the run, goes under "What is not verified".
|
package/dist/playbook.js
CHANGED
|
@@ -33,7 +33,7 @@ Set the repository up with whatever the host's CLI can do (Netlify's, Vercel's,
|
|
|
33
33
|
|
|
34
34
|
1. **Read the state.** The setup API's instructions and state came with the first message, trimmed to this setup; impeccable_state reads the state again. Done when you know the preview item's status, \`file\` and \`workflowStanding\`, whether there is a \`signin\` item, whether there is an \`apps\` item (the monorepo case comes first then), and the titles in \`notPartOfThisSetup\`.
|
|
35
35
|
2. **Read the repository**: manifests, the lockfile, scripts, framework config, CI workflows, env examples, the routes, the auth code and the seed. Done when you can name the package manager, the install, build and start commands, whether the pages need a server, how people sign in, and which pages a visitor opens without signing in.
|
|
36
|
-
3. **Set up the preview** as the vercel-previews case says when impeccable_hosted_previews lists Vercel's previews of the pull requests, as the netlify-previews case says when impeccable_netlify_previews lists Netlify's, or a site of this repository with Deploy Previews on (and as preview-protection says when the host's login or password stands in front of either), as the cloudflare-previews case says when impeccable_cloudflare_previews lists Cloudflare Pages', as the render-previews case says when impeccable_render_previews answers a Render site, as the vercel-setup case says when the checkout is set up for Vercel and Vercel has never deployed it, as the netlify-setup case says when the checkout is set up for Netlify and it lists none, else as the server-previews case says; then the live app's address as the live-app-address case says, in the method's write where it can be. Done when those cases' steps are done.
|
|
36
|
+
3. **Set up the preview** as the vercel-previews case says when impeccable_hosted_previews lists Vercel's previews of the pull requests, as the netlify-previews case says when impeccable_netlify_previews lists Netlify's, or a site of this repository with Deploy Previews on (and as preview-protection says when the host's login or password stands in front of either), as the cloudflare-previews case says when impeccable_cloudflare_previews lists Cloudflare's (a Pages project's, or a Worker's), as the render-previews case says when impeccable_render_previews answers a Render site, as the vercel-setup case says when the checkout is set up for Vercel and Vercel has never deployed it, as the netlify-setup case says when the checkout is set up for Netlify and it lists none, else as the server-previews case says; then the live app's address as the live-app-address case says, in the method's write where it can be. Done when those cases' steps are done.
|
|
37
37
|
4. **Set up the sign-in** as the sign-in case says, when the app has its own. Done when that case's steps are done.
|
|
38
38
|
5. **Run impeccable_checkup**, do what \`next\` says, and run it again. Done when \`status\` is complete, or all that is left is in \`forPerson\` or \`waitingFor\`.
|
|
39
39
|
6. **Open the pull requests** as the pull-requests case says. What waits on the person holds neither back. Done when that case's steps are done.
|
|
@@ -82,7 +82,7 @@ The person reads your [STATUS] lines, task titles and questions, here and on the
|
|
|
82
82
|
- **Questions**: one sentence, at most one hint line, and a title that names the subject.
|
|
83
83
|
|
|
84
84
|
## The report
|
|
85
|
-
impeccable_report ends the run: no tool or model call works after it, so call it once, alone, last. Open the report with the pull requests, one line each with its link: the test drive (each app's, named, on a repository with several apps), which Impeccable is reviewing (its review had not posted when the run ended; the web page shows it) and which the person closes when done, or, when none opened, what stopped it in a few words; then the setup pull request, when there is one, which they merge to keep previews working, or, when a host builds the previews and the repository needed no change, this one line with the host's name: *Nothing to merge: Vercel hosts your previews, so the repository needed no change.* Then these sections:
|
|
85
|
+
impeccable_report ends the run: no tool or model call works after it, so call it once, alone, last. Open the report with the pull requests, one line each with its link: the test drive (each app's, named, on a repository with several apps), which Impeccable is reviewing (its review had not posted when the run ended; the web page shows it) and which the person closes when done, or, when none opened, what stopped it in a few words; then the setup pull request, when there is one, which they merge to keep previews working, or, when a host builds the previews and the repository needed no change, this one line with the host's name: *Nothing to merge: Vercel hosts your previews, so the repository needed no change.* (on a repository with several apps, only when every chosen app is set up on a host's previews, naming each host). Where no preview method was saved for an app, the report says what stopped it and what the person can do, and never that a later pull request will be reviewed: nothing reviews it until a method is saved. impeccable_report's \`reason\` (on a repository with several apps, each such app's in \`apps\`) says that in one line, which the last screen shows. Then these sections:
|
|
86
86
|
|
|
87
87
|
\`\`\`markdown
|
|
88
88
|
## What we found
|
package/dist/tools.js
CHANGED
|
@@ -28,6 +28,7 @@ import { openBrowser } from './login.js';
|
|
|
28
28
|
import { STATUS_MAX, statusVoiceError, TASK_STATUSES } from './sync.js';
|
|
29
29
|
import { askPerson, reportDone, reportStatus, reportTasks, reportWait } from './ui/bridge.js';
|
|
30
30
|
import { SEE_THE_REVIEW } from './ui/look.js';
|
|
31
|
+
import { TaskOrder } from './ui/run-state.js';
|
|
31
32
|
import { noteSensitiveAnswer, plainText } from './ui/sensitive.js';
|
|
32
33
|
export { STATUS_MAX, statusVoiceError } from './sync.js';
|
|
33
34
|
/** The first way `value` breaks `schema`, or null when it fits. */
|
|
@@ -129,6 +130,8 @@ export const SETUP_ENDS = {
|
|
|
129
130
|
};
|
|
130
131
|
/** How many times a preview check the setup API turned away for its rate (one a repository every 30 seconds) is tried again (#338). */
|
|
131
132
|
const CHECK_RETRIES = 3;
|
|
133
|
+
/** The setup API signs a repository's saved accounts in once a minute: a save sooner is stored but not tried (#249). */
|
|
134
|
+
const SIGN_IN_CHECK_MS = 60_000;
|
|
132
135
|
/** The last screen's words for a run on several apps: the picked apps not set up, by name, the line naming which are and which are not, and a line for each that is not. */
|
|
133
136
|
export function appsSummary(apps) {
|
|
134
137
|
const picked = apps.filter((a) => a.state !== 'unpicked');
|
|
@@ -161,10 +164,12 @@ function stageError(status, tasks) {
|
|
|
161
164
|
}
|
|
162
165
|
/** The line under the last screen's heading, about the pull request that screen ends on: the setup pull request when there is one, else the test drive; and, where a host builds the previews and so no setup pull request was needed, one line saying so. Without a test drive, what stopped it, from what the run saved and met (#321). */
|
|
163
166
|
export function resultLine(report) {
|
|
167
|
+
// The run's own words for why no method was saved, where it gave them (REN-1108).
|
|
168
|
+
const why = report.reason && !report.methodSaved ? ` Why: ${report.reason.replace(/[.\s]+$/, '')}.` : '';
|
|
164
169
|
if (report.ended)
|
|
165
|
-
return SETUP_ENDS[report.ended].line
|
|
170
|
+
return `${SETUP_ENDS[report.ended].line}${why}`;
|
|
166
171
|
const lead = report.walled ? `Impeccable's own check of the preview met a wall (${report.walled}), so the test drive's review can't see its pages yet. The report, printed here when this screen closes, says what the run found.`
|
|
167
|
-
: !report.testPr ? `${report.unhonoured ?? noTestDrive(report)} The report, printed here when this screen closes, says what the run found.` : report.setupPr ? 'The setup pull request adds the workflow that lets Impeccable run on your previews. Merge it when you are ready.' : SEE_THE_REVIEW;
|
|
172
|
+
: !report.testPr ? `${report.unhonoured ?? noTestDrive(report)}${why} The report, printed here when this screen closes, says what the run found.` : report.setupPr ? 'The setup pull request adds the workflow that lets Impeccable run on your previews. Merge it when you are ready.' : SEE_THE_REVIEW;
|
|
168
173
|
// --signed-in-test-drive left an app without a test drive: said beside another app's drive or a wall too (#410).
|
|
169
174
|
const said = report.unhonoured && (report.walled || report.testPr) ? `${lead} ${report.unhonoured}` : lead;
|
|
170
175
|
return report.setupPr || !report.hostedBy ? said : `${said} ${nothingToMerge(report.hostedBy)}`;
|
|
@@ -198,7 +203,13 @@ export function ending(report) {
|
|
|
198
203
|
return { outcome, title, todo, code: 1 };
|
|
199
204
|
}
|
|
200
205
|
/** Said once, here and in the report, when the host builds the previews and the repository needed no change. */
|
|
201
|
-
export const nothingToMerge = (host) => `Nothing to merge: ${host} hosts your previews, so the repository needed no change.`;
|
|
206
|
+
export const nothingToMerge = (host) => `Nothing to merge: ${host} ${host.includes(' and ') ? 'host' : 'hosts'} your previews, so the repository needed no change.`;
|
|
207
|
+
/** The agent's words for why an app was not set up, kept only as plain words of at most 200 characters, with no address (a scheme, or a host with a path or query) and nothing shaped like a secret. */
|
|
208
|
+
function plainReason(text) {
|
|
209
|
+
const plain = text.trim();
|
|
210
|
+
const address = /[a-z][a-z0-9+.-]*:\/\/|[a-z0-9-]+(?:\.[a-z0-9-]+)+[/?#]/i.test(plain);
|
|
211
|
+
return plain && plain.length <= 200 && plainText(plain) === plain && !address && !scan('output', plain).length ? plain : undefined;
|
|
212
|
+
}
|
|
202
213
|
/** Names as a reader lists them: "a", "a and b", "a, b and c". */
|
|
203
214
|
const andList = (names) => (names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names.join(''));
|
|
204
215
|
/** The end screen's one line for the Netlify sites whose password the run set and saved, set but did not save, or saved as it was: every site in one line (#419). */
|
|
@@ -220,7 +231,7 @@ export function signedInLine(saved, app) {
|
|
|
220
231
|
const signed = accounts.filter((a) => a.outcome === 'signed-in' && typeof a.role === 'string').map(role);
|
|
221
232
|
const not = accounts.filter((a) => a.outcome !== 'signed-in' && typeof a.role === 'string').map(role);
|
|
222
233
|
if (!signed.length)
|
|
223
|
-
return undefined;
|
|
234
|
+
return not.length ? `${app ? `${app}: ` : ''}${andList(not)} did not sign in` : undefined;
|
|
224
235
|
return `${app ? `${app}: signed` : 'Signed'} in as ${andList(signed)}${not.length ? `; ${andList(not)} did not` : ''}`;
|
|
225
236
|
}
|
|
226
237
|
/** The last screen's line for each host, in the order first met: "Cloudflare: Acme's Account · project crewline", "Netlify: team Acme · site crewline", "Vercel: team acme · project crewline" (#360). */
|
|
@@ -268,9 +279,10 @@ const REPORT = {
|
|
|
268
279
|
description: `What waits on the person besides merging the setup pull request and closing the test drive: one line each, at most ${STATUS_MAX} characters, such as "Add the admin test account on the setup page". The terminal counts them. Leave it out when nothing does.`,
|
|
269
280
|
items: { type: 'string', minLength: 1 },
|
|
270
281
|
},
|
|
282
|
+
reason: { type: 'string', minLength: 1, description: 'On a repository of one app where no preview method was saved: why, in plain words of at most 200 characters, such as "Render\'s pull request previews are off, and the person skipped turning them on". The last screen shows it. Names only, never an address or a key.' },
|
|
271
283
|
apps: {
|
|
272
284
|
type: 'array',
|
|
273
|
-
description: 'On a repository with several apps: for each picked app whose preview was not proven or keyed, why, in plain words of at most 200 characters, such as "not checked: the person stopped after the first app". Names only, never an address or a key.',
|
|
285
|
+
description: 'On a repository with several apps: for each picked app whose preview was not proven or keyed, why, in plain words of at most 200 characters, such as "not checked: the person stopped after the first app". The last screen shows it on that app\'s line. Names only, never an address or a key.',
|
|
274
286
|
items: { type: 'object', additionalProperties: false, required: ['appKey', 'reason'], properties: { appKey: { type: 'string', minLength: 1 }, reason: { type: 'string', minLength: 1 } } },
|
|
275
287
|
},
|
|
276
288
|
},
|
|
@@ -1632,6 +1644,8 @@ export function createTools(ctx) {
|
|
|
1632
1644
|
const budget = () => ({ used: retry.used, pushReservations: retry.pushReservations, remaining: Math.max(0, 3 - retry.used), historyKnown: retry.historyKnown });
|
|
1633
1645
|
const attemptTasks = () => attempts().map((a, i) => ({ id: `preview-attempt-${a.runId}-${a.attempt}`,
|
|
1634
1646
|
title: `Preview job attempt ${i + 1}`, status: a.status === 'success' ? 'completed' : a.status === 'running' ? 'in_progress' : 'failed' }));
|
|
1647
|
+
/** The task list's order, the terminal's and the web page's alike (REN-1119). */
|
|
1648
|
+
const taskOrder = new TaskOrder();
|
|
1635
1649
|
const withAttempts = (tasks) => [...tasks.filter((t) => !t.id.startsWith('preview-attempt-')), ...attemptTasks()];
|
|
1636
1650
|
async function observe(signal) {
|
|
1637
1651
|
const pr = retry.pr;
|
|
@@ -1663,6 +1677,8 @@ export function createTools(ctx) {
|
|
|
1663
1677
|
/** The origins of Production deployments Vercel made of a pushed branch that no locked project of this folder's made: served at a project's own address without a login, so no proof opens them. */
|
|
1664
1678
|
const publicProduction = new Set();
|
|
1665
1679
|
const cloudflareListed = new Set();
|
|
1680
|
+
/** The address pattern of each Worker preview's origin Cloudflare's evidence listed, which the address alone cannot tell (#265), and of the Worker's own address it answered as production, the token scope they share: kept past a later read, for the last screen. */
|
|
1681
|
+
const workerPatterns = new Map();
|
|
1666
1682
|
/** The origins of the successful Render previews impeccable_render_previews listed, of the app's own site. */
|
|
1667
1683
|
const renderListed = new Set();
|
|
1668
1684
|
let hostedPreviewProven = false;
|
|
@@ -1705,6 +1721,9 @@ export function createTools(ctx) {
|
|
|
1705
1721
|
const label = pagesProject(origin);
|
|
1706
1722
|
if (label)
|
|
1707
1723
|
return [{ host: 'Cloudflare', project: label, ...(accountOf.has(`cloudflare ${label}`) ? { account: accountOf.get(`cloudflare ${label}`) } : {}) }];
|
|
1724
|
+
const worker = /^https:\/\/\{branch\}-([^.]+)\./.exec(workerPatterns.get(origin) ?? '')?.[1];
|
|
1725
|
+
if (worker)
|
|
1726
|
+
return [{ host: 'Cloudflare', project: worker }];
|
|
1708
1727
|
const site = netlifySiteOf(url);
|
|
1709
1728
|
if (site)
|
|
1710
1729
|
return [{ host: 'Netlify', project: site, ...(accountOf.has(`netlify ${site}`) ? { account: accountOf.get(`netlify ${site}`) } : {}) }];
|
|
@@ -2371,8 +2390,22 @@ export function createTools(ctx) {
|
|
|
2371
2390
|
return proof;
|
|
2372
2391
|
};
|
|
2373
2392
|
/** A proof opened the app's preview at `url`, so its method may be saved. */
|
|
2393
|
+
/** On a repository with several apps, a Preview method task done while a picked app has no method saved names whose it is, so its tick does not read as every app's (REN-1108). */
|
|
2394
|
+
const labelled = (tasks) => {
|
|
2395
|
+
if (!appsRepo)
|
|
2396
|
+
return tasks;
|
|
2397
|
+
const saved = [...(pickedApps ?? [])].filter((key) => appMethods.has(key));
|
|
2398
|
+
const some = saved.length > 0 && saved.length < pickedApps.size;
|
|
2399
|
+
// A label of an earlier list goes once every picked app has its method.
|
|
2400
|
+
return tasks.map((t) => (t.status !== 'completed' || !stagesOf(t.title).includes('Preview method') ? t : { ...t, title: some ? `Preview method: ${andList(saved.map(appName))} only` : t.title.replace(/^Preview method: .* only$/, 'Preview method') }));
|
|
2401
|
+
};
|
|
2402
|
+
/** On a repository with several apps, the host whose preview a proof opened for each app: the last screen names them all, or none where an app is not set up (REN-1108). */
|
|
2403
|
+
const appHosts = new Map();
|
|
2374
2404
|
const proveApp = (key, url) => {
|
|
2375
2405
|
provenApps.add(key);
|
|
2406
|
+
const host = previewHostName(url);
|
|
2407
|
+
if (host)
|
|
2408
|
+
appHosts.set(key, host);
|
|
2376
2409
|
noteApp(key, { preview_proven: true, preview_host: new URL(url).hostname });
|
|
2377
2410
|
};
|
|
2378
2411
|
/** Each app's folder, by key, as the state names it: what its host project or site must build. */
|
|
@@ -2671,7 +2704,7 @@ export function createTools(ctx) {
|
|
|
2671
2704
|
}
|
|
2672
2705
|
if (recordedChanges)
|
|
2673
2706
|
previews.changes.clear();
|
|
2674
|
-
const tasks = withAttempts(ctx.sync.payload.tasks);
|
|
2707
|
+
const tasks = taskOrder.sort(withAttempts(ctx.sync.payload.tasks));
|
|
2675
2708
|
ctx.sync.update({ tasks });
|
|
2676
2709
|
reportTasks(tasks);
|
|
2677
2710
|
return JSON.stringify({ ...observation, attempts: attempts(), budget: budget() });
|
|
@@ -2699,7 +2732,7 @@ export function createTools(ctx) {
|
|
|
2699
2732
|
}
|
|
2700
2733
|
const saved = object(p.body);
|
|
2701
2734
|
if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !== provenPatterns.get(appsRepo ? String(saved.appKey) : ''))) {
|
|
2702
|
-
return `Refused: the only address pattern saved is the one impeccable_prove_preview answered for ${appsRepo ? `this app's (${String(saved.appKey)})` : 'a'} Netlify, Cloudflare
|
|
2735
|
+
return `Refused: the only address pattern saved is the one impeccable_prove_preview answered for ${appsRepo ? `this app's (${String(saved.appKey)})` : 'a'} Netlify, Cloudflare or Render preview. Nothing was saved.`;
|
|
2703
2736
|
}
|
|
2704
2737
|
// The before is the live app's address, and only one a proof opened with this app: an address no key opens is not set, and one the person saved is never cleared from here.
|
|
2705
2738
|
if (target.url.endsWith('/preview') && saved && 'beforeUrl' in saved && (typeof saved.beforeUrl !== 'string' || !URL.canParse(saved.beforeUrl) || !provenLive.has(new URL(saved.beforeUrl).origin))) {
|
|
@@ -2734,14 +2767,48 @@ export function createTools(ctx) {
|
|
|
2734
2767
|
let response = await call(pi, target.url, { method: target.method, body });
|
|
2735
2768
|
// The setup API checks a repository's preview once per 30 seconds, whichever app asks: the next app's check waits its turn here, a bounded wait in place of the sleep the fence refuses (#338).
|
|
2736
2769
|
const check = /\/(connection-check|latest-pull-check)$/.test(target.url);
|
|
2770
|
+
const pause = ctx.checkWait ?? ((ms, stop) => sleep(ms, undefined, { signal: stop }));
|
|
2737
2771
|
for (let tries = 0; check && response.status === 429 && tries < CHECK_RETRIES && !signal?.aborted; tries++) {
|
|
2738
2772
|
const seconds = Math.min(Math.max(Math.ceil(response.retryAfter ?? 30), 1), 60);
|
|
2739
2773
|
said(`Waiting ${seconds} s for the next preview check`);
|
|
2740
|
-
await (
|
|
2774
|
+
await pause(seconds * 1000, signal).catch(() => undefined);
|
|
2741
2775
|
if (signal?.aborted)
|
|
2742
2776
|
break;
|
|
2743
2777
|
response = await call(pi, target.url, { method: target.method, body });
|
|
2744
2778
|
}
|
|
2779
|
+
// A password the save's sign-in turned away is asked for once more, on the same account screen, and the list saved again once the setup API can sign in again (#249).
|
|
2780
|
+
const tried = object(object(response.data)?.signInCheck)?.accounts;
|
|
2781
|
+
const turnedAway = new Set(testAccounts && response.ok && Array.isArray(tried) ? tried.filter((a) => object(a)?.outcome === 'wrong-credentials').map((a) => String(object(a)?.role)) : []);
|
|
2782
|
+
if (turnedAway.size) {
|
|
2783
|
+
const checkedAt = Date.now();
|
|
2784
|
+
let retyped = false;
|
|
2785
|
+
for (const account of object(body)?.accounts) {
|
|
2786
|
+
const role = String(account.role ?? 'default');
|
|
2787
|
+
if (!turnedAway.has(role))
|
|
2788
|
+
continue;
|
|
2789
|
+
const answer = await ask({ id: `${role}-account`, kind: 'account', title: role === 'default' ? 'Test account' : `${role.charAt(0).toUpperCase()}${role.slice(1)} account`, prompt: `Add the ${role === 'default' ? '' : `${role} `}test account.`, hint: `That password did not sign in${typeof account.loginPage === 'string' ? ` at ${account.loginPage}` : ''}. Type it again, or skip.`, prefill: String(account.username ?? '') });
|
|
2790
|
+
// A stop closes the screen: no other account is asked for, and nothing is saved again.
|
|
2791
|
+
if (answer.closed || signal?.aborted) {
|
|
2792
|
+
retyped = false;
|
|
2793
|
+
break;
|
|
2794
|
+
}
|
|
2795
|
+
if (answer.skipped)
|
|
2796
|
+
continue;
|
|
2797
|
+
// Kept with what the person typed, so the answer never carries it back to the model.
|
|
2798
|
+
typed.set(`secret:${randomBytes(6).toString('hex')}`, answer.password ?? '');
|
|
2799
|
+
Object.assign(account, { username: answer.username, password: answer.password });
|
|
2800
|
+
retyped = true;
|
|
2801
|
+
}
|
|
2802
|
+
const wait = SIGN_IN_CHECK_MS - (Date.now() - checkedAt);
|
|
2803
|
+
if (retyped && wait > 0) {
|
|
2804
|
+
said(`Waiting ${Math.ceil(wait / 1000)} s for the next sign-in check`);
|
|
2805
|
+
await pause(wait, signal).catch(() => undefined);
|
|
2806
|
+
}
|
|
2807
|
+
// A second save that fails leaves the first one's answer: those accounts are saved.
|
|
2808
|
+
const again = retyped && !signal?.aborted ? await call(pi, target.url, { method: target.method, body }) : undefined;
|
|
2809
|
+
if (again?.ok)
|
|
2810
|
+
response = again;
|
|
2811
|
+
}
|
|
2745
2812
|
if (testAccounts && response.ok)
|
|
2746
2813
|
retry.signedIn?.saved.add(appsRepo ? String(object(p.body)?.appKey) : '');
|
|
2747
2814
|
// The accounts the save signed in with, said on screen: the tasks show no status line (#417).
|
|
@@ -2888,7 +2955,6 @@ export function createTools(ctx) {
|
|
|
2888
2955
|
}),
|
|
2889
2956
|
tool('impeccable_progress', 'Progress', 'Show the person where things stand, here and on the web page: the [STATUS] line and the whole task list. It refuses, saving nothing, a status line out of the playbook\'s voice, more than one task in progress, a status line about another task than the one in progress, a task once completed that is no longer, Preview method completed before a method is saved, and a Preview workflow or Setup pull request task once a host\'s previews are the method.', PROGRESS, async (p) => {
|
|
2890
2957
|
const status = p.status.trim();
|
|
2891
|
-
const tasks = withAttempts(p.tasks);
|
|
2892
2958
|
const own = p.tasks.filter((t) => !t.id.startsWith('preview-attempt-'));
|
|
2893
2959
|
const voice = statusVoiceError(status) ?? stageError(status, own);
|
|
2894
2960
|
if (voice)
|
|
@@ -2903,6 +2969,8 @@ export function createTools(ctx) {
|
|
|
2903
2969
|
const unneeded = hostMethod && !perApp && !ctx.sync.payload.setup_pr ? own.filter((t) => /\bsetup pull request\b/i.test(t.title) || stagesOf(t.title).includes('Preview workflow')) : [];
|
|
2904
2970
|
if (unneeded.length)
|
|
2905
2971
|
return `Refused: ${savedHost ?? 'the host'} builds this run's previews, so the repository needs no workflow and no setup pull request. Leave ${unneeded.map((t) => `"${t.title}"`).join(' and ')} off the list: a task the run never does is not shown, not even as skipped. Nothing was saved: call impeccable_progress again.`;
|
|
2972
|
+
// Ordered only once taken, so a refused list leaves the order as it was.
|
|
2973
|
+
const tasks = taskOrder.sort(labelled(withAttempts(p.tasks)));
|
|
2906
2974
|
ctx.sync.update({ phase: 'running', status, tasks });
|
|
2907
2975
|
reportStatus(status);
|
|
2908
2976
|
reportTasks(tasks);
|
|
@@ -3052,13 +3120,24 @@ export function createTools(ctx) {
|
|
|
3052
3120
|
if (a.ended)
|
|
3053
3121
|
noteApp(a.key, { reason: `The setup ended for it: the person said no to ${SETUP_ENDS[a.ended].said}.` });
|
|
3054
3122
|
for (const { appKey, reason } of p.apps ?? []) {
|
|
3055
|
-
const plain = reason
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3123
|
+
const plain = plainReason(reason);
|
|
3124
|
+
if (!plain)
|
|
3125
|
+
continue;
|
|
3126
|
+
noteApp(appKey, { reason: plain });
|
|
3127
|
+
// The last screen says why on the app's own line, rather than sending the person to the report (REN-1108).
|
|
3128
|
+
const app = apps?.find((a) => a.key === appKey && a.state === 'unset' && !a.ended);
|
|
3129
|
+
if (app)
|
|
3130
|
+
app.left = `${app.name}: ${plain.replace(/[.\s]+$/, '')}.`;
|
|
3059
3131
|
}
|
|
3132
|
+
const reason = !appsRepo && typeof p.reason === 'string' ? plainReason(p.reason) : undefined;
|
|
3133
|
+
// The hosts build every preview only where every picked app is set up with a host's previews: on a repository where one app is not, no host speaks for it (REN-1108).
|
|
3134
|
+
const hostsOf = picked?.every((a) => a.state === 'ready' && appMethods.get(a.key) === 'preview-deploy' && appHosts.has(a.key)) ? [...new Set(picked.map((a) => appHosts.get(a.key)))] : [];
|
|
3135
|
+
const hostedBy = apps ? (hostsOf.length ? andList(hostsOf) : undefined) : savedHost;
|
|
3060
3136
|
// The web page lists what is left too, within the Worker's 20 lines (REPORT and the check above keep each line non-empty and short).
|
|
3061
|
-
|
|
3137
|
+
// The list as the run left the apps: a Preview method done for some apps only says whose (REN-1108).
|
|
3138
|
+
const tasks = taskOrder.sort(labelled(ctx.sync.payload.tasks));
|
|
3139
|
+
reportTasks(tasks);
|
|
3140
|
+
ctx.sync.update({ tasks, phase: 'completed', status: ended ? SETUP_ENDS[ended].title : 'Setup finished', question: null, report_markdown: markdown, test_pr: drive, ...(several ? { test_prs: Object.fromEntries(kept) } : {}), left: left.slice(0, 20) });
|
|
3062
3141
|
const res = await ctx.sync.flush();
|
|
3063
3142
|
if (res && res.status >= 400 && res.status < 500) {
|
|
3064
3143
|
// A definite no: the run is still open on the server, so the session goes back to what it was and the agent can send the report again.
|
|
@@ -3073,7 +3152,7 @@ export function createTools(ctx) {
|
|
|
3073
3152
|
}
|
|
3074
3153
|
// Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
|
|
3075
3154
|
const hosts = hostLines(hostsUsed());
|
|
3076
|
-
ctx.onReport({ markdown, left, testPr: drive, ...(several ? { testPrs: kept.map(([key, pr]) => ({ appKey: key, name: appName(key), ...pr })) } : {}), setupPr: ctx.sync.payload.setup_pr, ...(unhonoured ? { unhonoured } : {}), ...(
|
|
3155
|
+
ctx.onReport({ markdown, left, testPr: drive, ...(several ? { testPrs: kept.map(([key, pr]) => ({ appKey: key, name: appName(key), ...pr })) } : {}), setupPr: ctx.sync.payload.setup_pr, ...(unhonoured ? { unhonoured } : {}), ...(hostedBy ? { hostedBy } : {}), ...(reason ? { reason } : {}), ...(apps ? { apps } : {}), methodSaved, ...(lastLock && !hostedPreviewProven ? { lock: lastLock === 'netlify-protection' && lastTeamLogin ? 'netlify-login' : lastLock } : {}), ...(!apps && checkWalls.size ? { walled: [...checkWalls.values()].at(-1) } : {}), ...(hosts.length ? { hosts } : {}), ...(ended ? { ended } : {}) });
|
|
3077
3156
|
pi.shutdown();
|
|
3078
3157
|
const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
|
|
3079
3158
|
return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
|
|
@@ -3234,7 +3313,7 @@ export function createTools(ctx) {
|
|
|
3234
3313
|
noteApp(appKey, { host_project: site });
|
|
3235
3314
|
return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
|
|
3236
3315
|
}),
|
|
3237
|
-
tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in; where cf is not installed here, the person is asked to install it first, on its own screen). production is the project's own address, https://<project>.pages.dev, the live app, for the before. cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. pullRequestPreviews says whether the Pages project built from this repository builds previews of pull requests at all (enabled false, with the cause: source, deployments or previews); worker says Cloudflare builds a Worker from the repository (Workers Builds),
|
|
3316
|
+
tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in; where cf is not installed here, the person is asked to install it first, on its own screen). production is the project's own address, https://<project>.pages.dev, the live app, for the before. cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. pullRequestPreviews says whether the Pages project built from this repository builds previews of pull requests at all (enabled false, with the cause: source, deployments or previews); worker says Cloudflare builds a Worker from the repository (Workers Builds); where its branch builds make previews, they are listed too, on workers.dev (the alias https://<branch>-<worker>.<subdomain>.workers.dev, with the pattern it stands for), and with no Pages project production is the Worker's own address; githubApp says what gh reads of Cloudflare's GitHub app on the owner, and the page where it gets access. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer, and again on the person's yes when the time ran out; where the project's settings build every branch and no build came, the wait's screen opens the GitHub page where Cloudflare's app gets access to the repository instead, and waits again. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
|
|
3238
3317
|
const pull = p.pull;
|
|
3239
3318
|
const branch = p.branch;
|
|
3240
3319
|
if (pull !== undefined && branch !== undefined)
|
|
@@ -3252,7 +3331,9 @@ export function createTools(ctx) {
|
|
|
3252
3331
|
return `Refused: read impeccable_cloudflare_previews with appKey ${appKey} and no pull or branch first: it tells which Pages project builds that app.`;
|
|
3253
3332
|
// The name of the account the project was found in is for the last screen alone.
|
|
3254
3333
|
let accountName;
|
|
3255
|
-
|
|
3334
|
+
// A Worker Preview's build names no address: its alias is made from the pattern the plain read's Worker preview stood for.
|
|
3335
|
+
const pattern = cloudflareEvidence?.previews.find((preview) => preview.pattern)?.pattern;
|
|
3336
|
+
const readAll = () => (ctx.cloudflarePreviewReader ?? readCloudflarePreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, branch, onAccountName: (name) => (accountName = name), ...(directory !== undefined && pull === undefined && branch === undefined ? { directory } : {}), ...(appProject ? { project: appProject.project } : {}), ...(pattern ? { pattern } : {}) });
|
|
3256
3337
|
const read = async () => {
|
|
3257
3338
|
const evidence = await readAll();
|
|
3258
3339
|
return appProject ? { ...evidence, previews: evidence.previews.filter((preview) => pagesProject(preview.url) === appProject.label) } : evidence;
|
|
@@ -3265,7 +3346,7 @@ export function createTools(ctx) {
|
|
|
3265
3346
|
// unless it covers them all, or Cloudflare built a preview of the repository already, which it could not without the app.
|
|
3266
3347
|
const app = cloudflareEvidence?.githubApp;
|
|
3267
3348
|
const access = branch !== undefined && previewFacts.get('cloudflare')?.enabled === true && app?.repositories !== 'all' && !cloudflareEvidence?.previews.length ? { page: app?.page ?? CLOUDFLARE_APP_PAGE, now: app?.installed === false, ...(typeof p.appKey === 'string' ? { app: p.appKey } : {}) } : undefined;
|
|
3268
|
-
let waited = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal, access) : await read();
|
|
3349
|
+
let waited = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare ${pattern ? 'Workers' : 'Pages'} to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal, access) : await read();
|
|
3269
3350
|
// cf not on this machine (#396): its install is asked before anything installs it, and the evidence read again with it.
|
|
3270
3351
|
if (!subject && waited.cli === 'missing') {
|
|
3271
3352
|
await installCf(pi, signal);
|
|
@@ -3292,10 +3373,16 @@ export function createTools(ctx) {
|
|
|
3292
3373
|
continue;
|
|
3293
3374
|
for (const address of [preview.url, ...(preview.alias ? [preview.alias] : [])]) {
|
|
3294
3375
|
cloudflareListed.add(new URL(address).origin);
|
|
3376
|
+
if (preview.pattern)
|
|
3377
|
+
workerPatterns.set(new URL(address).origin, preview.pattern);
|
|
3295
3378
|
if (appKey !== undefined)
|
|
3296
3379
|
listedApps.set(new URL(address).origin, { appKey });
|
|
3297
3380
|
}
|
|
3298
3381
|
}
|
|
3382
|
+
// The Worker's own address, the live app with no Pages project, is in its previews' scope: Access's token goes there as it goes to a Pages project's own address.
|
|
3383
|
+
const workerPattern = evidence.previews.find((preview) => preview.pattern)?.pattern;
|
|
3384
|
+
if (workerPattern && !evidence.project && evidence.production)
|
|
3385
|
+
workerPatterns.set(new URL(evidence.production).origin, workerPattern);
|
|
3299
3386
|
if (evidence.pullRequestPreviews)
|
|
3300
3387
|
previewFacts.set('cloudflare', evidence.pullRequestPreviews);
|
|
3301
3388
|
const named = evidence.pullRequestPreviews?.project || evidence.project;
|
|
@@ -3304,7 +3391,7 @@ export function createTools(ctx) {
|
|
|
3304
3391
|
noteProduction(evidence.unmatched ? undefined : evidence.production, 'cloudflare', undefined, appKey);
|
|
3305
3392
|
return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
|
|
3306
3393
|
}),
|
|
3307
|
-
tool('impeccable_prove_preview', 'Check a hosted preview', 'Open a preview address impeccable_hosted_previews, impeccable_netlify_previews, impeccable_cloudflare_previews or impeccable_render_previews listed for a pull request or a pushed branch (a successful Preview deployment, a deploy preview that is ready, a Pages build that succeeded, or a Render preview), or a page on it, as a visitor signed in nowhere, and prove it: it answers, and the page shows words a file of this checkout holds. proven true lets impeccable_write save intendedMethod "preview-deploy"; for Netlify, Cloudflare Pages and Render it answers the address pattern to save beside it. gate "vercel-protection", "netlify-protection" or "cloudflare-access" means the host\'s protection stands in front of it. Or open the live app\'s address, the production one of those tools answered or one a file of this checkout names (the README\'s live-site line), or a page on it, the same way: proven true lets impeccable_write save it as beforeUrl, and nothing else. A key the run got in place for the same project or site goes with it. Nothing of the page comes back.', PROVE_PREVIEW, async (p, pi, signal) => {
|
|
3394
|
+
tool('impeccable_prove_preview', 'Check a hosted preview', 'Open a preview address impeccable_hosted_previews, impeccable_netlify_previews, impeccable_cloudflare_previews or impeccable_render_previews listed for a pull request or a pushed branch (a successful Preview deployment, a deploy preview that is ready, a Pages build that succeeded, or a Render preview), or a page on it, as a visitor signed in nowhere, and prove it: it answers, and the page shows words a file of this checkout holds. proven true lets impeccable_write save intendedMethod "preview-deploy"; for Netlify, Cloudflare (Pages, or a Worker\'s previews) and Render it answers the address pattern to save beside it. gate "vercel-protection", "netlify-protection" or "cloudflare-access" means the host\'s protection stands in front of it. Or open the live app\'s address, the production one of those tools answered or one a file of this checkout names (the README\'s live-site line), or a page on it, the same way: proven true lets impeccable_write save it as beforeUrl, and nothing else. A key the run got in place for the same project or site goes with it. Nothing of the page comes back.', PROVE_PREVIEW, async (p, pi, signal) => {
|
|
3308
3395
|
const url = p.url;
|
|
3309
3396
|
const origin = URL.canParse(url) ? new URL(url).origin : null;
|
|
3310
3397
|
if (origin && publicProduction.has(origin))
|
|
@@ -3317,16 +3404,31 @@ export function createTools(ctx) {
|
|
|
3317
3404
|
return "Refused: open the address of a preview that impeccable_hosted_previews, impeccable_netlify_previews, impeccable_cloudflare_previews or impeccable_render_previews listed for a pull request or a pushed branch, or a page on it; or, for the before, the live app's address one of them answered as production, or the site's or project's own address on one of the hosts that a file of this checkout names.";
|
|
3318
3405
|
// The Netlify site, by the address's shape or, for the site's custom domain, as its tool named it: its password goes to the site's own addresses.
|
|
3319
3406
|
const site = netlifySiteOf(url) ?? known?.site;
|
|
3320
|
-
// A saved service token opens its Pages project's previews: Access admits it wherever the application's policy covers.
|
|
3321
|
-
const project = pagesProject(origin);
|
|
3407
|
+
// A saved service token opens its Pages project's previews, or its Worker's: Access admits it wherever the application's policy covers.
|
|
3408
|
+
const project = pagesProject(origin) ?? workerPatterns.get(origin);
|
|
3322
3409
|
const token = accessToken && accessStored && project && accessProjects.has(project) ? accessToken : undefined;
|
|
3323
|
-
const
|
|
3410
|
+
const prove = () => provePreview(url, p.expect, pi.cwd, {
|
|
3324
3411
|
fetch: ctx.previewFetch, signal, live: live ? (known?.host ?? true) : false,
|
|
3325
3412
|
// The project's bypass secret opens the project's own addresses: the previews it proved, and the production address read from the same project.
|
|
3326
3413
|
...(bypassFor(origin, live) ? { bypass: bypassFor(origin, live) } : {}),
|
|
3327
3414
|
...(site && sitePasswords.has(site) ? { password: sitePasswords.get(site).value } : {}),
|
|
3328
3415
|
...(token ? { access: token } : {}),
|
|
3329
3416
|
});
|
|
3417
|
+
let proved = await prove();
|
|
3418
|
+
// Render reports a preview's deploy successful before its edge serves that build (REN-1117): a page the push added answers 404 for a while, so it is asked for again here, within a bounded wait, the way a command's sleep would but the fence refuses.
|
|
3419
|
+
if (!live && renderListed.has(origin) && proved.status === 404) {
|
|
3420
|
+
const wait = ctx.hostWait?.serve ?? { totalMs: 90_000, everyMs: 5_000 };
|
|
3421
|
+
said('Waiting for Render to serve the new page');
|
|
3422
|
+
for (const end = Date.now() + wait.totalMs; proved.status === 404 && Date.now() < end && !signal?.aborted;) {
|
|
3423
|
+
await sleep(wait.everyMs, undefined, { signal }).catch(() => undefined);
|
|
3424
|
+
if (!signal?.aborted)
|
|
3425
|
+
proved = await prove();
|
|
3426
|
+
}
|
|
3427
|
+
if (proved.status === 404 && !signal?.aborted)
|
|
3428
|
+
proved = { ...proved, reason: `The address still answered 404 ${Math.round(wait.totalMs / 1000)} s after Render reported the preview built: the page is not in the preview's build.` };
|
|
3429
|
+
}
|
|
3430
|
+
// A Worker's preview proven answers the pattern its evidence listed.
|
|
3431
|
+
const proof = proved.proven && !live && workerPatterns.has(origin) ? { ...proved, pattern: workerPatterns.get(origin) } : proved;
|
|
3330
3432
|
// A live address proven is the before, and nothing else: no method, no pattern and no lock to get past follow from it.
|
|
3331
3433
|
if (live) {
|
|
3332
3434
|
if (proof.proven)
|
|
@@ -4012,7 +4114,7 @@ export function createTools(ctx) {
|
|
|
4012
4114
|
return null;
|
|
4013
4115
|
};
|
|
4014
4116
|
let answer;
|
|
4015
|
-
const project = pagesProject(new URL(at.url).origin);
|
|
4117
|
+
const project = pagesProject(new URL(at.url).origin) ?? workerPatterns.get(new URL(at.url).origin);
|
|
4016
4118
|
// The token in hand goes only to a project it opened already, as the proofs send it: any other address is read for an application of the person's first.
|
|
4017
4119
|
if (accessToken && project && accessProjects.has(project) && (await prove(accessToken)).proven) {
|
|
4018
4120
|
// The token in hand opens this address too: stored already, or stored now.
|
|
@@ -4065,14 +4167,14 @@ export function createTools(ctx) {
|
|
|
4065
4167
|
if (project)
|
|
4066
4168
|
accessProjects.add(project);
|
|
4067
4169
|
}
|
|
4068
|
-
const pattern = told.status === 'saved' ? proof?.pattern : undefined;
|
|
4170
|
+
const pattern = told.status === 'saved' ? (proof?.pattern ?? workerPatterns.get(new URL(at.url).origin)) : undefined;
|
|
4069
4171
|
accessOutcome = { ...told, ...(told.status === 'saved' ? { proven: true, ...(pattern ? { pattern } : {}) } : {}) };
|
|
4070
4172
|
// The person's no to the service token leaves the previews behind Cloudflare Access: the setup ends there for the app (#413).
|
|
4071
4173
|
if (told.status === 'skipped')
|
|
4072
4174
|
decline('no-access', at.app?.appKey);
|
|
4073
4175
|
if (told.status === 'saved') {
|
|
4074
4176
|
hostedPreviewProven = true;
|
|
4075
|
-
provenHost = 'Cloudflare Pages';
|
|
4177
|
+
provenHost = previewHostName(at.url) ?? 'Cloudflare Pages';
|
|
4076
4178
|
usedPreviews.add(at.url);
|
|
4077
4179
|
if (at.app)
|
|
4078
4180
|
proveApp(at.app.appKey, at.url);
|
package/dist/ui/run-state.js
CHANGED
|
@@ -3,6 +3,22 @@ import { statusLine } from '../sync.js';
|
|
|
3
3
|
export function statusFromEvent(event) {
|
|
4
4
|
return event.type === 'message_end' ? statusLine(event.message) : null;
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* The order a task list reads in, in the terminal and on the web page alike (REN-1119): the tasks that left pending
|
|
8
|
+
* (skipped too), in the order they first did, then those still waiting, each group in the order given. A task that
|
|
9
|
+
* waits under the task in progress (Preview method while Test drive runs) never shows above it unticked (#419).
|
|
10
|
+
*/
|
|
11
|
+
export class TaskOrder {
|
|
12
|
+
moved = new Set();
|
|
13
|
+
sort(tasks) {
|
|
14
|
+
for (const t of tasks)
|
|
15
|
+
if (t.status !== 'pending')
|
|
16
|
+
this.moved.add(t.id);
|
|
17
|
+
const order = [...this.moved];
|
|
18
|
+
const rank = (t) => (this.moved.has(t.id) ? order.indexOf(t.id) : order.length);
|
|
19
|
+
return tasks.map((t, i) => ({ t, i })).sort((a, b) => rank(a.t) - rank(b.t) || a.i - b.i).map(({ t }) => t);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
6
22
|
/** After this long with nothing new on screen, the status band says the run is still working. */
|
|
7
23
|
const SILENCE_MS = 60_000;
|
|
8
24
|
/** How long something has taken, the way the screen says it: "45 s", "2 min", "1 h 5 min". */
|
|
@@ -30,8 +46,8 @@ export class RunState {
|
|
|
30
46
|
waiting;
|
|
31
47
|
/** The tasks that have been in progress, done or failed in this run. */
|
|
32
48
|
started = new Set();
|
|
33
|
-
/** The
|
|
34
|
-
|
|
49
|
+
/** The order the list shows the tasks in. */
|
|
50
|
+
order = new TaskOrder();
|
|
35
51
|
/** When something last changed on screen: a status, the task list, an answered question. */
|
|
36
52
|
changedAt;
|
|
37
53
|
constructor(now = Date.now, first) {
|
|
@@ -68,15 +84,9 @@ export class RunState {
|
|
|
68
84
|
for (const t of tasks)
|
|
69
85
|
if (t.status === 'in_progress' || t.status === 'completed' || t.status === 'failed')
|
|
70
86
|
this.started.add(t.id);
|
|
71
|
-
for (const t of tasks)
|
|
72
|
-
if (t.status !== 'pending')
|
|
73
|
-
this.moved.add(t.id);
|
|
74
87
|
// A task that has started never reads as not started again (#390).
|
|
75
88
|
const shown = tasks.map((t) => ({ ...t, ...(t.status === 'pending' && this.started.has(t.id) ? { started: true } : {}) }));
|
|
76
|
-
|
|
77
|
-
const order = [...this.moved];
|
|
78
|
-
const rank = (t) => (this.moved.has(t.id) ? order.indexOf(t.id) : order.length);
|
|
79
|
-
this.tasks = shown.map((t, i) => ({ t, i })).sort((a, b) => rank(a.t) - rank(b.t) || a.i - b.i).map(({ t }) => t);
|
|
89
|
+
this.tasks = this.order.sort(shown);
|
|
80
90
|
this.touch();
|
|
81
91
|
}
|
|
82
92
|
/** Something happened that the person saw, so the silence starts again. */
|