@1aboveio/skills 0.18.0 → 0.19.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.
Files changed (41) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/runtime/skills/distribution/generated/recipes.json +43 -23
  4. package/runtime/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +1 -1
  5. package/runtime/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +23 -1
  6. package/skills/engineering/engineering-runtime/coherence/workflow.json +65 -15
  7. package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +1 -1
  8. package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +23 -1
  9. package/skills/engineering/resolve-issues/SKILL.md +1 -1
  10. package/skills/engineering/resolve-issues/generated/workflow-repair-policy.json +55 -11
  11. package/skills/engineering/resolve-issues/scripts/run-state.mjs +4 -4
  12. package/skills/engineering/resolve-release/references/related-skills.md +1 -0
  13. package/skills/engineering/rush-issues/LICENSE +3 -0
  14. package/skills/engineering/rush-issues/SKILL.md +179 -0
  15. package/skills/engineering/rush-issues/agents/openai.yaml +9 -0
  16. package/skills/engineering/rush-issues/evals/evals.json +65 -0
  17. package/skills/engineering/rush-issues/references/canary.md +45 -0
  18. package/skills/engineering/rush-issues/references/cicd.md +37 -0
  19. package/skills/engineering/rush-issues/references/combine.md +51 -0
  20. package/skills/engineering/rush-issues/references/expire.md +55 -0
  21. package/skills/engineering/rush-issues/references/exploration.md +53 -0
  22. package/skills/engineering/rush-issues/references/implementation.md +66 -0
  23. package/skills/engineering/rush-issues/references/preflight.md +31 -0
  24. package/skills/engineering/rush-issues/references/profiling.md +78 -0
  25. package/skills/engineering/rush-issues/references/review.md +48 -0
  26. package/skills/engineering/rush-issues/references/shared-modules.md +66 -0
  27. package/skills/engineering/rush-issues/references/task-plan.md +110 -0
  28. package/skills/engineering/rush-issues/scripts/discover-models.mjs +9 -0
  29. package/skills/engineering/rush-issues/scripts/model-catalog.mjs +9 -0
  30. package/skills/engineering/rush-issues/scripts/preflight-models.mjs +466 -0
  31. package/skills/engineering/rush-release/LICENSE +3 -0
  32. package/skills/engineering/rush-release/SKILL.md +99 -0
  33. package/skills/engineering/rush-release/agents/openai.yaml +8 -0
  34. package/skills/engineering/rush-release/evals/evals.json +44 -0
  35. package/skills/engineering/rush-release/references/candidate.md +30 -0
  36. package/skills/engineering/rush-release/references/cut.md +66 -0
  37. package/skills/engineering/rush-release/references/preflight.md +47 -0
  38. package/skills/engineering/rush-release/references/publish.md +100 -0
  39. package/skills/engineering/rush-release/scripts/apply.mjs +185 -0
  40. package/skills/engineering/rush-release/scripts/green-head.mjs +231 -0
  41. package/skills/engineering/rush-release/scripts/plan.mjs +264 -0
@@ -0,0 +1,66 @@
1
+ # Cut
2
+
3
+ Turn the frozen SHA into a versioned commit without taking later trunk commits
4
+ along for the ride.
5
+
6
+ ## 1. Plan
7
+
8
+ ```bash
9
+ node <skillsRoot>/rush-release/scripts/plan.mjs --sha <candidateSha> --json \
10
+ > /tmp/rush-release-plan.json
11
+ ```
12
+
13
+ The plan names `baselineTag`, `currentVersion`, `bump`, `reason`,
14
+ `nextVersion`, `tag`, `changelogMarkdown`, and `versionFiles`.
15
+
16
+ SemVer (0.x treats breaking as minor unless the human asks for strict):
17
+
18
+ | Content since baseline | Bump |
19
+ |---|---|
20
+ | breaking (`feat!:`, `BREAKING CHANGE:`) | major (minor under 0.x) |
21
+ | `feat:` | minor |
22
+ | otherwise | patch |
23
+
24
+ Changelog entries come from conventional-commit subjects in
25
+ `baselineTag..candidateSha` (or the recent first-parent log when there is no
26
+ tag). Keep an existing `## [Unreleased]` body — derived entries merge with it,
27
+ they do not replace it.
28
+
29
+ ## 2. Confirm
30
+
31
+ Show the frozen SHA, proposed version/tag, bump reason, changelog, version
32
+ files, and publisher. Wait for confirmation. A different bump the human names
33
+ wins, as long as it is still SemVer.
34
+
35
+ Do not create a branch, edit files, or tag before that confirmation.
36
+
37
+ ## 3. Apply and commit
38
+
39
+ Branch from the frozen SHA only:
40
+
41
+ ```bash
42
+ git switch -c release/<tag> <candidateSha>
43
+ jq -r '.changelogMarkdown' /tmp/rush-release-plan.json | \
44
+ node <skillsRoot>/rush-release/scripts/apply.mjs \
45
+ --version <nextVersion> --date <YYYY-MM-DD> \
46
+ --changelog-section - --json
47
+ git add -- <paths from apply>
48
+ git commit -m "chore(release): <nextVersion>"
49
+ ```
50
+
51
+ `apply.mjs` requires the confirmed plan's `changelogMarkdown` on stdin, writes
52
+ that section, and bumps the discovered identity files. It refuses a missing or
53
+ empty changelog section rather than silently replacing the plan with a bare
54
+ version heading. If the human edited the notes, write the confirmed markdown
55
+ to stdin instead of the raw plan field.
56
+
57
+ Then:
58
+
59
+ - Diff must be metadata only. If product code changed, reset and stop.
60
+ - `git rev-parse HEAD^` must equal the frozen SHA.
61
+ - Do not rebase onto a newer `main`, merge `main` in, or squash onto a moved
62
+ tip. Any of those would tag commits this candidate check never saw.
63
+
64
+ The cut SHA is `HEAD` after the commit. It is the immutable PR-head identity;
65
+ the release identity is the provider's verified merged SHA on `main`.
66
+ Proceed to [publish.md](publish.md).
@@ -0,0 +1,47 @@
1
+ # Preflight
2
+
3
+ Preflight has three outputs: trunk `main`, the publisher to watch, and the
4
+ files that carry the version. It does not pick the SHA, bump the version, or
5
+ push.
6
+
7
+ ## 1. Trunk
8
+
9
+ Fetch `origin`. Confirm the release trunk is `main` from those fetched refs
10
+ (`origin/main` exists). A repo whose production branch is `master` or `release`
11
+ is not this workflow.
12
+
13
+ ## 2. Publisher
14
+
15
+ Detect how a tag becomes a shipped artifact. Prefer what the human named;
16
+ otherwise look at the tree:
17
+
18
+ | Signal | Publisher |
19
+ |---|---|
20
+ | `cloudbuild.yaml` / `cloudbuild.json`, or a Cloud Build trigger on `v*` / `v*-rc.*` tags | `cloudbuild` |
21
+ | `package.json` with a publish workflow, `publishConfig`, or an explicit npm release | `npm` |
22
+ | both | watch both |
23
+ | neither, and the human did not name one | refuse — nothing to watch |
24
+
25
+ Record `publisher` as `cloudbuild`, `npm`, or `both`. If a GitHub Actions
26
+ workflow publishes on `release: published` rather than on tag push, note that
27
+ `gh release create` is required at publish time.
28
+
29
+ ## 3. Version files
30
+
31
+ List identity files present at the repo root (and only those):
32
+
33
+ - `package.json` (and `package-lock.json` when it exists)
34
+ - `pyproject.toml`
35
+ - `VERSION`
36
+
37
+ Do not bump nested workspace packages unless the human names them. The cut
38
+ must stay metadata-only.
39
+
40
+ ## 4. Baseline
41
+
42
+ Read the current version from those files when present. List annotated/lightweight
43
+ release tags matching `vMAJOR.MINOR.PATCH` with no prerelease. The highest such
44
+ tag is the changelog baseline. A prerelease (`v1.2.0-rc.1`) is not a baseline.
45
+
46
+ Preflight succeeds when trunk, publisher, version files, and baseline are
47
+ recorded. Proceed to [candidate.md](candidate.md).
@@ -0,0 +1,100 @@
1
+ # Publish
2
+
3
+ Push the cut, tag it, and watch the publisher. The tag is the deploy trigger
4
+ in this lane, so a pushed tag that fails to ship **burns the version** — the
5
+ next attempt takes the next patch. Do not move or delete the tag to "reuse" it.
6
+
7
+ ## 1. Push and merge the metadata PR
8
+
9
+ Push the immutable cut head, then open a PR against `main`. Do not create or
10
+ push the release tag yet:
11
+
12
+ ```bash
13
+ git push origin HEAD:refs/heads/release/<tag>
14
+ gh pr create --base main --head release/<tag> \
15
+ --title "chore(release): <nextVersion>" \
16
+ --body "Metadata-only GitHub Flow release cut from <candidateSha>."
17
+ ```
18
+
19
+ Deliver that PR through the repository's configured merge queue and its
20
+ stateful watcher. The queue must test the synthetic merged tree. Freeze the
21
+ provider-reported merge commit as `<mergedSha>` and independently verify the
22
+ PR's head was `<cutSha>`. A squash merge is refused because `<cutSha>` would
23
+ not be an ancestor of `<mergedSha>`.
24
+
25
+ If `main` is genuinely unprotected and direct pushes are the repository's
26
+ documented release path, a successful fast-forward of `<cutSha>` to `main`
27
+ may use `<cutSha>` as `<mergedSha>`. Never infer this from local state: re-fetch
28
+ and verify the remote ref.
29
+
30
+ ## 2. Verify and tag the merged main SHA
31
+
32
+ The release tag must name a commit reachable from the remote default branch.
33
+ This is the ancestry gate used by release publishers to refuse off-main
34
+ releases:
35
+
36
+ ```bash
37
+ git fetch origin main
38
+ git merge-base --is-ancestor "<cutSha>" "<mergedSha>"
39
+ git merge-base --is-ancestor "<mergedSha>" origin/main
40
+ test "$(git rev-parse "<cutSha>^{tree}")" = \
41
+ "$(git rev-parse "<mergedSha>^{tree}")"
42
+ test "$(git show "<mergedSha>:package.json" | jq -r .version)" = "<nextVersion>"
43
+ git tag -a "<tag>" "<mergedSha>" -m "Release <tag>"
44
+ git push --no-verify origin "refs/tags/<tag>"
45
+ ```
46
+
47
+ The tree equality check proves the provider merge introduced no post-freeze
48
+ `main` changes beyond the approved cut. If it fails, do not tag: abandon the
49
+ unpublished cut and restart candidate selection and planning from current
50
+ `main`. Use the repository's declared version file in the `git show` check
51
+ when it is not `package.json`. Re-read the exact required queue/CI result for
52
+ `<mergedSha>` before tagging. A green cut head is not evidence for a different
53
+ merge commit.
54
+
55
+ `--no-verify` is for tag refs, because a Mergify (or similar) pre-push hook
56
+ that rewrites `git push` into a stack push is correct for branches and wrong
57
+ for immutable tags.
58
+
59
+ Re-tagging the same SHA is a resume no-op. A tag that already names a
60
+ **different** SHA is a stop. There is no `--force`.
61
+
62
+ If preflight recorded a workflow that publishes only on
63
+ `release: published`, create the GitHub Release after the tag is on the
64
+ remote (`gh release create <tag> --notes-file …`). Otherwise the tag push is
65
+ the trigger.
66
+
67
+ ## 3. Watch
68
+
69
+ Launch one background watcher; do not poll in the foreground.
70
+
71
+ **Cloud Build** (`publisher` `cloudbuild` or `both`):
72
+
73
+ ```bash
74
+ gcloud builds list --limit=1 --format='value(id,status,substitutions.TAG_NAME)' \
75
+ --filter="substitutions.TAG_NAME=<tag> OR tags=<tag>"
76
+ ```
77
+
78
+ Success is `SUCCESS` for that tag. `FAILURE` / `TIMEOUT` / `CANCELLED` is a
79
+ hand-back. Load `cloud-debug` only to diagnose, not to rerun the release.
80
+
81
+ **npm** (`publisher` `npm` or `both`):
82
+
83
+ ```bash
84
+ npm view <package>@<nextVersion> version
85
+ ```
86
+
87
+ Success is stdout equal to `<nextVersion>`. An `E404` means "not published
88
+ yet" and the watcher continues. Any other npm error is inconclusive — stop
89
+ rather than treating it as unpublished or published.
90
+
91
+ When a GitHub Actions workflow is the npm publisher, `gh run list` /
92
+ `gh run watch` for the tag SHA is the wait; `npm view` remains the proof the
93
+ artifact exists.
94
+
95
+ ## 4. Failure
96
+
97
+ If the watch fails, leave the tag in place and hand back: cut SHA, tag,
98
+ publisher logs, and that the version is burned. Do not delete the tag, do not
99
+ roll back production, and do not open a `resolve-release` traffic shift. A
100
+ fix ships as the next patch from a new green HEAD.
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ // Write changelog section + version identity files. Does not commit.
3
+ //
4
+ // Usage:
5
+ // node apply.mjs --version <x.y.z> --date <YYYY-MM-DD> --changelog-section - [--json]
6
+ //
7
+ // Exit: 0 ok · 1 IO/parse failed · 2 usage
8
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { isMainModule } from '../../engineering-runtime/scripts/main-module.mjs'
11
+ import { discoverVersionFiles, parseSemver } from './plan.mjs'
12
+
13
+ export function insertChangelog(markdown, { version, date, section }) {
14
+ const heading = `## [${version}] - ${date}`
15
+ const body = String(section || '').trim() || heading
16
+ const existing = String(markdown || '')
17
+ if (!body.startsWith(heading)) {
18
+ const err = new Error(`changelog section must start with ${heading}`)
19
+ err.exitCode = 2
20
+ throw err
21
+ }
22
+ const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
23
+ const versionHeading = new RegExp(`^## \\[${escapedVersion}\\][^\\n]*`, 'm').exec(existing)
24
+ if (versionHeading) {
25
+ const start = versionHeading.index
26
+ const rest = existing.slice(start)
27
+ const next = rest.slice(versionHeading[0].length).search(/^## \[/m)
28
+ const end = next === -1 ? existing.length : start + versionHeading[0].length + next
29
+ const actual = existing.slice(start, end).trim()
30
+ if (actual === body || actual.startsWith(`${body}\n`)) return existing
31
+ const err = new Error(`existing ${version} changelog section does not match the confirmed plan`)
32
+ err.exitCode = 1
33
+ throw err
34
+ }
35
+
36
+ const unreleased = /^## \[Unreleased\]\s*\n/m.exec(existing)
37
+ if (unreleased) {
38
+ const start = unreleased.index + unreleased[0].length
39
+ const next = existing.slice(start).search(/^## \[/m)
40
+ const end = next === -1 ? existing.length : start + next
41
+ const kept = existing.slice(start, end).trim()
42
+ const combined = kept ? `${body}\n\n${kept}` : body
43
+ return `${existing.slice(0, unreleased.index)}## [Unreleased]\n\n${heading}\n${stripHeading(combined, version)}\n\n${existing.slice(end).replace(/^\n+/, '')}`
44
+ }
45
+
46
+ const title = /^# .+\n/.exec(existing)
47
+ if (title) {
48
+ return `${existing.slice(0, title[0].length)}\n${body}\n\n${existing.slice(title[0].length).replace(/^\n+/, '')}`
49
+ }
50
+ return `# Changelog\n\n${body}\n`
51
+ }
52
+
53
+ function stripHeading(text, version) {
54
+ return text.replace(new RegExp(`^## \\[${version}\\][^\\n]*\\n?`), '').trim()
55
+ }
56
+
57
+ export function bumpPackageJson(text, version) {
58
+ const pkg = JSON.parse(text)
59
+ pkg.version = version
60
+ return `${JSON.stringify(pkg, null, 2)}\n`
61
+ }
62
+
63
+ export function bumpPackageLock(text, version) {
64
+ const lock = JSON.parse(text)
65
+ lock.version = version
66
+ if (lock.packages && lock.packages[''] && typeof lock.packages[''] === 'object') {
67
+ lock.packages[''].version = version
68
+ }
69
+ return `${JSON.stringify(lock, null, 2)}\n`
70
+ }
71
+
72
+ export function bumpPyproject(text, version) {
73
+ let inProject = false
74
+ let replaced = false
75
+ const lines = String(text).split('\n')
76
+ const out = lines.map((line) => {
77
+ if (/^\[project\]\s*$/.test(line) || /^\[tool\.poetry\]\s*$/.test(line)) inProject = true
78
+ else if (/^\[/.test(line)) inProject = false
79
+ if (inProject && /^version\s*=/.test(line)) {
80
+ replaced = true
81
+ return `version = "${version}"`
82
+ }
83
+ return line
84
+ })
85
+ if (!replaced) {
86
+ const err = new Error('pyproject.toml has no [project]/[tool.poetry] version')
87
+ err.exitCode = 1
88
+ throw err
89
+ }
90
+ return out.join('\n')
91
+ }
92
+
93
+ export function applyRelease({
94
+ version,
95
+ date,
96
+ cwd = process.cwd(),
97
+ changelogSection = null,
98
+ now = new Date(),
99
+ } = {}) {
100
+ if (!parseSemver(version)) {
101
+ const err = new Error('usage: apply.mjs --version <x.y.z> --date <YYYY-MM-DD> --changelog-section - [--json]')
102
+ err.exitCode = 2
103
+ throw err
104
+ }
105
+ if (typeof changelogSection !== 'string' || changelogSection.trim() === '') {
106
+ const err = new Error('apply.mjs requires the confirmed plan changelog via --changelog-section -')
107
+ err.exitCode = 2
108
+ throw err
109
+ }
110
+ const stamp = date || now.toISOString().slice(0, 10)
111
+ const files = discoverVersionFiles(cwd)
112
+ const changed = []
113
+ const changelogPath = join(cwd, 'CHANGELOG.md')
114
+ const previous = existsSync(changelogPath) ? readFileSync(changelogPath, 'utf8') : ''
115
+ writeFileSync(changelogPath, insertChangelog(previous, { version, date: stamp, section: changelogSection }))
116
+ changed.push('CHANGELOG.md')
117
+
118
+ if (files.includes('package.json')) {
119
+ writeFileSync(join(cwd, 'package.json'), bumpPackageJson(readFileSync(join(cwd, 'package.json'), 'utf8'), version))
120
+ changed.push('package.json')
121
+ }
122
+ if (files.includes('package-lock.json')) {
123
+ writeFileSync(join(cwd, 'package-lock.json'), bumpPackageLock(readFileSync(join(cwd, 'package-lock.json'), 'utf8'), version))
124
+ changed.push('package-lock.json')
125
+ }
126
+ if (files.includes('pyproject.toml')) {
127
+ writeFileSync(join(cwd, 'pyproject.toml'), bumpPyproject(readFileSync(join(cwd, 'pyproject.toml'), 'utf8'), version))
128
+ changed.push('pyproject.toml')
129
+ }
130
+ if (files.includes('VERSION')) {
131
+ writeFileSync(join(cwd, 'VERSION'), `${version}\n`)
132
+ changed.push('VERSION')
133
+ }
134
+ return { version, date: stamp, changed }
135
+ }
136
+
137
+ function readStdin() {
138
+ return readFileSync(0, 'utf8')
139
+ }
140
+
141
+ function parseArgs(argv) {
142
+ const opts = { json: false, version: null, date: null, changelogSection: null }
143
+ for (let i = 0; i < argv.length; i += 1) {
144
+ const arg = argv[i]
145
+ if (arg === '--json') opts.json = true
146
+ else if (arg === '--version') opts.version = argv[++i]
147
+ else if (arg === '--date') opts.date = argv[++i]
148
+ else if (arg === '--changelog-section') opts.changelogFrom = argv[++i]
149
+ else if (arg === '--help' || arg === '-h') opts.help = true
150
+ else {
151
+ const err = new Error(`unknown argument: ${arg}`)
152
+ err.exitCode = 2
153
+ throw err
154
+ }
155
+ }
156
+ if (opts.help) return opts
157
+ if (!opts.version || !opts.date || !opts.changelogFrom) {
158
+ const err = new Error('usage: apply.mjs --version <x.y.z> --date <YYYY-MM-DD> --changelog-section - [--json]')
159
+ err.exitCode = 2
160
+ throw err
161
+ }
162
+ if (opts.changelogFrom === '-') opts.changelogSection = readStdin()
163
+ else if (opts.changelogFrom) opts.changelogSection = opts.changelogFrom
164
+ return opts
165
+ }
166
+
167
+ export function main(argv = process.argv.slice(2)) {
168
+ const opts = parseArgs(argv)
169
+ if (opts.help) {
170
+ process.stdout.write('apply.mjs --version <x.y.z> --date <YYYY-MM-DD> --changelog-section - [--json]\n')
171
+ return 0
172
+ }
173
+ const result = applyRelease(opts)
174
+ process.stdout.write(opts.json ? `${JSON.stringify(result, null, 2)}\n` : `${result.changed.join('\n')}\n`)
175
+ return 0
176
+ }
177
+
178
+ if (isMainModule(import.meta.url)) {
179
+ try {
180
+ process.exitCode = main()
181
+ } catch (error) {
182
+ process.stderr.write(`${error.message}\n`)
183
+ process.exitCode = error.exitCode || 1
184
+ }
185
+ }
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ // Newest first-parent SHA on trunk whose required CI is already green.
3
+ //
4
+ // Usage:
5
+ // node green-head.mjs [--trunk main] [--max 50] [--json]
6
+ //
7
+ // Exit: 0 found · 1 no green head / inspect failed · 2 usage
8
+ import { spawnSync } from 'node:child_process'
9
+ import { isMainModule } from '../../engineering-runtime/scripts/main-module.mjs'
10
+
11
+ export const DEFAULT_TRUNK = 'main'
12
+ export const DEFAULT_MAX = 50
13
+ const PASSING = new Set(['success', 'neutral', 'skipped'])
14
+ const FAILING = new Set(['failure', 'failed', 'error', 'timed_out', 'cancelled', 'action_required', 'stale', 'startup_failure'])
15
+
16
+ export function parseGithubRemote(url) {
17
+ const raw = String(url || '').trim()
18
+ const ssh = raw.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/)
19
+ if (ssh) return { owner: ssh[1], repo: ssh[2] }
20
+ const https = raw.match(/^https:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/)
21
+ if (https) return { owner: https[1], repo: https[2] }
22
+ const sshUrl = raw.match(/^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/)
23
+ if (sshUrl) return { owner: sshUrl[1], repo: sshUrl[2] }
24
+ return null
25
+ }
26
+
27
+ export function runGit(args, { cwd, allowFail = false } = {}) {
28
+ const result = spawnSync('git', args, { encoding: 'utf8', cwd })
29
+ if (result.status !== 0 && !allowFail) {
30
+ const err = new Error(result.stderr.trim() || `git ${args.join(' ')} failed`)
31
+ err.exitCode = 1
32
+ throw err
33
+ }
34
+ return result
35
+ }
36
+
37
+ export function ghApi(path, { cwd, paginate = false } = {}) {
38
+ const args = ['api', path]
39
+ if (paginate) args.push('--paginate', '--slurp')
40
+ const result = spawnSync('gh', args, { encoding: 'utf8', cwd })
41
+ if (result.status !== 0) {
42
+ const err = new Error(result.stderr.trim() || `gh api ${path} failed`)
43
+ err.exitCode = 1
44
+ err.status = result.status
45
+ throw err
46
+ }
47
+ return result.stdout ? JSON.parse(result.stdout) : null
48
+ }
49
+
50
+ export function requiredContexts(protection) {
51
+ if (!protection || typeof protection !== 'object') return []
52
+ const checks = protection.required_status_checks
53
+ if (!checks) return []
54
+ const names = []
55
+ for (const context of checks.contexts || []) names.push(String(context))
56
+ for (const check of checks.checks || []) {
57
+ if (check && check.context) names.push(String(check.context))
58
+ }
59
+ return [...new Set(names)]
60
+ }
61
+
62
+ function conclusionOf(run) {
63
+ return String(run?.conclusion || run?.status || '').toLowerCase()
64
+ }
65
+
66
+ export function inspectGreen({ status, checkRuns, required }) {
67
+ const runs = Array.isArray(checkRuns)
68
+ ? checkRuns.flatMap((page) => Array.isArray(page?.check_runs) ? page.check_runs : [page])
69
+ : checkRuns?.check_runs || []
70
+ const statuses = Array.isArray(status?.statuses) ? status.statuses : []
71
+ const state = String(status?.state || '').toLowerCase()
72
+ const byName = new Map()
73
+ for (const run of runs) {
74
+ const name = String(run.name || run.context || '')
75
+ if (name && !byName.has(name)) byName.set(name, run)
76
+ }
77
+
78
+ if (required.length > 0) {
79
+ const missing = []
80
+ const pending = []
81
+ const failed = []
82
+ for (const name of required) {
83
+ const run = byName.get(name)
84
+ const statusContext = statuses.find((item) => item.context === name)
85
+ const token = run ? conclusionOf(run) : String(statusContext?.state || '').toLowerCase()
86
+ if (!token) missing.push(name)
87
+ else if (token === 'pending' || token === 'queued' || token === 'in_progress') pending.push(name)
88
+ else if (FAILING.has(token)) failed.push(name)
89
+ else if (!PASSING.has(token) && token !== 'success') failed.push(name)
90
+ }
91
+ const green = missing.length === 0 && pending.length === 0 && failed.length === 0
92
+ return { green, missing, pending, failed, required: true }
93
+ }
94
+
95
+ const failedStatuses = statuses.filter((item) => FAILING.has(String(item?.state || '').toLowerCase()))
96
+ const pendingStatuses = statuses.filter((item) => ['pending', 'queued', 'in_progress', ''].includes(String(item?.state || '').toLowerCase()))
97
+ const passingStatuses = statuses.filter((item) => PASSING.has(String(item?.state || '').toLowerCase()))
98
+ if (state === 'failure' || state === 'error' || failedStatuses.length > 0) {
99
+ return { green: false, missing: [], pending: [], failed: ['combined-status'], required: false }
100
+ }
101
+ const pendingRuns = runs.filter((run) => {
102
+ const token = conclusionOf(run)
103
+ return token === 'pending' || token === 'queued' || token === 'in_progress' || token === ''
104
+ })
105
+ const failedRuns = runs.filter((run) => FAILING.has(conclusionOf(run)))
106
+ const passingRuns = runs.filter((run) => PASSING.has(conclusionOf(run)))
107
+ if (failedRuns.length > 0) {
108
+ return { green: false, missing: [], pending: [], failed: failedRuns.map((run) => run.name), required: false }
109
+ }
110
+ if (pendingRuns.length > 0 || pendingStatuses.length > 0) {
111
+ return {
112
+ green: false,
113
+ missing: [],
114
+ pending: [
115
+ ...pendingRuns.map((run) => run.name || 'check-run'),
116
+ ...pendingStatuses.map((item) => item.context || 'status-context'),
117
+ ],
118
+ failed: [],
119
+ required: false,
120
+ }
121
+ }
122
+ if (state === 'success' || passingRuns.length > 0 || passingStatuses.length > 0) {
123
+ return { green: true, missing: [], pending: [], failed: [], required: false }
124
+ }
125
+ return { green: false, missing: ['no-checks'], pending: [], failed: [], required: false }
126
+ }
127
+
128
+ export function pickGreenHead(shas, inspect) {
129
+ for (const sha of shas) {
130
+ const verdict = inspect(sha)
131
+ if (verdict.green) return { sha, verdict }
132
+ }
133
+ return null
134
+ }
135
+
136
+ function loadProtection(owner, repo, trunk, api) {
137
+ try {
138
+ return api(`repos/${owner}/${repo}/branches/${trunk}/protection`)
139
+ } catch (error) {
140
+ if (/HTTP 404|Not Found|no required/i.test(error.message)) return null
141
+ throw error
142
+ }
143
+ }
144
+
145
+ export function findGreenHead({
146
+ trunk = DEFAULT_TRUNK,
147
+ max = DEFAULT_MAX,
148
+ cwd,
149
+ git = runGit,
150
+ api = (path, options = {}) => ghApi(path, { cwd, ...options }),
151
+ } = {}) {
152
+ git(['fetch', 'origin', trunk], { cwd })
153
+ const remote = parseGithubRemote(git(['remote', 'get-url', 'origin'], { cwd }).stdout)
154
+ if (!remote) {
155
+ const err = new Error('origin is not a GitHub remote')
156
+ err.exitCode = 1
157
+ throw err
158
+ }
159
+ const log = git(['log', `origin/${trunk}`, '--first-parent', '-n', String(max), '--format=%H'], { cwd })
160
+ const shas = log.stdout.split('\n').map((line) => line.trim()).filter(Boolean)
161
+ if (shas.length === 0) {
162
+ const err = new Error(`no commits on origin/${trunk}`)
163
+ err.exitCode = 1
164
+ throw err
165
+ }
166
+ const protection = loadProtection(remote.owner, remote.repo, trunk, api)
167
+ const required = requiredContexts(protection)
168
+ const inspect = (sha) => {
169
+ const status = api(`repos/${remote.owner}/${remote.repo}/commits/${sha}/status`)
170
+ const checks = api(`repos/${remote.owner}/${remote.repo}/commits/${sha}/check-runs?per_page=100`, { paginate: true })
171
+ return inspectGreen({ status, checkRuns: checks, required })
172
+ }
173
+ const picked = pickGreenHead(shas, inspect)
174
+ if (!picked) {
175
+ const err = new Error('no-green-head')
176
+ err.exitCode = 1
177
+ throw err
178
+ }
179
+ return {
180
+ trunk,
181
+ owner: remote.owner,
182
+ repo: remote.repo,
183
+ tipSha: shas[0],
184
+ candidateSha: picked.sha,
185
+ walked: shas.indexOf(picked.sha) + 1,
186
+ requiredChecks: required,
187
+ verdict: picked.verdict,
188
+ }
189
+ }
190
+
191
+ function parseArgs(argv) {
192
+ const opts = { trunk: DEFAULT_TRUNK, max: DEFAULT_MAX, json: false }
193
+ for (let i = 0; i < argv.length; i += 1) {
194
+ const arg = argv[i]
195
+ if (arg === '--json') opts.json = true
196
+ else if (arg === '--trunk') opts.trunk = argv[++i]
197
+ else if (arg === '--max') opts.max = Number(argv[++i])
198
+ else if (arg === '--help' || arg === '-h') opts.help = true
199
+ else {
200
+ const err = new Error(`unknown argument: ${arg}`)
201
+ err.exitCode = 2
202
+ throw err
203
+ }
204
+ }
205
+ if (!opts.trunk || !Number.isInteger(opts.max) || opts.max < 1) {
206
+ const err = new Error('usage: green-head.mjs [--trunk main] [--max 50] [--json]')
207
+ err.exitCode = 2
208
+ throw err
209
+ }
210
+ return opts
211
+ }
212
+
213
+ export function main(argv = process.argv.slice(2)) {
214
+ const opts = parseArgs(argv)
215
+ if (opts.help) {
216
+ process.stdout.write('green-head.mjs [--trunk main] [--max 50] [--json]\n')
217
+ return 0
218
+ }
219
+ const result = findGreenHead({ trunk: opts.trunk, max: opts.max })
220
+ process.stdout.write(opts.json ? `${JSON.stringify(result, null, 2)}\n` : `${result.candidateSha}\n`)
221
+ return 0
222
+ }
223
+
224
+ if (isMainModule(import.meta.url)) {
225
+ try {
226
+ process.exitCode = main()
227
+ } catch (error) {
228
+ process.stderr.write(`${error.message}\n`)
229
+ process.exitCode = error.exitCode || 1
230
+ }
231
+ }