@catalyst-cloud/cli 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/bin/catalyst-skills.js +8 -0
- package/bin/catalyst.js +5 -0
- package/bin/launch.js +154 -0
- package/dist/args.js +280 -0
- package/dist/ask.js +161 -0
- package/dist/browser.js +20 -0
- package/dist/cli.js +397 -0
- package/dist/config.js +241 -0
- package/dist/contract-types.js +4 -0
- package/dist/contract.js +184 -0
- package/dist/detach.js +10 -0
- package/dist/environment.js +207 -0
- package/dist/errors.js +27 -0
- package/dist/events.js +106 -0
- package/dist/execution.js +451 -0
- package/dist/oauth.js +300 -0
- package/dist/pagination.js +76 -0
- package/dist/prompt.js +35 -0
- package/dist/published.js +79 -0
- package/dist/query.js +248 -0
- package/dist/ready.js +380 -0
- package/dist/release.js +142 -0
- package/dist/replica.js +614 -0
- package/dist/runtime-store.js +135 -0
- package/dist/runtime-verb.js +66 -0
- package/dist/runtime.js +87 -0
- package/dist/sdk.js +29 -0
- package/dist/secret.js +190 -0
- package/dist/semver.js +18 -0
- package/dist/skill-shape.js +189 -0
- package/dist/skills.js +129 -0
- package/dist/transport.js +205 -0
- package/dist/ts-deps-loader.js +113 -0
- package/dist/watch/consumer.js +141 -0
- package/dist/watch/cursor-file.js +62 -0
- package/dist/watch.js +175 -0
- package/dist/write.js +224 -0
- package/package.json +60 -0
- package/skills/catalyst-github/SKILL.md +35 -0
- package/skills/catalyst-github/agents/openai.yaml +6 -0
- package/skills/catalyst-github/agents/portability.yaml +4 -0
- package/skills/catalyst-github/references/is-it-mergeable.md +57 -0
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +61 -0
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +124 -0
- package/skills/catalyst-github/scripts/lib/cli.mjs +103 -0
- package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-github/scripts/lib/pull.mjs +82 -0
- package/skills/catalyst-github/scripts/read-pr.mjs +97 -0
- package/skills/catalyst-linear/SKILL.md +43 -0
- package/skills/catalyst-linear/agents/openai.yaml +6 -0
- package/skills/catalyst-linear/agents/portability.yaml +5 -0
- package/skills/catalyst-linear/references/reading-a-ticket.md +52 -0
- package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +53 -0
- package/skills/catalyst-linear/references/writing-to-linear.md +43 -0
- package/skills/catalyst-linear/scripts/comment.mjs +59 -0
- package/skills/catalyst-linear/scripts/create-ticket.mjs +44 -0
- package/skills/catalyst-linear/scripts/label.mjs +48 -0
- package/skills/catalyst-linear/scripts/lib/cli.mjs +164 -0
- package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-linear/scripts/move.mjs +41 -0
- package/skills/catalyst-linear/scripts/read-ticket.mjs +93 -0
- package/skills/catalyst-linear/scripts/search.mjs +49 -0
- package/skills/catalyst-onboard/SKILL.md +57 -0
- package/skills/catalyst-onboard/agents/openai.yaml +6 -0
- package/skills/catalyst-onboard/agents/portability.yaml +5 -0
- package/skills/catalyst-onboard/references/declaring-a-repository.md +23 -0
- package/skills/catalyst-onboard/references/skill-sources.md +35 -0
- package/skills/catalyst-onboard/references/the-one-path.md +149 -0
- package/skills/catalyst-onboard/references/what-a-phase-needs.md +46 -0
- package/skills/catalyst-onboard/references/what-the-browser-owns.md +50 -0
- package/skills/catalyst-onboard/references/who-fixes-what.md +44 -0
- package/skills/catalyst-onboard/scripts/lib/cli.mjs +117 -0
- package/skills/catalyst-onboard/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +345 -0
- package/skills/catalyst-setup/SKILL.md +36 -0
- package/skills/catalyst-setup/agents/openai.yaml +6 -0
- package/skills/catalyst-setup/agents/portability.yaml +4 -0
- package/skills/catalyst-setup/references/what-each-check-means.md +88 -0
- package/skills/catalyst-setup/scripts/check.mjs +75 -0
- package/skills/catalyst-setup/scripts/lib/cli.mjs +103 -0
- package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
- package/skills/connect-me/SKILL.md +63 -0
- package/skills/connect-me/agents/openai.yaml +6 -0
- package/skills/connect-me/agents/portability.yaml +5 -0
- package/skills/connect-me/references/keeping-the-replica-running.md +88 -0
- package/skills/connect-me/scripts/lib/cli.mjs +185 -0
- package/skills/connect-me/scripts/lib/credential.mjs +29 -0
- package/skills/connect-me/scripts/verify-connection.mjs +68 -0
- package/skills/how-catalyst-works/SKILL.md +43 -0
- package/skills/how-catalyst-works/agents/openai.yaml +6 -0
- package/skills/how-catalyst-works/agents/portability.yaml +4 -0
- package/skills/how-catalyst-works/references/coding-accounts.md +51 -0
- package/skills/how-catalyst-works/references/stages-and-mapping.md +56 -0
- package/skills/how-catalyst-works/references/the-ladder.md +41 -0
- package/skills/how-catalyst-works/references/what-catalyst-is.md +30 -0
- package/skills/how-catalyst-works/references/what-runs-next.md +77 -0
- package/skills/how-catalyst-works/references/when-a-phase-fails.md +57 -0
- package/skills/how-catalyst-works/scripts/explain-ticket.mjs +41 -0
- package/skills/how-catalyst-works/scripts/lib/cli.mjs +164 -0
- package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
- package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
- package/skills/how-catalyst-works/scripts/whats-running.mjs +65 -0
- package/skills/run-this-project/SKILL.md +45 -0
- package/skills/run-this-project/agents/openai.yaml +6 -0
- package/skills/run-this-project/agents/portability.yaml +5 -0
- package/skills/run-this-project/assets/stall-policy.json +15 -0
- package/skills/run-this-project/references/making-work-ready.md +60 -0
- package/skills/run-this-project/references/reacting-to-events.md +76 -0
- package/skills/run-this-project/references/stalls-and-escalation.md +63 -0
- package/skills/run-this-project/scripts/lib/cli.mjs +185 -0
- package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
- package/skills/run-this-project/scripts/make-ready.mjs +64 -0
- package/skills/run-this-project/scripts/scope-status.mjs +0 -0
- package/skills/run-this-project/scripts/watch-scope.mjs +61 -0
- package/skills/unstick/SKILL.md +41 -0
- package/skills/unstick/agents/openai.yaml +6 -0
- package/skills/unstick/agents/portability.yaml +5 -0
- package/skills/unstick/references/playbook.md +51 -0
- package/skills/unstick/scripts/lib/cli.mjs +135 -0
- package/skills/unstick/scripts/lib/credential.mjs +29 -0
- package/skills/unstick/scripts/unstick.mjs +57 -0
- package/skills/what-needs-me/SKILL.md +41 -0
- package/skills/what-needs-me/agents/openai.yaml +6 -0
- package/skills/what-needs-me/agents/portability.yaml +5 -0
- package/skills/what-needs-me/references/raising-a-decision.md +41 -0
- package/skills/what-needs-me/references/reading-the-inbox.md +38 -0
- package/skills/what-needs-me/references/settling-an-answer.md +37 -0
- package/skills/what-needs-me/scripts/inbox.mjs +56 -0
- package/skills/what-needs-me/scripts/lib/cli.mjs +135 -0
- package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
- package/skills/what-needs-me/scripts/raise.mjs +53 -0
- package/skills/what-needs-me/scripts/settle.mjs +73 -0
- package/skills/whats-happening/SKILL.md +43 -0
- package/skills/whats-happening/agents/openai.yaml +6 -0
- package/skills/whats-happening/agents/portability.yaml +4 -0
- package/skills/whats-happening/assets/status-reply.json +77 -0
- package/skills/whats-happening/references/reading-the-board.md +43 -0
- package/skills/whats-happening/references/reprioritising.md +37 -0
- package/skills/whats-happening/references/routing-work.md +36 -0
- package/skills/whats-happening/references/status-reply.md +34 -0
- package/skills/whats-happening/references/why-is-it-stuck.md +62 -0
- package/skills/whats-happening/scripts/explain.mjs +28 -0
- package/skills/whats-happening/scripts/lib/cli.mjs +135 -0
- package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
- package/skills/whats-happening/scripts/snapshot.mjs +149 -0
- package/vendor/README.md +9 -0
- package/vendor/paths/index.d.ts +85 -0
- package/vendor/paths/index.js +148 -0
- package/vendor/paths/legacy-installer.d.ts +36 -0
- package/vendor/paths/legacy-installer.js +154 -0
- package/vendor/paths/node.d.ts +18 -0
- package/vendor/paths/node.js +102 -0
- package/vendor/paths/provenance.json +17 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: catalyst-github
|
|
3
|
+
description: >-
|
|
4
|
+
Catalyst's GitHub: show a ticket's pull request with its checks, reviews and review threads, say whether it is mergeable under this repository's policy, and explain what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). Use when someone asks "show me the PR", "what are the checks saying", "why hasn't it merged", "what does the queue need", "what does that label mean", or wants the branch and merge conventions Catalyst follows.
|
|
5
|
+
allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
|
|
6
|
+
---
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.8.0 — written in this repository for customer tenants -->
|
|
8
|
+
|
|
9
|
+
# Catalyst's GitHub
|
|
10
|
+
|
|
11
|
+
You answer for the pull requests Catalyst opens and moves. The person asking wants to see one PR, know what is red, and know whether it will merge. Everything tenant-specific (the reviewer's login, the merge policy per repository, the four PR label names, the required checks) comes from the contract at run time and is printed by the scripts; you never restate it from memory.
|
|
12
|
+
|
|
13
|
+
## Run first
|
|
14
|
+
|
|
15
|
+
- `node scripts/read-pr.mjs --help` — a ticket's PR (or a PR by node id): state, branch, head, linked ticket, GitHub's mergeable state, every check, status and review. `--all` lists every PR for a ticket.
|
|
16
|
+
- `node scripts/is-it-mergeable.mjs --help` — the three legs (checks, reviewer signal, unresolved threads) judged under the repository's policy, plus the prerequisites; exit 1 when a leg is red.
|
|
17
|
+
|
|
18
|
+
Both read through `catalyst-skills query`, `contract` and, when fresh, `replica`; the first stderr line names the source, and your answer repeats it.
|
|
19
|
+
|
|
20
|
+
## Load on demand
|
|
21
|
+
|
|
22
|
+
| when | read |
|
|
23
|
+
| -- | -- |
|
|
24
|
+
| the person asks what Catalyst did to the branch or PR, why the title changed, why history was rewritten, what a label means, or what happens on merge | `references/what-a-pr-accumulates.md` |
|
|
25
|
+
| the person asks why a PR has not merged, what the queue needs, what a clean review pass looks like, or what a policy requires | `references/is-it-mergeable.md` |
|
|
26
|
+
|
|
27
|
+
## Rules
|
|
28
|
+
|
|
29
|
+
- Never compose a URL or call the API yourself. Every read is a `catalyst-skills` verb behind a script; run the script, do not read it.
|
|
30
|
+
- A queue-ready label is the cloud's attestation, never a lever you apply; "merged by the queue" is the terminal signal.
|
|
31
|
+
- Hold labels are the human's lever. Describe them; do not apply or remove them.
|
|
32
|
+
- Say what a key cannot see by name (PR labels, the reviewer's reaction, the queue's own state); never guess it.
|
|
33
|
+
- An inconclusive leg is not a red one. Report "not proven from this read" and name what would settle it.
|
|
34
|
+
- Never poll. To wait on a merge, subscribe through the project-running skill's watch; do not re-run a script in a loop.
|
|
35
|
+
- Cite the PR as `<owner/name>#<number>` and the ticket by its identifier.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
interface:
|
|
2
|
+
display_name: "Catalyst's GitHub"
|
|
3
|
+
short_description: "Show a ticket's pull request with its checks, reviews and threads, and say whether it is mergeable under this repository's policy"
|
|
4
|
+
default_prompt: "Use $catalyst-github to show me the PR for this ticket and whether it can merge."
|
|
5
|
+
policy:
|
|
6
|
+
allow_implicit_invocation: true
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Is it mergeable?
|
|
2
|
+
|
|
3
|
+
The rule is invariant; the policy that applies to a repository is not. The policy names, the default, each repository's resolved policy and its source (repository setting, tenant setting, or the default), the reviewer login and the required check names all come from the contract's `merge` block at run time. `node scripts/is-it-mergeable.mjs <ticket>` reads them and prints one line per leg; the text below explains what those lines mean.
|
|
4
|
+
|
|
5
|
+
## Prerequisites before any leg counts
|
|
6
|
+
|
|
7
|
+
A merge queue never takes a PR that is closed, still a draft, or in conflict with its base branch, and neither does the cloud's evaluator. The script reports these first: the pr phase is what marks a draft ready for review; a conflict (GitHub reports the mergeable state as dirty) is repaired by a remediate round that rebases the branch.
|
|
8
|
+
|
|
9
|
+
## The three legs
|
|
10
|
+
|
|
11
|
+
Merge evidence has three independent legs. Two are unconditional under every policy; the third is what the policy decides.
|
|
12
|
+
|
|
13
|
+
| leg | passes when | always required? |
|
|
14
|
+
| -- | -- | -- |
|
|
15
|
+
| checks | every gating check at the current head completed with a non-failing conclusion (success, neutral or skipped count as non-failing). A pending check, or an empty set, is unknown, never green. The queue's own status check is not evidence about the PR and is excluded. | yes |
|
|
16
|
+
| reviewer | the reviewer signal the policy accepts (below) | only under a reviewer-attestation policy |
|
|
17
|
+
| threads | zero unresolved review threads, and no resolved thread whose resolving commit has left the head | yes |
|
|
18
|
+
|
|
19
|
+
Which checks are gating: the names the queue configuration itself requires, plus any extra gating check the cloud names. The contract serves the checks the cloud's own remediation waits on as `cloudRemediateRequiredChecks`; a check that cannot block a merge is informational and never a reason to withhold the label.
|
|
20
|
+
|
|
21
|
+
## The policies
|
|
22
|
+
|
|
23
|
+
Three policy names are served on `merge.policies`; the default is `merge.defaultPolicy`; a repository's own is `merge.repositories[].policy` with `policySource` saying where it came from. Read them from the contract; do not assume a repository is on the default.
|
|
24
|
+
|
|
25
|
+
| policy as served today | reviewer leg passes when |
|
|
26
|
+
| -- | -- |
|
|
27
|
+
| the default (an attestation policy) | a clean pass at the current head, or findings raised on an earlier head together with green checks and zero unresolved threads. In plain terms: the reviewer found something, the fix landed, every thread it raised is resolved, and the checks are green at the current head. That PR re-earns eligibility on its own; nobody has to ask the reviewer again. |
|
|
28
|
+
| the strict variant | a clean pass at the current head, always. Findings on an earlier head keep blocking until the reviewer looks again. |
|
|
29
|
+
| checks and threads only | the reviewer requirement is dropped entirely; the two unconditional legs decide. |
|
|
30
|
+
|
|
31
|
+
A clean pass is a reaction: a thumbs-up from the reviewer login posted at or after the head commit's own timestamp. The comment-shaped clean pass ("no major issues" in place of threads) is honoured only if `cleanPassShapes[]` says so for that kind; the script does not judge it either way.
|
|
32
|
+
|
|
33
|
+
## What the script can and cannot see
|
|
34
|
+
|
|
35
|
+
The mirrored PR detail carries checks, legacy commit statuses, review objects with their state, GitHub's mergeable state and the auto-merge flag. It does not carry reactions, PR labels, or the commit each review was submitted against. So:
|
|
36
|
+
|
|
37
|
+
- checks: judged fully from the detail.
|
|
38
|
+
- reviewer: reported as inconclusive unless the policy waives it. The script says whether the reviewer left review objects, but whether a clean-pass reaction followed, and whether those reviews stand at the current head or an earlier one, is not mirrored.
|
|
39
|
+
- threads: read from the local replica's review-thread rows when the replica is fresh (resolved flag per thread); inconclusive otherwise, with the command that starts the replica. Thread ancestry against force-pushes is judged by the cloud, not here.
|
|
40
|
+
|
|
41
|
+
The exit code is 1 only for a proven-red leg or prerequisite. An inconclusive leg leaves exit 0 and a verdict that says "not proven mergeable from this read". The cloud's own evaluator, which holds the reaction and the ancestry, is the authority; a queue-ready label on the PR means it already said yes.
|
|
42
|
+
|
|
43
|
+
## What happens after the label
|
|
44
|
+
|
|
45
|
+
Once the cloud's merge phase applies the queue-ready label, four events each wake one bounded remediate round with no operator in the loop: a required check turning red, a fresh conflict with the base branch, a non-clean review, and a dequeue by the queue. The round repairs what it can, pushes, and the PR re-enters the queue itself. A scheduled sweep is the independent backstop for a labelled PR that is not moving. A hand-posted review request is never the required unblock for the ordinary shape.
|
|
46
|
+
|
|
47
|
+
## The levers a human has
|
|
48
|
+
|
|
49
|
+
- The hold label keeps an eligible PR out of the queue; removing it lets the PR enter on its own.
|
|
50
|
+
- The preview label requests a preview and holds the merge; removing it is the approval.
|
|
51
|
+
- Replying on a review thread and re-resolving it re-attests that thread against the current head.
|
|
52
|
+
- A comment on the ticket releases a remediate round that changed nothing, and a validate hold.
|
|
53
|
+
- A path the queue excludes is merged by hand; the cloud marks such a PR with the hand-steps hold automatically.
|
|
54
|
+
|
|
55
|
+
## Reading the answer
|
|
56
|
+
|
|
57
|
+
Lead with the verdict line, then the red legs with what unblocks each, then the inconclusive ones with what would settle them. Name the policy and where it came from. Say which source the read used (the first stderr line). If the person wants to wait for the merge, subscribe through the project-running skill's watch rather than re-running this script in a loop.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# What a pull request accumulates as Catalyst moves a ticket
|
|
2
|
+
|
|
3
|
+
Most of this page restates invariants: how Catalyst names branches, opens and rewrites pull requests, and what it pushes when. The parts that vary per tenant are read live from the contract's `merge` block and never restated here: the reviewer's login (`merge.reviewerLogin`), which clean-pass shapes are honoured (`merge.cleanPassShapes[]`), the four PR label names (`merge.prLabels`), the checks the cloud waits on (`merge.cloudRemediateRequiredChecks`), and the policy per repository (`merge.repositories[]`, else `merge.defaultPolicy`). `catalyst-skills contract --path merge` prints them.
|
|
4
|
+
|
|
5
|
+
## The branch
|
|
6
|
+
|
|
7
|
+
The branch is named exactly the ticket identifier, with no prefix: ticket `ABC-123` works on branch `ABC-123`. It is created by the implement phase once that phase has changes to push. A ticket that has never reached implement has no branch, which is why an eligibility explanation can say a branch is missing: nothing is wrong, nothing has run yet.
|
|
8
|
+
|
|
9
|
+
A separate work-in-progress namespace exists for runner scratch pushes; a pull request is never opened from it.
|
|
10
|
+
|
|
11
|
+
## The pull request, phase by phase
|
|
12
|
+
|
|
13
|
+
| phase | what it does to the PR |
|
|
14
|
+
| -- | -- |
|
|
15
|
+
| implement | pushes the branch and opens a draft PR with a placeholder. The title is Conventional-Commit shaped: `feat(<scopes>): <ticket> — <ticket title>`, scopes derived from the changed top-level app or package directories (at most three), the whole title capped at 110 characters. The body says the description is pending from the pr phase. |
|
|
16
|
+
| validate | runs the gate and writes its report; touches the PR only through the checks the push triggers. |
|
|
17
|
+
| pr | writes the real title and body from its own artifact (first line the title, then the body), rebases the branch onto the current base, force-pushes, updates the PR, and marks it ready for review. |
|
|
18
|
+
| remediate | repairs one failure class (a red check, a conflict, review findings, a dequeue) and pushes with a compare-and-swap force push, so a push that raced it is never overwritten. |
|
|
19
|
+
| merge | evaluates the evidence below and, when it is sufficient, applies the queue-ready label. It performs no merge itself. |
|
|
20
|
+
|
|
21
|
+
No `Closes <ticket>` trailer is inserted. The ticket reaches Done through the merge webhook, not through any phase (see the last section).
|
|
22
|
+
|
|
23
|
+
## Force-pushes are routine
|
|
24
|
+
|
|
25
|
+
Three phases rewrite history on the ticket branch: implement pushes fresh with force, pr rebases and force-pushes, remediate force-pushes with a lease. Two consequences a reader must hold:
|
|
26
|
+
|
|
27
|
+
- A review thread resolved against one commit may stop being evidence once the head moves. Catalyst walks the PR's own force-push timeline: a rebase that carries the same fix forward still counts; a force-push that dropped the fix does not, and the refusal names the thread and both commits. Replying on the thread and re-resolving it re-attests against the current head.
|
|
28
|
+
- A check result belongs to a head. After any push, the checks at the new head start from nothing; "no check has reported yet" is unknown, never green.
|
|
29
|
+
|
|
30
|
+
## The reviewer signal
|
|
31
|
+
|
|
32
|
+
An automated reviewer (login from the contract) reviews every PR. Its signal is one of four: clean, findings at the current head, findings at an earlier head, no review. A clean pass is a reaction, not a review object: a thumbs-up from the reviewer posted at or after the head commit's own timestamp. A second shape, a terse "no major issues" comment in place of review threads, exists as a documented convention; whether it is honoured is served on `cleanPassShapes[].honoured` and must be read there, never assumed. What counts under each policy is on `references/is-it-mergeable.md`.
|
|
33
|
+
|
|
34
|
+
## Labels on the PR
|
|
35
|
+
|
|
36
|
+
Four labels matter, all named on `merge.prLabels`, all matched exactly (a queue's label conditions are exact matches, which is why every hold has its own name):
|
|
37
|
+
|
|
38
|
+
| contract field | who applies it | meaning |
|
|
39
|
+
| -- | -- | -- |
|
|
40
|
+
| `queueReady` | the cloud's merge phase, once its own evidence gate passes | an attestation that the evidence was sufficient. It is not a lever: applying it by hand makes nothing merge that would not have merged, and removing it is not a hold. |
|
|
41
|
+
| `hold` | a human | the one manual escape hatch. An eligible PR stays out of the queue while it carries this. |
|
|
42
|
+
| `handStepsHold` | the cloud, automatically | the PR touches a path the queue configuration excludes (schema or migration paths, typically), so a person has to merge it by hand. |
|
|
43
|
+
| `preview` | a human | "deploy me a preview and hold the merge until I have looked". Removing the label is the approval. |
|
|
44
|
+
|
|
45
|
+
The mirror does not carry PR labels or reactions, so your key cannot read which holds a PR carries or whether the reviewer reacted; the scripts say so by name. GitHub's own page, or the tenant's settings, is where those live.
|
|
46
|
+
|
|
47
|
+
## The queue is optional and opt-out
|
|
48
|
+
|
|
49
|
+
When a repository runs a merge queue, entry is opt-out: a PR against the base branch that is green on its required checks, not a draft, has zero unresolved review threads, carries no hold label, and touches no excluded path enters the queue on its own. Nobody applies a label to make that happen. The terminal signal is the queue's own merge ("merged by" the queue bot), not any return value from a merge command. Without a queue, the queue-ready label is still the cloud's attestation and the merge itself is whatever the repository's own setup does with it.
|
|
50
|
+
|
|
51
|
+
## After the label
|
|
52
|
+
|
|
53
|
+
A problem after the attestation does not wait on a person noticing. A red required check, a fresh conflict with the base branch, a non-clean review, or a dequeue by the queue each wakes one bounded remediate round automatically, which repairs what it can, re-pushes, and re-enters the queue itself. An independent scheduled sweep remains as a backstop for a PR that carries the label and is going nowhere. Hand-posting a review request to the reviewer is never the required unblock for the ordinary remediation shape.
|
|
54
|
+
|
|
55
|
+
## What happens on merge
|
|
56
|
+
|
|
57
|
+
The ticket moves to Done when the real merge webhook arrives, keyed to the pull request's merged event, with a bounded sweep as the backstop for a dropped webhook. Measured latency is a few seconds. A phase never writes Done; a ticket still not Done a minute after its PR merged is a finding, not a chore. Done is not the same as live: a change to a service deploys on its own pipeline after the merge.
|
|
58
|
+
|
|
59
|
+
## Reading a PR through this skill
|
|
60
|
+
|
|
61
|
+
`node scripts/read-pr.mjs <ticket>` follows the ticket's own record to the pull requests that name it, picks the open one (else the merged one, else the newest), and prints the mirrored detail: state, draft and merged flags, branch and base, head commit, the linked ticket and its stage, GitHub's own mergeable state and auto-merge flag, every check with status and conclusion, legacy commit statuses, and every mirrored review with its state. `--all` lists every PR for the ticket; `--json` prints the raw document. The first stderr line names the source, a fresh replica or the API, and the answer should repeat it.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// is-it-mergeable.mjs — the three legs of merge evidence for one PR (checks, reviewer signal,
|
|
3
|
+
// unresolved threads) judged under the policy the tenant contract resolves for its repository, plus
|
|
4
|
+
// the prerequisites a queue applies before any leg counts (open, not a draft, no conflict). Every
|
|
5
|
+
// input comes from `catalyst-skills query`, `contract` and, when fresh, `replica sql`.
|
|
6
|
+
// Exit 0 no leg is red, 1 a leg or prerequisite is red, 2 this machine is not connected.
|
|
7
|
+
import { parseJson, runCli, runCliOrExit } from "./lib/cli.mjs";
|
|
8
|
+
import { resolvePull, truthy } from "./lib/pull.mjs";
|
|
9
|
+
|
|
10
|
+
const HELP = `Usage: node scripts/is-it-mergeable.mjs <ticket | pr-node-id> [--json]
|
|
11
|
+
|
|
12
|
+
Judges one pull request against the merge policy the tenant contract resolves for its repository:
|
|
13
|
+
checks every gating check at the head completed without failing (pending or absent is not green)
|
|
14
|
+
reviewer the reviewer signal the policy requires; the clean-pass reaction itself is not mirrored,
|
|
15
|
+
so this leg is reported as inconclusive rather than guessed, unless the policy waives it
|
|
16
|
+
threads zero unresolved review threads, read from the local replica when it is fresh
|
|
17
|
+
|
|
18
|
+
A red prerequisite (closed, draft, merge conflict) is also exit 1. Inconclusive legs never fail the
|
|
19
|
+
exit code; the cloud's own evaluator, which holds the reaction and the thread ancestry, is authoritative.
|
|
20
|
+
--json prints {pr, policy, legs[], verdict} instead of lines.`;
|
|
21
|
+
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
24
|
+
console.log(HELP);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
const json = args.includes("--json");
|
|
28
|
+
const target = args.find((a) => !a.startsWith("--"));
|
|
29
|
+
if (!target) {
|
|
30
|
+
console.error("is-it-mergeable needs a ticket or a PR node id");
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { ticket, detail } = resolvePull(target);
|
|
35
|
+
const merge = parseJson(runCliOrExit(["contract", "--path", "merge", "--json"]).stdout);
|
|
36
|
+
if (!merge || !Array.isArray(merge.policies)) {
|
|
37
|
+
console.error("the contract's merge block did not parse; run: catalyst-skills contract --refresh");
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const repoId = String(detail.repo_id ?? "");
|
|
42
|
+
const repoRow = (merge.repositories ?? []).find((r) => `${r.owner}/${r.name}`.toLowerCase() === repoId.toLowerCase());
|
|
43
|
+
const policy = repoRow?.policy ?? merge.defaultPolicy;
|
|
44
|
+
const policySource = repoRow ? repoRow.policySource : "default";
|
|
45
|
+
const requiresReviewer = policy !== "checks-and-threads";
|
|
46
|
+
const acceptsEarlierHead = policy === "codex-attestation";
|
|
47
|
+
|
|
48
|
+
const legs = [];
|
|
49
|
+
const add = (leg, state, line) => legs.push({ leg, state, line });
|
|
50
|
+
|
|
51
|
+
// ── prerequisites the queue applies before any leg counts ──────────────────────────────────────
|
|
52
|
+
if (truthy(detail.merged)) {
|
|
53
|
+
add("state", "pass", `already merged${detail.merged_at ? ` at ${new Date(Number(detail.merged_at)).toISOString()}` : ""}; nothing left to judge`);
|
|
54
|
+
} else {
|
|
55
|
+
const state = String(detail.state ?? "").toLowerCase();
|
|
56
|
+
if (state && state !== "open") add("state", "fail", `the PR is ${state}, not open`);
|
|
57
|
+
else if (truthy(detail.draft)) add("state", "fail", "the PR is still a draft; the pr phase marks it ready for review, a queue never takes a draft");
|
|
58
|
+
else add("state", "pass", "open and ready for review");
|
|
59
|
+
const ms = String(detail.mergeable_state ?? "").toLowerCase();
|
|
60
|
+
if (ms === "dirty" || detail.mergeable === false || detail.mergeable === 0) add("conflict", "fail", `GitHub reports a merge conflict with the base branch (mergeable_state=${ms || "unknown"}); a remediate round rebases it`);
|
|
61
|
+
else if (ms) add("conflict", "pass", `GitHub reports mergeable_state=${ms}`);
|
|
62
|
+
else add("conflict", "inconclusive", "GitHub has not reported a mergeable state for this head yet");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── leg 1: checks ───────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
const checks = Array.isArray(detail.checks) ? detail.checks : [];
|
|
67
|
+
const required = Array.isArray(merge.cloudRemediateRequiredChecks) ? merge.cloudRemediateRequiredChecks : [];
|
|
68
|
+
const notQueue = checks.filter((c) => !/mergify|^queue:|^summary$/i.test(String(c.name ?? "")));
|
|
69
|
+
const gating = notQueue.filter((c) => required.includes(String(c.name)));
|
|
70
|
+
const judged = gating.length > 0 ? gating : notQueue;
|
|
71
|
+
const ok = new Set(["success", "neutral", "skipped"]);
|
|
72
|
+
const red = judged.filter((c) => String(c.status).toLowerCase() === "completed" && !ok.has(String(c.conclusion).toLowerCase()));
|
|
73
|
+
const pending = judged.filter((c) => String(c.status).toLowerCase() !== "completed");
|
|
74
|
+
const statuses = Array.isArray(detail.commit_statuses) ? detail.commit_statuses : [];
|
|
75
|
+
const redStatuses = statuses.filter((s) => ["failure", "error"].includes(String(s.state).toLowerCase()));
|
|
76
|
+
const scope = gating.length > 0 ? `the ${gating.length} required check(s) the contract names` : `every mirrored check (none of the contract's required names is present at this head)`;
|
|
77
|
+
if (judged.length === 0 && redStatuses.length === 0) add("checks", "inconclusive", "no check has reported at this head; an unrun set is unknown, never green");
|
|
78
|
+
else if (red.length > 0 || redStatuses.length > 0) add("checks", "fail", `red: ${[...red.map((c) => `${c.name} (${c.conclusion})`), ...redStatuses.map((s) => `${s.context} (${s.state})`)].join(", ")}`);
|
|
79
|
+
else if (pending.length > 0) add("checks", "inconclusive", `still running: ${pending.map((c) => c.name).join(", ")} — judged over ${scope}`);
|
|
80
|
+
else add("checks", "pass", `green over ${scope}`);
|
|
81
|
+
|
|
82
|
+
// ── leg 2: the reviewer signal ─────────────────────────────────────────────────────────────────
|
|
83
|
+
const reviews = Array.isArray(detail.reviews) ? detail.reviews : [];
|
|
84
|
+
const login = String(merge.reviewerLogin ?? "");
|
|
85
|
+
const byReviewer = reviews.filter((r) => String(r.reviewer_name ?? "") === login || String(r.reviewer_id ?? "") === `github:${login}`);
|
|
86
|
+
if (!requiresReviewer) add("reviewer", "pass", `policy ${policy} needs no reviewer signal`);
|
|
87
|
+
else if (byReviewer.length > 0) {
|
|
88
|
+
const states = [...new Set(byReviewer.map((r) => String(r.state ?? "?")))].join(", ");
|
|
89
|
+
add("reviewer", "inconclusive", `${login} left ${byReviewer.length} review object(s) (${states}); whether they stand at the current head or an earlier one, and whether a clean-pass reaction followed, is not mirrored${acceptsEarlierHead ? " — under this policy, findings on an earlier head plus green checks and zero unresolved threads are accepted" : " — this policy requires a clean pass at the current head"}`);
|
|
90
|
+
} else add("reviewer", "inconclusive", `no review object from ${login} is mirrored; a clean pass is a reaction, which this read cannot see`);
|
|
91
|
+
|
|
92
|
+
// ── leg 3: unresolved threads, from the replica when it is fresh ───────────────────────────────
|
|
93
|
+
const replica = parseJson(runCli(["replica", "status", "--json"]).stdout);
|
|
94
|
+
if (replica && replica.verdict === "fresh") {
|
|
95
|
+
const num = Number(detail.number);
|
|
96
|
+
const esc = repoId.replace(/'/g, "''");
|
|
97
|
+
const sql = `select resolved, count(*) as n from pr_review_threads where repo_id = '${esc}' and pr_number = ${Number.isInteger(num) ? num : -1} group by resolved`;
|
|
98
|
+
const rows = parseJson(runCli(["replica", "sql", sql, "--json"]).stdout) ?? [];
|
|
99
|
+
const count = (want) => rows.filter((r) => (want === null ? r.resolved === null || r.resolved === undefined : Number(r.resolved) === want)).reduce((a, r) => a + Number(r.n ?? 0), 0);
|
|
100
|
+
const unresolved = count(0);
|
|
101
|
+
const unknown = count(null);
|
|
102
|
+
if (unresolved > 0) add("threads", "fail", `${unresolved} unresolved review thread(s)`);
|
|
103
|
+
else if (unknown > 0) add("threads", "inconclusive", `${unknown} thread(s) with no resolved flag mirrored`);
|
|
104
|
+
else add("threads", "pass", `${count(1)} thread(s), all resolved (ancestry against force-pushes is judged by the cloud, not here)`);
|
|
105
|
+
} else {
|
|
106
|
+
add("threads", "inconclusive", `the PR detail carries no thread count and the replica is ${replica?.verdict ?? "unavailable"}; start it (catalyst-skills replica start --detach) for a local read`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const failed = legs.filter((l) => l.state === "fail");
|
|
110
|
+
const open = legs.filter((l) => l.state === "inconclusive");
|
|
111
|
+
let verdict;
|
|
112
|
+
if (failed.length > 0) verdict = `NOT MERGEABLE: ${failed.map((l) => l.leg).join(", ")}`;
|
|
113
|
+
else if (open.length > 0) verdict = `NOT PROVEN MERGEABLE from this read (${open.map((l) => l.leg).join(", ")} inconclusive); the cloud's evaluator decides`;
|
|
114
|
+
else verdict = "MERGEABLE as far as this read can see";
|
|
115
|
+
|
|
116
|
+
const header = `${repoId}#${detail.number ?? "?"}${ticket ? ` (${ticket})` : ""} head ${String(detail.head_sha ?? "").slice(0, 12)} — policy ${policy} (${policySource})`;
|
|
117
|
+
if (json) {
|
|
118
|
+
console.log(JSON.stringify({ pr: { repo_id: repoId, number: detail.number, node_id: detail.node_id, ticket, head_sha: detail.head_sha }, policy: { name: policy, source: policySource }, legs, verdict }));
|
|
119
|
+
} else {
|
|
120
|
+
console.log(header);
|
|
121
|
+
for (const l of legs) console.log(`${l.state === "pass" ? "ok " : l.state === "fail" ? "FAIL" : "? "} ${l.leg}: ${l.line}`);
|
|
122
|
+
console.log(verdict);
|
|
123
|
+
}
|
|
124
|
+
process.exit(failed.length > 0 ? 1 : 0);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/cli.mjs — the one way a skill script reaches the Catalyst Cloud SDK and API: by spawning the
|
|
3
|
+
// catalyst-skills CLI this bundle installed. It reads customer.json to find that CLI and nothing
|
|
4
|
+
// else; it never holds the key itself. This file is a library — run a sibling script with
|
|
5
|
+
// --help for usage. Identical in every skill of this bundle on purpose (skills install one directory
|
|
6
|
+
// at a time, so nothing shared outside the skill would ever be installed).
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { CONNECT_COMMAND, hasCredential } from "./credential.mjs";
|
|
12
|
+
|
|
13
|
+
export const PACKAGE = "@catalyst-cloud/catalyst-skills";
|
|
14
|
+
export const CONNECT_HINT = `this machine is not connected to a tenant yet — run: ${CONNECT_COMMAND}`;
|
|
15
|
+
|
|
16
|
+
/** ~/.config/catalyst-cloud/customer.json, honouring CATALYST_SKILLS_HOME before HOME. */
|
|
17
|
+
export function configPath() {
|
|
18
|
+
const home = process.env.CATALYST_SKILLS_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
19
|
+
return join(home, ".config", "catalyst-cloud", "customer.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The customer config, or exit 2 with one line naming the connect command. Never throws. */
|
|
23
|
+
export function requireCustomerConfig() {
|
|
24
|
+
const path = configPath();
|
|
25
|
+
if (!existsSync(path)) {
|
|
26
|
+
console.error(CONNECT_HINT);
|
|
27
|
+
process.exit(2);
|
|
28
|
+
}
|
|
29
|
+
let cfg;
|
|
30
|
+
try {
|
|
31
|
+
cfg = JSON.parse(readFileSync(path, "utf8"));
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error(`${path} could not be read (${err instanceof Error ? err.message : String(err)}) — ${CONNECT_HINT}`);
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
36
|
+
if (!hasCredential(cfg) || typeof cfg.baseUrl !== "string") {
|
|
37
|
+
console.error(`${path} is missing required fields — ${CONNECT_HINT}`);
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
return cfg;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Run one catalyst-skills verb and return {code, stdout, stderr}. Spawns the CLI whose path the
|
|
45
|
+
* connect step recorded in customer.json; falls back to `npx @catalyst-cloud/catalyst-skills` when
|
|
46
|
+
* no path is recorded or it no longer exists. Exits 2 when the machine is not connected.
|
|
47
|
+
*/
|
|
48
|
+
export function runCli(args, opts = {}) {
|
|
49
|
+
const cfg = requireCustomerConfig();
|
|
50
|
+
const recorded = typeof cfg.cliPath === "string" && existsSync(cfg.cliPath);
|
|
51
|
+
const command = recorded ? process.execPath : process.platform === "win32" ? "npx.cmd" : "npx";
|
|
52
|
+
const argv = recorded ? [cfg.cliPath, ...args] : [PACKAGE, ...args];
|
|
53
|
+
const res = spawnSync(command, argv, {
|
|
54
|
+
encoding: "utf8",
|
|
55
|
+
input: opts.stdin,
|
|
56
|
+
env: process.env,
|
|
57
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
58
|
+
shell: !recorded && process.platform === "win32",
|
|
59
|
+
});
|
|
60
|
+
if (res.error) {
|
|
61
|
+
console.error(`could not run ${recorded ? cfg.cliPath : `npx ${PACKAGE}`}: ${res.error.message}`);
|
|
62
|
+
process.exit(2);
|
|
63
|
+
}
|
|
64
|
+
return { code: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run a verb that must succeed. On exit 2 (the CLI's "not configured / refused" class) the script
|
|
69
|
+
* exits 2; on any other non-zero exit it exits 1. The CLI's own stderr is forwarded either way so the
|
|
70
|
+
* reason is never lost.
|
|
71
|
+
*/
|
|
72
|
+
export function runCliOrExit(args, opts = {}) {
|
|
73
|
+
const res = runCli(args, opts);
|
|
74
|
+
if (res.code === 0) return res;
|
|
75
|
+
const why = res.stderr.trim() || res.stdout.trim() || `catalyst-skills ${args.join(" ")} exited ${res.code}`;
|
|
76
|
+
console.error(why);
|
|
77
|
+
process.exit(res.code === 2 ? 2 : 1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Parse the CLI's --json stdout; null when it is not JSON (the caller decides what that means). */
|
|
81
|
+
export function parseJson(stdout) {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(stdout);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Forward the `source: replica|api (...)` line a query verb prints, so the answer names its source. */
|
|
90
|
+
export function forwardSourceLine(res) {
|
|
91
|
+
for (const line of res.stderr.split("\n")) {
|
|
92
|
+
if (line.startsWith("source:")) console.error(line);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** True for a ticket identifier such as ABC-123; false for a GitHub node id or anything else. */
|
|
97
|
+
export function looksLikeTicket(s) {
|
|
98
|
+
return /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(s);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
102
|
+
console.log("lib/cli.mjs is a library used by the scripts beside it; run any of those with --help for usage.");
|
|
103
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/credential.mjs — is this machine connected? The ONE place a skill script decides it, vendored
|
|
3
|
+
// byte-identical into every skill's scripts/lib/ from skill-lib/credential.mjs at the package root
|
|
4
|
+
// (`npm run skill-lib:sync`; a test fails on any drift). Skills install one directory at a time, so
|
|
5
|
+
// each carries its own copy. This file is a library — run a sibling script with --help for usage.
|
|
6
|
+
//
|
|
7
|
+
// customer.json carries exactly one credential: a personal key (`key`), or the keyless login's
|
|
8
|
+
// session (`auth`, the recommended rail). A script never reads either for its value: it spawns the
|
|
9
|
+
// catalyst-skills CLI, which authenticates with whichever is present and refreshes a login's token
|
|
10
|
+
// itself. A new credential kind lands here, once.
|
|
11
|
+
|
|
12
|
+
/** The command that connects this machine, as every not-connected line names it. */
|
|
13
|
+
export const CONNECT_COMMAND =
|
|
14
|
+
"npx @catalyst-cloud/catalyst-skills login (or, with a personal key: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login)";
|
|
15
|
+
|
|
16
|
+
/** True when `cfg` (parsed customer.json) holds a usable credential of either kind. Never throws. */
|
|
17
|
+
export function hasCredential(cfg) {
|
|
18
|
+
if (cfg === null || typeof cfg !== "object") return false;
|
|
19
|
+
const key = cfg["key"];
|
|
20
|
+
if (typeof key === "string" && key !== "") return true;
|
|
21
|
+
const login = cfg["auth"];
|
|
22
|
+
return (
|
|
23
|
+
login !== null &&
|
|
24
|
+
typeof login === "object" &&
|
|
25
|
+
login["kind"] === "oauth" &&
|
|
26
|
+
typeof login["refreshToken"] === "string" &&
|
|
27
|
+
login["refreshToken"] !== ""
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/pull.mjs — resolve "a ticket or a PR id" to one mirrored pull-request detail, the way both
|
|
3
|
+
// scripts in this skill need it. A ticket resolves through its own record's linked pull requests
|
|
4
|
+
// (the mirror links a PR to the ticket its text names), then the chosen PR's detail is read by node
|
|
5
|
+
// id. A library: run read-pr.mjs or is-it-mergeable.mjs with --help for usage.
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { forwardSourceLine, looksLikeTicket, parseJson, runCli, runCliOrExit } from "./cli.mjs";
|
|
8
|
+
|
|
9
|
+
export function truthy(v) {
|
|
10
|
+
return v === true || v === 1 || v === "1" || v === "true";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readIssue(ticket, extra = []) {
|
|
14
|
+
const res = runCli(["query", "issue", ticket, ...extra, "--json"]);
|
|
15
|
+
forwardSourceLine(res);
|
|
16
|
+
if (res.code !== 0) {
|
|
17
|
+
console.error(res.stderr.trim() || `${ticket}: not found`);
|
|
18
|
+
process.exit(res.code === 2 ? 2 : 1);
|
|
19
|
+
}
|
|
20
|
+
const issue = parseJson(res.stdout);
|
|
21
|
+
if (!issue || typeof issue !== "object") {
|
|
22
|
+
console.error(`${ticket}: the ticket read did not return JSON`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
return issue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Every PR that names the ticket, newest number first. Exits 1 when the ticket is unknown. A source
|
|
30
|
+
* whose ticket detail carries no `linked_pulls` field at all (a replica built by an SDK that predates
|
|
31
|
+
* the ticket-to-PR link) is not evidence of "no PR": the ticket is re-read from the API, which is
|
|
32
|
+
* origin-fresh and carries the field, and the second source line says so. An empty list from a source
|
|
33
|
+
* that has the field is a real "no PR yet".
|
|
34
|
+
*/
|
|
35
|
+
export function linkedPulls(ticket) {
|
|
36
|
+
let issue = readIssue(ticket);
|
|
37
|
+
if (issue.linked_pulls === undefined) {
|
|
38
|
+
console.error("note: this source carries no linked pull requests on a ticket; re-reading the ticket from the API");
|
|
39
|
+
issue = readIssue(ticket, ["--source", "api"]);
|
|
40
|
+
}
|
|
41
|
+
const pulls = Array.isArray(issue.linked_pulls) ? issue.linked_pulls : [];
|
|
42
|
+
return { issue, pulls: [...pulls].sort((a, b) => Number(b.number ?? 0) - Number(a.number ?? 0)) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The PR that matters for a ticket: an open one first, else the merged one, else the newest. */
|
|
46
|
+
export function pickPull(pulls) {
|
|
47
|
+
const open = pulls.find((p) => String(p.state ?? "").toLowerCase() === "open" && !truthy(p.merged));
|
|
48
|
+
if (open) return open;
|
|
49
|
+
const merged = pulls.find((p) => truthy(p.merged));
|
|
50
|
+
return merged ?? pulls[0] ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One PR's detail by node id: checks, reviews, commit statuses, mergeable state, linked ticket. */
|
|
54
|
+
export function pullDetail(nodeId) {
|
|
55
|
+
const res = runCliOrExit(["query", "pull", nodeId, "--json"]);
|
|
56
|
+
forwardSourceLine(res);
|
|
57
|
+
const detail = parseJson(res.stdout);
|
|
58
|
+
if (!detail || typeof detail !== "object") {
|
|
59
|
+
console.error(`${nodeId}: the pull read did not return JSON`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
return detail;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Ticket or node id → {ticket, detail, linked}. Exits 1 when a ticket has no PR yet. */
|
|
66
|
+
export function resolvePull(arg) {
|
|
67
|
+
if (looksLikeTicket(arg)) {
|
|
68
|
+
const { pulls } = linkedPulls(arg);
|
|
69
|
+
const chosen = pickPull(pulls);
|
|
70
|
+
if (!chosen) {
|
|
71
|
+
console.error(`${arg}: no pull request names this ticket yet — the implement phase opens one when it has a branch`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
return { ticket: arg, detail: pullDetail(String(chosen.node_id)), linked: pulls };
|
|
75
|
+
}
|
|
76
|
+
const detail = pullDetail(arg);
|
|
77
|
+
return { ticket: detail.linear_issue_identifier ? String(detail.linear_issue_identifier) : null, detail, linked: [] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
81
|
+
console.log("lib/pull.mjs is a library used by read-pr.mjs and is-it-mergeable.mjs; run those with --help for usage.");
|
|
82
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// read-pr.mjs — one ticket's pull request (or one PR by node id) as the mirror holds it: state,
|
|
3
|
+
// branch, head, base, linked ticket and its stage, GitHub's own mergeable verdict, every check,
|
|
4
|
+
// review and commit status. Reads through `catalyst-skills query`; composes no URL.
|
|
5
|
+
import { linkedPulls, resolvePull, truthy } from "./lib/pull.mjs";
|
|
6
|
+
|
|
7
|
+
const HELP = `Usage: node scripts/read-pr.mjs <ticket | pr-node-id> [--all] [--json]
|
|
8
|
+
|
|
9
|
+
<ticket> a ticket identifier such as ABC-123: its open PR is shown (else the merged one, else the newest)
|
|
10
|
+
<pr-node-id> a GitHub pull-request node id, as printed by "catalyst-skills query pulls"
|
|
11
|
+
--all for a ticket: list every PR that names it instead of one detail
|
|
12
|
+
--json print the raw detail document instead of the summary
|
|
13
|
+
|
|
14
|
+
Exit 0 shown, 1 not found or no PR yet, 2 this machine is not connected to a tenant.
|
|
15
|
+
The first stderr line names the source the CLI read from (a fresh replica, or the API).`;
|
|
16
|
+
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
19
|
+
console.log(HELP);
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
const json = args.includes("--json");
|
|
23
|
+
const all = args.includes("--all");
|
|
24
|
+
const target = args.find((a) => !a.startsWith("--"));
|
|
25
|
+
if (!target) {
|
|
26
|
+
console.error("read-pr needs a ticket or a PR node id");
|
|
27
|
+
console.log(HELP);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fmt(v, fallback = "?") {
|
|
32
|
+
return v === null || v === undefined || v === "" ? fallback : String(v);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function when(ms) {
|
|
36
|
+
const n = Number(ms);
|
|
37
|
+
return Number.isFinite(n) && n > 0 ? new Date(n).toISOString() : fmt(ms, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (all) {
|
|
41
|
+
if (!/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(target)) {
|
|
42
|
+
console.error("--all takes a ticket identifier");
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
const { pulls } = linkedPulls(target);
|
|
46
|
+
if (json) {
|
|
47
|
+
console.log(JSON.stringify(pulls));
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
if (pulls.length === 0) {
|
|
51
|
+
console.log(`${target}: no pull request names this ticket yet`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
for (const p of pulls) {
|
|
55
|
+
const flags = [truthy(p.draft) ? "draft" : null, truthy(p.merged) ? "merged" : null].filter(Boolean).join(" ");
|
|
56
|
+
console.log(`${fmt(p.repo_id)}#${fmt(p.number)} ${fmt(p.state)}${flags ? ` ${flags}` : ""} ${fmt(p.title, "")} [${fmt(p.node_id, "")}]`);
|
|
57
|
+
}
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { ticket, detail } = resolvePull(target);
|
|
62
|
+
if (json) {
|
|
63
|
+
console.log(JSON.stringify(detail));
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const checks = Array.isArray(detail.checks) ? detail.checks : [];
|
|
68
|
+
const reviews = Array.isArray(detail.reviews) ? detail.reviews : [];
|
|
69
|
+
const statuses = Array.isArray(detail.commit_statuses) ? detail.commit_statuses : [];
|
|
70
|
+
const state = [fmt(detail.state), truthy(detail.draft) ? "draft" : null, truthy(detail.merged) ? `merged ${when(detail.merged_at)}` : null].filter(Boolean).join(", ");
|
|
71
|
+
|
|
72
|
+
console.log(`${fmt(detail.repo_id)}#${fmt(detail.number)} ${fmt(detail.title, "(no title yet)")}`);
|
|
73
|
+
console.log(`state: ${state} by ${fmt(detail.author_login)} opened ${when(detail.created_at)} updated ${when(detail.updated_at)}`);
|
|
74
|
+
console.log(`branch: ${fmt(detail.head_ref)} → ${fmt(detail.base_ref)} head ${fmt(detail.head_sha).slice(0, 12)}`);
|
|
75
|
+
console.log(`ticket: ${fmt(ticket ?? detail.linear_issue_identifier, "none named")}${detail.linked_issue_state ? ` (stage: ${detail.linked_issue_state})` : ""}`);
|
|
76
|
+
console.log(`github says: mergeable=${fmt(detail.mergeable, "unknown")} state=${fmt(detail.mergeable_state, "unknown")} auto-merge=${truthy(detail.auto_merge) ? "on" : "off"}`);
|
|
77
|
+
if (detail.blocked_on_ask && typeof detail.blocked_on_ask === "object") {
|
|
78
|
+
const b = detail.blocked_on_ask;
|
|
79
|
+
console.log(`blocked on an ask: ${fmt(b.identifier ?? b.id, "")} ${fmt(b.title, "")}`.trim());
|
|
80
|
+
}
|
|
81
|
+
console.log(`node id: ${fmt(detail.node_id)}`);
|
|
82
|
+
|
|
83
|
+
console.log(`checks (${checks.length}):`);
|
|
84
|
+
if (checks.length === 0) console.log(" none reported at this head yet");
|
|
85
|
+
for (const c of checks) console.log(` ${fmt(c.status)}/${fmt(c.conclusion, "-")} ${fmt(c.name)}`);
|
|
86
|
+
|
|
87
|
+
if (statuses.length > 0) {
|
|
88
|
+
console.log(`commit statuses (${statuses.length}):`);
|
|
89
|
+
for (const s of statuses) console.log(` ${fmt(s.state)} ${fmt(s.context)}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(`reviews (${reviews.length}):`);
|
|
93
|
+
if (reviews.length === 0) console.log(" none mirrored; a reviewer's clean pass is a reaction, which this read does not carry");
|
|
94
|
+
for (const r of reviews) console.log(` ${fmt(r.state)} ${fmt(r.reviewer_name, fmt(r.reviewer_id))} ${when(r.submitted_at)}`);
|
|
95
|
+
|
|
96
|
+
console.log("not mirrored here: PR labels (holds, queue attestation) and the reviewer's reaction — see references/what-a-pr-accumulates.md");
|
|
97
|
+
process.exit(0);
|