This commit is contained in:
1 parent
de99f7263f
commit
abab3a67e2
19 files changed
+427
-186
No files matched your search
@@ -124,7 +124,9 @@ approve path carries the trap.) `pr-review.sh` sends the correct token for the d
|
||||
Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict
|
||||
placed.
|
||||
|
||||
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. For a repository with no CI configured at all, `pr-merge.sh --no-ci-expected` is the sanctioned merge path: it forwards to `ci-queue-wait.sh --no-ci-expected`, which reclassifies a zero-context merge head as queue-clear only when the acting token holds repository admin and `MOSAIC_GIT_IDENTITY` names the asserting identity (a caller without one is refused with exit 78 before the admin lookup), and records the assertion (or its refusal) in the same JSONL audit log. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||
The guard exits nonzero for non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT`; merge holds until evidence is available. The legacy `pr-merge.sh --no-ci-expected` path forwards that assertion to the queue guard, which requires repository admin and `MOSAIC_GIT_IDENTITY`. The legacy merge interface retains its options and consults the declaration at the PR's base commit. Pass `--expect-head <approved-full-sha>` to bind a verdict; the `--json` adapter requires it. Gitea uses an atomic head pin and GitHub passes the expected SHA.
|
||||
|
||||
REST forge wrappers require `MOSAIC_GIT_IDENTITY` and an operator-provisioned token in the identity's host-specific store. For hosts other than `git.uscllc.com` and `git.mosaicstack.dev`, that store is `$MOSAIC_BRAIN_HOME/credentials/forge/<host>/<id>.token` (brain defaults to `~/.mosaic`). The two fleet hosts use their per-seat or per-service token paths; see [API wrapper identity and credentials](../tools/git/README.md#api-wrapper-identity-and-credentials). API token resolution no longer falls back to git-config identity, shared `credentials.sh` tokens, `GITEA_TOKEN`, or `~/.git-credentials`. The separate tea login resolver retains its legacy git-config and host-default behavior; set the environment identity to align both paths.
|
||||
|
||||
### Code Review (Codex)
|
||||
|
||||
|
||||
@@ -40,109 +40,39 @@ Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` f
|
||||
|
||||
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
|
||||
|
||||
## Per-agent Gitea identity (Gate-16 author≠reviewer)
|
||||
## API wrapper identity and credentials
|
||||
|
||||
By default, git push/fetch (via `git-credential-mosaic`) and the API wrappers above (via
|
||||
`detect-platform.sh`'s `get_gitea_token`) all authenticate as the single shared Gitea
|
||||
account/token configured through `tools/_lib/credentials.sh`. That means every agent in a
|
||||
fleet commits, pushes, and opens PRs under one identity — with no cryptographic
|
||||
separation between an author and a reviewer.
|
||||
The REST wrappers require `MOSAIC_GIT_IDENTITY` to name the acting account. They
|
||||
select exactly one host-bound token file, fail closed when it is missing, and
|
||||
verify `/user` against that identity before a write. Operators provision these
|
||||
files; the wrappers do not create them. `<brain>` is `MOSAIC_BRAIN_HOME` when
|
||||
set, otherwise `~/.mosaic`.
|
||||
|
||||
`git-credential-mosaic`, `get_gitea_token()`, and the `gitea-mosaicstack` /
|
||||
`gitea-usc` arms of `load_credentials` resolve an optional **per-agent identity**:
|
||||
| Host and identity | Token file |
|
||||
| --- | --- |
|
||||
| `git.mosaicstack.dev` or `git.uscllc.com`, with `<brain>/fleet/agents/<id>/` present | `<brain>/fleet/agents/<id>/secrets/gitea-{mosaicstack,usc}-<id>.token` |
|
||||
| Those hosts, other identities | `~/.config/mosaic/secrets/gitea-tokens/gitea-{mosaicstack,usc}-<id>.token` |
|
||||
| Any other host, including github.com | `<brain>/credentials/forge/<host>/<id>.token` |
|
||||
|
||||
1. `MOSAIC_GIT_IDENTITY` environment variable, or
|
||||
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
|
||||
non-persistent shells — `git config mosaic.gitIdentity <agent-id>`), or
|
||||
3. (git-credential-mosaic only) the username git itself supplies for the credential
|
||||
request.
|
||||
Use the actual hostname (and port when specified) as `<host>` and the matching
|
||||
instance name in the two fleet-host paths. Keep tokens private and out of git.
|
||||
There is no fallback to another identity or store.
|
||||
|
||||
### Which store a credential is read from
|
||||
**Migration:** set `MOSAIC_GIT_IDENTITY` explicitly and provision the appropriate
|
||||
file before upgrading. API token resolution no longer falls back to git config
|
||||
`mosaic.gitIdentity`, a shared `credentials.sh` token, `GITEA_TOKEN`, or
|
||||
`~/.git-credentials`. The git credential helper and other service credential
|
||||
loaders have their own contracts and are not configured by this change.
|
||||
|
||||
The store is chosen by what the identity **is**, not by which file happens to exist first:
|
||||
The separate tea-based read paths resolve a tea login rather than a REST token;
|
||||
their legacy resolver still accepts `git config mosaic.gitIdentity` or a host
|
||||
default when the environment identity is absent. Set `MOSAIC_GIT_IDENTITY` for
|
||||
both paths to avoid selecting different actors. Explicit `--login` overrides
|
||||
remain host-bound as described above.
|
||||
|
||||
| The identity | Its credential is read from |
|
||||
| ------------------------------------------------------------------ | -------------------------------------------------------------------------- |
|
||||
| has a directory at `<brain>/fleet/agents/<id>/` — it is a **seat** | `<brain>/fleet/agents/<id>/secrets/gitea-{usc,mosaicstack}-<id>.token` |
|
||||
| does not — it is a **service identity** | `~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-<id>.token` |
|
||||
### Enabling the Git credential helper for a clone
|
||||
|
||||
`<brain>` is `MOSAIC_BRAIN_HOME` if set, else `~/.mosaic` — the same resolution
|
||||
`packages/mosaic/src/fleet/brain-home.ts` performs.
|
||||
|
||||
The Gitea arms of `load_credentials` obtain a seat token through the production
|
||||
`git-credential-mosaic` entrypoint, rather than reading the slot directly. That retains
|
||||
the entrypoint's clean-environment and process-ancestry fence. The Gitea URL remains
|
||||
provider configuration from the service store. A caller-supplied `GITEA_TOKEN` retains
|
||||
its established environment precedence.
|
||||
|
||||
**There is no precedence between the two stores and no fallback from one to the other.**
|
||||
A seat whose slot is empty is refused even when a same-named token sits in the framework
|
||||
store. One credential lives in exactly one location: a second copy is drift rather than
|
||||
redundancy, and the way drift surfaces is a stale copy returning 401, which reads as a
|
||||
revoked token and sends whoever debugs it to the wrong place.
|
||||
|
||||
### What happens when nothing resolves
|
||||
|
||||
| identity resolves | token in its store | host runs a fleet | result |
|
||||
| ----------------- | ------------------ | ----------------- | ------------------------- |
|
||||
| yes | yes | — | that identity + token |
|
||||
| yes | no | — | **fail closed** |
|
||||
| no | — | yes | **fail closed** |
|
||||
| no | — | no | shared account, unchanged |
|
||||
|
||||
A host "runs a fleet" when `<brain>/fleet/agents` exists — the same signal `brain-home.ts`
|
||||
uses to decide a brain is active.
|
||||
|
||||
Failing closed means: nothing is emitted, the exit status is nonzero, a stderr diagnostic
|
||||
names the identity, its source, the store it resolved to and the path that was expected,
|
||||
and `git-credential-mosaic` additionally appends a record (identity, host, reason, cwd —
|
||||
never a token value) to `${MOSAIC_CREDENTIAL_SPOOL:-~/.local/state/mosaic-credential-escalations}`.
|
||||
The git operation fails; nothing is attributed to anyone.
|
||||
|
||||
The shared-account fallback that used to cover these two cases is why a PR could be
|
||||
authored, commented and merged under an account whose owner did not open it — every seat
|
||||
shared one identity, so the record could not be traced back afterwards. An
|
||||
under-provisioned agent is refused rather than handed the most privileged account
|
||||
available.
|
||||
|
||||
**On a host with no fleet, nothing changes**: no `fleet/agents` directory means the shared
|
||||
account still answers, so this is a no-op for an operator who has not provisioned per-slot
|
||||
tokens. On a host that does run a fleet, a human doing manual git work needs an identity
|
||||
of their own — `MOSAIC_GIT_IDENTITY=<id>` with a provisioned slot. There is deliberately no
|
||||
environment variable that restores the fallback; one would reintroduce exactly the
|
||||
substitution this removes.
|
||||
|
||||
### The tea path: login resolution (#1356)
|
||||
|
||||
The wrappers that go through `tea` (`issue-list.sh`, `pr-list.sh`, `pr-view.sh`,
|
||||
`lane-brief.sh`, and the tea half of `issue-close.sh`) cannot use a token directly: tea
|
||||
0.14 only acts as a **login** already stored in `~/.config/tea/config.yml`. Those wrappers
|
||||
therefore resolve a login name, not a token, and the resolution follows the same identity
|
||||
as above:
|
||||
|
||||
1. Resolve the identity (`MOSAIC_GIT_IDENTITY`, then `git config mosaic.gitIdentity`).
|
||||
2. Derive the Gitea instance from the repo host (`git.mosaicstack.dev` → `mosaicstack`,
|
||||
`git.uscllc.com` → `usc`), or from the owner when `--repo owner/name` is given.
|
||||
3. The canonical login is `<instance>-<identity>`. If tea has it, that login acts.
|
||||
4. If the identity is set but that login is missing, the wrapper **fails closed**: nonzero
|
||||
exit, empty stdout, and a stderr line naming the login it wanted and the source of the
|
||||
identity. When `tea` itself is not installed the message says so instead, since "no such
|
||||
login" would send the reader to create a login they cannot create.
|
||||
5. With **no identity set**, the old host-default behaviour is unchanged (first login
|
||||
configured for that host, else the API fallback).
|
||||
|
||||
Step 4 replaced a fallback that picked any login configured for the host, which meant a
|
||||
seat with no login of its own silently acted as whichever seat had configured one. That
|
||||
satisfied the author≠reviewer gate on paper while one actor held both names.
|
||||
|
||||
**Provisioning the logins.** `tools/fleet/seat-logins.sh` projects each seat's token from
|
||||
its secrets store into tea's config under the canonical name. tea's config is a derived
|
||||
cache of the secrets store: regenerate it with the script, never hand-edit it. Run it with
|
||||
`--seat <seat>` for one seat (all seats when omitted), dry-run by default, `--apply` to write. A hand-made
|
||||
alias holding a seat's token blocks its canonical name (tea refuses one token under two
|
||||
names); `--adopt` renames it.
|
||||
|
||||
### Enabling it for a clone
|
||||
This section covers Git authentication, not the REST wrapper identity above.
|
||||
|
||||
The framework installer syncs `git-credential-mosaic` to
|
||||
`~/.config/mosaic/tools/git/git-credential-mosaic` (executable) on every install/update,
|
||||
@@ -167,32 +97,6 @@ otherwise careful never to touch. Because identity is already resolved per-workt
|
||||
(`mosaic.gitIdentity`), the correct granularity for registering the helper is per-clone
|
||||
too, so a documented manual step is the right shape here, not a global auto-write.
|
||||
|
||||
### Running these tests
|
||||
|
||||
`MOSAIC_GIT_IDENTITY` is inherited into each test's sandbox `HOME`, and **the tests disagree
|
||||
about which value they need**, so no single ambient value passes all 29. Measured on `next` at
|
||||
`a480ee83`, two full passes differing only in that variable:
|
||||
|
||||
| tests | identity exported | identity unset |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | -------------- |
|
||||
| `gitea-login-resolution`, `issue-comment-readback`, `issue-create-interactive-auth`, `pr-edit`, `pr-merge-gitea-empty-uid`, `pr-metadata-gitea` | **fail** | pass |
|
||||
| `issue-close-fail-closed` | pass | **fail** |
|
||||
| remaining 22 | pass | pass |
|
||||
|
||||
The six fail because inside a sandbox `HOME` the identity has no `fleet/agents/<id>/`
|
||||
directory, so it is classified as a **service identity**, its store is unpopulated, and the
|
||||
resolver correctly refuses with `Refusing to borrow another slot's token`. That is the
|
||||
documented fail-closed behaviour above, reached from a state the test never intended.
|
||||
`issue-close-fail-closed` is the mirror image: it asserts that no comment POST is attempted, so
|
||||
it needs an identity resolving to an empty slot, and with the variable unset the shared account
|
||||
answers and the POST goes through.
|
||||
|
||||
These read as wrapper regressions rather than as environment. Two seats independently
|
||||
misdiagnosed them as a patch defect while reviewing #1352. Until each test controls its own
|
||||
value (#1353), `env -u MOSAIC_GIT_IDENTITY` is the closest thing to a clean run at 28/29, with
|
||||
`issue-close-fail-closed` the expected failure — and **"the suite passes" is not a statement
|
||||
anyone can make here without naming the ambient value that produced it.**
|
||||
|
||||
### PowerShell parity
|
||||
|
||||
`detect-platform.ps1`'s Gitea wrappers authenticate through `tea` CLI logins
|
||||
@@ -315,7 +219,7 @@ scheduling may merge immediately and returns observed merged state rather than q
|
||||
|
||||
Schema 1/2 remain readable. Schema 3 adds optional `merge`, with strict key/value validation
|
||||
through the shared declaration validator and repo-decl helper. Consumers apply the existing
|
||||
branch gates to both versions 2 and 3. Absent merge policy means allowed=[squash], default=squash.
|
||||
branch gates to both versions 2 and 3. Absent merge policy means allowed=[squash], default=squash. The legacy merge interface retains its options and reads a declaration from the PR base commit; a missing or unreadable declaration uses the legacy path. It refuses when that policy selects a non-squash default, unless an explicit squash is allowed. Explicit `--json` calls use the adapter and require `--expect-head`.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -334,8 +238,8 @@ branch gates to both versions 2 and 3. Absent merge policy means allowed=[squash
|
||||
```
|
||||
|
||||
`allowed_methods` is non-empty and unique; default and shape methods must belong to it.
|
||||
Unknown keys/shape names fail validation. Promotion is declared trunk→release; absorb is
|
||||
release→trunk and requires its explicit shape mapping; other allowed contributions are task.
|
||||
Unknown keys/shape names fail validation. Promotion is declared trunk→release within the same repository; absorb is
|
||||
release→trunk within the same repository and requires its explicit shape mapping; other allowed contributions are task.
|
||||
Other targets still fail transition validation. Method requests cannot override a shape mapping.
|
||||
Invalid declarations never fall back to a wider merge policy.
|
||||
|
||||
|
||||
@@ -126,15 +126,20 @@ def checks(statuses, runs, required, allow_none=False):
|
||||
return dict(state=state, required=required, observed=observed, complete=True)
|
||||
|
||||
|
||||
def reviews(items, head, author, required_count=1):
|
||||
def reviews(items, head, author, required_count=1, provider='github', official_required=False):
|
||||
latest = {}
|
||||
for r in sorted(items, key=lambda r: r.get('id', 0)):
|
||||
login = (r.get('user') or {}).get('login')
|
||||
state = str(r.get('state', '')).upper()
|
||||
if login and state not in ('COMMENTED', 'PENDING'):
|
||||
if provider != 'github':
|
||||
if r.get('dismissed') is True or state not in ('APPROVED', 'REQUEST_CHANGES'):
|
||||
continue
|
||||
elif state in ('COMMENTED', 'PENDING'):
|
||||
continue
|
||||
if login:
|
||||
latest[login] = r
|
||||
rejected = any(str(r.get('state')).upper() in ('REQUEST_CHANGES', 'CHANGES_REQUESTED') for r in latest.values())
|
||||
approved = sum(login != author and str(r.get('state')).upper() == 'APPROVED' and r.get('commit_id') == head for login, r in latest.items()) >= max(1, required_count) and not rejected
|
||||
approved = sum(login != author and str(r.get('state')).upper() == 'APPROVED' and r.get('commit_id') == head and (not official_required or r.get('official') is True) for login, r in latest.items()) >= max(1, required_count) and not rejected
|
||||
return dict(decision='changes_requested' if rejected else 'approved' if approved else 'missing', reviewed_sha=head if approved else None, complete=True, approved=approved)
|
||||
|
||||
|
||||
@@ -276,7 +281,7 @@ class Client:
|
||||
raise Failure(75, 'transient', 'Provider server error', outcome='unknown' if writing else 'none')
|
||||
if not 200 <= status < 300:
|
||||
raise Failure(3 if writing else 6, 'gate' if writing else 'malformed', 'Provider refused request (HTTP %s)' % status)
|
||||
if not raw.strip() and status == 204:
|
||||
if not raw.strip() and (status == 204 or writing):
|
||||
return None
|
||||
try:
|
||||
result = json.loads(raw)
|
||||
@@ -309,7 +314,7 @@ class Client:
|
||||
if not isinstance(value, list):
|
||||
raise Failure(6, 'malformed', 'Provider collection is not a list')
|
||||
result.extend(value)
|
||||
if len(value) < LIMIT:
|
||||
if not value or self.provider == 'github' and len(value) < LIMIT:
|
||||
if len(result) < expected_total:
|
||||
raise Failure(6,'incomplete','Provider returned fewer records than total_count')
|
||||
return result
|
||||
@@ -465,6 +470,7 @@ def protection(client, base):
|
||||
return None, False
|
||||
r = matching[0]
|
||||
client.review_count = r.get('required_approvals',1)
|
||||
client.review_official_required = r.get('enable_approvals_whitelist') is True
|
||||
return (r.get('status_check_contexts') if r.get('enable_status_check') is True else None), False
|
||||
|
||||
|
||||
@@ -482,7 +488,7 @@ def snapshot(client, number, expected):
|
||||
check = checks(statuses, runs, required)
|
||||
comments, threads, integration = review_comments(client, number)
|
||||
issue_comments = client.pages(root + '/issues/' + str(number) + '/comments')
|
||||
review = reviews(comments['reviews'], head, (p.get('user') or {}).get('login'), getattr(client,'review_count',1))
|
||||
review = reviews(comments['reviews'], head, (p.get('user') or {}).get('login'), getattr(client,'review_count',1), client.provider, getattr(client,'review_official_required',False))
|
||||
if getattr(client,'codeowners_required',False) and (integration or {}).get('review_decision') != 'APPROVED':
|
||||
review.update(approved=False,decision='codeowner_approval_unknown',reviewed_sha=None)
|
||||
unresolved = sum(t['isResolved'] is False for t in threads) if client.provider == 'github' else sum(c['resolved'] is False for c in comments['comments']) if all(c['resolved'] is not None for c in comments['comments']) else None
|
||||
@@ -651,7 +657,7 @@ def declared_method(client, p, requested):
|
||||
origin_normal = run([str(HERE.parent/'structure/validate-repo-json.sh'), '--normalize-remote', origin])
|
||||
if normal.returncode or origin_normal.returncode or normal.stdout != origin_normal.stdout:
|
||||
raise Failure(3, 'policy', 'POLICY-REFUSED: target declaration canonical remote differs from origin')
|
||||
args = ['python3', '-I', str(HERE/'merge-policy.py'), str(declaration), '--head', p['head']['ref'], '--base', base['ref']]
|
||||
args = ['python3', '-I', str(HERE/'merge-policy.py'), str(declaration), '--head', p['head']['ref'], '--base', base['ref'], '--head-repo', (p['head'].get('repo') or {}).get('full_name') or '', '--base-repo', client.repo]
|
||||
if requested:
|
||||
args += ['--method', requested]
|
||||
result = run(args)
|
||||
@@ -692,8 +698,12 @@ def merge(client, number, requested, expected):
|
||||
key = 'do' if client.version == '28.2.0' else 'Do'
|
||||
client.http(client.root+'/pulls/'+str(number)+'/merge','POST',{key:method,'head_commit_id':expected})
|
||||
try:
|
||||
after = same_base(same_head(client,number,expected), p)
|
||||
if after.get('merged') is not True or not after.get('merge_commit_sha'):
|
||||
after = same_head(client,number,expected)
|
||||
original_identity, after_identity = base_identity(p), base_identity(after)
|
||||
if original_identity[:1] + original_identity[2:] != after_identity[:1] + after_identity[2:]:
|
||||
raise Failure(75, 'base_moved', 'PR identity changed after merge', outcome='unknown')
|
||||
merge_sha = after.get('merge_commit_sha')
|
||||
if after.get('merged') is not True or not isinstance(merge_sha, str) or not re.fullmatch(r'[0-9a-fA-F]{40}', merge_sha):
|
||||
raise Failure(6,'verification','Merge not proven by PR readback',outcome='unknown')
|
||||
except Failure as e:
|
||||
e.outcome='unknown'
|
||||
|
||||
@@ -28,12 +28,16 @@ def policy(doc):
|
||||
return dict(p, shapes=shapes)
|
||||
|
||||
|
||||
def resolve(doc, head, base, request):
|
||||
def resolve(doc, head, base, request, head_repo=None, base_repo=None):
|
||||
p = policy(doc)
|
||||
if not head or not base or head == base:
|
||||
return 3, {'error':'POLICY-REFUSED: head/base must identify a non-self transition'}
|
||||
trunk, release = doc.get('integration_trunk'), doc.get('release_branch')
|
||||
shape = 'promotion' if head == trunk and base == release and trunk != release else 'absorb' if head == release and base == trunk and trunk != release else 'task'
|
||||
special_pair = trunk != release and ((head == trunk and base == release) or (head == release and base == trunk))
|
||||
if special_pair and not (head_repo and base_repo):
|
||||
return 3, {'error':'POLICY-REFUSED: promotion or absorb requires both repository identities'}
|
||||
same_repo = bool(head_repo and base_repo and head_repo == base_repo)
|
||||
shape = 'promotion' if same_repo and head == trunk and base == release and trunk != release else 'absorb' if same_repo and head == release and base == trunk and trunk != release else 'task'
|
||||
mapped = p['shapes'].get(shape)
|
||||
selected = request or mapped or p['default_method']
|
||||
if selected not in p['allowed_methods'] or mapped and selected != mapped:
|
||||
@@ -52,13 +56,15 @@ def main():
|
||||
parser.add_argument('--head')
|
||||
parser.add_argument('--base')
|
||||
parser.add_argument('--method')
|
||||
parser.add_argument('--head-repo')
|
||||
parser.add_argument('--base-repo')
|
||||
a = parser.parse_args()
|
||||
try:
|
||||
doc = json.loads(pathlib.Path(a.file).read_text())
|
||||
if not isinstance(doc,dict):
|
||||
raise ValueError('declaration must be an object')
|
||||
p = policy(doc)
|
||||
code, value = (0,p) if a.validate else resolve(doc,a.head,a.base,a.method)
|
||||
code, value = (0,p) if a.validate else resolve(doc,a.head,a.base,a.method,a.head_repo,a.base_repo)
|
||||
except (OSError, ValueError, TypeError) as e:
|
||||
code, value = 6, {'error':'VALIDATION_ERROR merge: ' + str(e)}
|
||||
print(json.dumps(value, sort_keys=True))
|
||||
|
||||
@@ -20,19 +20,23 @@ for ((arg_i=1; arg_i<=$#; arg_i++)); do
|
||||
exec python3 -I "$SCRIPT_DIR/forge-api.py" merge "$@"
|
||||
fi
|
||||
done
|
||||
# A declared merge policy also applies to callers that have not adopted --json.
|
||||
declaration_root=$(git rev-parse --show-toplevel 2>/dev/null) || declaration_root=""
|
||||
if [[ -n "$declaration_root" && -f "$declaration_root/.mosaic/repo.json" ]] && python3 -I -c 'import json,sys
|
||||
try:
|
||||
d=json.load(open(sys.argv[1])); sys.exit(0 if isinstance(d,dict) and "merge" in d else 1)
|
||||
except (ValueError,OSError):
|
||||
sys.exit(0)' "$declaration_root/.mosaic/repo.json"; then
|
||||
exec python3 -I "$SCRIPT_DIR/forge-api.py" merge "$@"
|
||||
fi
|
||||
# Preserve the original argument vector if pinned policy selects the adapter.
|
||||
ORIGINAL_ARGS=("$@")
|
||||
MERGE_TEMP_DIRS=()
|
||||
cleanup_merge_temp_dirs() {
|
||||
local path
|
||||
for path in "${MERGE_TEMP_DIRS[@]}"; do
|
||||
[[ -n "$path" ]] && rm -rf -- "$path"
|
||||
done
|
||||
}
|
||||
trap cleanup_merge_temp_dirs EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
# Default values
|
||||
PR_NUMBER=""
|
||||
MERGE_METHOD="squash"
|
||||
METHOD_EXPLICIT=false
|
||||
DELETE_BRANCH=false
|
||||
DRY_RUN=false
|
||||
EXPECT_HEAD=""
|
||||
@@ -83,6 +87,7 @@ while [[ $# -gt 0 ]]; do
|
||||
;;
|
||||
-m|--method)
|
||||
MERGE_METHOD="$2"
|
||||
METHOD_EXPLICIT=true
|
||||
shift 2
|
||||
;;
|
||||
-d|--delete-branch)
|
||||
@@ -142,10 +147,6 @@ if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$MERGE_METHOD" != "squash" ]]; then
|
||||
echo "POLICY-REFUSED: absent merge policy permits squash only." >&2
|
||||
exit 3
|
||||
fi
|
||||
if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
|
||||
exit 1
|
||||
@@ -165,20 +166,33 @@ fi
|
||||
|
||||
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
||||
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
||||
BASE_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefOid") or "").strip())')"
|
||||
if [[ ! "$BASE_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "POLICY-REFUSED: PR base commit SHA is missing or invalid." >&2
|
||||
exit 3
|
||||
fi
|
||||
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
|
||||
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
|
||||
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||
BASE_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("baseRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; d=json.load(sys.stdin); value=d.get("headRepository") or ""; owner=d.get("headRepositoryOwner") or {}; name=value.get("name") or "" if isinstance(value,dict) else ""; login=owner.get("login") or "" if isinstance(owner,dict) else ""; print((value.get("nameWithOwner") or value.get("full_name") or (login+"/"+name if login and name else "")) if isinstance(value,dict) else str(value).strip())')"
|
||||
BASE_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; d=json.load(sys.stdin); value=d.get("baseRepository") or ""; owner=d.get("baseRepositoryOwner") or {}; name=value.get("name") or "" if isinstance(value,dict) else ""; login=owner.get("login") or "" if isinstance(owner,dict) else ""; print((value.get("nameWithOwner") or value.get("full_name") or (login+"/"+name if login and name else "")) if isinstance(value,dict) else str(value).strip())')"
|
||||
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
|
||||
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
|
||||
# T51 WP5b: transition validation against the declaration (spec 4.1/4.2).
|
||||
# Target branches are validated against the declaration, NEVER hardcoded;
|
||||
# the legacy main/next check survives only for undeclared repos during the
|
||||
# rollout window (4.3 irreversible class, loud warning).
|
||||
# A missing or unreadable declaration at the observed base keeps legacy behavior.
|
||||
# Never load merge policy or transition data from the caller's working tree.
|
||||
pinned_decl=""
|
||||
pinned_dir=$(mktemp -d "${TMPDIR:-/tmp}/mosaic-merge-policy.XXXXXX")
|
||||
MERGE_TEMP_DIRS+=("$pinned_dir")
|
||||
if git show "$BASE_SHA:.mosaic/repo.json" > "$pinned_dir/repo.json" 2>/dev/null; then
|
||||
pinned_decl="$pinned_dir/repo.json"
|
||||
fi
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
|
||||
if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then
|
||||
source "$SCRIPT_DIR/repo-decl.sh"
|
||||
repo_decl_load
|
||||
repo_decl_load "$pinned_decl"
|
||||
else
|
||||
DECL_STATE=absent; DECL_SCHEMA=""
|
||||
# Compatibility callbacks are invoked by declaration checks below.
|
||||
@@ -191,6 +205,35 @@ else
|
||||
repo_decl_remote_matches() { return 0; }
|
||||
repo_decl_check_transition() { return 2; }
|
||||
fi
|
||||
# A base declaration with a merge key governs both paths. Validate it before
|
||||
# allowing legacy squash; non-squash requests go through the normalized adapter.
|
||||
has_merge_policy=false
|
||||
if [[ -n "${pinned_decl:-}" ]] && python3 -I -c 'import json,sys
|
||||
try:
|
||||
d=json.load(open(sys.argv[1])); sys.exit(0 if isinstance(d,dict) and "merge" in d else 1)
|
||||
except (ValueError,OSError): sys.exit(1)' "$pinned_decl"; then
|
||||
has_merge_policy=true
|
||||
fi
|
||||
if [[ "$has_merge_policy" == true ]]; then
|
||||
if [[ "$DECL_STATE" != valid ]]; then
|
||||
echo "POLICY-REFUSED: invalid pinned base merge declaration." >&2
|
||||
exit 3
|
||||
fi
|
||||
if [[ "$MERGE_METHOD" != squash ]]; then
|
||||
cleanup_merge_temp_dirs
|
||||
exec python3 -I "$SCRIPT_DIR/forge-api.py" merge "${ORIGINAL_ARGS[@]}"
|
||||
fi
|
||||
policy_args=(--head-repo "$HEAD_REPO" --base-repo "$BASE_REPO")
|
||||
[[ "$METHOD_EXPLICIT" == true ]] && policy_args+=(--method squash)
|
||||
if ! policy_result=$(repo_decl_merge_method "$HEAD_BRANCH" "$BASE_BRANCH" "${policy_args[@]}") ||
|
||||
! python3 -I -c 'import json,sys; sys.exit(0 if json.loads(sys.argv[1]).get("method") == "squash" else 1)' "$policy_result"; then
|
||||
echo "POLICY-REFUSED: pinned base merge policy does not select legacy squash." >&2
|
||||
exit 3
|
||||
fi
|
||||
elif [[ "$MERGE_METHOD" != squash ]]; then
|
||||
echo "POLICY-REFUSED: absent merge policy permits squash only." >&2
|
||||
exit 3
|
||||
fi
|
||||
if [[ "$DECL_STATE" == invalid ]]; then
|
||||
repo_decl_report_invalid
|
||||
fi
|
||||
@@ -279,7 +322,6 @@ else:
|
||||
|
||||
LAST_GITEA_HTTP_CODE="000"
|
||||
LAST_GITEA_ERROR=""
|
||||
MERGE_TEMP_DIRS=()
|
||||
GITEA_CURL_MAX_BYTES="${MOSAIC_GITEA_CURL_MAX_BYTES:-1048576}"
|
||||
GITEA_CURL_MAX_TIME="${MOSAIC_GITEA_CURL_MAX_TIME_SEC:-30}"
|
||||
GITEA_CURL_CONNECT_TIMEOUT="${MOSAIC_GITEA_CURL_CONNECT_TIMEOUT_SEC:-10}"
|
||||
@@ -323,18 +365,6 @@ print(ascii(message))
|
||||
PY
|
||||
}
|
||||
|
||||
# EXIT invokes this cleanup callback.
|
||||
# shellcheck disable=SC2317
|
||||
cleanup_merge_temp_dirs() {
|
||||
local path
|
||||
for path in "${MERGE_TEMP_DIRS[@]}"; do
|
||||
[[ -n "$path" ]] && rm -rf -- "$path"
|
||||
done
|
||||
}
|
||||
trap cleanup_merge_temp_dirs EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
fetch_gitea_pr_head() {
|
||||
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
|
||||
local response_file raw_code api_url auth_config curl_rc
|
||||
|
||||
@@ -111,7 +111,28 @@ PY
|
||||
detect_platform > /dev/null
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,headRepositoryOwner,baseRefName,baseRefOid,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||
# gh pr view does not expose the base repository. Bind both repositories,
|
||||
# refs and SHAs to the same pull-request REST observation.
|
||||
OWNER=$(get_repo_owner)
|
||||
REPO=$(get_repo_name)
|
||||
HOST=$(get_remote_host)
|
||||
RAW=$(gh api --hostname "$HOST" "/repos/$OWNER/$REPO/pulls/$PR_NUMBER")
|
||||
METADATA=$(PR_VIEW_JSON="$METADATA" PR_REST_JSON="$RAW" python3 -I - "$PR_NUMBER" <<'PY_GITHUB'
|
||||
import json, os, sys
|
||||
view = json.loads(os.environ['PR_VIEW_JSON'])
|
||||
pr = json.loads(os.environ['PR_REST_JSON'])
|
||||
if not isinstance(view, dict) or not isinstance(pr, dict) or pr.get('number') != int(sys.argv[1]):
|
||||
raise SystemExit('Error: GitHub PR metadata identity mismatch')
|
||||
for side in ('head', 'base'):
|
||||
item = pr.get(side) or {}
|
||||
repo = item.get('repo') or {}
|
||||
view[side + 'RefName'] = item.get('ref') or ''
|
||||
view[side + 'RefOid'] = item.get('sha') or ''
|
||||
view[side + 'Repository'] = repo.get('full_name') or ''
|
||||
print(json.dumps(view))
|
||||
PY_GITHUB
|
||||
)
|
||||
write_metadata "$METADATA"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
OWNER=$(get_repo_owner)
|
||||
@@ -235,6 +256,7 @@ normalized = {
|
||||
'headRefOid': head_sha,
|
||||
'headRepository': head_repo,
|
||||
'baseRefName': base_ref,
|
||||
'baseRefOid': nested(data, 'base', 'sha') or '',
|
||||
'baseRepository': base_repo,
|
||||
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
|
||||
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
|
||||
|
||||
@@ -48,11 +48,15 @@ repo_decl_load() {
|
||||
DECL_ORIGIN_N=""
|
||||
|
||||
local root
|
||||
root="$(git rev-parse --show-toplevel 2>/dev/null)" || {
|
||||
repo_decl_warn "no git repository — declaration consumption skipped"
|
||||
return 0
|
||||
}
|
||||
DECL_FILE="$root/.mosaic/repo.json"
|
||||
if [ "$#" -gt 0 ]; then
|
||||
DECL_FILE="$1"
|
||||
else
|
||||
root="$(git rev-parse --show-toplevel 2>/dev/null)" || {
|
||||
repo_decl_warn "no git repository — declaration consumption skipped"
|
||||
return 0
|
||||
}
|
||||
DECL_FILE="$root/.mosaic/repo.json"
|
||||
fi
|
||||
[ -f "$DECL_FILE" ] || return 0
|
||||
|
||||
if [ ! -x "$REPO_DECL_VALIDATOR" ] && [ ! -f "$REPO_DECL_VALIDATOR" ]; then
|
||||
@@ -179,7 +183,9 @@ repo_decl_check_transition() { # $1 head, $2 base
|
||||
trunk-release)
|
||||
if [ "$head" = "$DECL_RELEASE" ] && [ "$base" = "$DECL_TRUNK" ]; then
|
||||
# Absorb is opt-in through a declared shape mapping, never a CLI exception.
|
||||
if python3 -I "$REPO_DECL_SCRIPT_DIR/merge-policy.py" "$DECL_FILE" --head "$head" --base "$base" >/dev/null; then
|
||||
local merge_policy
|
||||
if merge_policy=$(python3 -I "$REPO_DECL_SCRIPT_DIR/merge-policy.py" "$DECL_FILE" --validate) &&
|
||||
python3 -I -c 'import json,sys; sys.exit(0 if json.loads(sys.argv[1]).get("shapes", {}).get("absorb") else 1)' "$merge_policy"; then
|
||||
return 0
|
||||
fi
|
||||
repo_decl_warn "absorb transition requires explicit merge policy"
|
||||
|
||||
@@ -74,6 +74,8 @@ class CLI(unittest.TestCase):
|
||||
if '?' not in path and path!='graphql' and not path.startswith('/swagger.v1.json'):
|
||||
pagination='per_page' if github else 'limit'
|
||||
expected[method+' '+url+'?'+pagination+'=100&page=1']=entry
|
||||
if not github and isinstance(response.get('data'),list):
|
||||
expected[method+' '+url+'?limit=100&page=2']={'path':path,'response':{'data':[]}}
|
||||
(self.root/'responses.json').write_text(json.dumps(expected))
|
||||
|
||||
def run_wrapper(self,name,*args,host='github.com'):
|
||||
@@ -271,6 +273,11 @@ class Redirects(unittest.TestCase):
|
||||
if self.headers.get('Content-Length'): self.rfile.read(int(self.headers['Content-Length']))
|
||||
route=(self.command,urlsplit(self.path).path)
|
||||
status,headers,data=(200,[],{'ok':True}) if trap else owner.routes.get(route,(418,[],{'unexpected':True}))
|
||||
query=urlsplit(self.path).query
|
||||
if route[1] in ('/relative-statuses','/moved-statuses') or route[1].endswith('/statuses'):
|
||||
if query and status in (301,302,303,307,308):
|
||||
headers=[(k,v+'?'+query if k.lower()=='location' and '?' not in v else v) for k,v in headers]
|
||||
if query and 'page=2' in query and isinstance(data,list): data=[]
|
||||
if isinstance(data,bytes):
|
||||
self.connection.sendall(data);self.close_connection=True;return
|
||||
self.send_response(status)
|
||||
@@ -448,7 +455,7 @@ class Redirects(unittest.TestCase):
|
||||
p=subprocess.run(['bash','-x',str(HERE/'pr-ci-wait.sh'),'--host',self.host,'--repo','owner/repo','--json','-n','1','-t','1','-i','1','--expect-head',self.head],cwd=self.root,env=self.env,capture_output=True,text=True,timeout=20)
|
||||
self.assertEqual(p.returncode,0,p.stderr);data=json.loads(p.stdout)
|
||||
self.assertEqual(data['head_sha'],self.head);self.assertEqual(data['data']['head_repository'],'fork/repo');self.assertEqual(data['data']['checks']['state'],'success')
|
||||
self.assertTrue(any(c['path'].startswith(key[1]) for c in self.calls));self.assertTrue(any(c['path']=='/moved-statuses' for c in self.calls))
|
||||
self.assertTrue(any(c['path'].startswith(key[1]) for c in self.calls));self.assertTrue(any(c['path'].split('?',1)[0]=='/moved-statuses' for c in self.calls))
|
||||
self.assertEqual(self.trap_calls,[])
|
||||
self.assertTrue(all(c['authenticated'] for c in self.calls if not c['path'].startswith('/swagger.v1.json')))
|
||||
self.assertNotIn('fixture-token',p.stdout+p.stderr)
|
||||
|
||||
@@ -63,6 +63,103 @@ class Contract(unittest.TestCase):
|
||||
self.assertFalse(self.api.reviews(reviews, 'new', 'author')['approved'])
|
||||
|
||||
|
||||
def test_gitea_non_decisive_and_dismissed_reviews(self):
|
||||
from unittest.mock import patch
|
||||
fixture = SnapshotContract('test_ready_needs_all_evidence')
|
||||
fixture.api = self.api
|
||||
c = fixture.fake()
|
||||
c.provider, c.version = 'gitea', '28.2.0'
|
||||
def check(rows, official_required=False):
|
||||
c.review_official_required = official_required
|
||||
data = {'complete':True, 'reviews':rows, 'comments':[]}
|
||||
with patch.object(self.api,'protection',return_value=(['build'],False)), patch.object(self.api,'review_comments',return_value=(data,[],None)):
|
||||
return self.api.snapshot(c,1,None)['reviews']
|
||||
for event in ('COMMENT', 'REQUEST_REVIEW', 'PENDING'):
|
||||
with self.subTest(event=event):
|
||||
rows = [dict(id=1, state='REQUEST_CHANGES', user={'login':'bob'}, commit_id='a'*40),
|
||||
dict(id=2, state=event, user={'login':'bob'}, commit_id='a'*40),
|
||||
dict(id=3, state='APPROVED', user={'login':'alice'}, commit_id='a'*40)]
|
||||
self.assertEqual(check(rows)['decision'], 'changes_requested')
|
||||
rows = [dict(id=1, state='APPROVED', user={'login':'alice'}, commit_id='a'*40, dismissed=True)]
|
||||
self.assertFalse(check(rows)['approved'])
|
||||
rows[0]['dismissed'] = False
|
||||
rows[0]['official'] = False
|
||||
self.assertFalse(check(rows,True)['approved'])
|
||||
rows[0]['official'] = True
|
||||
self.assertTrue(check(rows,True)['approved'])
|
||||
|
||||
def test_github_comment_does_not_clear_changes_requested(self):
|
||||
rows=[dict(id=1,state='CHANGES_REQUESTED',user={'login':'bob'},commit_id='a'),
|
||||
dict(id=2,state='COMMENTED',user={'login':'bob'},commit_id='a'),
|
||||
dict(id=3,state='APPROVED',user={'login':'alice'},commit_id='a')]
|
||||
self.assertEqual(self.api.reviews(rows,'a','author',provider='github')['decision'],'changes_requested')
|
||||
|
||||
def test_gitea_capped_pages_include_late_rejection(self):
|
||||
import urllib.parse
|
||||
c = self.api.Client('git.example.test', 'owner/repo'); c.provider = 'gitea'
|
||||
rows = [dict(id=i, state='APPROVED', user={'login':'alice'}, commit_id='a') for i in range(1, 120)]
|
||||
rows.append(dict(id=120, state='REQUEST_CHANGES', user={'login':'bob'}, commit_id='a'))
|
||||
def http(path):
|
||||
page = int(urllib.parse.parse_qs(urllib.parse.urlsplit(path).query)['page'][0])
|
||||
return rows[(page-1)*50:page*50]
|
||||
c.http = http
|
||||
result = c.pages('/reviews')
|
||||
self.assertEqual(len(result), 120)
|
||||
self.assertEqual(self.api.reviews(result, 'a', 'author')['decision'], 'changes_requested')
|
||||
|
||||
def test_gitea_empty_write_and_post_merge_moved_base(self):
|
||||
import os, types
|
||||
from unittest.mock import patch
|
||||
c = self.api.Client('git.example.test', 'owner/repo'); c.provider='gitea'; c.version='28.2.0'
|
||||
c.token='synthetic'; c.actor_verified=True
|
||||
c.transport=lambda *args: types.SimpleNamespace(returncode=0,stdout=b'HTTP/1.1 200 OK\r\n\r\n')
|
||||
original = {'head':{'sha':'a'*40,'ref':'topic','repo':{'full_name':'owner/repo'}}, 'base':{'sha':'b'*40,'ref':'main','repo':{'full_name':'owner/repo'}}}
|
||||
after = {'head':dict(original['head']), 'base':dict(original['base'],sha='d'*40), 'merged':True, 'merge_commit_sha':'d'*40}
|
||||
reads=iter([original, original, after])
|
||||
c.pr=lambda number: next(reads)
|
||||
status=dict(base_ref='main',base_sha='b'*40,base_repository='owner/repo',head_ref='topic',head_sha='a'*40,head_repository='owner/repo',ready=True,blockers=[])
|
||||
with patch.dict(os.environ,MOSAIC_GIT_IDENTITY='fixture'), patch.object(self.api,'declared_method',return_value='squash'), patch.object(self.api,'forge_method'), patch.object(self.api,'snapshot',return_value=status), patch.object(self.api,'queue_guard'):
|
||||
self.assertEqual(self.api.merge(c,1,None,'a'*40)['merge_sha'],'d'*40)
|
||||
|
||||
def test_gitea_post_merge_base_tip_can_move(self):
|
||||
from unittest.mock import patch
|
||||
c=self.api.Client('git.example.test','owner/repo'); c.provider='gitea'; c.version='28.2.0'
|
||||
original={'head':{'sha':'a'*40,'ref':'topic','repo':{'full_name':'owner/repo'}}, 'base':{'sha':'b'*40,'ref':'main','repo':{'full_name':'owner/repo'}}}
|
||||
after={'head':dict(original['head']),'base':dict(original['base'],sha='d'*40),'merged':True,'merge_commit_sha':'d'*40}
|
||||
reads=iter([original,original,after]); c.pr=lambda number:next(reads)
|
||||
c.http=lambda *args,**kwargs: None
|
||||
status=dict(base_ref='main',base_sha='b'*40,base_repository='owner/repo',head_ref='topic',head_sha='a'*40,head_repository='owner/repo',ready=True,blockers=[])
|
||||
with patch.object(self.api,'declared_method',return_value='squash'), patch.object(self.api,'forge_method'), patch.object(self.api,'snapshot',return_value=status), patch.object(self.api,'queue_guard'):
|
||||
self.assertEqual(self.api.merge(c,1,None,'a'*40)['merge_sha'],'d'*40)
|
||||
|
||||
def test_gitea_protection_whitelist_requires_official_approval(self):
|
||||
c = self.api.Client('git.example.test','owner/repo'); c.provider='gitea'
|
||||
c.pages = lambda path: [dict(branch_name='main', required_approvals=1, enable_status_check=True, status_check_contexts=['build'], enable_approvals_whitelist=True)]
|
||||
self.api.protection(c,'main')
|
||||
rows=[dict(id=1,state='APPROVED',user={'login':'alice'},commit_id='a',official=False)]
|
||||
self.assertFalse(self.api.reviews(rows,'a','author',1,c.provider,c.review_official_required)['approved'])
|
||||
|
||||
def test_github_merge_json_and_invalid_readbacks(self):
|
||||
import copy
|
||||
from unittest.mock import patch
|
||||
original={'head':{'sha':'a'*40,'ref':'topic','repo':{'full_name':'owner/repo'}}, 'base':{'sha':'b'*40,'ref':'main','repo':{'full_name':'owner/repo'}}}
|
||||
status=dict(base_ref='main',base_sha='b'*40,base_repository='owner/repo',head_ref='topic',head_sha='a'*40,head_repository='owner/repo',ready=True,blockers=[])
|
||||
for mutation in ('valid','bad_sha','bad_sha_numeric','retarget'):
|
||||
with self.subTest(mutation=mutation):
|
||||
c=self.api.Client('github.com','owner/repo'); c.provider='github'; c.version='documented'
|
||||
after=copy.deepcopy(original); after.update(merged=True,merge_commit_sha='d'*40)
|
||||
if mutation=='bad_sha': after['merge_commit_sha']='bad'
|
||||
if mutation=='bad_sha_numeric': after['merge_commit_sha']=123
|
||||
if mutation=='retarget': after['base']['ref']='other'
|
||||
reads=iter([original,original,after]); c.pr=lambda number:next(reads)
|
||||
c.http=lambda path,method='GET',body=None: {'merged':True,'sha':'d'*40}
|
||||
with patch.object(self.api,'declared_method',return_value='squash'), patch.object(self.api,'forge_method'), patch.object(self.api,'snapshot',return_value=status), patch.object(self.api,'queue_guard'):
|
||||
if mutation=='valid': self.assertEqual(self.api.merge(c,1,None,'a'*40)['merge_sha'],'d'*40)
|
||||
else:
|
||||
with self.assertRaises(self.api.Failure) as caught: self.api.merge(c,1,None,'a'*40)
|
||||
self.assertEqual(caught.exception.outcome,'unknown')
|
||||
|
||||
|
||||
class SnapshotContract(Contract):
|
||||
def fake(self, race=False, missing=False):
|
||||
api = self.api
|
||||
|
||||
@@ -14,7 +14,7 @@ class Policy(unittest.TestCase):
|
||||
if policy is not None:
|
||||
doc['merge'] = policy
|
||||
path.write_text(json.dumps(doc))
|
||||
args = ['python3','-I',str(HERE/'merge-policy.py'),str(path),'--head',head,'--base',base]
|
||||
args = ['python3','-I',str(HERE/'merge-policy.py'),str(path),'--head',head,'--base',base,'--head-repo','owner/repo','--base-repo','owner/repo']
|
||||
if method:
|
||||
args += ['--method',method]
|
||||
p = subprocess.run(args,capture_output=True,text=True)
|
||||
@@ -26,6 +26,157 @@ class Policy(unittest.TestCase):
|
||||
policy = dict(allowed_methods=['squash','merge'],default_method='squash',shapes={'absorb':'merge'})
|
||||
self.assertEqual(self.resolve(policy,head='stable')[1].get('method'),'merge')
|
||||
self.assertEqual(self.resolve(policy,method='squash',head='stable')[0],3)
|
||||
def test_fork_branch_named_trunk_cannot_promote(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=pathlib.Path(directory)/'repo.json'
|
||||
path.write_text(json.dumps(dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release')))
|
||||
args=['python3','-I',str(HERE/'merge-policy.py'),str(path),'--head','next','--base','main','--head-repo','alice/stack','--base-repo','owner/stack']
|
||||
result=subprocess.run(args,capture_output=True,text=True)
|
||||
self.assertEqual(result.returncode,3,result.stdout+result.stderr)
|
||||
args[-3]='owner/stack'
|
||||
result=subprocess.run(args,capture_output=True,text=True)
|
||||
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
|
||||
|
||||
def test_fork_release_branch_is_task_not_absorb(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=pathlib.Path(directory)/'repo.json'
|
||||
path.write_text(json.dumps(dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release',merge=dict(allowed_methods=['merge','squash'],default_method='squash',shapes={'absorb':'merge'}))))
|
||||
args=['python3','-I',str(HERE/'merge-policy.py'),str(path),'--head','main','--base','next','--head-repo','alice/stack','--base-repo','owner/stack']
|
||||
result=subprocess.run(args,capture_output=True,text=True)
|
||||
self.assertEqual(result.returncode,0,result.stderr)
|
||||
self.assertEqual(json.loads(result.stdout)['shape'],'task')
|
||||
self.assertEqual(json.loads(result.stdout)['method'],'squash')
|
||||
|
||||
def test_adapter_rejects_fork_promotion(self):
|
||||
import os, importlib.util
|
||||
spec=importlib.util.spec_from_file_location('fork_policy',HERE/'forge-api.py')
|
||||
api=importlib.util.module_from_spec(spec); spec.loader.exec_module(api)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root=pathlib.Path(directory); (root/'.mosaic').mkdir()
|
||||
def git(*args):
|
||||
return subprocess.run(['git',*args],cwd=root,capture_output=True,text=True,check=True).stdout.strip()
|
||||
git('init','-q'); git('remote','add','origin','https://git.example.test/owner/stack.git')
|
||||
doc=dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release',canonical_remote='https://git.example.test/owner/stack',canonical_clone='host:/src/project')
|
||||
(root/'.mosaic/repo.json').write_text(json.dumps(doc)); git('add','.mosaic/repo.json')
|
||||
git('-c','user.name=Fixture','-c','[email protected]','commit','-qm','policy')
|
||||
p={'head':{'ref':'next','repo':{'full_name':'alice/stack'}},'base':{'ref':'main','sha':git('rev-parse','HEAD'),'repo':{'full_name':'owner/stack'}}}
|
||||
c=api.Client('git.example.test','owner/stack'); old=os.getcwd()
|
||||
try:
|
||||
os.chdir(root)
|
||||
with self.assertRaises(api.Failure) as caught: api.declared_method(c,p,None)
|
||||
self.assertEqual(caught.exception.code,3)
|
||||
p['head']['repo']['full_name']='owner/stack'
|
||||
self.assertEqual(api.declared_method(c,p,None),'squash')
|
||||
finally: os.chdir(old)
|
||||
|
||||
def test_branch_precheck_still_requires_absorb_mapping(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=pathlib.Path(directory)/'repo.json'
|
||||
doc=dict(schema_version=3,integration_trunk='integration',release_branch='stable',flow='trunk-release')
|
||||
def check():
|
||||
return subprocess.run(['bash','-c','source "$1/repo-decl.sh"; DECL_STATE=valid; DECL_FLOW=trunk-release; DECL_TRUNK=integration; DECL_RELEASE=stable; DECL_FILE="$2"; repo_decl_check_transition stable integration','fixture',str(HERE),str(path)],capture_output=True,text=True)
|
||||
path.write_text(json.dumps(doc))
|
||||
self.assertNotEqual(check().returncode,0)
|
||||
doc['merge']=dict(allowed_methods=['merge'],default_method='merge',shapes={'absorb':'merge'})
|
||||
path.write_text(json.dumps(doc))
|
||||
self.assertEqual(check().returncode,0)
|
||||
|
||||
def test_missing_repository_identity_refuses_promotion_and_absorb(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=pathlib.Path(directory)/'repo.json'
|
||||
doc=dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release',merge=dict(allowed_methods=['merge'],default_method='merge',shapes={'absorb':'merge'}))
|
||||
path.write_text(json.dumps(doc))
|
||||
for head,base in [('next','main'),('main','next')]:
|
||||
for head_repo,base_repo in [('', 'owner/repo'),('owner/repo',''),('','')]:
|
||||
with self.subTest(head=head,head_repo=head_repo,base_repo=base_repo):
|
||||
args=['python3','-I',str(HERE/'merge-policy.py'),str(path),'--head',head,'--base',base,'--head-repo',head_repo,'--base-repo',base_repo]
|
||||
result=subprocess.run(args,capture_output=True,text=True)
|
||||
self.assertEqual(result.returncode,3,result.stdout+result.stderr)
|
||||
|
||||
def test_legacy_wrapper_uses_pinned_base_not_worktree_merge_policy(self):
|
||||
import os, shutil
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root=pathlib.Path(directory); repo=root/'repo'; repo.mkdir()
|
||||
git_tools=root/'tools/git'; git_tools.mkdir(parents=True)
|
||||
structure=root/'tools/structure'; structure.mkdir()
|
||||
for name in ['pr-merge.sh','repo-decl.sh','merge-policy.py']:
|
||||
shutil.copy(HERE/name,git_tools/name)
|
||||
shutil.copy(HERE.parent/'structure/validate-repo-json.sh',structure/'validate-repo-json.sh')
|
||||
(git_tools/'detect-platform.sh').write_text('detect_platform() { echo github; }\nget_repo_owner() { echo owner; }\nget_repo_name() { echo repo; }\n')
|
||||
def git(*args):
|
||||
return subprocess.run(['git',*args],cwd=repo,capture_output=True,text=True,check=True).stdout.strip()
|
||||
git('init','-q'); git('remote','add','origin','https://github.com/owner/repo')
|
||||
(repo/'.mosaic').mkdir()
|
||||
doc=dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release',canonical_remote='https://github.com/owner/repo',canonical_clone='host:/src/repo',merge=dict(allowed_methods=['merge'],default_method='merge'))
|
||||
path=repo/'.mosaic/repo.json'; path.write_text(json.dumps(doc)); git('add','.mosaic/repo.json')
|
||||
git('-c','user.name=Fixture','-c','[email protected]','commit','-qm','base policy')
|
||||
base=git('rev-parse','HEAD'); del doc['merge']; path.write_text(json.dumps(doc))
|
||||
metadata=dict(baseRefName='next',baseRefOid=base,baseRepository={'name':'repo'},baseRepositoryOwner={'login':'owner'},headRefName='task',headRefOid='a'*40,headRepository={'name':'repo'},headRepositoryOwner={'login':'owner'})
|
||||
script=git_tools/'pr-metadata.sh'; script.write_text("#!/bin/sh\nprintf '%s\\n' "+repr(json.dumps(metadata))+"\n"); script.chmod(0o755)
|
||||
env=dict(os.environ,HOME=str(root),GIT_CONFIG_GLOBAL='/dev/null',GIT_CONFIG_NOSYSTEM='1')
|
||||
result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True)
|
||||
self.assertEqual(result.returncode,3,result.stdout+result.stderr)
|
||||
self.assertIn('POLICY-REFUSED',result.stderr)
|
||||
# A missing declaration helper must not bypass a base merge policy.
|
||||
(git_tools/'repo-decl.sh').unlink()
|
||||
result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True)
|
||||
self.assertNotEqual(result.returncode,0,result.stdout+result.stderr)
|
||||
shutil.copy(HERE/'repo-decl.sh',git_tools/'repo-decl.sh')
|
||||
# An uncommitted local merge policy cannot override a base without one.
|
||||
git('add','.mosaic/repo.json')
|
||||
git('-c','user.name=Fixture','-c','[email protected]','commit','-qm','base without merge policy')
|
||||
metadata['baseRefOid']=git('rev-parse','HEAD')
|
||||
doc['merge']=dict(allowed_methods=['merge'],default_method='merge')
|
||||
path.write_text(json.dumps(doc))
|
||||
script.write_text("#!/bin/sh\nprintf '%s\\n' "+repr(json.dumps(metadata))+"\n")
|
||||
result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True)
|
||||
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
|
||||
self.assertIn('Dry run:',result.stdout)
|
||||
# A merge default cannot silently become squash, while an explicit
|
||||
# squash remains allowed when the base policy permits it.
|
||||
doc['merge']=dict(allowed_methods=['squash','merge'],default_method='merge')
|
||||
path.write_text(json.dumps(doc)); git('add','.mosaic/repo.json')
|
||||
git('-c','user.name=Fixture','-c','[email protected]','commit','-qm','base merge default')
|
||||
metadata['baseRefOid']=git('rev-parse','HEAD')
|
||||
del doc['merge']; path.write_text(json.dumps(doc))
|
||||
script.write_text("#!/bin/sh\nprintf '%s\\n' "+repr(json.dumps(metadata))+"\n")
|
||||
result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True)
|
||||
self.assertNotEqual(result.returncode,0,result.stdout+result.stderr)
|
||||
result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run','-m','squash'],cwd=repo,env=env,capture_output=True,text=True)
|
||||
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
|
||||
# Without an observed base SHA there is no revision to inspect.
|
||||
metadata.pop('baseRefOid')
|
||||
script.write_text("#!/bin/sh\nprintf '%s\\n' "+repr(json.dumps(metadata))+"\n")
|
||||
result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True)
|
||||
self.assertNotEqual(result.returncode,0,result.stdout+result.stderr)
|
||||
|
||||
def test_github_metadata_reads_pr_repository_identities(self):
|
||||
import os, shutil
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root=pathlib.Path(directory); tools=root/'tools'; tools.mkdir()
|
||||
shutil.copy(HERE/'pr-metadata.sh',tools/'pr-metadata.sh')
|
||||
(tools/'detect-platform.sh').write_text('detect_platform() { PLATFORM=github; }\nget_repo_owner() { echo owner; }\nget_repo_name() { echo repo; }\nget_remote_host() { echo github.com; }\n')
|
||||
gh=root/'gh'
|
||||
gh.write_text("""#!/usr/bin/env python3
|
||||
import json,sys
|
||||
args=sys.argv[1:]
|
||||
if args[:2]==['pr','view']:
|
||||
requested=args[args.index('--json')+1].split(',')
|
||||
if 'baseRepository' in requested or 'baseRepositoryOwner' in requested: sys.exit(2)
|
||||
print(json.dumps({'number':1,'baseRefOid':'b'*40}))
|
||||
elif args[0]=='api' and '/repos/owner/repo/pulls/1' in args:
|
||||
print(json.dumps({'number':1,'base':{'sha':'b'*40,'ref':'next','repo':{'full_name':'owner/repo'}},'head':{'sha':'a'*40,'ref':'task','repo':{'full_name':'fork/repo'}}}))
|
||||
else: sys.exit(3)
|
||||
""")
|
||||
gh.chmod(0o755)
|
||||
env=dict(os.environ,PATH=str(root)+os.pathsep+os.environ['PATH'],HOME=str(root))
|
||||
result=subprocess.run(['bash',str(tools/'pr-metadata.sh'),'-n','1'],env=env,capture_output=True,text=True)
|
||||
self.assertEqual(result.returncode,0,result.stderr)
|
||||
data=json.loads(result.stdout)
|
||||
self.assertEqual(data['baseRefOid'],'b'*40)
|
||||
self.assertEqual(data['baseRepository'],'owner/repo')
|
||||
self.assertEqual(data['headRepository'],'fork/repo')
|
||||
|
||||
def test_invalid_does_not_fall_back(self):
|
||||
for policy in [{}, {'allowed_methods':['force_merge'],'default_method':'force_merge'}, {'allowed_methods':['squash'],'default_method':'merge'}, {'allowed_methods':['squash'],'default_method':'squash','unexpected':True}]:
|
||||
self.assertEqual(self.resolve(policy)[0],6)
|
||||
|
||||
@@ -23,7 +23,7 @@ cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
||||
|
||||
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"mosaic-cli-p0","baseRepository":"mosaicstack/stack","headRefName":"mosaic-cli-p2-socket-res","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}'
|
||||
printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"mosaic-cli-p0","baseRepository":"mosaicstack/stack","headRefName":"mosaic-cli-p2-socket-res","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}'
|
||||
SH
|
||||
|
||||
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
|
||||
|
||||
@@ -21,7 +21,7 @@ cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
||||
|
||||
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"next","headRefName":"fix/b1-fork-branch","headRefOid":"fedcba9876543210fedcba9876543210fedcba98","headRepository":"stack-mos-dt-0/stack"}'
|
||||
printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"next","headRefName":"fix/b1-fork-branch","headRefOid":"fedcba9876543210fedcba9876543210fedcba98","headRepository":"stack-mos-dt-0/stack"}'
|
||||
SH
|
||||
|
||||
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
|
||||
|
||||
@@ -93,7 +93,7 @@ if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef01234
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||
|
||||
@@ -21,7 +21,7 @@ make_fixture() {
|
||||
local base_branch="${3:-main}"
|
||||
cat > "$tools/pr-metadata.sh" <<SH
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"$base_branch","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
|
||||
printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"$base_branch","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
|
||||
SH
|
||||
cat > "$tools/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
|
||||
@@ -62,7 +62,7 @@ case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||
single) head_sha=1111111111111111111111111111111111111111 ;;
|
||||
*) echo "unknown commits mode" >&2; exit 2 ;;
|
||||
esac
|
||||
printf '{"number":42,"title":"%s","author":"poster","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha"
|
||||
printf '{"number":42,"title":"%s","author":"poster","baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha"
|
||||
SH
|
||||
|
||||
cat > "$case_dir/ci-queue-wait.sh" <<'SH'
|
||||
|
||||
@@ -19,7 +19,7 @@ cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
||||
|
||||
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/no-ci-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}'
|
||||
printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/no-ci-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}'
|
||||
SH
|
||||
|
||||
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
|
||||
|
||||
@@ -15,7 +15,7 @@ cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
||||
|
||||
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
|
||||
printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
|
||||
SH
|
||||
|
||||
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
|
||||
|
||||
@@ -86,7 +86,7 @@ cat > "$FIXTURE_DIR/gitea-standard.json" <<'JSON'
|
||||
"state": "open",
|
||||
"user": {"login": "edith"},
|
||||
"head": {"ref": "edith/t_39ce717c-authentik-smoke-gate"},
|
||||
"base": {"ref": "main"},
|
||||
"base": {"ref": "main", "sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},
|
||||
"labels": [{"name": "ci"}],
|
||||
"assignees": [{"login": "edith"}],
|
||||
"html_url": "https://git.uscllc.com/USC/uconnect/pulls/1905"
|
||||
@@ -291,6 +291,8 @@ expected_head = sys.argv[2]
|
||||
assert data["number"] == expected_number, data
|
||||
assert data["baseRefName"] == "main", data
|
||||
assert data["headRefName"] == expected_head, data
|
||||
if expected_number == 1905:
|
||||
assert data["baseRefOid"] == "b" * 40, data
|
||||
PY
|
||||
}
|
||||
|
||||
|
||||
@@ -259,9 +259,13 @@ STUB
|
||||
echo "== C. pr-merge transition validation (no hardcoded targets) =="
|
||||
mkmerge() { # $1 dir, $2 base, $3 head -> stubs pr-metadata; runs pr-merge; sets M_RC/M_OUT/M_ERR
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$1/tools/git/pr-merge.sh" 2>/dev/null || true
|
||||
git -C "$1" add -A -- .mosaic
|
||||
git -C "$1" diff --cached --quiet || git -C "$1" commit -q -m 'pinned base declaration'
|
||||
local base_sha
|
||||
base_sha=$(git -C "$1" rev-parse HEAD)
|
||||
cat > "$1/tools/git/pr-metadata.sh" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"$2","headRefName":"$3","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"acme/widgets","title":"t","author":{"login":"a"}}'
|
||||
printf '%s\n' '{"baseRefName":"$2","baseRefOid":"$base_sha","baseRepository":"acme/widgets","headRefName":"$3","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"acme/widgets","title":"t","author":{"login":"a"}}'
|
||||
EOF
|
||||
chmod +x "$1/tools/git/pr-metadata.sh"
|
||||
cat > "$1/tools/git/ci-queue-wait.sh" <<'EOF'
|
||||
@@ -272,7 +276,7 @@ EOF
|
||||
mkdir -p "$1/stub"
|
||||
mkmetadata "$1"
|
||||
M_RC=0
|
||||
M_OUT="$(cd "$1" && env -i PATH="$1/stub:/usr/bin:/bin" HOME="$1/home" GITEA_TOKEN=stub-token \
|
||||
M_OUT="$(cd "$1" && env -i PATH="$1/stub:/usr/bin:/bin" HOME="$1/home" TMPDIR="$SB" GITEA_TOKEN=stub-token \
|
||||
bash "$1/tools/git/pr-merge.sh" -n 7 --dry-run < /dev/null 2>"$1/merr.txt")" || M_RC=$?
|
||||
M_ERR="$(cat "$1/merr.txt")"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user