omakit 0.6.2 → 0.6.4
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 +26 -3
- package/package.json +1 -1
- package/skills/omarchy-plugin-build/SKILL.md +3 -0
- package/skills/omarchy-plugin-submit/SKILL.md +38 -2
- package/skills/omarchy-plugin-validation-watch/SKILL.md +55 -4
- package/tools/blocks/commit.json +2 -2
- package/tools/marketplace/cli.mjs +52 -10
- package/tools/marketplace/measure-repository-owner.mjs +56 -0
- package/tools/marketplace/options.mjs +3 -3
- package/tools/marketplace/report.mjs +21 -5
- package/tools/marketplace/review-cost.mjs +33 -6
- package/tools/marketplace/submit.mjs +28 -2
- package/tools/marketplace/usage.mjs +9 -2
- package/tools/marketplace/watch.mjs +211 -9
package/README.md
CHANGED
|
@@ -6,7 +6,26 @@
|
|
|
6
6
|
|
|
7
7
|
The three parts are the Run and Store blocks a plugin copies into its own tree, the marketplace's own checks run locally, and repository suites run in a disposable guest. Behind the first are M13's 1,001 security blocker comments from one measured week: 587 raise a Run line, 527 a Store line, and runner plus store handle 777 of 1,001 when read as an upper bound, not as a result for any plugin ([M13](docs/MEASUREMENTS.md#m13-what-the-review-blocks-on-over-one-week-of-comments-and-which-of-it-a-block-can-own)). No reviewer has seen a ported plugin yet.
|
|
8
8
|
|
|
9
|
-
[](https://github.com/tcballard/omarchy-badges) [](https://www.npmjs.com/package/omakit) [](https://github.com/mtolhuys/omakit/actions/workflows/ci.yml)
|
|
9
|
+
[](https://github.com/tcballard/omarchy-badges) [](https://www.npmjs.com/package/omakit) [](https://github.com/mtolhuys/omakit/actions/workflows/ci.yml)
|
|
10
|
+
|
|
11
|
+
<!-- TODO(socket-badge): Restore the live Socket package-report badge when its endpoint reliably returns image/svg+xml without a Cloudflare challenge. Last checked 2026-09-19. -->
|
|
12
|
+
|
|
13
|
+
## What it delivers
|
|
14
|
+
|
|
15
|
+
| Command | What you get | Documentation |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| `omakit add <block> [<plugin-dir>]` | Copy the Run or Store block into a plugin. The plugin owns the files, and an edited copy is never overwritten. | [Blocks](docs/BLOCKS.md) |
|
|
18
|
+
| `omakit inspect <plugin-dir>` | See what the tree does and what deserves attention, with the file and line for each observation. | [Inspect](docs/INSPECT.md) |
|
|
19
|
+
| `omakit verify <plugin-repo>` | Get the marketplace's security result for your commit, exactly as it would see it. | [Verify](docs/COMMANDS.md#omakit-verify) |
|
|
20
|
+
| `omakit submit <plugin-repo>` | Run every submission check and get the issue title and body ready to paste. Nothing is posted. | [Submit](docs/SUBMIT.md) |
|
|
21
|
+
| `omakit watch ...` | See whether the marketplace-validated commit is still current and what to do when it is not. | [Watch](docs/VALIDATION_WATCH.md) |
|
|
22
|
+
| `omakit audit [<plugin>]` | Find installed plugins running commits the marketplace never validated. | [Audit](docs/AUDIT.md) |
|
|
23
|
+
| `omakit lab <command>` | Set up, inspect, prove in or prune the disposable Omarchy guest. | [Lab](docs/LAB.md) |
|
|
24
|
+
| `omakit weigh <plugin>` | Measure a plugin's CPU and child processes against the shell's own baseline, then restore the shell config. | [Weigh](docs/WEIGH.md) |
|
|
25
|
+
| `omakit setup`, `omakit doctor` | Prepare or check the pin, completion, tools and disposable lab. | [Install](docs/INSTALL.md) |
|
|
26
|
+
| `omakit pin`, `omakit upgrade`, `omakit parity`, `omakit help --agent` | Maintain Omakit or inspect its own contract and parity proof. | [All commands](docs/COMMANDS.md) |
|
|
27
|
+
|
|
28
|
+
The complete syntax, options, exit codes and JSON/output contract are in the [command reference](docs/COMMANDS.md).
|
|
10
29
|
|
|
11
30
|
## Install
|
|
12
31
|
|
|
@@ -19,7 +38,7 @@ Omakit needs [Node 22+](package.json) and Git. `omakit setup` prepares the pinne
|
|
|
19
38
|
|
|
20
39
|
## Build
|
|
21
40
|
|
|
22
|
-
`omakit add run <plugin-dir>` and `omakit add store <plugin-dir>` copy versioned process and private-state plumbing into the plugin's `omakit/` directory; the plugin owns those files, and an edited copy is never overwritten. Commit the copied files before checking, because check reads committed `HEAD` unless you pass `--allow-dirty`.
|
|
41
|
+
`omakit add run <plugin-dir>` and `omakit add store <plugin-dir>` copy versioned process and private-state plumbing into the plugin's `omakit/` directory; the plugin owns those files, and an edited copy is never overwritten. Commit the copied files before checking, because check reads committed `HEAD` unless you pass `--allow-dirty`. Why that plumbing is worth taking from somewhere else is [in one page](docs/WHY.md).
|
|
23
42
|
|
|
24
43
|

|
|
25
44
|
|
|
@@ -65,7 +84,11 @@ After one `omakit lab setup`, `omakit lab prove <suite>` boots a fresh overlay f
|
|
|
65
84
|
|
|
66
85
|
## Documentation
|
|
67
86
|
|
|
68
|
-
|
|
87
|
+
Every page, and which question each one answers: [the documentation
|
|
88
|
+
index](docs/README.md).
|
|
89
|
+
|
|
90
|
+
- [The path from a plugin to a listing](docs/WALKTHROUGH.md) and [when a command stops](docs/FAILURES.md)
|
|
91
|
+
- [Why the blocks exist](docs/WHY.md), [the blocks themselves](docs/BLOCKS.md) and [their design spike](docs/BLOCKS_SPIKE.md)
|
|
69
92
|
- [Commands](docs/COMMANDS.md), [inspect](docs/INSPECT.md), [submit](docs/SUBMIT.md), [watch](docs/VALIDATION_WATCH.md), [audit](docs/AUDIT.md) and [weigh](docs/WEIGH.md)
|
|
70
93
|
- [The disposable lab](docs/LAB.md) and [every measurement](docs/MEASUREMENTS.md)
|
|
71
94
|
- [Installation](docs/INSTALL.md), [media recipes](docs/media/README.md) and [repository rules](AGENTS.md)
|
package/package.json
CHANGED
|
@@ -8,6 +8,9 @@ description: Build the process and state plumbing of an Omarchy Quattro plugin w
|
|
|
8
8
|
This is the build job: copy the measured process and private-state plumbing
|
|
9
9
|
into the plugin before checking, tracking or proving anything around it.
|
|
10
10
|
|
|
11
|
+
The argument behind the blocks, for a reader who wants it, is
|
|
12
|
+
`docs/WHY.md` in the omakit repository.
|
|
13
|
+
|
|
11
14
|
## Start every process through Run
|
|
12
15
|
|
|
13
16
|
Whenever plugin code starts a program, it goes through the Run block, never
|
|
@@ -162,5 +162,41 @@ words, and so should you.
|
|
|
162
162
|
Tell the owner the one thing nobody tells them: the marketplace validated one
|
|
163
163
|
exact commit, and **editing the issue body** is the only action that makes it
|
|
164
164
|
validate a newer one. Pushing a fix does nothing. Commenting "fixed in `abc123`" does nothing.
|
|
165
|
-
Then use `omakit watch <issue-url>` to check it
|
|
166
|
-
`skills/omarchy-plugin-validation-watch/SKILL.md`.
|
|
165
|
+
Then use `omakit watch <issue-url> <path-to-the-plugin-repo>` to check it
|
|
166
|
+
later, and see `skills/omarchy-plugin-validation-watch/SKILL.md`.
|
|
167
|
+
|
|
168
|
+
When the issue has to be edited, whether to retry after a push or because the
|
|
169
|
+
marketplace refused it, follow the protocol below. It is the same protocol
|
|
170
|
+
the validation-watch skill names, under the same name.
|
|
171
|
+
|
|
172
|
+
## Retry edit protocol
|
|
173
|
+
|
|
174
|
+
A retry edit is never typed. It is the body `omakit submit` renders, and
|
|
175
|
+
`omakit watch` verifies afterwards that the issue says what `origin` says.
|
|
176
|
+
The reason is omacom/omarchy-plugin-marketplace#7787, 2026-09-20: an agent
|
|
177
|
+
following a skill retyped the whole body for a retry, the Repository URL
|
|
178
|
+
came out as `mtolhuijs/omacrunch` where origin says `mtolhuys/omacrunch`,
|
|
179
|
+
the Maintainer notes were wiped, and the marketplace refused the issue as
|
|
180
|
+
`repository-unreachable` 40 seconds after the edit.
|
|
181
|
+
|
|
182
|
+
1. Re-run `omakit submit` with the same `--category`, `--tags` and `--notes`
|
|
183
|
+
as the original submission, plus `--body-out <file>`. The Repository URL
|
|
184
|
+
in that body comes from `origin`; you never type it.
|
|
185
|
+
2. Read the current issue body (`gh issue view <url> --json body`). Diff it
|
|
186
|
+
against the new body. The only lines allowed to differ are under
|
|
187
|
+
"### Maintainer notes". Any other difference means you are about to change
|
|
188
|
+
the submission; stop and show the owner the diff.
|
|
189
|
+
3. With the owner's explicit approval: `gh issue edit <url> --body-file <file>`.
|
|
190
|
+
4. Run `omakit watch <url> <path-to-the-plugin-repo>`. Expect `current` within
|
|
191
|
+
two minutes. `wrong-repository` or `refused` means step 2 was skipped; fix
|
|
192
|
+
it now, by the same steps.
|
|
193
|
+
|
|
194
|
+
Never retype the body. Never write the Repository URL by hand. Never edit a
|
|
195
|
+
`current` issue to bump it: a fresh validation clears the reviewer's human
|
|
196
|
+
decision and costs a complete re-read of the plugin.
|
|
197
|
+
|
|
198
|
+
`submission.issue-repository-url` is the check that catches the typo before
|
|
199
|
+
the next run: when you have an open submission issue for this plugin and its
|
|
200
|
+
Repository URL is not `origin`, `submit` is refused and the remedy names the
|
|
201
|
+
issue and the URL to put back. Put it back with the protocol above, not by
|
|
202
|
+
editing the field by hand.
|
|
@@ -29,11 +29,22 @@ not a measurement of all 464 issues in the queue.
|
|
|
29
29
|
## Run the check
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
|
-
omakit watch https://github.com/omacom/omarchy-plugin-marketplace/issues/<number>
|
|
32
|
+
omakit watch https://github.com/omacom/omarchy-plugin-marketplace/issues/<number> <path-to-the-plugin-repo>
|
|
33
33
|
omakit watch --all --json
|
|
34
34
|
omakit watch --list --json
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
Give the plugin's checkout (or its github.com URL) as the second argument
|
|
38
|
+
whenever you have it; without one, the current directory is the subject
|
|
39
|
+
when it is such a checkout and its `manifest.json` is the plugin the issue
|
|
40
|
+
names. A directory that is another plugin is not compared, and the report
|
|
41
|
+
says so (`plugin.subjectSkipped` in `--json`): pass the plugin then, do not
|
|
42
|
+
read the origin it prints as the one to put in the issue. With a subject,
|
|
43
|
+
the issue's Repository URL is compared with the plugin's `origin`, and the
|
|
44
|
+
verdict `wrong-repository` names a mismatch before anything else is
|
|
45
|
+
compared. `--json` carries it as `plugin.origin` and
|
|
46
|
+
`plugin.repositoryMatches` (`null` when there is no subject, as in `--all`).
|
|
47
|
+
|
|
37
48
|
If `omakit` is not installed: `npm install --global omakit` (Omarchy ships Node
|
|
38
49
|
and npm through mise), then `omakit doctor`; if the command is not found after
|
|
39
50
|
the install, `"$(npm prefix --global)/bin/omakit" setup` prints the one line
|
|
@@ -64,9 +75,8 @@ and publish a newer upstream commit", read from the pin.
|
|
|
64
75
|
**`stale`.** The validated commit is behind the repository. Tell the owner plainly: the marketplace has not
|
|
65
76
|
seen the newer commit, and the fix is to **edit the issue body** (any edit
|
|
66
77
|
re-triggers validation and the baseline against the new commit). Do not advise
|
|
67
|
-
them to push again, and do not advise them to comment.
|
|
68
|
-
|
|
69
|
-
outside this tool.
|
|
78
|
+
them to push again, and do not advise them to comment. The edit is made by the
|
|
79
|
+
retry edit protocol below, never by retyping the body.
|
|
70
80
|
|
|
71
81
|
**`current`.** The validated commit is the current HEAD. The submission is genuinely waiting
|
|
72
82
|
on a person, or on a fix the reviewer asked for that has not been made. Read the
|
|
@@ -74,10 +84,51 @@ review comments and address the substance. Do not edit the issue to "bump" it: a
|
|
|
74
84
|
fresh validation clears the reviewer's human decision and costs him a complete
|
|
75
85
|
re-read of the plugin.
|
|
76
86
|
|
|
87
|
+
**`wrong-repository`.** The issue's Repository URL is not the plugin's
|
|
88
|
+
`origin`, so the marketplace is validating another repository, or none. The
|
|
89
|
+
output prints both URLs. Tell the owner which one the issue says and which one
|
|
90
|
+
origin says, and fix it by the retry edit protocol below: the body `omakit
|
|
91
|
+
submit` renders carries the origin, and nothing else on the issue changes. Do
|
|
92
|
+
not correct the field by hand.
|
|
93
|
+
|
|
94
|
+
**`refused`.** The marketplace's last validation of this issue failed, and
|
|
95
|
+
that refusal is newer than the last baseline marker. The output prints the
|
|
96
|
+
marketplace's own code, reason and action, read from the pinned feedback
|
|
97
|
+
table; an unmatched reason is printed verbatim as `unrecognised`. Fix what
|
|
98
|
+
the code names in the repository first when it is the repository
|
|
99
|
+
(`readme-missing`, `manifest-invalid`), and when it is the issue itself
|
|
100
|
+
(`repository-unreachable`, `submission-fields-invalid`) the fix is the retry
|
|
101
|
+
edit protocol below. Never report `refused` as waiting on the reviewer.
|
|
102
|
+
|
|
77
103
|
**`unknown`.** There is no validated commit to compare, the last baseline did not
|
|
78
104
|
complete, or the repository's HEAD could not be read. The output says which. Never
|
|
79
105
|
report this as `current`.
|
|
80
106
|
|
|
107
|
+
## Retry edit protocol
|
|
108
|
+
|
|
109
|
+
A retry edit is never typed. It is the body `omakit submit` renders, and
|
|
110
|
+
`omakit watch` verifies afterwards that the issue says what `origin` says.
|
|
111
|
+
The reason is omacom/omarchy-plugin-marketplace#7787, 2026-09-20: an agent
|
|
112
|
+
following this skill retyped the whole body for a retry, the Repository URL
|
|
113
|
+
came out as `mtolhuijs/omacrunch` where origin says `mtolhuys/omacrunch`,
|
|
114
|
+
the Maintainer notes were wiped, and the marketplace refused the issue as
|
|
115
|
+
`repository-unreachable` 40 seconds after the edit.
|
|
116
|
+
|
|
117
|
+
1. Re-run `omakit submit` with the same `--category`, `--tags` and `--notes`
|
|
118
|
+
as the original submission, plus `--body-out <file>`. The Repository URL
|
|
119
|
+
in that body comes from `origin`; you never type it.
|
|
120
|
+
2. Read the current issue body (`gh issue view <url> --json body`). Diff it
|
|
121
|
+
against the new body. The only lines allowed to differ are under
|
|
122
|
+
"### Maintainer notes". Any other difference means you are about to change
|
|
123
|
+
the submission; stop and show the owner the diff.
|
|
124
|
+
3. With the owner's explicit approval: `gh issue edit <url> --body-file <file>`.
|
|
125
|
+
4. Run `omakit watch <url> <path-to-the-plugin-repo>`. Expect `current` within
|
|
126
|
+
two minutes. `wrong-repository` or `refused` means step 2 was skipped; fix
|
|
127
|
+
it now, by the same steps.
|
|
128
|
+
|
|
129
|
+
Never retype the body. Never write the Repository URL by hand. Never edit a
|
|
130
|
+
`current` issue to bump it.
|
|
131
|
+
|
|
81
132
|
## Before you conclude that the reviewer is behind
|
|
82
133
|
|
|
83
134
|
Check the assumption. The maintainer's own queue is a median half a day old, and
|
package/tools/blocks/commit.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"commit": "
|
|
3
|
-
"recordedAt": "2026-09-
|
|
2
|
+
"commit": "a0f946e1e900fda2b6d9c7004da3b8684958ec1a",
|
|
3
|
+
"recordedAt": "2026-09-21T06:04:50.384Z",
|
|
4
4
|
"how": "Written by `node tools/blocks/record-commit.mjs` in the release workflow before `npm pack`, so a packaged omakit, which has no Git checkout, still names the commit its block files come from. In a checkout this file is null and `git rev-parse HEAD` is the source; a package with null here was packed without the release step, and `omakit add` refuses to stamp a header it cannot name."
|
|
5
5
|
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// `add` is the one command that writes into a plugin tree: the block's own
|
|
20
20
|
// files under omakit/, never over a modified copy; docs/BLOCKS.md says what.
|
|
21
21
|
|
|
22
|
-
import { readdirSync, readFileSync } from "node:fs"
|
|
22
|
+
import { readdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
23
23
|
import { dirname, resolve } from "node:path"
|
|
24
24
|
import { fileURLToPath } from "node:url"
|
|
25
25
|
import { ensurePin, requirePin } from "./pin.mjs"
|
|
@@ -27,7 +27,7 @@ import { marketplaceBaselineSection } from "./verify.mjs"
|
|
|
27
27
|
import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
|
|
28
28
|
import { submitPreflight } from "./submit.mjs"
|
|
29
29
|
import { askChoices, askWatchIssues } from "./ask.mjs"
|
|
30
|
-
import { validationWatch, discoverWatchIssues, validationWatchAll } from "./watch.mjs"
|
|
30
|
+
import { validationWatch, discoverWatchIssues, validationWatchAll, resolveWatchSubject } from "./watch.mjs"
|
|
31
31
|
import { renderSubmit, renderWatch, renderWatchList, renderWatchAll, renderDoctor, renderVerify } from "./report.mjs"
|
|
32
32
|
import { consequence } from "./preflight.mjs"
|
|
33
33
|
import { doctor } from "./doctor.mjs"
|
|
@@ -83,6 +83,8 @@ const REMEDY = Object.freeze({
|
|
|
83
83
|
"interrupted": "shell.json was restored; run it again when the desktop is yours to restart.",
|
|
84
84
|
"refused": "Fix what the report names, then run it again.",
|
|
85
85
|
"unknown": "Read what could not be compared in the report above; each row says why.",
|
|
86
|
+
"wrong-repository": "Edit the issue and set the Repository URL field to the plugin's origin, printed in the report. Change nothing else.",
|
|
87
|
+
"no-origin": "Give the checkout a github.com origin remote, or pass the repository URL as the subject.",
|
|
86
88
|
"drift": "Return each plugin to its validated commit with the git checkout printed beside it, or validate the newer commit through the form the report names.",
|
|
87
89
|
"not-compared": "Run it in the desktop session whose shell runs these plugins: omarchy-plugin-catalog named no readable source directory for them.",
|
|
88
90
|
"problems": "omakit setup",
|
|
@@ -195,6 +197,20 @@ function refuse(args, document, human, error) {
|
|
|
195
197
|
return conclude({ command: CONTEXT.command, args, exit: 1, document, human, error: failure({ ...error, table: REMEDY }) })
|
|
196
198
|
}
|
|
197
199
|
|
|
200
|
+
/**
|
|
201
|
+
* `--body-out`: the rendered issue body, byte for byte and nothing else,
|
|
202
|
+
* so a retry edit is the body `submit` renders and never a body retyped.
|
|
203
|
+
* On #7787 (2026-09-20) the retry was typed, and the Repository URL came
|
|
204
|
+
* out as another account. A person passes the file to `gh`'s
|
|
205
|
+
* `issue edit --body-file`; this tool writes a local file and posts
|
|
206
|
+
* nothing. A body that was not rendered (a refusal, a
|
|
207
|
+
* listing) writes no file and says so.
|
|
208
|
+
*/
|
|
209
|
+
function writeBodyOut(out, body) {
|
|
210
|
+
writeFileSync(resolve(out), body)
|
|
211
|
+
return resolve(out)
|
|
212
|
+
}
|
|
213
|
+
|
|
198
214
|
async function cmdSubmit(args) {
|
|
199
215
|
const target = positionals(args)[0]
|
|
200
216
|
if (!target) fail("usage", "submit needs a target: `omakit submit <target> --category <c> --tags <a,b>`", 2)
|
|
@@ -242,7 +258,22 @@ async function cmdSubmit(args) {
|
|
|
242
258
|
failFrom(error)
|
|
243
259
|
}
|
|
244
260
|
spinner.done()
|
|
245
|
-
const
|
|
261
|
+
const bodyOut = option(args, "--body-out")
|
|
262
|
+
if (bodyOut && result.issue?.body) {
|
|
263
|
+
try {
|
|
264
|
+
result.bodyFile = writeBodyOut(bodyOut, result.issue.body)
|
|
265
|
+
} catch (error) {
|
|
266
|
+
failFrom(Object.assign(error, { message: `--body-out ${resolve(bodyOut)} could not be written: ${error.message}` }))
|
|
267
|
+
}
|
|
268
|
+
} else if (bodyOut) {
|
|
269
|
+
result.bodyFile = null
|
|
270
|
+
}
|
|
271
|
+
const human = (colour) => {
|
|
272
|
+
const text = renderSubmit(result, { colour })
|
|
273
|
+
if (!bodyOut) return text
|
|
274
|
+
const c = styler(colour)
|
|
275
|
+
return `${text.replace(/\n+$/, "")}\n${result.bodyFile ? `${mark("pass", c)}wrote the body to ${withHomeAbbreviated(result.bodyFile)}` : `${mark("skipped", c)}no body was rendered, so --body-out wrote nothing`}\n`
|
|
276
|
+
}
|
|
246
277
|
// Three outcomes, two exit codes: `ready` and `listed` are both healthy
|
|
247
278
|
// states, and only a refusal is a 1.
|
|
248
279
|
if (result.outcome === "refused") {
|
|
@@ -254,7 +285,7 @@ async function cmdSubmit(args) {
|
|
|
254
285
|
}
|
|
255
286
|
|
|
256
287
|
async function cmdWatch(args) {
|
|
257
|
-
const issueUrl = positionals(args)
|
|
288
|
+
const [issueUrl, subjectTarget] = positionals(args)
|
|
258
289
|
const all = args.includes("--all")
|
|
259
290
|
const list = args.includes("--list")
|
|
260
291
|
const user = option(args, "--user")
|
|
@@ -265,11 +296,18 @@ async function cmdWatch(args) {
|
|
|
265
296
|
if (!issueUrl && !all && !list && !interactive) {
|
|
266
297
|
fail("usage", "watch needs an issue or a mode: `omakit watch <issue-url>`, `omakit watch --all`, or `omakit watch --list`. A terminal can pick issues with `omakit watch`.", 2)
|
|
267
298
|
}
|
|
299
|
+
if (subjectTarget !== undefined && !issueUrl) {
|
|
300
|
+
fail("usage", "a subject belongs to one issue: `omakit watch <issue-url> <subject>`.", 2)
|
|
301
|
+
}
|
|
268
302
|
const spinner = spinnerFor(args)
|
|
269
303
|
let result
|
|
270
304
|
try {
|
|
271
305
|
if (issueUrl) {
|
|
272
|
-
|
|
306
|
+
// The subject is the plugin's own origin: a path or a github.com URL
|
|
307
|
+
// given after the issue, or the current directory when it is a
|
|
308
|
+
// checkout with one. The issue's Repository URL is compared with it.
|
|
309
|
+
const subject = resolveWatchSubject(subjectTarget)
|
|
310
|
+
result = await validationWatch({ repoRoot: ROOT, issueUrl, subject, onPhase: spinner.phase })
|
|
273
311
|
} else {
|
|
274
312
|
const discovery = await discoverWatchIssues({ user, onPhase: spinner.phase })
|
|
275
313
|
if (list) {
|
|
@@ -292,13 +330,17 @@ async function cmdWatch(args) {
|
|
|
292
330
|
// A comparison that could not be made is a refusal the tool means, exit
|
|
293
331
|
// 1: the validated commit is not known to be current. Stale is a fact
|
|
294
332
|
// about the marketplace, not a failure, and exits 0. Measured on
|
|
295
|
-
// 2026-09-19: an unknown verdict exited 2, the usage status.
|
|
296
|
-
|
|
297
|
-
|
|
333
|
+
// 2026-09-19: an unknown verdict exited 2, the usage status. An issue
|
|
334
|
+
// that names the wrong repository, or one the marketplace refused, is
|
|
335
|
+
// the same kind of refusal: nothing on it is being validated, and the
|
|
336
|
+
// verdict's own action is the remedy.
|
|
337
|
+
if (["unknown", "wrong-repository", "refused"].includes(result.verdict?.state)) {
|
|
338
|
+
refuse(args, result, human, { code: result.verdict.state, message: result.verdict.summary, remedy: result.verdict.action || REMEDY[result.verdict.state] })
|
|
298
339
|
return
|
|
299
340
|
}
|
|
300
|
-
if (result.summary?.unknown > 0) {
|
|
301
|
-
|
|
341
|
+
if (result.summary?.unknown > 0 || result.summary?.refused > 0) {
|
|
342
|
+
const counts = [result.summary.unknown > 0 && `${result.summary.unknown} could not be compared`, result.summary.refused > 0 && `${result.summary.refused} refused by the marketplace`].filter(Boolean).join(", ")
|
|
343
|
+
refuse(args, result, human, { code: result.summary.unknown > 0 ? "unknown" : "refused", message: `${counts} of ${result.summary.total} issue${result.summary.total === 1 ? "" : "s"}`, remedy: result.summary.unknown > 0 ? REMEDY.unknown : "Read each refused row: the marketplace's own reason and action are printed beside it." })
|
|
302
344
|
return
|
|
303
345
|
}
|
|
304
346
|
succeed(args, result, human)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// M15 reproduction: open submission issues whose Repository URL names an
|
|
2
|
+
// owner other than the issue's author. All remote reads use the existing
|
|
3
|
+
// GET-only client; an issue whose body yields no repository is counted as
|
|
4
|
+
// unreadable, never as a match or a mismatch.
|
|
5
|
+
//
|
|
6
|
+
// The case behind it: omacom/omarchy-plugin-marketplace#7787, 2026-09-20. A
|
|
7
|
+
// retry edit typed by an agent put `mtolhuijs` where origin says `mtolhuys`,
|
|
8
|
+
// and the marketplace refused it as `repository-unreachable` 40 seconds after
|
|
9
|
+
// the edit event. A URL whose owner is not the author is not wrong by itself
|
|
10
|
+
// (organisations, forks and co-maintainers exist), so the count is the size
|
|
11
|
+
// of the population this check has to tell a typo apart from, not a count of
|
|
12
|
+
// typos.
|
|
13
|
+
import { resolve } from "node:path"
|
|
14
|
+
import { pathToFileURL } from "node:url"
|
|
15
|
+
import { MARKETPLACE_PIN, requirePin } from "./pin.mjs"
|
|
16
|
+
import { repositoryIssues } from "./github.mjs"
|
|
17
|
+
import { repositoryFor } from "./watch.mjs"
|
|
18
|
+
import { parseGitHubUrl } from "../subject/resolve.mjs"
|
|
19
|
+
|
|
20
|
+
export function ownerCounts(rows) {
|
|
21
|
+
const readable = rows.filter((row) => row.owner !== null)
|
|
22
|
+
const differs = readable.filter((row) => row.ownerDiffers).length
|
|
23
|
+
return { total: rows.length, readable: readable.length, unreadable: rows.length - readable.length,
|
|
24
|
+
ownerDiffers: differs, ownerMatches: readable.length - differs,
|
|
25
|
+
differsShareOfReadable: readable.length ? differs / readable.length : null }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function measureRepositoryOwner(repoRoot, { discover = repositoryIssues, label = "submission" } = {}) {
|
|
29
|
+
const openedAt = new Date().toISOString()
|
|
30
|
+
const { dir: pinDir } = requirePin(repoRoot)
|
|
31
|
+
const [owner, repository] = new URL(MARKETPLACE_PIN.repository).pathname.slice(1).split("/")
|
|
32
|
+
const subjects = (await discover(owner, repository, undefined, { labels: label }))
|
|
33
|
+
.filter((subject) => subject.state === "open" && !subject.pull_request)
|
|
34
|
+
.sort((a, b) => a.number - b.number)
|
|
35
|
+
const rows = []
|
|
36
|
+
for (const subject of subjects) {
|
|
37
|
+
const author = String(subject.user?.login || "")
|
|
38
|
+
const parsed = await repositoryFor(pinDir, subject)
|
|
39
|
+
const gh = parsed.url ? parseGitHubUrl(parsed.url) : null
|
|
40
|
+
rows.push({ issue: subject.number, issueUrl: `${MARKETPLACE_PIN.repository}/issues/${subject.number}`, author,
|
|
41
|
+
labels: (subject.labels || []).map((entry) => (typeof entry === "string" ? entry : entry?.name)).filter(Boolean),
|
|
42
|
+
repository: gh?.url || null, owner: gh?.owner || null,
|
|
43
|
+
ownerDiffers: gh ? gh.owner.toLowerCase() !== author.toLowerCase() : null,
|
|
44
|
+
reason: gh ? null : parsed.error || "no github.com repository URL in the body" })
|
|
45
|
+
}
|
|
46
|
+
return { measurement: "M15", date: openedAt.slice(0, 10), openedAt, completedAt: new Date().toISOString(),
|
|
47
|
+
marketplace: MARKETPLACE_PIN.repository, marketplacePin: MARKETPLACE_PIN.commit,
|
|
48
|
+
command: "node tools/marketplace/measure-repository-owner.mjs", sample: false,
|
|
49
|
+
population: `All open non-PR marketplace issues labelled ${label} at discovery`,
|
|
50
|
+
method: "Repository URL read from each issue body with the pinned submission parser; its owner compared case-insensitively with the issue author's login; a body without a readable github.com URL is unreadable",
|
|
51
|
+
...ownerCounts(rows), rows }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
55
|
+
process.stdout.write(`${JSON.stringify(await measureRepositoryOwner(resolve(process.cwd())), null, 2)}\n`)
|
|
56
|
+
}
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
export const ACCEPTED = Object.freeze({
|
|
21
21
|
setup: Object.freeze({ valued: [], flags: ["--yes", "--completion"], positionals: 0 }),
|
|
22
22
|
pin: Object.freeze({ valued: [], flags: [], positionals: 0 }),
|
|
23
|
-
submit: Object.freeze({ valued: ["--category", "--tags", "--notes", "--suggest-tag", "--name", "--out"], flags: ["--offline", "--allow-dirty", "--json"], positionals: 1 }),
|
|
24
|
-
watch: Object.freeze({ valued: ["--out", "--user"], flags: ["--json", "--all", "--list"], positionals:
|
|
23
|
+
submit: Object.freeze({ valued: ["--category", "--tags", "--notes", "--suggest-tag", "--name", "--out", "--body-out"], flags: ["--offline", "--allow-dirty", "--json"], positionals: 1 }),
|
|
24
|
+
watch: Object.freeze({ valued: ["--out", "--user"], flags: ["--json", "--all", "--list"], positionals: 2 }),
|
|
25
25
|
verify: Object.freeze({ valued: ["--out"], flags: ["--allow-dirty", "--json"], positionals: 1 }),
|
|
26
26
|
help: Object.freeze({ valued: [], flags: ["--agent"], positionals: 0 }),
|
|
27
27
|
upgrade: Object.freeze({ valued: [], flags: ["--dry-run"], positionals: 0 }),
|
|
@@ -37,7 +37,7 @@ export const ACCEPTED = Object.freeze({
|
|
|
37
37
|
/** The accepted options of a command in the words a refusal prints: `--runs N, --all, ...`. */
|
|
38
38
|
export function acceptedWords(name) {
|
|
39
39
|
const spec = ACCEPTED[name]
|
|
40
|
-
const value = (option) => ({ "--out": "FILE", "--runs": "N", "--count": "N", "--offset": "N", "--window": "S", "--settle": "S", "--category": "C", "--tags": "A,B", "--from": "FILE", "--toolchain": "DIR" }[option] || "TEXT")
|
|
40
|
+
const value = (option) => ({ "--out": "FILE", "--body-out": "FILE", "--runs": "N", "--count": "N", "--offset": "N", "--window": "S", "--settle": "S", "--category": "C", "--tags": "A,B", "--from": "FILE", "--toolchain": "DIR" }[option] || "TEXT")
|
|
41
41
|
return [...spec.valued.map((option) => `${option} ${value(option)}`), ...spec.flags].join(", ")
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -178,9 +178,13 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
|
|
|
178
178
|
: `${result.validationCommit.local} is the local commit; the marketplace validates the default-branch HEAD it resolves when the issue is opened.`
|
|
179
179
|
// A skipped check is said on the READY line itself, so the one line an
|
|
180
180
|
// agent quotes does not read as "everything passed" when one check never
|
|
181
|
-
// ran. The count is the whole `skipped` list;
|
|
181
|
+
// ran. The count is the whole `skipped` list; --offline skips, and the
|
|
182
|
+
// open-issue check also skips without a credential or an open issue, and
|
|
183
|
+
// then the reason is on its own row.
|
|
184
|
+
const skippedChecks = (result.checks || []).filter((entry) => entry.verdict === "skipped")
|
|
182
185
|
const skipped = (result.skipped || []).length
|
|
183
|
-
const
|
|
186
|
+
const offlineOnly = skippedChecks.length > 0 && skippedChecks.every((entry) => /\(--offline\)/.test(entry.detail))
|
|
187
|
+
const skippedNote = skipped ? ` ${skipped === 1 ? "1 check" : `${skipped} checks`} skipped (${offlineOnly ? "--offline" : "each says why"}).` : ""
|
|
184
188
|
out.push(...verdict("pass", "READY", `every blocking check passed.${skippedNote} ${validation}`, c))
|
|
185
189
|
out.push("")
|
|
186
190
|
out.push(...section("issue title", c))
|
|
@@ -214,6 +218,14 @@ export function renderWatch(result, { colour = colourEnabled() } = {}) {
|
|
|
214
218
|
out.push(...field("state", `${result.read.state}${result.read.labels.length ? `; labels ${result.read.labels.join(", ")}` : ""}`, c))
|
|
215
219
|
out.push(...field("title", result.read.title, c))
|
|
216
220
|
out.push(...field("plugin repo", result.plugin.repository || `unreadable: ${result.plugin.repositoryError}`, c, { wrapValue: false }))
|
|
221
|
+
if (result.plugin.origin) {
|
|
222
|
+
const origin = result.plugin.repositoryMatches === false ? c("fail", result.plugin.origin) : result.plugin.origin
|
|
223
|
+
out.push(...field("origin", origin, c, { wrapValue: false }))
|
|
224
|
+
out.push(...continuation(result.plugin.repositoryMatches === false ? "the issue names a different repository" : result.plugin.repositoryMatches ? "the issue names this repository" : "not compared: the issue names no repository", c))
|
|
225
|
+
} else if (result.plugin.subjectSkipped) {
|
|
226
|
+
out.push(...field("origin", "not compared", c))
|
|
227
|
+
out.push(...continuation(result.plugin.subjectSkipped.reason, c))
|
|
228
|
+
}
|
|
217
229
|
if (result.plugin.form === "verify" || result.plugin.form === "verify-legacy") {
|
|
218
230
|
out.push(...field("form", "plugin update request, read with the marketplace's verification parser", c))
|
|
219
231
|
}
|
|
@@ -233,8 +245,12 @@ export function renderWatch(result, { colour = colourEnabled() } = {}) {
|
|
|
233
245
|
} else if (result.headError) {
|
|
234
246
|
out.push(...field("current HEAD", `unreadable: ${result.headError.message}`, c))
|
|
235
247
|
}
|
|
248
|
+
if (result.refusal) {
|
|
249
|
+
out.push(...field("refused", `${result.refusal.code} at ${result.refusal.at || "an unrecorded time"}`, c))
|
|
250
|
+
out.push(...continuation(result.refusal.reason, c))
|
|
251
|
+
}
|
|
236
252
|
out.push("")
|
|
237
|
-
const state = { current: "pass", stale: "fail", unknown: "unknown" }[result.verdict.state] || "unknown"
|
|
253
|
+
const state = { current: "pass", stale: "fail", refused: "fail", "wrong-repository": "fail", unknown: "unknown" }[result.verdict.state] || "unknown"
|
|
238
254
|
out.push(...verdict(state, `VALIDATION ${result.verdict.state.toUpperCase()}`, result.verdict.summary, c))
|
|
239
255
|
if (result.verdict.action) {
|
|
240
256
|
out.push("")
|
|
@@ -266,7 +282,7 @@ export function renderWatchList(result, { colour = colourEnabled() } = {}) {
|
|
|
266
282
|
/** Compact batch report; exact commits and full discussion remain in JSON. */
|
|
267
283
|
export function renderWatchAll(result, { colour = colourEnabled() } = {}) {
|
|
268
284
|
const c = styler(colour)
|
|
269
|
-
const out = [...field("account", result.account, c), ...field("issues", `${result.summary.total} checked; ${result.summary.current} current, ${result.summary.stale} stale, ${result.summary.unknown} unknown`, c)]
|
|
285
|
+
const out = [...field("account", result.account, c), ...field("issues", `${result.summary.total} checked; ${result.summary.current} current, ${result.summary.stale} stale, ${result.summary.refused || 0} refused, ${result.summary.unknown} unknown`, c)]
|
|
270
286
|
if (result.reviewCostSummary) {
|
|
271
287
|
const cost = result.reviewCostSummary
|
|
272
288
|
const skipped = cost.skipped.length ? `; ${cost.skipped.length} diff(s) skipped (reasons on issue rows)` : ""
|
|
@@ -275,7 +291,7 @@ export function renderWatchAll(result, { colour = colourEnabled() } = {}) {
|
|
|
275
291
|
out.push("")
|
|
276
292
|
for (const row of result.issues) {
|
|
277
293
|
const state = row.report?.verdict.state || "unknown"
|
|
278
|
-
const style = { current: "pass", stale: "fail", unknown: "unknown" }[state]
|
|
294
|
+
const style = { current: "pass", stale: "fail", refused: "fail", "wrong-repository": "fail", unknown: "unknown" }[state] || "unknown"
|
|
279
295
|
out.push(...verdict(style, state.toUpperCase(), `#${row.issue.number} ${watchIssueTitle(row.report?.read.title || row.issue.title)}`, c))
|
|
280
296
|
out.push(...field("issue", row.issue.url, c, { wrapValue: false }))
|
|
281
297
|
if (row.documentationDiff?.docsOnly === null) out.push(...field("diff skipped", watchIssueTitle(row.documentationDiff.reason), c))
|
|
@@ -6,6 +6,7 @@ import { requirePin } from "./pin.mjs"
|
|
|
6
6
|
import { token, issue, parseIssueUrl, compareCommits } from "./github.mjs"
|
|
7
7
|
import { discoverWatchIssues, repositoryFor } from "./watch.mjs"
|
|
8
8
|
import { repositorySlug } from "./registry.mjs"
|
|
9
|
+
import { submissionContract } from "./form.mjs"
|
|
9
10
|
|
|
10
11
|
/** Outcome and update label names come from the pin, rather than a second policy. */
|
|
11
12
|
export async function reviewPolicy(repoRoot) {
|
|
@@ -18,26 +19,52 @@ export async function reviewPolicy(repoRoot) {
|
|
|
18
19
|
return { automated: policy.securityBaselineOutcome([], []), manual, updateLabel, reviewLabel: `security-${manual}` }
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The same discovery as watch --all; no credential or incomplete reads are
|
|
24
|
+
* not zero issues. An issue is the author's for this plugin when its
|
|
25
|
+
* Repository URL is the repository, or, for a `[Plugin]:` submission whose
|
|
26
|
+
* URL is another repository, when its title carries the manifest's name or
|
|
27
|
+
* its body names the manifest's id: on #7787 (2026-09-20) the retyped URL
|
|
28
|
+
* made the author's own issue invisible to a match on the URL alone, and
|
|
29
|
+
* the one check that could have named the typo had nothing to look at.
|
|
30
|
+
* Each matched issue is returned with `sameRepository`, so a caller can
|
|
31
|
+
* tell the two apart.
|
|
32
|
+
*
|
|
33
|
+
* @returns {{ count: number|null, reason: string, account?: string, issues?: { number: number, url: string, repositoryUrl: string|null, sameRepository: boolean, matchedBy: "repository"|"name"|"id" }[] }}
|
|
34
|
+
*/
|
|
35
|
+
export async function openIssuesForRepository({ repoRoot, repository, pluginName = "", pluginId = "", offline = false, github = {} }) {
|
|
23
36
|
if (offline) return { count: null, reason: "not checked (--offline)" }
|
|
24
37
|
if (!(github.token || token)()) return { count: null, reason: "not checked: no GitHub credential" }
|
|
25
38
|
try {
|
|
26
39
|
const discovery = await discoverWatchIssues({ github })
|
|
27
40
|
const { dir } = requirePin(repoRoot)
|
|
28
|
-
|
|
41
|
+
const { titleTemplate } = await submissionContract({ pinDir: dir })
|
|
42
|
+
const name = String(pluginName || "").trim().toLowerCase()
|
|
43
|
+
const id = String(pluginId || "").trim()
|
|
44
|
+
const matched = []
|
|
29
45
|
let next = 0
|
|
30
46
|
async function worker() {
|
|
31
47
|
while (next < discovery.issues.length) {
|
|
32
|
-
const
|
|
48
|
+
const entry = discovery.issues[next++]
|
|
49
|
+
const target = parseIssueUrl(entry.url)
|
|
33
50
|
const subject = await (github.issue || issue)(target.owner, target.repository, target.number)
|
|
34
51
|
const parsed = await repositoryFor(dir, subject)
|
|
35
52
|
if (!parsed.url) throw new Error(`repository unknown on issue #${target.number}: ${parsed.error}`)
|
|
36
|
-
|
|
53
|
+
const row = { number: target.number, url: entry.url, repositoryUrl: parsed.url, sameRepository: repositorySlug(parsed.url) === repositorySlug(repository), matchedBy: null }
|
|
54
|
+
if (row.sameRepository) row.matchedBy = "repository"
|
|
55
|
+
else if (parsed.kind === "submission" && titleTemplate && String(subject.title || "").startsWith(titleTemplate)) {
|
|
56
|
+
const titled = String(subject.title).slice(titleTemplate.length).trim().toLowerCase()
|
|
57
|
+
if (name && titled === name) row.matchedBy = "name"
|
|
58
|
+
else if (id && String(subject.body || "").includes(id)) row.matchedBy = "id"
|
|
59
|
+
}
|
|
60
|
+
if (row.matchedBy) matched.push(row)
|
|
37
61
|
}
|
|
38
62
|
}
|
|
39
63
|
await Promise.all(Array.from({ length: Math.min(4, discovery.issues.length) }, worker))
|
|
40
|
-
|
|
64
|
+
matched.sort((a, b) => a.number - b.number)
|
|
65
|
+
const other = matched.filter((row) => !row.sameRepository).length
|
|
66
|
+
return { count: matched.length, account: discovery.account, issues: matched,
|
|
67
|
+
reason: `watch --all discovery: ${matched.length} open issue(s) for this plugin${other ? `, ${other} of them naming another repository` : ""}` }
|
|
41
68
|
} catch (error) {
|
|
42
69
|
return { count: null, reason: `not checked (${error.code || "issue-discovery-unavailable"}): ${error.message}` }
|
|
43
70
|
}
|
|
@@ -437,6 +437,33 @@ export async function submitPreflight(options) {
|
|
|
437
437
|
: null,
|
|
438
438
|
}))
|
|
439
439
|
|
|
440
|
+
// --- the author's own open issue --------------------------------------------
|
|
441
|
+
// Discovered once, here, for two readers: the check below, and review.cost.
|
|
442
|
+
// On the subject's own listing the check is omitted with the other body
|
|
443
|
+
// checks: there is no submission to retry.
|
|
444
|
+
|
|
445
|
+
if (!options.offline) phase("reading your open marketplace issues")
|
|
446
|
+
const openIssues = await openIssuesForRepository({ repoRoot, repository: subject.repository.url, pluginName, pluginId: tree.pluginId,
|
|
447
|
+
offline: options.offline === true, github: options.github })
|
|
448
|
+
const ownIssues = openIssues.issues || []
|
|
449
|
+
const mismatched = ownIssues.filter((row) => !row.sameRepository)
|
|
450
|
+
const originForIssue = subject.repository.url || "the plugin's origin"
|
|
451
|
+
if (!listing) checks.push(check("submission.issue-repository-url", {
|
|
452
|
+
source: "omakit",
|
|
453
|
+
why: "The marketplace validates the Repository URL in the issue, and a retry is an edit to that issue. On omacom/omarchy-plugin-marketplace#7787 (2026-09-20, 19:18 UTC) a retry edit typed by hand put mtolhuijs where origin says mtolhuys, and the marketplace refused it as repository-unreachable 40 seconds after the edit event. M15, measured 2026-09-20 over all 651 open submission issues: 27 of the 646 with a readable URL name an owner other than the issue's author, so the URL is compared with the plugin's own origin, never with the author's login. Not a marketplace rule; an Omakit check that the issue says what origin says.",
|
|
454
|
+
severity: "blocking",
|
|
455
|
+
skipped: openIssues.count === null || ownIssues.length === 0,
|
|
456
|
+
verdict: mismatched.length === 0,
|
|
457
|
+
detail: openIssues.count === null
|
|
458
|
+
? openIssues.reason
|
|
459
|
+
: ownIssues.length === 0
|
|
460
|
+
? `no open submission issue by ${openIssues.account} for this plugin`
|
|
461
|
+
: mismatched.length
|
|
462
|
+
? mismatched.map((row) => `issue #${row.number} says ${row.repositoryUrl}, origin says ${originForIssue}`).join("; ")
|
|
463
|
+
: ownIssues.map((row) => `issue #${row.number} matches origin`).join("; "),
|
|
464
|
+
remedy: mismatched.length ? mismatched.map((row) => `Edit issue #${row.number} and set the Repository URL field to ${originForIssue}. Change nothing else.`) : null,
|
|
465
|
+
}))
|
|
466
|
+
|
|
440
467
|
// --- the baseline preflight ----------------------------------------------
|
|
441
468
|
|
|
442
469
|
phase("running the official security baseline over a local snapshot")
|
|
@@ -470,8 +497,7 @@ export async function submitPreflight(options) {
|
|
|
470
497
|
|
|
471
498
|
const policy = await reviewPolicy(repoRoot)
|
|
472
499
|
const review = reviewCostVerdict({ baseline: consequence, policy,
|
|
473
|
-
openIssues: consequence?.outcome === policy.manual ?
|
|
474
|
-
offline: options.offline === true, github: options.github }) : { count: null, reason: consequence?.outcome === policy.automated ? "open issue count not checked: automated baseline" : preflight.skipReason || preflight.refusal?.message || "baseline outcome needs findings resolved" },
|
|
500
|
+
openIssues: consequence?.outcome === policy.manual ? openIssues : { count: null, reason: consequence?.outcome === policy.automated ? "open issue count not checked: automated baseline" : preflight.skipReason || preflight.refusal?.message || "baseline outcome needs findings resolved" },
|
|
475
501
|
why: `MEASUREMENTS.md M4: ${figure(figures.outcomes[policy.manual] || 0)} of ${figure(figures.withBaseline)} recorded listing baselines required review at the pin. M9: on 2026-09-15, 140 of 307 open update issues carried the manual-review label; 4 of 139 compared validated diffs were docs-only, with 1 unavailable. The baseline scans the whole snapshot, not the update diff, so unchanged capabilities also require another review. Sources and exact marketplace HEAD are recorded in MEASUREMENTS.md.`,
|
|
476
502
|
})
|
|
477
503
|
checks.push(review.check)
|
|
@@ -74,10 +74,13 @@ export const COMMANDS = Object.freeze([
|
|
|
74
74
|
"omakit submit <target> --category <c> --tags <a,b> [--notes <text>]",
|
|
75
75
|
" [--suggest-tag <t>] [--name <n>] [--offline]",
|
|
76
76
|
" [--allow-dirty] [--json] [--out <file>]",
|
|
77
|
+
" [--body-out <file>]",
|
|
77
78
|
],
|
|
78
79
|
lines: [
|
|
79
80
|
"Every check that is knowable before submitting, the resolved commit, and",
|
|
80
81
|
"the exact issue title and body. Prints them. Never posts anything.",
|
|
82
|
+
"--body-out writes the rendered body, and nothing else, to a file, for a",
|
|
83
|
+
"retry edit a person makes with `gh`'s `issue edit --body-file`.",
|
|
81
84
|
"Three outcomes: READY (exit 0, the body), REFUSED (exit 1, no body), and",
|
|
82
85
|
"LISTED (exit 0): the plugin is already listed by its own repository, so",
|
|
83
86
|
"the submission form is not the route and nothing is asked. An id taken",
|
|
@@ -89,13 +92,17 @@ export const COMMANDS = Object.freeze([
|
|
|
89
92
|
},
|
|
90
93
|
{
|
|
91
94
|
signature: [
|
|
92
|
-
"omakit watch <issue-url> [--json] [--out <file>]",
|
|
95
|
+
"omakit watch <issue-url> [<subject>] [--json] [--out <file>]",
|
|
93
96
|
"omakit watch [--all | --list] [--user <login>] [--json] [--out <file>]",
|
|
94
97
|
],
|
|
95
98
|
lines: [
|
|
96
99
|
"Compare the commit the marketplace validated on a submission issue with",
|
|
97
100
|
"the plugin repository's current default-branch HEAD, and say what makes",
|
|
98
|
-
"it validate a newer one. Read-only.",
|
|
101
|
+
"it validate a newer one. Read-only. <subject> is the plugin's checkout",
|
|
102
|
+
"or its github.com URL (default: the current directory when it is one",
|
|
103
|
+
"and its manifest is the issue's plugin): the issue's Repository URL is",
|
|
104
|
+
"compared with that origin, and a failed",
|
|
105
|
+
"validation is read back as the marketplace's own code.",
|
|
99
106
|
"--all checks your open marketplace issues; --list lists them first.",
|
|
100
107
|
"Without a URL or either flag, a terminal asks which issues to check.",
|
|
101
108
|
"Uses your gh account, or --user to read another public account. JSON",
|
|
@@ -20,10 +20,14 @@
|
|
|
20
20
|
|
|
21
21
|
import { join } from "node:path"
|
|
22
22
|
import { pathToFileURL } from "node:url"
|
|
23
|
+
import { readFileSync } from "node:fs"
|
|
23
24
|
import { MARKETPLACE_PIN, requirePin } from "./pin.mjs"
|
|
24
25
|
import { authenticatedUser, repositoryIssues, defaultBranchHead, issue, issueComments, parseIssueUrl, token, GitHubError } from "./github.mjs"
|
|
25
26
|
import { liveRegistry } from "./registry.mjs"
|
|
26
27
|
import { reviewPolicy, validatedDocumentationDiff } from "./review-cost.mjs"
|
|
28
|
+
import { parseGitHubUrl, resolveSubject, SubjectError } from "../subject/resolve.mjs"
|
|
29
|
+
import { omakitCacheDir } from "./paths.mjs"
|
|
30
|
+
import { submissionContract } from "./form.mjs"
|
|
27
31
|
|
|
28
32
|
export class WatchError extends Error {
|
|
29
33
|
constructor(code, message) {
|
|
@@ -97,7 +101,9 @@ export async function validationWatchAll({ repoRoot, discovery, github = {}, rea
|
|
|
97
101
|
}
|
|
98
102
|
}
|
|
99
103
|
await Promise.all(Array.from({ length: Math.min(4, discovery.issues.length) }, worker))
|
|
100
|
-
|
|
104
|
+
// No subject in a batch, so no row can be wrong-repository; a refused
|
|
105
|
+
// row is one the marketplace answered with a failure, and is counted.
|
|
106
|
+
const summary = { total: results.length, current: 0, stale: 0, refused: 0, unknown: 0 }
|
|
101
107
|
for (const result of results) summary[result.report?.verdict.state || "unknown"] += 1
|
|
102
108
|
// M9: count labels on the listed issues, then compare only updates in the manual queue.
|
|
103
109
|
const manual = results.filter((row) => {
|
|
@@ -187,24 +193,167 @@ export async function repositoryFor(pinDir, subject) {
|
|
|
187
193
|
return { url: null, kind: null, error: errors.join("; ") }
|
|
188
194
|
}
|
|
189
195
|
|
|
196
|
+
/** The last marketplace validation comment on the issue, or null. */
|
|
197
|
+
function lastValidationComment(comments) {
|
|
198
|
+
return (comments || [])
|
|
199
|
+
.filter((comment) => String(comment.body || "").includes("<!-- marketplace-validation -->"))
|
|
200
|
+
.at(-1) || null
|
|
201
|
+
}
|
|
202
|
+
|
|
190
203
|
/** The short commit the validation comment reports, as a fallback when no baseline marker exists. */
|
|
191
204
|
export function validationCommentCommit(comments) {
|
|
192
|
-
const validation = (comments
|
|
193
|
-
.filter((comment) => String(comment.body || "").includes("<!-- marketplace-validation -->"))
|
|
194
|
-
.at(-1)
|
|
205
|
+
const validation = lastValidationComment(comments)
|
|
195
206
|
if (!validation) return null
|
|
196
207
|
const short = String(validation.body).match(/passed at commit `([0-9a-f]{7,40})`/i)?.[1]
|
|
197
208
|
return short ? { short: short.toLowerCase(), createdAt: validation.created_at || null } : null
|
|
198
209
|
}
|
|
199
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The marketplace's own failure table, read from the pin: every code
|
|
213
|
+
* `publicSubmissionFailure` can answer with, and the reason and action it
|
|
214
|
+
* prints for each. The table itself is not exported by the pinned module,
|
|
215
|
+
* so the codes are read out of its text and each is put through the
|
|
216
|
+
* exported function, which is the same path the failure comment took.
|
|
217
|
+
*/
|
|
218
|
+
export async function submissionFeedback(pinDir) {
|
|
219
|
+
const path = join(pinDir, "scripts/submission-feedback.mjs")
|
|
220
|
+
const { publicSubmissionFailure } = await import(pathToFileURL(path).href)
|
|
221
|
+
const codes = [...readFileSync(path, "utf8").matchAll(/^ "([a-z][a-z0-9-]*)": \{$/gm)].map((match) => match[1])
|
|
222
|
+
return codes.map((code) => ({ code, ...publicSubmissionFailure({ code }) }))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* What the last validation comment says: `passed` with the short commit, or
|
|
227
|
+
* `failed` with the reason mapped back to the marketplace's code through
|
|
228
|
+
* the pinned feedback table. The marketplace edits its validation comment
|
|
229
|
+
* in place on every run (measured on #7787: the comment created at
|
|
230
|
+
* 13:38:35 carried `updated_at` 19:41:00 after three validations), so the
|
|
231
|
+
* time of a refusal is the comment's `updated_at`, not its `created_at`.
|
|
232
|
+
*
|
|
233
|
+
* @param {object[]} comments
|
|
234
|
+
* @param {{ code: string, reason: string, action: string }[]} feedback from submissionFeedback()
|
|
235
|
+
*/
|
|
236
|
+
export function validationComment(comments, feedback = []) {
|
|
237
|
+
const validation = lastValidationComment(comments)
|
|
238
|
+
if (!validation) return null
|
|
239
|
+
const body = String(validation.body)
|
|
240
|
+
const at = validation.updated_at || validation.created_at || null
|
|
241
|
+
const passed = body.match(/passed at commit `([0-9a-f]{7,40})`/i)?.[1]
|
|
242
|
+
if (passed) return { kind: "passed", short: passed.toLowerCase(), at, createdAt: validation.created_at || null }
|
|
243
|
+
// `❌ **Validation failed:** <reason>` then a blank line and the action;
|
|
244
|
+
// the bold markers are the marketplace's Markdown and are not part of
|
|
245
|
+
// the reason. A comment that is neither is reported as it is.
|
|
246
|
+
const failed = body.match(/Validation failed:\*{0,2}\s*(.+)/)
|
|
247
|
+
if (!failed) return { kind: "unrecognised", at, createdAt: validation.created_at || null, text: body.replace(/<!--.*?-->/gs, "").trim().slice(0, 500) }
|
|
248
|
+
const reason = failed[1].replace(/\*+/g, "").trim()
|
|
249
|
+
const rest = body.slice(failed.index + failed[0].length).replace(/\*+/g, "").trim()
|
|
250
|
+
const known = feedback.find((entry) => entry.reason === reason || entry.reason.replace(/\.$/, "") === reason.replace(/\.$/, ""))
|
|
251
|
+
return {
|
|
252
|
+
kind: "failed",
|
|
253
|
+
code: known ? known.code : "unrecognised",
|
|
254
|
+
reason,
|
|
255
|
+
action: known ? known.action : rest.split(/\n\s*\n/)[0] || null,
|
|
256
|
+
at,
|
|
257
|
+
createdAt: validation.created_at || null,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The labels the marketplace puts on a submission, read from the pin: the
|
|
263
|
+
* two blocking labels from the baseline policy, `validated` from the label
|
|
264
|
+
* set the approval script requires, and the manual-review label from the
|
|
265
|
+
* review policy.
|
|
266
|
+
*/
|
|
267
|
+
export async function marketplaceLabels(pinDir) {
|
|
268
|
+
const policy = await import(pathToFileURL(join(pinDir, "scripts/security-baseline-policy.mjs")).href)
|
|
269
|
+
const approval = readFileSync(join(pinDir, "scripts/approve-submission.mjs"), "utf8")
|
|
270
|
+
const validated = approval.match(/for \(const required of \["submission", "([^"]+)"/)?.[1]
|
|
271
|
+
if (!validated) throw new Error("cannot read the validated label from the pin")
|
|
272
|
+
const manual = policy.currentSecurityBaselinePolicy.maintainerVerificationOutcome
|
|
273
|
+
return { blocking: [...policy.securityBaselineBlockingLabels], validated, reviewRequired: `security-${manual}` }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The origin a watch compares the issue with. A github.com URL is taken as
|
|
278
|
+
* given; a path is a Git checkout whose `origin` is read the way `submit`
|
|
279
|
+
* reads it, and the working tree may be dirty, since only the remote is
|
|
280
|
+
* read. With no argument the current directory is the subject when it is
|
|
281
|
+
* such a checkout, and there is no subject otherwise. A cwd subject also
|
|
282
|
+
* carries its root manifest's name and id, because it was not asserted by
|
|
283
|
+
* anyone: validationWatch() compares it only when the issue is that plugin.
|
|
284
|
+
*
|
|
285
|
+
* @returns {{ origin: string, source: "url"|"path"|"cwd", manifest?: { name: string, id: string }|null }|null}
|
|
286
|
+
*/
|
|
287
|
+
export function resolveWatchSubject(target, { cwd = process.cwd(), cacheRoot = omakitCacheDir() } = {}) {
|
|
288
|
+
if (target !== undefined) {
|
|
289
|
+
const gh = /^(?:https:\/\/|git@)/.test(String(target)) ? parseGitHubUrl(target) : null
|
|
290
|
+
if (gh) return { origin: gh.url, source: "url" }
|
|
291
|
+
if (/^https?:\/\//.test(String(target))) throw new WatchError("usage", `the subject is a github.com repository URL or a local checkout, got ${target}`)
|
|
292
|
+
const subject = resolveSubject(target, { cacheRoot, allowDirty: true })
|
|
293
|
+
if (!subject.repository.url) throw new SubjectError("no-origin", `${subject.dir} has no github.com origin remote, so there is no repository to compare the issue with`)
|
|
294
|
+
return { origin: subject.repository.url, source: "path" }
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
const subject = resolveSubject(cwd, { cacheRoot, allowDirty: true })
|
|
298
|
+
return subject.repository.url ? { origin: subject.repository.url, source: "cwd", manifest: rootManifest(subject.dir) } : null
|
|
299
|
+
} catch {
|
|
300
|
+
return null
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The name and id of the root manifest.json, or null when there is none or it does not parse. */
|
|
305
|
+
function rootManifest(dir) {
|
|
306
|
+
try {
|
|
307
|
+
const manifest = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"))
|
|
308
|
+
return { name: String(manifest?.name || "").trim(), id: String(manifest?.id || "").trim() }
|
|
309
|
+
} catch {
|
|
310
|
+
return null
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Whether an implicit (cwd) subject is the plugin the issue is about: the
|
|
316
|
+
* issue title carries the manifest's name after the pinned title template,
|
|
317
|
+
* or the body names the manifest's id. An explicit subject is the caller's
|
|
318
|
+
* assertion and always applies. Reviewed on the branch before release: run
|
|
319
|
+
* from the omakit checkout itself, `omakit watch <omacrunch issue>` compared
|
|
320
|
+
* the issue with omakit's origin, reported wrong-repository, and printed an
|
|
321
|
+
* action to put omakit's URL into omacrunch's issue; the #7787 mistake with
|
|
322
|
+
* the tool's authority behind it.
|
|
323
|
+
*
|
|
324
|
+
* @returns {boolean}
|
|
325
|
+
*/
|
|
326
|
+
export function subjectApplies(watchSubject, issue, titleTemplate) {
|
|
327
|
+
if (!watchSubject) return false
|
|
328
|
+
if (watchSubject.source !== "cwd") return true
|
|
329
|
+
const manifest = watchSubject.manifest
|
|
330
|
+
if (!manifest) return false
|
|
331
|
+
const title = String(issue?.title || "")
|
|
332
|
+
if (manifest.name && titleTemplate && title.startsWith(titleTemplate)) {
|
|
333
|
+
if (title.slice(titleTemplate.length).trim().toLowerCase() === manifest.name.toLowerCase()) return true
|
|
334
|
+
}
|
|
335
|
+
return Boolean(manifest.id) && String(issue?.body || "").includes(manifest.id)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Two github.com repository URLs name the same repository: https, no `.git`, no trailing slash, owner and name case-insensitively. */
|
|
339
|
+
export function sameRepository(a, b) {
|
|
340
|
+
const left = parseGitHubUrl(a)
|
|
341
|
+
const right = parseGitHubUrl(b)
|
|
342
|
+
if (!left || !right) return null
|
|
343
|
+
return left.owner.toLowerCase() === right.owner.toLowerCase() && left.repository.toLowerCase() === right.repository.toLowerCase()
|
|
344
|
+
}
|
|
345
|
+
|
|
200
346
|
/**
|
|
201
347
|
* @param {{ repoRoot: string, issueUrl: string, onPhase?: (name: string) => void,
|
|
348
|
+
* subject?: { origin: string, source?: string }|null,
|
|
202
349
|
* github?: { issue?: typeof issue, issueComments?: typeof issueComments, defaultBranchHead?: typeof defaultBranchHead } }} options
|
|
203
350
|
* `github` is injectable for tests, so the whole path from an issue body to
|
|
204
351
|
* a verdict can be run on data that never left the machine; the default is
|
|
205
|
-
* the read-only GitHub access in `github.mjs`.
|
|
352
|
+
* the read-only GitHub access in `github.mjs`. `subject` is the plugin's
|
|
353
|
+
* own origin, from resolveWatchSubject(); with one, the issue's Repository
|
|
354
|
+
* URL is compared with it, and without one (`watch --all`) it is not.
|
|
206
355
|
*/
|
|
207
|
-
export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {} }) {
|
|
356
|
+
export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}, subject: watchSubject = null }) {
|
|
208
357
|
// Optional: told the name of the step about to run, so a terminal can say
|
|
209
358
|
// what is happening while the network answers. Never affects the result.
|
|
210
359
|
const phase = onPhase || (() => {})
|
|
@@ -229,6 +378,22 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}
|
|
|
229
378
|
const repositoryUrl = repository.url
|
|
230
379
|
const repositoryError = repository.error
|
|
231
380
|
const issueKind = repository.kind
|
|
381
|
+
// #7787, 2026-09-20 19:18 UTC: a retry edit typed the Repository URL as
|
|
382
|
+
// mtolhuijs/omacrunch on an issue whose plugin lives at mtolhuys/omacrunch.
|
|
383
|
+
// The marketplace validates the URL in the issue, so it validated a
|
|
384
|
+
// repository that does not exist and refused within 40 seconds. The issue
|
|
385
|
+
// is compared with origin here, so the typo is named as what it is rather
|
|
386
|
+
// than reported as a HEAD that could not be read.
|
|
387
|
+
// A subject nobody named (the current directory) is compared only when
|
|
388
|
+
// it is this plugin; otherwise it is reported as not compared, with the
|
|
389
|
+
// directory's origin, so a person sees why and can pass the plugin.
|
|
390
|
+
const titleTemplate = watchSubject?.source === "cwd" ? (await submissionContract({ pinDir })).titleTemplate : ""
|
|
391
|
+
const applies = subjectApplies(watchSubject, subject, titleTemplate)
|
|
392
|
+
const origin = applies ? watchSubject.origin : null
|
|
393
|
+
const subjectSkipped = watchSubject && !applies
|
|
394
|
+
? { origin: watchSubject.origin, reason: `the current directory is ${watchSubject.origin}, not this plugin; pass the plugin checkout or URL as the second argument` }
|
|
395
|
+
: null
|
|
396
|
+
const repositoryMatches = origin && repositoryUrl ? sameRepository(repositoryUrl, origin) : null
|
|
232
397
|
|
|
233
398
|
let validated = null
|
|
234
399
|
let baselineError = null
|
|
@@ -249,6 +414,18 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}
|
|
|
249
414
|
baselineError = { code: error.code || "baseline-unreadable", message: error.message }
|
|
250
415
|
}
|
|
251
416
|
const fallback = validated ? null : validationCommentCommit(comments)
|
|
417
|
+
const validation = validationComment(comments, await submissionFeedback(pinDir))
|
|
418
|
+
const labelNames = await marketplaceLabels(pinDir)
|
|
419
|
+
// A refusal is the current state when it is newer than the baseline
|
|
420
|
+
// marker, and the marker wins when it is newer. On #7787 the marker said
|
|
421
|
+
// checkedAt 16:12 (the hand-edited retry that still validated) and the
|
|
422
|
+
// validation comment said "Validation failed" with updated_at 19:19:13
|
|
423
|
+
// (the typed retry); the marker was three hours stale and the refusal
|
|
424
|
+
// was the state of the issue. The next validation at 19:34 wrote a newer
|
|
425
|
+
// marker, and from then on the marker was the state again.
|
|
426
|
+
const refusal = validation?.kind === "failed" && (!validated || Date.parse(validation.at || "") > Date.parse(validated.checkedAt || ""))
|
|
427
|
+
? { code: validation.code, reason: validation.reason, action: validation.action, at: validation.at }
|
|
428
|
+
: null
|
|
252
429
|
let previousValidated = null
|
|
253
430
|
if (validated) {
|
|
254
431
|
// Revalidation on an existing issue may precede the latest marker. Ignore
|
|
@@ -265,6 +442,11 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}
|
|
|
265
442
|
}
|
|
266
443
|
|
|
267
444
|
const labels = (subject.labels || []).map((label) => (typeof label === "string" ? label : label?.name)).filter(Boolean)
|
|
445
|
+
const labelState = {
|
|
446
|
+
blocking: labels.filter((label) => labelNames.blocking.includes(label)),
|
|
447
|
+
validated: labels.includes(labelNames.validated),
|
|
448
|
+
reviewRequired: labels.includes(labelNames.reviewRequired),
|
|
449
|
+
}
|
|
268
450
|
const authorComments = comments.filter((comment) => comment?.user?.login === subject.user?.login)
|
|
269
451
|
// The discussion is a person's: the marketplace's own bot and any other
|
|
270
452
|
// automation (GitHub marks an app's account `type: "Bot"`, and names it
|
|
@@ -311,10 +493,13 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}
|
|
|
311
493
|
lastMaintainerCommentAt: maintainerComments.at(-1)?.created_at || null,
|
|
312
494
|
authenticated: Boolean(token()),
|
|
313
495
|
},
|
|
314
|
-
plugin: { repository: repositoryUrl, repositoryError, form: issueKind },
|
|
496
|
+
plugin: { repository: repositoryUrl, repositoryError, form: issueKind, origin, repositoryMatches, subjectSkipped },
|
|
315
497
|
validated,
|
|
316
498
|
previousValidated,
|
|
499
|
+
validationComment: validation,
|
|
317
500
|
validationCommentFallback: fallback,
|
|
501
|
+
refusal,
|
|
502
|
+
labelState,
|
|
318
503
|
baselineError,
|
|
319
504
|
head,
|
|
320
505
|
headError,
|
|
@@ -324,7 +509,7 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}
|
|
|
324
509
|
createdAt: maintainerComments.at(-1).created_at || null,
|
|
325
510
|
authorAssociation: maintainerComments.at(-1).author_association || null,
|
|
326
511
|
} : null,
|
|
327
|
-
verdict: validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl }),
|
|
512
|
+
verdict: validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl, origin, repositoryMatches, refusal }),
|
|
328
513
|
}
|
|
329
514
|
}
|
|
330
515
|
|
|
@@ -333,7 +518,24 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}
|
|
|
333
518
|
// truthy string "unknown", so an issue whose body named no repository was
|
|
334
519
|
// reported as a HEAD that could not be read, and the branch below that names
|
|
335
520
|
// the real cause was reachable from the unit test alone.
|
|
336
|
-
export function validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl }) {
|
|
521
|
+
export function validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl, origin = null, repositoryMatches = null, refusal = null }) {
|
|
522
|
+
// The two states that say the issue itself is wrong come first: a
|
|
523
|
+
// comparison of commits on the wrong repository, or after a refusal,
|
|
524
|
+
// describes nothing the marketplace is looking at.
|
|
525
|
+
if (repositoryMatches === false) {
|
|
526
|
+
return {
|
|
527
|
+
state: "wrong-repository",
|
|
528
|
+
summary: `The issue's Repository URL is ${repositoryUrl}, the plugin's origin is ${origin}. The marketplace validates the URL in the issue, so it is validating the wrong repository, or none.`,
|
|
529
|
+
action: `Edit the issue and set the Repository URL field to ${origin}. Change nothing else.`,
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (refusal) {
|
|
533
|
+
return {
|
|
534
|
+
state: "refused",
|
|
535
|
+
summary: `The marketplace refused this issue at ${refusal.at || "an unrecorded time"}: ${refusal.code}, ${refusal.reason}${refusal.action ? ` ${refusal.action}` : ""}`,
|
|
536
|
+
action: refusal.action || REFRESH_ACTION,
|
|
537
|
+
}
|
|
538
|
+
}
|
|
337
539
|
if (baselineError) {
|
|
338
540
|
return {
|
|
339
541
|
state: "unknown",
|