yadflow 3.18.1 → 4.0.0-next.1
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 +355 -0
- package/README.md +79 -26
- package/bin/commands.mjs +41 -0
- package/bin/yad.mjs +437 -124
- package/cli/artifact-status.mjs +34 -15
- package/cli/checkpoint.mjs +69 -49
- package/cli/codeowners-command.mjs +170 -0
- package/cli/codeowners.mjs +397 -0
- package/cli/commit.mjs +13 -9
- package/cli/companion.mjs +2 -2
- package/cli/dial.mjs +183 -0
- package/cli/docs.mjs +88 -32
- package/cli/doctor.mjs +1472 -97
- package/cli/epic-state.mjs +3478 -232
- package/cli/epic.mjs +506 -0
- package/cli/errors.mjs +4 -1
- package/cli/gate.mjs +1002 -209
- package/cli/history.mjs +556 -0
- package/cli/hook.mjs +266 -55
- package/cli/hubcommit.mjs +6 -17
- package/cli/index-command.mjs +87 -0
- package/cli/ledger.mjs +57 -7
- package/cli/lib.mjs +184 -18
- package/cli/manifest.mjs +367 -56
- package/cli/migrate.mjs +726 -53
- package/cli/mode.mjs +170 -0
- package/cli/next.mjs +349 -90
- package/cli/openpr.mjs +191 -39
- package/cli/people.mjs +654 -0
- package/cli/plan.mjs +417 -132
- package/cli/platform.mjs +110 -129
- package/cli/product-index.mjs +287 -0
- package/cli/protection.mjs +706 -0
- package/cli/reconcile.mjs +38 -12
- package/cli/repo-publish.mjs +24 -26
- package/cli/repo.mjs +23 -14
- package/cli/report.mjs +21 -15
- package/cli/review.mjs +24 -27
- package/cli/riskmap-command.mjs +289 -0
- package/cli/riskmap.mjs +373 -0
- package/cli/setup.mjs +139 -287
- package/cli/ship.mjs +7 -6
- package/cli/skill.mjs +180 -0
- package/cli/skip.mjs +211 -30
- package/cli/thread.mjs +42 -17
- package/cli/tidy.mjs +20 -20
- package/cli/update-commit.mjs +22 -22
- package/cli/usage.mjs +115 -109
- package/package.json +3 -3
- package/skills/sdlc/config.yaml +166 -87
- package/skills/sdlc/module-help.csv +35 -35
- package/skills/yad-analysis/SKILL.md +125 -65
- package/skills/yad-architecture/SKILL.md +34 -23
- package/skills/yad-architecture/references/contract-format.md +10 -8
- package/skills/yad-backfill/SKILL.md +14 -8
- package/skills/yad-backfill/references/backfill.md +1 -1
- package/skills/yad-change/SKILL.md +127 -52
- package/skills/yad-change/references/triage.md +42 -28
- package/skills/yad-checks/SKILL.md +89 -45
- package/skills/yad-checks/references/check-gates.md +315 -92
- package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
- package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
- package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
- package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
- package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
- package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
- package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
- package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
- package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
- package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
- package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
- package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
- package/skills/yad-commit/SKILL.md +6 -6
- package/skills/yad-connect-design/SKILL.md +6 -6
- package/skills/yad-connect-design/references/design-context.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +2 -2
- package/skills/yad-connect-docs/SKILL.md +12 -12
- package/skills/yad-connect-docs/references/docs-registry.md +1 -1
- package/skills/yad-connect-learning/SKILL.md +5 -5
- package/skills/yad-connect-learning/references/learning-registry.md +2 -2
- package/skills/yad-connect-repos/SKILL.md +92 -54
- package/skills/yad-connect-repos/references/code-context.md +6 -6
- package/skills/yad-connect-repos/references/hub-config.md +68 -58
- package/skills/yad-connect-repos/references/repos-registry.md +10 -9
- package/skills/yad-connect-repos/references/risk-map.md +81 -0
- package/skills/yad-connect-testing/SKILL.md +6 -6
- package/skills/yad-connect-testing/references/testing-context.md +3 -4
- package/skills/yad-connect-testing/references/testing-registry.md +2 -2
- package/skills/yad-defects/SKILL.md +8 -8
- package/skills/yad-discovery/SKILL.md +130 -94
- package/skills/yad-discovery/references/discovery-schema.md +23 -7
- package/skills/yad-discovery/references/foundation-schema.md +374 -0
- package/skills/yad-docs/SKILL.md +16 -11
- package/skills/yad-docs/references/data-mapping.md +9 -7
- package/skills/yad-docs/templates/app/package-lock.json +3 -3
- package/skills/yad-docs-overview/SKILL.md +32 -17
- package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
- package/skills/yad-docs-sync/SKILL.md +10 -5
- package/skills/yad-docs-sync/references/staleness.md +8 -7
- package/skills/yad-engineer-review/SKILL.md +88 -24
- package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
- package/skills/yad-epic/SKILL.md +178 -100
- package/skills/yad-epic/references/state-schema.md +626 -117
- package/skills/yad-hub-bridge/SKILL.md +66 -48
- package/skills/yad-hub-bridge/references/bridge.md +110 -83
- package/skills/yad-hub-bridge/references/login-roster.md +163 -70
- package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
- package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
- package/skills/yad-implement/SKILL.md +29 -15
- package/skills/yad-implement/references/implement-conventions.md +2 -2
- package/skills/yad-learn/SKILL.md +9 -9
- package/skills/yad-learn/references/learning-state.md +2 -2
- package/skills/yad-open-pr/SKILL.md +64 -29
- package/skills/yad-pair-review/SKILL.md +18 -16
- package/skills/yad-pair-review/references/session-state.md +4 -4
- package/skills/yad-pr-template/SKILL.md +48 -27
- package/skills/yad-pr-template/references/risk-routing.md +97 -24
- package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
- package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
- package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
- package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
- package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
- package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
- package/skills/yad-reconcile/SKILL.md +3 -3
- package/skills/yad-report/SKILL.md +5 -5
- package/skills/yad-review-companion/SKILL.md +12 -9
- package/skills/yad-review-gate/SKILL.md +198 -79
- package/skills/yad-review-gate/references/gating.md +230 -54
- package/skills/yad-run/SKILL.md +86 -56
- package/skills/yad-run/references/run-loop.md +67 -45
- package/skills/yad-ship/SKILL.md +18 -14
- package/skills/yad-spec/SKILL.md +31 -17
- package/skills/yad-spec/references/spec-handoff.md +17 -5
- package/skills/yad-status/SKILL.md +114 -56
- package/skills/yad-stories/SKILL.md +42 -27
- package/skills/yad-stories/references/story-schema.md +10 -9
- package/skills/yad-stub/SKILL.md +59 -48
- package/skills/yad-sync-repos/SKILL.md +3 -3
- package/skills/yad-test-cases/SKILL.md +37 -30
- package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
- package/skills/yad-timeline/SKILL.md +8 -7
- package/skills/yad-ui/SKILL.md +46 -25
- package/cli/roster.mjs +0 -164
- package/skills/sdlc/install.sh +0 -68
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
// E70 — what the PLATFORM holds on a repo's branch: branch protection, and whether any rule requires an
|
|
2
|
+
// approval before a merge. yad never enforces a merge itself (Part 4: platform branch protection is "the
|
|
3
|
+
// real enforcement"), so this is a READ, reported by `yad doctor`, and it may never claim more than it read.
|
|
4
|
+
//
|
|
5
|
+
// THE TRAP THIS FILE EXISTS FOR. A failed call is not an answer. GitHub answers 404 on classic branch
|
|
6
|
+
// protection to anyone who is not an admin — even on a branch its own `branches/<b>` call reports as
|
|
7
|
+
// `protected: true` (checked on cli/cli's trunk, 2026-09-22). So "no rules" is PROVEN only from calls that
|
|
8
|
+
// succeeded, and anything else is `null` — "not known", with the reason — never "none" and never "fine".
|
|
9
|
+
//
|
|
10
|
+
// What counts as an approval rule (the user's decision, 2026-09-22): a required approving review count
|
|
11
|
+
// above 0.
|
|
12
|
+
// GitHub — a ruleset `pull_request` rule (`rules/branches/<b>`: every ACTIVE rule on the branch, from
|
|
13
|
+
// the repo or the organisation; needs only read access), and classic branch protection's
|
|
14
|
+
// `required_pull_request_reviews` (`branches/<b>/protection`: admins only).
|
|
15
|
+
// GitLab — a project approval rule with `approvals_required` above 0 that applies to the branch. The
|
|
16
|
+
// API is GitLab Premium and Ultimate only; on Free an approval never blocks a merge. A refusal
|
|
17
|
+
// is "needs Premium, or your login may not read it" — the tier is never guessed.
|
|
18
|
+
// Protection alone (no direct push) is a FACT beside the answer. So is a rule that covers only SOME files
|
|
19
|
+
// (a code-owner review, a GitHub ruleset's named reviewers): it is an approval rule, but not one on every
|
|
20
|
+
// change, so it is never the count — and a line that names no count says "on every change" beside it.
|
|
21
|
+
//
|
|
22
|
+
// Every call is the user's own `gh`/`glab`, logged in as the user, to the repo's own host (rule 8: nothing
|
|
23
|
+
// leaves the machine for anywhere else). `YAD_PLATFORM_READ=0` turns the reads off — for offline work, and
|
|
24
|
+
// for the test suite, which must never ask the developer's real account (the `YAD_PLATFORM_LOGIN=0`
|
|
25
|
+
// precedent). `runner` is injectable so every answer is testable without a network.
|
|
26
|
+
import { run } from './lib.mjs';
|
|
27
|
+
import { cliFor, hostFromGitUrl, detectPlatform } from './platform.mjs';
|
|
28
|
+
|
|
29
|
+
export const PLATFORM_NAME = { github: 'GitHub', gitlab: 'GitLab' };
|
|
30
|
+
const TIMEOUT = 10_000;
|
|
31
|
+
|
|
32
|
+
// A word with an `@` after its first character may be an e-mail address, and no address is ever printed
|
|
33
|
+
// (E67). A branch or a repo path is printed as written otherwise.
|
|
34
|
+
export const shown = (w) => (typeof w === 'string' && w.indexOf('@', 1) >= 0 ? 'a name with an @ in it' : `${w}`);
|
|
35
|
+
|
|
36
|
+
// The repo's path on its host (`owner/repo`, or `group/sub/project` on GitLab), from a git remote URL in
|
|
37
|
+
// either form. Not the `repoSlug` the doctor used to carry, which kept only the last two parts and so named
|
|
38
|
+
// the wrong GitLab project under a subgroup.
|
|
39
|
+
export function repoPathFromGitUrl(url = '') {
|
|
40
|
+
if (typeof url !== 'string' || !url.trim()) return null;
|
|
41
|
+
const u = url.trim();
|
|
42
|
+
let p;
|
|
43
|
+
// scp-like `[user@]host:path`. A `scheme://` URL never matches: the colon is followed by `/`.
|
|
44
|
+
const scp = u.match(/^(?:[^@/]+@)?[^/:]+:(?!\/)(.+)$/);
|
|
45
|
+
if (scp) p = scp[1];
|
|
46
|
+
else {
|
|
47
|
+
try { p = new URL(u).pathname; } catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
p = p.replace(/^\/+/, '').replace(/\/+$/, '').replace(/\.git$/, '');
|
|
50
|
+
const parts = p.split('/').filter(Boolean);
|
|
51
|
+
return parts.length >= 2 ? parts.join('/') : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The HTTP status of a failed `gh api` / `glab api` call, or null when there was none (offline, a timeout,
|
|
55
|
+
// the host is down). gh ends with `gh: Not Found (HTTP 404)`; glab with `glab: 404 Not Found (HTTP 404)`
|
|
56
|
+
// and prints the body `{"message":"401 Unauthorized"}` on stdout.
|
|
57
|
+
export function httpStatus(r) {
|
|
58
|
+
const text = `${r?.stderr || ''}\n${r?.stdout || ''}`;
|
|
59
|
+
const m = text.match(/\(HTTP (\d{3})\)/) || text.match(/\bglab: (\d{3})\b/) || text.match(/"message":\s*"(\d{3})\b/)
|
|
60
|
+
|| text.match(/"status":\s*"(\d{3})"/);
|
|
61
|
+
return m ? Number(m[1]) : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A failed call keeps its body too (E109): both CLIs print the platform's answer on stdout, so a caller
|
|
65
|
+
// can read WHY it failed. A body that is not JSON is null, never an error. Only the GitLab branch read
|
|
66
|
+
// looks at it today — see `gitlabBranchCause`.
|
|
67
|
+
function api(runner, cli, host, pathname) {
|
|
68
|
+
const r = runner(cli, ['api', '--hostname', host, pathname], { timeout: TIMEOUT });
|
|
69
|
+
if (r.ok) {
|
|
70
|
+
try { return { ok: true, body: JSON.parse(r.stdout) }; } catch { return { ok: false, status: null, unreadable: true }; }
|
|
71
|
+
}
|
|
72
|
+
let body = null;
|
|
73
|
+
try { body = JSON.parse(r.stdout); } catch { /* no body yad can read: the status is all there is */ }
|
|
74
|
+
return { ok: false, status: httpStatus(r), body };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// E109 — which of the two causes a GitLab branch 404 proved, from the body's `message`, or null.
|
|
78
|
+
// GitLab's branches API runs `require_repository_enabled!` before it looks the branch up, and that answers
|
|
79
|
+
// `not_found!("Repository")` when `feature_available?(:repository, user)` is false — the repository is
|
|
80
|
+
// turned off for the project, or this login's access is too low to read it. Only after that does a
|
|
81
|
+
// missing branch answer `not_found!("Branch")`. Both checked live on gitlab.com, 2026-09-23 (glab 1.77
|
|
82
|
+
// prints the body on stdout): project 86809638's `main` answers `404 Repository Not Found`, and a branch
|
|
83
|
+
// gitlab-org/gitlab does not have answers `404 Branch Not Found`.
|
|
84
|
+
// A message is not an API contract, so only EXACT equality counts: a reworded message, another language
|
|
85
|
+
// on a self-hosted server, or no body at all is null, and the caller keeps its hedge word for word. A body
|
|
86
|
+
// may only REMOVE a cause from a sentence, never add certainty.
|
|
87
|
+
const GITLAB_404 = { '404 Repository Not Found': 'repository', '404 Branch Not Found': 'branch' };
|
|
88
|
+
const gitlabBranchCause = (res) => (res.status === 404 && typeof res.body?.message === 'string'
|
|
89
|
+
&& Object.hasOwn(GITLAB_404, res.body.message) ? GITLAB_404[res.body.message] : null);
|
|
90
|
+
|
|
91
|
+
// One page of 100 is read, never more. A FULL page proves nothing about what is past it, so a list that
|
|
92
|
+
// comes back with exactly PAGE entries and does not already hold the answer is "not known".
|
|
93
|
+
export const PAGE = 100;
|
|
94
|
+
|
|
95
|
+
// Why a call gave no answer, as a clause that finishes "not known — ".
|
|
96
|
+
function whyFailed(res, { platform, host, what, plural = false }) {
|
|
97
|
+
const name = PLATFORM_NAME[platform];
|
|
98
|
+
if (res.unreadable) return `${name} answered ${what} with something yad could not read`;
|
|
99
|
+
if (res.status == null) return `yad could not reach ${host} to read ${what} (offline, or the host did not answer)`;
|
|
100
|
+
if (res.status === 401) return `${name} refused to show ${what} (HTTP 401 — the login may have expired)`;
|
|
101
|
+
if (res.status === 403) return `${name} refused to show ${what} (HTTP 403 — your login may not read ${plural ? 'them' : 'it'})`;
|
|
102
|
+
// A repo, a project or a GitLab branch reaches this arm, and each can genuinely be absent. GitHub's
|
|
103
|
+
// BRANCH names its own cause at the call site, because one permission covers the repo read before it and
|
|
104
|
+
// the branch; GitLab's does not, because reading a project and reading its repository are two settings.
|
|
105
|
+
// Proven, not assumed: GitLab's `require_repository_enabled!` answers `not_found!("Repository")`, so a
|
|
106
|
+
// login that may read the project and not its repository gets a 404, never a 403. Checked live on
|
|
107
|
+
// gitlab.com 2026-09-23 — project 86809638 answers 200, and its `repository/branches/main` answers
|
|
108
|
+
// 404 `{"message":"404 Repository Not Found"}`. Since E109 the GitLab branch read names the cause when
|
|
109
|
+
// the body says which (`gitlabBranchCause`), and reaches this arm — the hedge — only when it does not.
|
|
110
|
+
// Every LIST this file reads names its own cause too, because "they do not exist" is what an empty list
|
|
111
|
+
// says, not a 404.
|
|
112
|
+
if (res.status === 404) return `${name} answered 404 for ${what} (it does not exist, or your login may not see it)`;
|
|
113
|
+
return `${name} answered HTTP ${res.status} for ${what}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const segment = (b) => b.split('/').map(encodeURIComponent).join('/');
|
|
117
|
+
|
|
118
|
+
// A GitLab protected-branch name may be a wildcard (`release-*`); `*` matches any run of characters.
|
|
119
|
+
export function branchMatches(pattern, branch) {
|
|
120
|
+
if (typeof pattern !== 'string') return false;
|
|
121
|
+
if (!pattern.includes('*')) return pattern === branch;
|
|
122
|
+
const re = new RegExp(`^${pattern.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`);
|
|
123
|
+
return re.test(branch);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A whole number the platform gave, or null: `true`, `"2"`, `2.5` and a missing value are none (and
|
|
127
|
+
// `Number(true)` is 1, which is why a bare `Number()` will not do). Used for an approval count — where
|
|
128
|
+
// null means "could not read", never 0 and never 1 — and for an id, which is printed only when it is one.
|
|
129
|
+
const count = (v) => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null);
|
|
130
|
+
|
|
131
|
+
// `gh auth status` is asked once per CLI and host per doctor run (`authCache`, which the caller makes
|
|
132
|
+
// fresh for each run): on a network that drops packets each ask can take the full timeout, and a Product
|
|
133
|
+
// with many repos on one host would pay it once per repo. Never kept longer — a login can change.
|
|
134
|
+
function loggedIn(runner, cli, host, authCache) {
|
|
135
|
+
const key = `${cli}\0${host}`;
|
|
136
|
+
if (!authCache.has(key)) authCache.set(key, runner(cli, ['auth', 'status', '--hostname', host], { timeout: TIMEOUT }).ok);
|
|
137
|
+
return authCache.get(key);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The answer, for one repo and one branch. Every field that could not be read is null with a reason:
|
|
141
|
+
// { platform, host, repo, branch, branchFrom, platformDefault,
|
|
142
|
+
// known: false, why, kind, cause, defaultMayBeHidden } — nothing could be asked at all, or
|
|
143
|
+
// (`cause`, E109: set only when a branch 404
|
|
144
|
+
// PROVED why — 'branch' when the branch is not
|
|
145
|
+
// there, 'repository' when the login cannot
|
|
146
|
+
// read the project's repository; absent when
|
|
147
|
+
// the 404 left both open; `defaultMayBeHidden`,
|
|
148
|
+
// E110: GitLab named no default, which it also
|
|
149
|
+
// does for a login that cannot read the code),
|
|
150
|
+
// { …, known: true,
|
|
151
|
+
// protected: true|false|null, protectedWhy, — is the branch protected at all?
|
|
152
|
+
// approvals: n|null, atLeast, approvalsWhy, from: [..],— the required approval count, and where
|
|
153
|
+
// rulesElsewhere: '<clause: how they miss it>', — GitLab only: rules the project has that do
|
|
154
|
+
// not reach this branch ("it names other
|
|
155
|
+
// branches", "one of them …, and another …"),
|
|
156
|
+
// rulesElsewhereOne: true|false, — whether that clause speaks of one rule,
|
|
157
|
+
// partlyRead: true|false, — something the platform holds was not read,
|
|
158
|
+
// rulesAnswered: true, — GitLab only: the approval-rules endpoint
|
|
159
|
+
// answered with a list, so the project is not
|
|
160
|
+
// on Free,
|
|
161
|
+
// rulesRefused: true, — GitLab only: it refused (401/403/404), which
|
|
162
|
+
// is the one answer that leaves the tier open
|
|
163
|
+
// codeOwners: true|false|null, — a code-owner review is required (a fact beside)
|
|
164
|
+
// fileReviewers: true|false|null } — a named reviewer for some files (GitHub only:
|
|
165
|
+
// a GitLab answer carries no such key)
|
|
166
|
+
// `kind` names the reason for a hint, so the hint never depends on the words of `why`.
|
|
167
|
+
export function readProtection({ platform, gitUrl, branch = null, branchFrom = 'registry' } = {}, { runner = run, env = process.env, authCache = new Map() } = {}) {
|
|
168
|
+
const host = hostFromGitUrl(gitUrl || '');
|
|
169
|
+
const repo = repoPathFromGitUrl(gitUrl || '');
|
|
170
|
+
const plat = platform || detectPlatform(gitUrl || '') || null;
|
|
171
|
+
const base = { platform: plat, host, repo, branch, branchFrom, platformDefault: null };
|
|
172
|
+
const unknown = (kind, why, extra = {}) => ({ ...base, ...extra, known: false, kind, why });
|
|
173
|
+
const cli = cliFor(plat);
|
|
174
|
+
if (!cli) {
|
|
175
|
+
return platform
|
|
176
|
+
? unknown('no-platform', `yad does not know the platform ${JSON.stringify(platform)} (it reads GitHub and GitLab)`)
|
|
177
|
+
: unknown('no-platform', 'no platform (GitHub or GitLab) is set, so there is no platform to ask');
|
|
178
|
+
}
|
|
179
|
+
if (env.YAD_PLATFORM_READ === '0') return unknown('off', 'platform reads are turned off (YAD_PLATFORM_READ=0)');
|
|
180
|
+
if (!host || !repo) return unknown('no-url', 'no git remote URL yad can read, so it cannot tell which repo to ask about');
|
|
181
|
+
if (!runner(process.platform === 'win32' ? 'where' : 'which', [cli], {}).ok) return unknown('no-cli', `${cli} is not installed, so yad cannot ask ${PLATFORM_NAME[plat]}`);
|
|
182
|
+
if (!loggedIn(runner, cli, host, authCache)) return unknown('no-login', `${cli} is not logged in for ${host} (or ${host} did not answer)`);
|
|
183
|
+
return plat === 'github' ? readGitHub(base, runner, unknown) : readGitLab(base, runner, unknown);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Read the repo, then settle the branch: yad's, else the platform's default (said in the line).
|
|
187
|
+
function repoAndBranch(base, runner, unknown, { cli, platform, at, what }) {
|
|
188
|
+
const meta = api(runner, cli, base.host, at);
|
|
189
|
+
if (!meta.ok) return { fail: unknown(meta.status === 404 ? 'no-repo' : 'other', whyFailed(meta, { platform, host: base.host, what })) };
|
|
190
|
+
const platformDefault = typeof meta.body?.default_branch === 'string' ? meta.body.default_branch : null;
|
|
191
|
+
if (base.branch) return { branch: base.branch, branchFrom: base.branchFrom, platformDefault };
|
|
192
|
+
if (!platformDefault) {
|
|
193
|
+
// E110: GitLab shows a project's default branch only to a login that may read its code (`expose
|
|
194
|
+
// :default_branch_or_main … if: … :read_code` in `basic_project_details.rb`; live, project 86809638
|
|
195
|
+
// answers 200 with no such key). So on GitLab "named none" is also what an unreadable repository looks
|
|
196
|
+
// like, and both causes stay open. Nothing is guessed from the missing key: it proves only that GitLab
|
|
197
|
+
// did not show one. `defaultMayBeHidden` is the fact the hint turns on, never the platform's name.
|
|
198
|
+
// GitHub shows the default to anyone who may read the repo, which the answer above just proved.
|
|
199
|
+
if (platform === 'gitlab') {
|
|
200
|
+
return { fail: unknown('no-default', "no default branch is set in yad's files, and GitLab named none (GitLab names it only to a login that can read the project's repository)", { defaultMayBeHidden: true }) };
|
|
201
|
+
}
|
|
202
|
+
return { fail: unknown('no-default', `no default branch is set in yad's files, and ${PLATFORM_NAME[platform]} named none`) };
|
|
203
|
+
}
|
|
204
|
+
return { branch: platformDefault, branchFrom: 'platform', platformDefault };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// A 404 on the branch: the platform does not have the branch yad's files name — or, when the platform
|
|
208
|
+
// itself just named this branch as the repo's default, a repo with no commits yet. That turns on what the
|
|
209
|
+
// repo read PROVED, never on where yad got the name, because yad's files may name the default too. On
|
|
210
|
+
// GitLab add "or one the login cannot read", because reading a project and reading its repository are two
|
|
211
|
+
// settings there — unless the answer's body names the cause (`gitlabBranchCause`, E109); on GitHub one
|
|
212
|
+
// permission covers both, so the repo read before this one rules it out.
|
|
213
|
+
const namedByPlatform = (branch, platformDefault) => platformDefault !== null && branch === platformDefault;
|
|
214
|
+
const branchMissing = (res, branch, platformDefault) => (res.status !== 404 ? 'other'
|
|
215
|
+
: namedByPlatform(branch, platformDefault) ? 'empty' : 'no-branch');
|
|
216
|
+
const RULESET_OF = { Organization: 'an organisation ruleset', Repository: 'a repo ruleset' };
|
|
217
|
+
// What the platform calls a change asking to be merged.
|
|
218
|
+
const REQUEST = { github: 'pull request', gitlab: 'merge request' };
|
|
219
|
+
// How a GitLab approval rule misses this branch, [one rule, more than one].
|
|
220
|
+
const MISSES = {
|
|
221
|
+
'protected-only': ['reaches protected branches only', 'reach protected branches only'],
|
|
222
|
+
named: ['names other branches', 'name other branches'],
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// One source of required approvals, as { floor, exact, why }: `floor` is the most it proved; `exact` says
|
|
226
|
+
// nothing was left unread; `why` says what was, when it was not.
|
|
227
|
+
const settle = (sources) => {
|
|
228
|
+
const floor = Math.max(0, ...sources.map((s) => s.floor));
|
|
229
|
+
const exact = sources.every((s) => s.exact);
|
|
230
|
+
const why = sources.filter((s) => !s.exact).map((s) => s.why).join('; ');
|
|
231
|
+
// `partlyRead` is the fact a hint keys on: something the platform holds was NOT read. `atLeast` also
|
|
232
|
+
// turns true for GitLab rules that overlap, where every call succeeded — that is not a partial read.
|
|
233
|
+
return floor > 0 ? { approvals: floor, atLeast: !exact, partlyRead: !exact, approvalsWhy: null }
|
|
234
|
+
: (exact ? { approvals: 0, atLeast: false, partlyRead: false, approvalsWhy: null } : { approvals: null, atLeast: false, partlyRead: true, approvalsWhy: why });
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
function readGitHub(base, runner, unknown) {
|
|
238
|
+
const { host, repo } = base;
|
|
239
|
+
const at = `repos/${repo.split('/').map(encodeURIComponent).join('/')}`;
|
|
240
|
+
const rb = repoAndBranch(base, runner, unknown, { cli: 'gh', platform: 'github', at, what: `the repo ${shown(repo)}` });
|
|
241
|
+
if (rb.fail) return rb.fail;
|
|
242
|
+
const { branch, branchFrom, platformDefault } = rb;
|
|
243
|
+
const out = { ...base, branch, branchFrom, platformDefault, known: true, protected: null, protectedWhy: null, approvals: null, atLeast: false, approvalsWhy: null, from: [], codeOwners: null };
|
|
244
|
+
const b = api(runner, 'gh', host, `${at}/branches/${segment(branch)}`);
|
|
245
|
+
if (!b.ok) {
|
|
246
|
+
const why = b.status !== 404
|
|
247
|
+
? whyFailed(b, { platform: 'github', host, what: `the branch ${shown(branch)}` })
|
|
248
|
+
: namedByPlatform(branch, platformDefault)
|
|
249
|
+
// GitHub named this branch as the repo's default moments ago, so the repo does not lack it.
|
|
250
|
+
? `GitHub answered 404 for the branch ${shown(branch)}, so this repo has no commits on it yet`
|
|
251
|
+
: `GitHub answered 404 for the branch ${shown(branch)}, so this repo does not have it`;
|
|
252
|
+
// `cause: 'branch'`: one GitHub permission covers the repo read just made and the branch, so a 404
|
|
253
|
+
// here proves the branch cause — the fact the hints turn on, never the platform's name (E109).
|
|
254
|
+
return { ...out, known: false, kind: branchMissing(b, branch, platformDefault), why, ...(b.status === 404 ? { cause: 'branch' } : {}) };
|
|
255
|
+
}
|
|
256
|
+
if (typeof b.body?.protected !== 'boolean') return { ...out, known: false, kind: 'no-flag', why: 'GitHub did not say whether the branch is protected' };
|
|
257
|
+
out.protected = b.body.protected;
|
|
258
|
+
// Rulesets: every ACTIVE rule on the branch ("evaluate" and "disabled" rulesets are not returned).
|
|
259
|
+
const rules = api(runner, 'gh', host, `${at}/rules/branches/${segment(branch)}?per_page=${PAGE}`);
|
|
260
|
+
let rs;
|
|
261
|
+
let noRulesets = false; // no ruleset is on this branch: the list came back empty, or the host has no rulesets API
|
|
262
|
+
let rulesetCodeOwners = false;
|
|
263
|
+
let rulesetReviewers = false; // true, false, or null (a reviewer's count could not be read)
|
|
264
|
+
if (rules.ok && Array.isArray(rules.body)) {
|
|
265
|
+
noRulesets = rules.body.length === 0;
|
|
266
|
+
let floor = 0;
|
|
267
|
+
let unreadable = false;
|
|
268
|
+
for (const r of rules.body) {
|
|
269
|
+
if (r?.type !== 'pull_request') continue;
|
|
270
|
+
if (r.parameters?.require_code_owner_review === true) rulesetCodeOwners = true;
|
|
271
|
+
// A named reviewer for some files (`required_reviewers`): like a code owner, a fact beside the count.
|
|
272
|
+
if (r.parameters?.required_reviewers !== undefined && !Array.isArray(r.parameters.required_reviewers)) {
|
|
273
|
+
// Present, but not a list: like a count that is not a whole number, that is "could not read".
|
|
274
|
+
if (rulesetReviewers === false) rulesetReviewers = null;
|
|
275
|
+
} else if (Array.isArray(r.parameters?.required_reviewers)) {
|
|
276
|
+
for (const x of r.parameters.required_reviewers) {
|
|
277
|
+
const m = count(x?.minimum_approvals);
|
|
278
|
+
if (m > 0) rulesetReviewers = true;
|
|
279
|
+
else if (m === null && rulesetReviewers === false) rulesetReviewers = null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const n = count(r.parameters?.required_approving_review_count);
|
|
283
|
+
if (n === null) { unreadable = true; continue; }
|
|
284
|
+
if (n > 0) {
|
|
285
|
+
floor = Math.max(floor, n);
|
|
286
|
+
out.from.push(`${RULESET_OF[r.ruleset_source_type] || 'a ruleset'}${count(r.ruleset_id) !== null ? ` (id ${r.ruleset_id})` : ''}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// GitHub's own flag is the starting point, but yad never takes "not protected" from it alone: it
|
|
290
|
+
// confirms with the rules on the branch. If the flag says no while GitHub also lists ACTIVE rules, the
|
|
291
|
+
// two answers disagree — that is "not known", never "protected" (which would go quiet about a branch
|
|
292
|
+
// anyone can push to) and never "no protection" either.
|
|
293
|
+
if (rules.body.length && out.protected === false) {
|
|
294
|
+
out.protected = null;
|
|
295
|
+
out.protectedWhy = 'GitHub\'s branch flag says no, but GitHub also lists active rules on the branch';
|
|
296
|
+
}
|
|
297
|
+
const full = rules.body.length >= PAGE;
|
|
298
|
+
rs = {
|
|
299
|
+
floor, exact: !unreadable && !full,
|
|
300
|
+
why: [
|
|
301
|
+
unreadable ? 'GitHub listed a pull request rule whose approval count yad could not read' : '',
|
|
302
|
+
full ? `the branch has ${PAGE} or more active rules and yad reads only the first ${PAGE}` : '',
|
|
303
|
+
].filter(Boolean).join('; '),
|
|
304
|
+
};
|
|
305
|
+
} else {
|
|
306
|
+
// A 404 here is not "no rules" (that answer is an empty list) and not a permission (the repo and the
|
|
307
|
+
// branch were just read with this login): it is a host whose API has no rulesets, such as an older
|
|
308
|
+
// GitHub Enterprise Server.
|
|
309
|
+
if (rules.status === 404) noRulesets = true;
|
|
310
|
+
const why = rules.status === 404
|
|
311
|
+
? 'GitHub answered 404 for the rules on the branch, which a host without the rulesets API does'
|
|
312
|
+
: whyFailed(rules.ok ? { unreadable: true } : rules, { platform: 'github', host, what: 'the rules on the branch', plural: true });
|
|
313
|
+
rs = { floor: 0, exact: false, why };
|
|
314
|
+
// `protected: false` may not count a ruleset, so with the rulesets unread it proves nothing.
|
|
315
|
+
if (out.protected === false) {
|
|
316
|
+
out.protected = null;
|
|
317
|
+
// The flag may not count a ruleset, so only the rules could settle it. On a 404 the reason beside
|
|
318
|
+
// this one says what that answer is, so this sentence names the gap and offers no hunt.
|
|
319
|
+
out.protectedWhy = noRulesets
|
|
320
|
+
? 'GitHub\'s branch flag says no, and only the rules on the branch could confirm it'
|
|
321
|
+
: 'GitHub\'s branch flag says no, and the rulesets that could confirm it could not be read';
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// Classic protection. GitHub's flag is false only when the branch has no classic protection, so there
|
|
325
|
+
// is nothing classic to read — and asking would only earn a 404 that proves nothing.
|
|
326
|
+
let cl = { floor: 0, exact: true, why: '' };
|
|
327
|
+
let classicCodeOwners = false;
|
|
328
|
+
if (b.body.protected) {
|
|
329
|
+
const p = api(runner, 'gh', host, `${at}/branches/${segment(branch)}/protection`);
|
|
330
|
+
if (p.ok && p.body && typeof p.body === 'object' && !Array.isArray(p.body)) {
|
|
331
|
+
const rev = p.body.required_pull_request_reviews;
|
|
332
|
+
if (rev == null) cl = { floor: 0, exact: true, why: '' };
|
|
333
|
+
else {
|
|
334
|
+
if (rev?.require_code_owner_reviews === true) classicCodeOwners = true;
|
|
335
|
+
const n = count(rev?.required_approving_review_count);
|
|
336
|
+
if (n === null) cl = { floor: 0, exact: false, why: 'GitHub\'s classic branch protection requires reviews, with a count yad could not read' };
|
|
337
|
+
else {
|
|
338
|
+
cl = { floor: n, exact: true, why: '' };
|
|
339
|
+
if (n > 0) out.from.push('classic branch protection');
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
} else if (p.ok) {
|
|
343
|
+
cl = { floor: 0, exact: false, why: 'GitHub answered classic branch protection with something yad could not read' };
|
|
344
|
+
} else {
|
|
345
|
+
cl = {
|
|
346
|
+
floor: 0, exact: false,
|
|
347
|
+
why: p.status === 404
|
|
348
|
+
// "or a ruleset protects it" is only a live cause while a ruleset could be there: with the rules
|
|
349
|
+
// read and empty, the branch has none, so that arm would send the reader hunting for nothing.
|
|
350
|
+
// The ruleset arm goes whenever the rules read ruled rulesets out — proven empty, or a host
|
|
351
|
+
// whose API has none. Offering it there would send the reader hunting for what cannot exist.
|
|
352
|
+
? `only a repo admin can read classic branch protection, and GitHub answered 404 (your login is not an admin${noRulesets ? '' : ', or the branch is protected by rulesets alone'})`
|
|
353
|
+
: whyFailed(p, { platform: 'github', host, what: 'classic branch protection' }),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// GitHub applies the strictest of the layered rules, so the count is exact only when both were read.
|
|
358
|
+
out.from = [...new Set(out.from)]; // one source, said once: 100 rules of one ruleset are one source
|
|
359
|
+
Object.assign(out, settle([rs, cl]));
|
|
360
|
+
out.codeOwners = rulesetCodeOwners || classicCodeOwners ? true : (rs.exact && cl.exact ? false : null);
|
|
361
|
+
out.fileReviewers = rulesetReviewers === true ? true : (rs.exact && rulesetReviewers === false ? false : null);
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function readGitLab(base, runner, unknown) {
|
|
366
|
+
const { host, repo } = base;
|
|
367
|
+
const at = `projects/${encodeURIComponent(repo)}`;
|
|
368
|
+
const rb = repoAndBranch(base, runner, unknown, { cli: 'glab', platform: 'gitlab', at, what: `the project ${shown(repo)}` });
|
|
369
|
+
if (rb.fail) return rb.fail;
|
|
370
|
+
const { branch, branchFrom, platformDefault } = rb;
|
|
371
|
+
const out = { ...base, branch, branchFrom, platformDefault, known: true, protected: null, protectedWhy: null, approvals: null, atLeast: false, approvalsWhy: null, from: [], codeOwners: null };
|
|
372
|
+
// The branch itself: it must exist (a typo in yad's files must never read as "nothing protects it"),
|
|
373
|
+
// and its `protected` flag is GitLab's own answer — wildcards and group-level protection included.
|
|
374
|
+
const b = api(runner, 'glab', host, `${at}/repository/branches/${encodeURIComponent(branch)}`);
|
|
375
|
+
if (!b.ok) {
|
|
376
|
+
// E109: the body may name which of the 404's two causes it was. Each sentence says what that body
|
|
377
|
+
// proved and no more; a body yad does not recognise keeps the hedge below, word for word.
|
|
378
|
+
const cause = gitlabBranchCause(b);
|
|
379
|
+
if (cause === 'repository') {
|
|
380
|
+
// The branch was never looked up, so nothing is said about it — whoever named it.
|
|
381
|
+
return { ...out, known: false, kind: 'no-repository', cause, why: `GitLab answered 404 for the branch ${shown(branch)} because this project's repository could not be read (it is turned off for the project, or your access is too low to read it)` };
|
|
382
|
+
}
|
|
383
|
+
if (cause === 'branch') {
|
|
384
|
+
const why = namedByPlatform(branch, platformDefault)
|
|
385
|
+
// GitLab named this branch as the project's default moments ago, so the project does not lack it.
|
|
386
|
+
? `GitLab answered 404 for the branch ${shown(branch)}, so this project has no commits on it yet`
|
|
387
|
+
: `GitLab answered 404 for the branch ${shown(branch)}, so this project does not have it`;
|
|
388
|
+
return { ...out, known: false, kind: branchMissing(b, branch, platformDefault), cause, why };
|
|
389
|
+
}
|
|
390
|
+
const why = whyFailed(b, { platform: 'gitlab', host, what: `the branch ${shown(branch)}` });
|
|
391
|
+
return { ...out, known: false, kind: branchMissing(b, branch, platformDefault), why };
|
|
392
|
+
}
|
|
393
|
+
if (typeof b.body?.protected !== 'boolean') return { ...out, known: false, kind: 'no-flag', why: 'GitLab did not say whether the branch is protected' };
|
|
394
|
+
out.protected = b.body.protected;
|
|
395
|
+
// The project's protected branches, for the code-owner fact only. The list leaves out protection set
|
|
396
|
+
// for a whole group, and a group's setting takes precedence over the project's — so an entry saying "no"
|
|
397
|
+
// proves nothing. Only a matching entry saying YES is proof, and only an UNPROTECTED branch proves no.
|
|
398
|
+
const pb = api(runner, 'glab', host, `${at}/protected_branches?per_page=${PAGE}`);
|
|
399
|
+
const matched = pb.ok && Array.isArray(pb.body) ? pb.body.filter((p) => branchMatches(p?.name, branch)) : [];
|
|
400
|
+
if (matched.some((p) => p?.code_owner_approval_required === true)) out.codeOwners = true;
|
|
401
|
+
else if (out.protected === false) out.codeOwners = false;
|
|
402
|
+
const ar = api(runner, 'glab', host, `${at}/approval_rules?per_page=${PAGE}`);
|
|
403
|
+
let src;
|
|
404
|
+
const elsewhere = new Map(); // how the rules that do not reach this branch miss it → how many do
|
|
405
|
+
let applied = 0; // rules that apply AND ask for an approval: each is met on its own, so 2 is a floor
|
|
406
|
+
let elsewhereRules = 0; // how many rules the project has that do not reach this branch (1 reads differently)
|
|
407
|
+
if (ar.ok && Array.isArray(ar.body)) {
|
|
408
|
+
let floor = 0;
|
|
409
|
+
const whys = new Set();
|
|
410
|
+
for (const r of ar.body) {
|
|
411
|
+
// A report rule (Coverage-Check, License-Check…) asks for an approval only when its report fails,
|
|
412
|
+
// so it does not hold every merge: not an approval rule here.
|
|
413
|
+
if (r?.rule_type === 'report_approver') continue;
|
|
414
|
+
const n = count(r?.approvals_required);
|
|
415
|
+
if (n === null) { whys.add('GitLab listed an approval rule whose count yad could not read'); continue; }
|
|
416
|
+
if (n === 0) continue;
|
|
417
|
+
let applies;
|
|
418
|
+
let unreadableReach = false; // this rule's list holds a name yad could not read, rather than no list
|
|
419
|
+
if (r.applies_to_all_protected_branches === true) applies = out.protected;
|
|
420
|
+
else if (Array.isArray(r.protected_branches)) {
|
|
421
|
+
// A listed branch yad cannot read settles nothing: a match wins, but "no match" is an answer only
|
|
422
|
+
// when every entry was readable.
|
|
423
|
+
const listed = r.protected_branches;
|
|
424
|
+
if (!listed.length) applies = true; // none listed: every branch
|
|
425
|
+
else if (listed.some((x) => branchMatches(x?.name, branch))) applies = true;
|
|
426
|
+
else if (listed.every((x) => typeof x?.name === 'string')) applies = false;
|
|
427
|
+
else { applies = null; unreadableReach = true; }
|
|
428
|
+
} else applies = null;
|
|
429
|
+
if (applies === null) {
|
|
430
|
+
// Two different gaps: GitLab gave no list of branches, or its list holds one yad could not read.
|
|
431
|
+
whys.add(unreadableReach
|
|
432
|
+
? 'GitLab listed an approval rule whose branch list holds a name yad could not read'
|
|
433
|
+
: 'GitLab did not say which branches an approval rule covers');
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (applies) {
|
|
437
|
+
applied += 1;
|
|
438
|
+
floor = Math.max(floor, n);
|
|
439
|
+
// A name the platform did not give is never printed as if it had: its id, else no name at all.
|
|
440
|
+
const named = typeof r.name === 'string' && r.name.trim() ? r.name.trim() : null;
|
|
441
|
+
// A rule NAMED "42" and a rule with ID 42 must not read the same, so an id is printed as an id —
|
|
442
|
+
// the shape the GitHub side already uses ("a repo ruleset (id 7)").
|
|
443
|
+
if (named !== null) out.from.push(`approval rule ${JSON.stringify(named)}`);
|
|
444
|
+
else out.from.push(count(r.id) !== null ? `an approval rule (id ${r.id})` : 'an approval rule');
|
|
445
|
+
}
|
|
446
|
+
// A rule the platform DID return that does not reach this branch: say which way it misses, so the
|
|
447
|
+
// line never claims "no approval rules", nor that protecting the branch would bring this one to it.
|
|
448
|
+
else {
|
|
449
|
+
elsewhereRules += 1;
|
|
450
|
+
const kind = r.applies_to_all_protected_branches === true ? 'protected-only' : 'named';
|
|
451
|
+
elsewhere.set(kind, (elsewhere.get(kind) || 0) + 1);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (ar.body.length >= PAGE) whys.add(`the project has ${PAGE} or more approval rules and yad reads only the first ${PAGE}`);
|
|
455
|
+
src = { floor, exact: !whys.size, why: [...whys].join('; ') };
|
|
456
|
+
} else {
|
|
457
|
+
src = {
|
|
458
|
+
floor: 0, exact: false,
|
|
459
|
+
why: ar.ok
|
|
460
|
+
? 'GitLab answered the approval rules with something yad could not read'
|
|
461
|
+
: ([401, 403, 404].includes(ar.status)
|
|
462
|
+
? `GitLab refused to show the approval rules (HTTP ${ar.status}): they need GitLab Premium or Ultimate, or your login may not read them`
|
|
463
|
+
: whyFailed(ar, { platform: 'gitlab', host, what: 'the approval rules', plural: true })),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
out.from = [...new Set(out.from)]; // a source named the same way is named once (the count is above)
|
|
467
|
+
Object.assign(out, settle([src]));
|
|
468
|
+
// The approval-rules endpoint answers on Premium and Ultimate only, so a list that came back settles the
|
|
469
|
+
// tier: a hint must not go on offering Free as the explanation (the round-15 rule, on the GitLab side).
|
|
470
|
+
if (ar.ok && Array.isArray(ar.body)) out.rulesAnswered = true;
|
|
471
|
+
// `rulesAnswered` is a fact for `--json` and nothing else: the hints turn on `rulesRefused`, the one
|
|
472
|
+
// answer that leaves the tier open — offline, or a body yad could not read, says nothing about Premium,
|
|
473
|
+
// and those lines point at what was not read instead.
|
|
474
|
+
else if (!ar.ok && [401, 403, 404].includes(ar.status)) out.rulesRefused = true;
|
|
475
|
+
if (elsewhere.size) {
|
|
476
|
+
const how = [...elsewhere];
|
|
477
|
+
const said = ([kind, n], first) => `${n === 1 ? (first ? 'one of them' : 'another') : (first ? 'some of them' : 'others')} ${MISSES[kind][n === 1 ? 0 : 1]}`;
|
|
478
|
+
const one = elsewhereRules === 1;
|
|
479
|
+
out.rulesElsewhere = how.length > 1
|
|
480
|
+
? `${said(how[0], true)}, and ${said(how[1], false)}`
|
|
481
|
+
: `${one ? 'it' : 'they'} ${MISSES[how[0][0]][one ? 0 : 1]}`;
|
|
482
|
+
out.rulesElsewhereOne = one;
|
|
483
|
+
}
|
|
484
|
+
// Each GitLab rule must be met on its own, and their approvers may overlap: two or more is a floor. It
|
|
485
|
+
// counts the rules that APPLIED — two rules can print the same name, and a floor is not a label count.
|
|
486
|
+
if (out.approvals > 0 && applied > 1) out.atLeast = true;
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// The doctor line for one answer: { status: 'ok'|'warn', message, hint }. `name` is the repo's name in
|
|
491
|
+
// yad (or "Product hub"). In solo mode (the user's decision) the facts print as `ok` with no banner — one
|
|
492
|
+
// person cannot approve their own pull request, so "no approval rules" may be the right setup — except
|
|
493
|
+
// the old solo warning: a required approval there blocks the solo developer's own merge.
|
|
494
|
+
//
|
|
495
|
+
// The Part 3 banner is printed word for word only when BOTH halves are proven: no approval rule AND no
|
|
496
|
+
// branch protection. Nothing here ever says a repo is safe, or that yad holds a merge.
|
|
497
|
+
export const BANNER = 'This repo has no approval rules and no branch protection. Anyone with write access can merge anything. yad will record what happens, but it cannot stop anything here.';
|
|
498
|
+
|
|
499
|
+
// No e-mail address is ever printed (E67). `shown` hides a name yad places itself; this last pass hides
|
|
500
|
+
// any other word with an `@` after its first character — a host, a rule name, a repo's name in yad, a
|
|
501
|
+
// platform string — wherever it came from. Leading quotes and brackets are not the first character.
|
|
502
|
+
// Only the word's core is replaced: the quotes, brackets and punctuation around it stay, so a sentence
|
|
503
|
+
// keeps its shape (`"bob@x.com")` becomes `"a name with an @ in it")`).
|
|
504
|
+
export const hideAddresses = (text) => (typeof text === 'string'
|
|
505
|
+
? text.replace(/\S+/g, (w) => {
|
|
506
|
+
const [, lead, core, trail] = w.match(/^([`"'([<]*)(.*?)([`"')\]>,;:.!?]*)$/);
|
|
507
|
+
return core.indexOf('@', 1) >= 0 ? `${lead}${shown('x@x')}${trail}` : w;
|
|
508
|
+
})
|
|
509
|
+
: text);
|
|
510
|
+
|
|
511
|
+
// The answer as `--json` prints it: every string in it passed through `hideAddresses`.
|
|
512
|
+
export function protectionJSON(r) {
|
|
513
|
+
const walk = (v) => (typeof v === 'string' ? hideAddresses(v) : Array.isArray(v) ? v.map(walk)
|
|
514
|
+
: v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])) : v);
|
|
515
|
+
return walk(r);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function protectionLine(r, opts = {}) {
|
|
519
|
+
const l = lineFor(r, opts);
|
|
520
|
+
return { ...l, message: hideAddresses(l.message), ...(l.hint ? { hint: hideAddresses(l.hint) } : {}) };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function lineFor(r, { name, solo = false } = {}) {
|
|
524
|
+
const P = PLATFORM_NAME[r.platform] || 'the platform';
|
|
525
|
+
const br = !r.branch ? 'the default branch' : (shown(r.branch) === r.branch ? `\`${r.branch}\`` : shown(r.branch));
|
|
526
|
+
const where = r.repo ? `${P} ${shown(r.repo)}` : P;
|
|
527
|
+
const other = r.platformDefault && shown(r.platformDefault) === r.platformDefault ? `\`${r.platformDefault}\`` : shown(r.platformDefault);
|
|
528
|
+
const note = r.branchFrom === 'platform'
|
|
529
|
+
? `yad's files name no default branch, so ${P}'s default was read`
|
|
530
|
+
: (r.platformDefault && r.branch && r.platformDefault !== r.branch ? `${P}'s own default branch is ${other}` : '');
|
|
531
|
+
const branchNote = note ? ` (${note})` : '';
|
|
532
|
+
const team = solo ? 'ok' : 'warn';
|
|
533
|
+
const unread = `ask someone who can see ${P}'s settings for ${br} about what yad could not read`;
|
|
534
|
+
const Unread = `${unread[0].toUpperCase()}${unread.slice(1)}`;
|
|
535
|
+
const settleIt = `ask someone who can see ${P}'s settings for ${br}`;
|
|
536
|
+
// The platform's own name for the setting that requires an approval (named, never explained: that is
|
|
537
|
+
// documentation's job, not the doctor's).
|
|
538
|
+
const setting = r.platform === 'gitlab'
|
|
539
|
+
? `an approval rule (GitLab Premium or Ultimate) for ${br}`
|
|
540
|
+
: `${P}'s branch protection or a ruleset for ${br}`;
|
|
541
|
+
if (!r.known) {
|
|
542
|
+
return { status: team, message: `${name}: not known whether ${br} requires an approval${branchNote} — ${r.why}`, hint: unknownHint(r) };
|
|
543
|
+
}
|
|
544
|
+
// EVERY SENTENCE BELOW STATES A FIELD THE READER SET, and nothing derived from one: who may merge or
|
|
545
|
+
// push is not read, so no line says who can merge, or that the platform "holds" anything (review round 3).
|
|
546
|
+
// Approvals asked only for some files are facts beside the count: said when true, said as not known when
|
|
547
|
+
// null, and left out only when proven false.
|
|
548
|
+
const scoped = [
|
|
549
|
+
...(r.codeOwners === true ? ['a code owner must approve a change to a file CODEOWNERS lists'] : []),
|
|
550
|
+
...(r.fileReviewers === true ? ['a named reviewer must approve a change to some files'] : []),
|
|
551
|
+
];
|
|
552
|
+
const unknownScoped = [
|
|
553
|
+
...(r.codeOwners === null ? ['whether a code owner must approve some files is not known'] : []),
|
|
554
|
+
...(r.fileReviewers === null ? ['whether a named reviewer must approve some files is not known'] : []),
|
|
555
|
+
];
|
|
556
|
+
// "also" only where something else is already required — an approval count.
|
|
557
|
+
const clauses = (withAlso) => {
|
|
558
|
+
const all = [...(withAlso ? scoped.map((x) => x.replace(' must ', ' must also ')) : scoped), ...unknownScoped];
|
|
559
|
+
return all.length ? `; ${all.join('; ')}` : '';
|
|
560
|
+
};
|
|
561
|
+
const owners = clauses(true);
|
|
562
|
+
const from = `(from: ${r.from.join('; ')})`;
|
|
563
|
+
const request = REQUEST[r.platform] || 'pull request';
|
|
564
|
+
if (r.approvals > 0) {
|
|
565
|
+
const n = `${r.atLeast ? 'at least ' : ''}${r.approvals} approval${r.approvals === 1 ? '' : 's'}`;
|
|
566
|
+
// Who may bypass a rule is not read, and neither is GitLab's "an author may approve" setting, so the
|
|
567
|
+
// solo line says what stands in the way, and what yad could not read about it.
|
|
568
|
+
const own = r.platform === 'gitlab'
|
|
569
|
+
? 'GitLab may not let you approve your own merge request (a project setting yad does not read), so the merge may be blocked'
|
|
570
|
+
: 'you cannot approve your own pull request, so the merge is blocked unless you may bypass the rule (who may bypass is not read)';
|
|
571
|
+
// A rule on a branch that is NOT protected covers a merge request only: a direct push skips it. (GitLab:
|
|
572
|
+
// a rule with no branch listed covers every branch. On GitHub a count always comes with protection.)
|
|
573
|
+
if (r.protected === false) {
|
|
574
|
+
return {
|
|
575
|
+
status: 'warn',
|
|
576
|
+
message: `${name}: ${br} is not protected on ${where}${branchNote} — anyone with write access can push to it directly, with no ${request}; a ${request} into it needs ${n} ${from}${owners}${solo ? `; and ${own}` : ''}`,
|
|
577
|
+
// …and a part that went unread is pointed at here too, as on every other partly read line.
|
|
578
|
+
// No `unknownScoped` here: this arm is GitLab-only, where a branch proven unprotected always has
|
|
579
|
+
// its code-owner fact settled and carries no named-reviewer fact at all.
|
|
580
|
+
hint: `${solo ? `relax the required approvals in ${setting}` : `protecting ${br} in ${P}'s settings limits who may push to it directly; yad only reports what is set`}${r.partlyRead ? `. ${Unread}` : ''}`,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
// Whether the branch is protected may be unknown beside a count that was read: say so, never silently.
|
|
584
|
+
const unsure = r.protected === null ? `; whether the branch is protected is not known — ${r.protectedWhy}` : '';
|
|
585
|
+
if (solo) {
|
|
586
|
+
return {
|
|
587
|
+
status: 'warn',
|
|
588
|
+
message: `${name}: solo mode, but a ${request} into ${br} on ${where}${branchNote} needs ${n} ${from}${owners}${unsure}${unsure ? '; and ' : ' — '}${own}`,
|
|
589
|
+
// …and what went unread, or who can settle a protection that is not known, as the team twins say.
|
|
590
|
+
hint: `relax the required approvals in ${setting}${unknownScoped.length || r.partlyRead ? `. ${Unread}` : (unsure ? `. ${settleIt[0].toUpperCase()}${settleIt.slice(1)}` : '')}`,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
if (unsure) {
|
|
594
|
+
// The protection is not known because two answers disagree, not because a call failed — so the
|
|
595
|
+
// pointer to "what yad could not read" belongs here only when something really was not read. Both
|
|
596
|
+
// terms are needed: a rule that sets a scoped fact makes it TRUE, not unknown, while its count is
|
|
597
|
+
// still unread (review 18).
|
|
598
|
+
const why = unknownScoped.length || r.partlyRead ? unread : settleIt;
|
|
599
|
+
return { status: 'warn', message: `${name}: a ${request} into ${br} on ${where}${branchNote} needs ${n} ${from}${owners}${unsure}`, hint: why };
|
|
600
|
+
}
|
|
601
|
+
const said = { status: 'ok', message: `${name}: a ${request} into ${br} on ${where}${branchNote} needs ${n} ${from}${owners} — yad reports this and enforces nothing` };
|
|
602
|
+
// A line that is partly unread carries its hint, as every other partly unread line does.
|
|
603
|
+
return unknownScoped.length || r.partlyRead ? { ...said, hint: unread } : said;
|
|
604
|
+
}
|
|
605
|
+
if (r.approvals === 0 && r.protected === false && r.rulesElsewhere) {
|
|
606
|
+
// The platform DID return approval rules; they do not reach this branch. "No approval rules" would be
|
|
607
|
+
// false, so the banner is not printed, and the sentence says which way they miss it.
|
|
608
|
+
const only = scoped.length ? `; only some changes need an approval (${scoped.join('; ')})` : '';
|
|
609
|
+
const many = !r.rulesElsewhereOne;
|
|
610
|
+
// No `unknownScoped` clause: this sentence is GitLab-only and needs `protected === false`, where the
|
|
611
|
+
// reader always proves `codeOwners` and never sets `fileReviewers`.
|
|
612
|
+
const msg = `${name}: ${br} is not protected on ${where}${branchNote} — anyone with write access can push to it directly, and the approval rule${many ? 's' : ''} ${P} has miss${many ? '' : 'es'} it: ${r.rulesElsewhere}, so ${many ? 'none of them holds' : 'it does not hold'} a merge into it${only}`;
|
|
613
|
+
return solo ? { status: 'ok', message: msg } : { status: 'warn', message: msg, hint: `protecting ${br} in ${P}'s settings limits who may push to it directly; only ${P} can require an approval before a merge, in ${setting}; yad only reports what is set` };
|
|
614
|
+
}
|
|
615
|
+
if (r.approvals === 0 && r.protected === null) {
|
|
616
|
+
// The count was read; the protection was not. Both are said, and neither borrows the other's answer.
|
|
617
|
+
// A colon, not a dash: this sentence already ends with a dash before the reason, and two dashes at
|
|
618
|
+
// one level let a reader take the middle as an aside.
|
|
619
|
+
const only = scoped.length ? ' on every change: only some changes need an approval (' + scoped.join('; ') + ')' : '';
|
|
620
|
+
const msg = `${name}: no rule on ${where}${branchNote} requires an approval to merge into ${br}${only || (unknownScoped.length ? ' on every change' : '')}${unknownScoped.length ? `; ${unknownScoped.join('; ')}` : ''}; whether the branch is protected is not known — ${r.protectedWhy}`;
|
|
621
|
+
// The PROTECTION read did not fail here — the platform gave two answers that disagree — so the hint
|
|
622
|
+
// names who can settle it. A scoped fact that could not be read keeps its own pointer. (A count of 0
|
|
623
|
+
// is only ever set from a complete read, so `partlyRead` is false on this path and is not tested.)
|
|
624
|
+
const ask = unknownScoped.length ? unread : settleIt;
|
|
625
|
+
return { status: solo ? 'ok' : 'warn', message: msg, hint: ask };
|
|
626
|
+
}
|
|
627
|
+
if (r.approvals === 0 && r.protected === false) {
|
|
628
|
+
// A rule for SOME files is still an approval rule (round 4), so the banner's "no approval rules" half
|
|
629
|
+
// is not proven while a scoped fact is true: that case gets its own sentence instead.
|
|
630
|
+
if (scoped.length) {
|
|
631
|
+
const msg = `${name}: ${br} is not protected on ${where}${branchNote} — anyone with write access can push to it directly; no rule requires an approval on every change, and only some changes need one (${scoped.join('; ')})`;
|
|
632
|
+
return solo ? { status: 'ok', message: msg } : { status: 'warn', message: msg, hint: `protecting ${br} in ${P}'s settings limits who may push to it directly; only ${P} can require an approval before a merge, in ${setting}; yad only reports what is set` };
|
|
633
|
+
}
|
|
634
|
+
if (solo) return { status: 'ok', message: `${name}: ${where} has no approval rules and no branch protection on ${br}${branchNote} — expected in solo mode; yad records what happens, but cannot stop anything here` };
|
|
635
|
+
return { status: 'warn', message: `${name} (${where}, branch ${br}${note ? `; ${note}` : ''}): ${BANNER}`, hint: `only ${P} can require an approval before a merge, in ${setting}; yad only reports what is set` };
|
|
636
|
+
}
|
|
637
|
+
if (r.approvals === 0 && r.protected === true) {
|
|
638
|
+
// "on every change": a code-owner or named-reviewer rule IS a rule that requires an approval, for the
|
|
639
|
+
// files it names — so the plain "no rule requires an approval" would contradict the clause after it.
|
|
640
|
+
// "on every change" whenever a rule for SOME files is true OR could not be read: either way, "no rule
|
|
641
|
+
// requires an approval" on its own would claim more than the reader knows.
|
|
642
|
+
const only = scoped.length ? ` — only some changes need one (${scoped.join('; ')})` : '';
|
|
643
|
+
const msg = `${name}: ${br} is protected on ${where}${branchNote}, but no rule requires an approval${scoped.length || unknownScoped.length ? ' on every change' : ''}${only}${unknownScoped.length ? `; ${unknownScoped.join('; ')}` : ''}`;
|
|
644
|
+
// A line that is partly unread keeps its hint in solo mode, as every could-not-read line does.
|
|
645
|
+
const advice = `only ${P} can require an approval before a merge, in ${setting}; yad only reports what is set`;
|
|
646
|
+
const hint = unknownScoped.length ? `${advice}. ${unread[0].toUpperCase()}${unread.slice(1)}` : advice;
|
|
647
|
+
if (solo) return unknownScoped.length ? { status: 'ok', message: msg, hint } : { status: 'ok', message: msg };
|
|
648
|
+
return { status: 'warn', message: msg, hint };
|
|
649
|
+
}
|
|
650
|
+
// approvals not known
|
|
651
|
+
if (r.protected === false) {
|
|
652
|
+
const msg = `${name}: ${br} is not protected on ${where}${branchNote}: anyone with write access can push to it directly; whether a merge needs an approval is not known — ${r.approvalsWhy}${clauses(false)}`;
|
|
653
|
+
const why = r.platform === 'gitlab' && r.rulesRefused
|
|
654
|
+
? `on GitLab Free an approval never blocks a merge; on Premium or Ultimate, a Maintainer can see the approval rules for ${br} in the project's merge request settings`
|
|
655
|
+
: unread;
|
|
656
|
+
// A line that could not be read keeps its hint in solo mode too: it names how to read it.
|
|
657
|
+
return { status: solo ? 'ok' : 'warn', message: msg, hint: why };
|
|
658
|
+
}
|
|
659
|
+
const msg = r.protected === true
|
|
660
|
+
? `${name}: ${br} is protected on ${where}${branchNote}, but whether a merge needs an approval is not known — ${r.approvalsWhy}${clauses(false)}`
|
|
661
|
+
: `${name}: whether ${br} is protected on ${where}${branchNote} is not known (${r.protectedWhy}), and neither is whether a merge needs an approval — ${r.approvalsWhy}${clauses(false)}`;
|
|
662
|
+
const hint = r.platform === 'gitlab'
|
|
663
|
+
// The twins say the same thing: a refusal leaves the tier open, and every other answer left something
|
|
664
|
+
// unread, which is what the reader needs pointed at.
|
|
665
|
+
? (r.rulesRefused
|
|
666
|
+
? `on GitLab Free an approval never blocks a merge; on Premium or Ultimate, a Maintainer can see the approval rules for ${br} in the project's merge request settings`
|
|
667
|
+
: unread)
|
|
668
|
+
: `ask a repo admin to check the required approvals for ${br} in ${P}'s settings`;
|
|
669
|
+
return { status: solo ? 'ok' : 'warn', message: msg, hint };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// The two actions for a GitLab repository yad could not read (E109's `no-repository`) or may not be able
|
|
673
|
+
// to (E110's hidden default): turned off — an Owner turns it on, or a non-owner asks for that; access too
|
|
674
|
+
// low — a Maintainer or Owner grants it. ONE string for both hints, so the twins cannot drift apart (they
|
|
675
|
+
// did once: E110's review).
|
|
676
|
+
const REPO_ACTIONS = 'if you own the GitLab project and its repository is turned off, turn it on in the project\'s settings; otherwise ask a Maintainer or Owner of the project to give your login access to its repository, or to turn it on';
|
|
677
|
+
|
|
678
|
+
function unknownHint(r) {
|
|
679
|
+
const cli = cliFor(r.platform);
|
|
680
|
+
switch (r.kind) {
|
|
681
|
+
case 'off': return 'unset YAD_PLATFORM_READ to let `yad doctor` ask the platform';
|
|
682
|
+
case 'no-cli': return `install ${cli} and log in, then run \`yad doctor\` again`;
|
|
683
|
+
case 'no-login': return `run \`${cli} auth login --hostname ${r.host}\`, then \`yad doctor\` again`;
|
|
684
|
+
case 'no-platform': return 'set `platform` (github or gitlab) in yad\'s files — `yad setup` for the Product, `yad repo connect` for a code repo';
|
|
685
|
+
case 'no-url': return 'add `git_url` to the repo\'s entry in .sdlc/repos.json (.sdlc/product.json, or hub.json, for the Product), or give the repo an origin remote';
|
|
686
|
+
// The access tail turns on what the read PROVED (`cause`), never on the platform: a 404 whose body was
|
|
687
|
+
// not recognised leaves a permission open, and one that named the branch closes it (E109).
|
|
688
|
+
// The tail's noun is GitLab's: GitHub never reaches it, because its reader sets `cause: 'branch'` on
|
|
689
|
+
// every branch 404.
|
|
690
|
+
case 'no-branch': return `check that \`default_branch\` in yad's files names a branch that exists on the platform${r.cause === 'branch' ? '' : ', or ask for access to the project\'s repository'}`;
|
|
691
|
+
// One action per open cause: the repository is turned off (an Owner — perhaps you — turns it on), or
|
|
692
|
+
// the login's access is too low (a Maintainer or Owner raises it).
|
|
693
|
+
case 'no-repository': return `${REPO_ACTIONS} — then run \`yad doctor\` again`;
|
|
694
|
+
case 'empty': return r.cause === 'branch'
|
|
695
|
+
? 'the platform names this default branch, but it has no commits yet — push a first commit, then run `yad doctor` again'
|
|
696
|
+
: 'the platform names this default branch, but it has no commits yet (or your login cannot see it) — push a first commit, or ask for access to the project\'s repository, then run `yad doctor` again';
|
|
697
|
+
case 'no-flag': return `ask someone who can see ${PLATFORM_NAME[r.platform] || 'the platform'}'s settings for ${shown(r.branch) === r.branch ? `\`${r.branch}\`` : shown(r.branch)}`;
|
|
698
|
+
// One action per open cause (E110): yad's files name no branch, and on GitLab the repository may be
|
|
699
|
+
// unreadable — turned off (an Owner turns it on) or too little access (a Maintainer or Owner grants it).
|
|
700
|
+
case 'no-default': return `set \`default_branch\` in yad's files (.sdlc/repos.json, or .sdlc/product.json — hub.json on an older Product)${r.defaultMayBeHidden
|
|
701
|
+
? `. GitLab also hides the default from a login that cannot read the repository: ${REPO_ACTIONS} — then run \`yad doctor\` again`
|
|
702
|
+
: ''}`;
|
|
703
|
+
case 'no-repo': return `check \`git_url\` in yad's files, or ask for access to the ${r.platform === 'gitlab' ? 'project' : 'repo'}`;
|
|
704
|
+
default: return 'fix what the message names, then run `yad doctor` again; nothing about this repo\'s protection is assumed meanwhile';
|
|
705
|
+
}
|
|
706
|
+
}
|