Compare commits

..
13 Commits
Author SHA1 Message Date
code-infra-01andGPT-6.1-Sol 5cde8cd15b docs(framework): record round-two credential contract and evidence (#1841)
ci/woodpecker/pr/ci Pipeline is running
Resolve R2-C3 by documenting anonymous authentication consistently. Record complete URL, transport-error and wrapper trace controls, correct the earlier S6 coverage claim, and preserve the independent baseline failure.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 16:06:10 -06:00
code-infra-01andGPT-6.1-Sol fc7b9d61b3 fix(framework): keep shell credentials inside private workflows (#1841)
Disable tracing before caller-side token capture and retain privacy through the entire workflow. Use anonymous curl config descriptors, keep request stdin available, and cover all token-consuming wrappers with trace and argv controls. Preserve signal handling and stateful readback fixtures.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 16:03:00 -06:00
code-infra-01andGPT-6.1-Sol bec37b6fdf fix(framework): bind provider URLs and reject curl errors (#1841)
Validate complete public and enterprise API URLs in transport fixtures. Treat nonzero curl exits as transport failures for every provider, preserving unknown mutation outcomes.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 16:01:05 -06:00
code-infra-01andGPT-6.1-Sol a193cf2845 docs(framework): record forge review fixes and verification (#1841)
Map review findings to fix commits and regressions; preserve offline sweep, targeted correction, mutant controls and baseline failures separately. Record deferred inherited literals and unverified live transport behavior.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 15:31:26 -06:00
code-infra-01andGPT-6.1-Sol 8325df7063 test(framework): isolate issue-close actor credentials (#1841)
Replace the inherited shared-token fixture dependency with a synthetic actor and host-bound token in a private fixture home. Keep the comment-before-close negative and positive controls intact.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 15:30:47 -06:00
code-infra-01andGPT-6.1-Sol 02c7f2b183 test(framework): finish explicit credential fixture migration (#1841)
Keep metadata fixture arms offline, bind explicit/default-port fixture credentials and avoid new shell diagnostics in isolated credential arms.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 15:25:38 -06:00
code-infra-01andGPT-6.1-Sol e62adbcc27 test(framework): use independent offline workflow controls (#1841)
Exercise separate T3-wake and notifier wrapper paths against expected verdicts; reject wrong-ready and missing-full-stop mutants. Keep schema fixtures offline, operator patterns external and generated guard fixtures in scratch.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 15:22:25 -06:00
code-infra-01andGPT-6.1-Sol d939601116 fix(framework): bind forge policy and credentials to trusted identities (#1841)
Resolve merge authority from the pinned target commit; reject full PR identity races and malformed conversation state. Require explicit host-bound actors, verify mutation identity and deliver auth through anonymous descriptors. Migrate legacy fixture credentials to explicit actors.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 15:21:39 -06:00
code-infra-01andGPT-6.1-Sol 55d6cc18e3 feat(framework): package provider-neutral PR workflow skills and parity evidence (#1841)
Promote the four attributed workflows and their git/watch skill dependencies. Document capabilities, merge declarations, unsupported profiles, optional notification prerequisites and measured verification limits. Exclude generated Python bytecode from published git tools.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 14:41:45 -06:00
code-infra-01andGPT-6.1-Sol 5d187488d4 fix(framework): verify enterprise review URLs and ambiguous outcomes (#1841)
Preserve provider-qualified conversation ownership on enterprise API prefixes and keep post-write malformed readback outcomes unknown. Make the absence assertion independent of scratch directory names.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 14:36:25 -06:00
code-infra-01andGPT-6.1-Sol 630cc86355 fix(framework): harden forge adapters and preserve SSH compatibility (#1841)
Keep credentials out of client argv, validate snapshot schema and ownership evidence, reject opaque GraphQL writes and isolate suite scratch roots. Retain existing SCP and SSH transport parsing.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 14:31:15 -06:00
code-infra-01andGPT-6.1-Sol f59a4c3483 feat(framework): enforce declared merge methods and verified integration controls (#1841)
Add schema-v3 merge policy with squash-only defaults, protected scheduling, explicit capability failures, normalized CI checks and guarded review mutations. Qualify legacy fake providers with public metadata fixtures.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 14:17:10 -06:00
code-infra-01andGPT-6.1-Sol bbfdacaa38 feat(framework): draft normalized forge status and review adapters (#1841)
Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 13:54:56 -06:00
94 changed files with 4950 additions and 1157 deletions

No files matched your search

@@ -0,0 +1,81 @@
# Forge wrapper review round 1 (#1841)
Reviewed candidate: 55d6cc18e378306146505303c9b73da01164a3a9.
Original base: 924438016e2eb1c2ca352bfbf9107143b8b342bb.
Implementation checkpoint: 8325df706 (new commits, no history rewrite).
The security and code findings are addressed by the following commits and controls.
All provider fixtures use synthetic credentials; no test writes to a live forge.
| Finding | Fix commit | Regression evidence |
| --- | --- | --- |
| S1 | d93960111 | `Policy.test_head_and_uncommitted_policy_cannot_authorize_merge`: a committed head and uncommitted declaration cannot widen the pinned target policy; absent policy remains squash-only; unavailable target objects refuse. |
| S2 | d93960111 | `CLI.test_missing_identity_refuses_before_requests`, `test_mutation_verifies_actor`, `test_enterprise_auth_uses_explicit_host_store`: no inherited provider login authority. |
| S3 | d93960111 | `Auth.test_sigterm_leaves_no_credential_files`: anonymous descriptor delivery, no token in argv, no named credential file after termination. |
| S4 | d93960111 | `ReviewRegressions.test_malformed_integration_is_unknown`: empty, false, string and malformed objects refuse; only null means absence. |
| S5 | d93960111 | `ReviewRegressions.test_null_thread_is_not_resolved`: resolution must be boolean. |
| S6 | d93960111; fixture completion 02c7f2b18 and 8325df706 | `Auth.test_no_identity_cannot_borrow_shared_or_git_config`, `test_host_bound_credential_and_xtrace`, `test_provider_store_separates_seat_and_service`; explicit-actor fixture migration. |
| C1 | d93960111 | `ReviewRegressions.test_same_sha_retarget_snapshot_refused`, `test_retarget_merge_never_writes`: full head/base ref, SHA and repository identity stay pinned across policy, aggregation and pre-write/readback checks. |
| C2 | e62adbcc2 | `Parity.test_independent_paths_match_expected_timeline_verdicts`, `test_profiles_use_same_independent_entry_paths`, `test_ready_and_stop_oracle`, `test_missing_notifier_cannot_arm`: separate wake/status and notifier/predicate/reread paths, recorded calls, independent expected verdicts and no writes. |
| C3 | e62adbcc2, 02c7f2b18 | Offline schema fixtures in `test-detect-platform-outside-repo.sh`, `test-issue-create-body-safety.sh`, `test-pr-metadata-gitea.sh`; unexpected network clients denied in the isolated sweep. |
| C4 | e62adbcc2 | `test-pr-skill-contract.py`: optional operator-local forbidden patterns default to empty; generic placeholder control. |
| C5 | e62adbcc2 | `git diff --check 924438016e2eb1c2ca352bfbf9107143b8b342bb`: exit 0. |
The policy adapter reads the declaration with `git show` from the provider's pinned
base commit in the origin-matched object store. It never consults the PR checkout's
policy or invokes its local declaration loader for mutation authority. The installed
validator still checks the target declaration. Missing target objects fail closed.
GitHub uses the explicit host-bound credential transport rather than inherited CLI
sessions. Every authenticated adapter path requires an actor, and mutation transport
verifies the authenticated account first. The inherited credential resolver no longer
uses shared tokens or git-config identity. Anonymous pipes carry authorization while
stdin remains available for request JSON. Credential tracing is disabled in a subshell.
The independent parity oracle was also run against two scratch-copy mutants. The
unmodified oracle exited 0; replacing ready with waiting exited 1; removing the
full-stop scheduled-integration guard exited 1. The mutation-control driver exited 0
only after asserting those child results. Live T3 and notifier delivery remain unverified.
## Verification
The per-suite results are recorded under `review_round_1` in
[the evidence file](1841-forge-wrapper-evidence.json). Python suites all exit 0:
103 adapter contract cases, 10 CLI cases, 4 authentication cases, 4 guard cases,
6 policy cases, 5 skill cases and 6 parity cases (138 runner cases total).
The declaration validator passes 101 assertions; Ajv schema and negative controls
exit 0. Bash syntax checks pass for all 107 git shell files. Full ShellCheck exits 1
on both the prior candidate (106 files) and the revised tree (107 files), with no
new file/code/message diagnostics. Corrected credential fixtures separately exit 0.
Workspace preflight, typecheck, lint and format checks exit 0. The three package
skill/manifest Vitest suites pass 97 tests. No dependencies were installed during
this review round; the existing system ShellCheck binary was used. Generated
push-guard fixtures now use task scratch storage and clean up instead of leaving
malformed JSON beneath the source tree.
The isolated shell sweep ran all 67 suites: 65 passed, the milestone baseline failed,
and the issue-close fixture still depended on a shared token. Commit 8325df706
converted that final fixture to a private synthetic actor; its isolated rerun and
ShellCheck both exit 0. There are therefore 66 passing suites after the targeted
correction, with the original sweep codes and rerun preserved separately.
The milestone-create usage suite still exits 1: missing title reaches the no-origin
error before the expected usage exit 2. The same isolated suite was run against the
pinned base and exits 1 there. This baseline failure is separate from the fixes.
## Follow-up and limits
S7 is intentionally excluded: inherited deployment-specific host/service literals
remain in `tools/git/detect-platform.sh` and `tools/git/README.md`. The eventual PR
body must list their cleanup under Follow-up, per the review scope decision.
Qualified older native review mutations and unverifiable scheduled-integration
operations remain unsupported as previously documented. No server upgrade or
repository setting change was attempted. Live provider mutation, real integration
scheduling, CI delivery and notifier delivery are unverified. Independent review of
the new head, PR creation and merge remain separate gates.
No additional local fixes were carried in this round. The original carried fix
remains Python import isolation in the shared detection/auth helper; no wholesale
operator-local tool directory was copied.
@@ -0,0 +1,77 @@
# Forge wrapper review round 2 (#1841)
Reviewed candidate: a193cf28458a7fc08f9ff4d6395526c5efd7918c.
Original base: 924438016e2eb1c2ca352bfbf9107143b8b342bb.
## Fixes and regression controls
R2-C1 and R2-S2 are fixed in bec37b6fd. Public GitHub REST and GraphQL use
`https://api.github.com`; enterprise REST uses `/api/v3` and GraphQL uses
`/api/graphql` on the declared host. Credential lookup remains bound to the
logical forge host. The CLI fake accepts only test-declared complete URLs and
rejects unexpected schemes, authorities, paths and queries. Its public and
enterprise snapshot regression requires a ready verdict, not merely equal
results from two paths.
Every nonzero curl exit now refuses before HTTP status or body parsing, including
when stdout contains a complete-looking successful HTTP response. The transport
contract covers exit 28 and 56 for reads and writes on public GitHub, enterprise
GitHub, Gitea and Forgejo; successful controls remain accepted. Failed writes
retain an unknown outcome. The focused URL and curl controls each failed with
exit 1 before the corresponding fix and passed with exit 0 afterward.
R2-S1 extends the earlier S6 fix to the complete shell credential lifetime. The
round-1 direct resolver test did not cover caller-side command-substitution
tracing; its earlier S6 completion claim was too broad. All 13 token-consuming
shell wrappers now execute credential resolution and all consumers in a private
subprocess with xtrace disabled. The caller never owns those token variables,
and tracing is never restored while they exist. The shared request helper sends
an escaped authentication config over an anonymous descriptor, preserves request
stdin, checks curl's exit status and closes the descriptor. Named authentication
config files and raw authentication headers in exec argv have been removed from
these consumers. The existing merge transport continues to use anonymous stdin
config and retains its once-only credential resolution.
`Auth.test_all_legacy_callers_keep_credentials_private_under_xtrace` executes an
authenticated endpoint in each wrapper under `bash -x`, records only safe
observations and checks stderr and argv for exposure. An inventory assertion
requires controls for every shell token consumer. Quote/backslash escaping is
checked independently. Scratch-copy mutants that remove metadata's private
boundary or put its token in curl argv are detected. Existing termination and
identity-refusal controls remain. The edit wrapper's signal test also caught a
subprocess signal-target regression; it now targets its actual Bash process and
preserves termination status without continuing to mutate.
Legacy stateful server fixtures now accept readable anonymous descriptors rather
than requiring regular authentication files. Review readback cleanup and edit
signal tests pass; these continue to assert persisted state, exact identity and
failure behavior. No live provider writes are involved.
R2-C3 updates the README to one current credential contract: private untraced
credential lifetime and anonymous authentication config delivery. Temporary files
are limited to response and policy data in approved scratch storage.
## Verification and limits
The per-command results and finding mapping are recorded in the `review_round_2`
section of [the evidence file](1841-forge-wrapper-evidence.json).
No dependencies were installed. No additional operator-local fixes were copied.
The inherited deployment-specific literals identified as S7 remain deferred to
the eventual PR's Follow-up section. Live provider writes, scheduler behavior,
notifier delivery, terminal-green CI and independent review of this revised head
remain unverified. This round does not create a PR or merge a branch.
The final isolated run contains 75 suites: all seven Python suites pass (146
runner cases), 66 of 67 git shell suites pass, and declaration validation passes.
The shell driver exits 1 solely for the separately reproduced milestone-create
missing-title baseline failure. Schema and negative controls, watch parity and
its mutant controls pass with external network clients denied. Bash syntax
passes all 107 shell files. Full ShellCheck exits 1 for both the prior candidate
and this tree; the final targeted callback annotation removes the last new
file/code/message diagnostic. Existing diagnostics remain.
Workspace preflight, typecheck, lint and format checks exit 0. The three package
skill/manifest Vitest suites pass 97 tests. `git diff --check` against the original
base exits 0. Commit hooks emit existing template configuration warnings, then
exit 0; hooks were not bypassed.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,79 @@
# Forge wrapper gaps (#1841)
Baseline: 924438016e2eb1c2ca352bfbf9107143b8b342bb, fetched integration head.
The implementation keeps provider APIs in wrappers, workflow authority in skills and
notifications in transport adapters. No live provider mutation is part of fixture tests.
Independent candidate review and integration remain separate delivery gates.
## Scope and decisions
- A: versioned capability/status envelope, exact-head checks and watch predicate.
- B: complete review/comment enumeration and verified native replies/resolution.
- C: declared merge methods and separate automatic-integration inspection/control.
- D: direct-write guard coverage, CI-wait mapping, compatibility fixtures and documentation.
- Skills: packaged workflows/dependencies with upstream attribution and transport parity tests.
The optional schema-v3 merge policy was approved during implementation. Absent policy
remains squash-only. Task, promotion and explicit absorb mappings constrain method requests;
provider allow_* settings further restrict them. Invalid/unknown policy never grants authority.
Schema 1/2 stay readable; version-3 consumers retain branch gates.
Gitea/Forgejo scheduled merge state remains unsupported because inspected schemas lack
readback. Native review mutations remain unsupported on the older inspected profiles.
Unknown capabilities and required evidence fail closed. App-bound required GitHub checks
remain unknown pending a source-specific matcher. Live authentication, repository settings,
provider mutations and notifier delivery are unverified.
## Compatibility and safety work
Source is upstream framework content. Local fixes are evaluated individually; Python import
isolation was carried into the shared detection/auth helper. No wholesale local tool copy was
used. Additional fixes bind metadata detection to authority, quote declaration values as shell
words, and execute legacy credential/CI bridges from a safe working directory.
Legacy fixture clients now explicitly supply public API metadata instead of relying on host
name guessing. The no-PyYAML fixture uses an isolated no-site interpreter, because production
-I intentionally ignores PYTHONPATH. The obsolete aggregate-only CI fixture migrated to the
normalized exact-head/empty-check controls. The unknown-version validator fixture moved to
version 4 when version 3 became supported.
## Verification checkpoint
Focused adapter, shell-client, policy, guard, skill and parity tests pass. The declaration suite
also passes its schema-version migration controls. New shell entry points pass ShellCheck.
Preflight, workspace typecheck and lint pass after installing locked dependencies. The package
dry-run includes the new skills, licenses and helper assets.
Final shell sweep ran 66 suites. After the isolated declaration rerun, 65 passed; the
milestone-create missing-title usage case remains exit 1 and reproduces on the pinned base.
Its provider/no-origin error precedes the expected usage exit 2. This unrelated baseline
failure is documented instead of absorbed. An earlier issue-close failure under ambient
host state disappeared in the fully isolated run.
All 68 declaration consumption assertions pass. The unknown-version fixture now uses v4;
the v3 validator and shell-quoting control pass. New shell entry points pass ShellCheck;
changed existing wrappers have no diagnostics beyond the measured baseline. All 106 git
shell files pass bash syntax checks. Package skill/manifest tests pass 97 cases, and the real
catalog discovers all six promoted skills. Publish-list assertions retain source and licenses
and exclude test bytecode, with a two-entry bytecode negative control before the exclusion.
See 1841-forge-wrapper-evidence.json for per-suite exit codes and file mapping. Locked workspace
dependencies were installed for required push checks. ShellCheck was extracted into temporary
scratch without a system install. No services, containers, migration runner, application server,
local framework rollout or provider mutation test was started. Independent review, PR, CI,
integration and release remain pending. A host notifier is an explicit optional prerequisite
outside supported T3 notifications; this change does not install one.
Parity tests replay normalized provider evidence through mocked transports. They do not prove
live T3 support or notifier delivery. A failed required read blocks either workflow; unsupported
schedule inspection blocks full-stop in either transport. Scheduling success is never a merge
completion claim.
## Review round 2
Public/enterprise API routing, transport-error refusal and complete shell
credential privacy are recorded in [round-2 evidence](1841-forge-review-round-2.md).
The round-1 S6 claim is corrected there: resolver tracing alone did not protect
caller-side token assignments. Review fixes remain on the original branch, with
new commits and no history rewrite; PR creation and merge remain held.
@@ -226,11 +226,11 @@ Multi-instance support: `-a <instance>` selects a named instance (e.g. `personal
### Repo Structure Declaration (T51)
```bash
# Validate a .mosaic/repo.json declaration (schema v1/v2, host:/ grammar,
# Validate a .mosaic/repo.json declaration (schema v1/v2/v3, host:/ grammar,
# ref grammar, cross-field rules, remote normalization; spec §5)
~/.config/mosaic/tools/structure/validate-repo-json.sh <repo>/.mosaic/repo.json
# CI authoring rule: new/edited declarations must be schema_version 2
# CI authoring rule: new/edited declarations must be schema_version 2 or 3
~/.config/mosaic/tools/structure/validate-repo-json.sh <repo>/.mosaic/repo.json --require-v2
# Display mode (warns and omits root-dependent checks when MOSAIC_HOST_ROOT unset)
@@ -360,3 +360,7 @@ mosaic-ensure-excalidraw --check # verify registration
- Prefer `trash` over `rm` when available — recoverable beats gone forever
- Never run destructive commands without explicit instruction
- Write it down — "mental notes" don't survive session restarts; files do
Repository declarations also support schema v3 with an optional, strictly validated merge policy.
See [the git wrapper merge-policy contract](../tools/git/README.md#repository-merge-policy-schema-version-3).
Absent policy remains squash-only; command-line method requests do not grant merge authority.
@@ -1,93 +1,8 @@
---
name: mosaic-gitea
description: Interact with Gitea repositories on git.mosaicstack.dev — create/merge/close PRs, manage issues, milestones, and CI queue waits. Use when working with Mosaic Stack git repos. Wraps scripts in ~/.config/mosaic/tools/git/. Works on both Gitea and GitHub (auto-detected via detect-platform.sh).
description: Use when an older workflow requests the Mosaic Gitea wrapper reference.
---
# mosaic-gitea
# Git wrapper compatibility
Git operations via Mosaic wrapper scripts. Platform-aware (Gitea or GitHub).
## Setup
Scripts auto-detect platform from git remote. Run from inside the repo directory.
Credentials come from the framework credentials loader (never from a shared env
file):
```bash
source ~/.config/mosaic/tools/_lib/credentials.sh
load_credentials gitea-mosaicstack
# Exports: GITEA_TOKEN, GITEA_URL
```
## Script Reference
All scripts in `~/.config/mosaic/tools/git/`.
### Pull Requests
| Script | Purpose | Key flags |
| ---------------- | ----------------------- | -------------------------------------------------------- |
| `pr-create.sh` | Create a PR | `-t "Title" [-b "Body"] [-B base] [-H head] [-i issue#]` |
| `pr-list.sh` | List open PRs | `[-s state]` |
| `pr-view.sh` | View PR details | `-n <pr#>` |
| `pr-merge.sh` | Squash-merge a PR | `-n <pr#> [-d]` (delete branch) |
| `pr-close.sh` | Close a PR | `-n <pr#>` |
| `pr-diff.sh` | Show PR diff | `-n <pr#>` |
| `pr-review.sh` | Submit a review | `-n <pr#> [-a approve\|request-changes]` |
| `pr-ci-wait.sh` | Wait for CI on PR | `-n <pr#>` |
| `pr-metadata.sh` | Get PR metadata as JSON | `-n <pr#>` |
### Issues
| Script | Purpose | Key flags |
| ------------------ | --------------- | --------------------------------------------------- |
| `issue-create.sh` | Create an issue | `-t "Title" [-b "Body"] [-l labels] [-m milestone]` |
| `issue-list.sh` | List issues | `[-s state] [-l label]` |
| `issue-view.sh` | View issue | `-n <issue#>` |
| `issue-close.sh` | Close issue | `-n <issue#>` |
| `issue-comment.sh` | Add comment | `-n <issue#> -c "Comment"` |
| `issue-assign.sh` | Assign issue | `-n <issue#> -u username` |
| `issue-edit.sh` | Edit issue | `-n <issue#> [-t title] [-b body]` |
### Milestones
| Script | Purpose | Key flags |
| --------------------- | ---------------- | ---------------------------- |
| `milestone-create.sh` | Create milestone | `-t "Title" [-d "due date"]` |
| `milestone-list.sh` | List milestones | — |
| `milestone-close.sh` | Close milestone | `-n <number>` |
### CI / Queue
| Script | Purpose | Key flags |
| -------------------- | ---------------------- | --------------------------- |
| `ci-queue-wait.sh` | Wait for CI queue slot | `[-t timeout] [-B branch]` |
| `detect-platform.sh` | Detect git platform | outputs `gitea` or `github` |
## Common Workflows
**Create PR from current branch:**
```bash
cd ~/src/<repo>
~/.config/mosaic/tools/git/pr-create.sh -t "feat: my feature" -b "Description" -i <issue#>
```
**Merge a PR (squash, with CI queue guard):**
```bash
cd ~/src/<repo>
~/.config/mosaic/tools/git/pr-merge.sh -n <pr#> -d
```
Branch protection is a gate, not an obstacle: if it blocks a merge, fix the cause —
a failing check, a moved head, or a missing review. Never bypass it with a raw
API call, a shared credential, or `force_merge`. Exceptional cases go to the
operator or the coordinating seat, still merged through the wrapper.
## Notes
- Mosaic policy: squash merges only, targeting `main`
- Gitea SSH: `[email protected]` → resolves via `~/.ssh/config` to the Gitea host's SSH port (e.g. `gitea.example.internal:2222`)
- Workers push branches and open PRs but **never merge** — orchestrator handles merges
Use [ms-git-operations](../ms-git-operations/SKILL.md) for the provider-neutral wrapper contract and capability table. Repository policy and provider capabilities determine behavior. For PR waiting use [ms-babysit-pr](../ms-babysit-pr/SKILL.md). Do not keep separate forge-specific write recipes.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Theo Browne
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,24 @@
---
name: ms-babysit-pr
description: Use when the user asks to monitor, watch or babysit a pull request through review and CI.
---
# Babysit a PR
Follow ms-full-send's proof and authority gates, ms-git-operations and ms-watch. Monitoring grants no merge authority. Full-stop never merges or schedules.
Handle current events before waiting. Read `pr-status.sh -n NUMBER --json`, pin head/base, then enumerate `pr-review-comments.sh -n NUMBER --json` completely. Verify findings against source, including old and resolved/outdated comments. Fix real in-scope defects, prove changes and refresh independent review on the new head. Use `pr-review-reply.sh --comment-id ID --body-file PATH --expect-head SHA --json` for native replies and `pr-review-resolve.sh --thread-id ID --resolve --expect-head SHA --json` for authorized resolution. PR number and repository targeting are required too. Never substitute a PR-level issue comment for a native reply. Unsupported conversation operations and incomplete lists leave explicit blockers.
T3 linking and notifications are optional transports. When available, register the URL with `link_pull_request`, verify it through `list_thread_pull_requests`, and report failures. They never supply readiness truth.
- Delegated children return URL, head SHA and pending evidence to their parent. They establish no competing watches.
- If `watch_pull_request` supports the forge, handle current events, arm one watch and yield. On wake, reread pr-status. A runtime error is not proof of unsupported forge; report it without creating a second owner.
- If T3 watching is absent or explicitly unsupported, use one bounded ms-watch over `pr-watch-check.sh -n NUMBER --baseline ABS_JSON --json`, with explicit host/repo. Store a private baseline envelope per owner/host/repo/PR. The predicate performs one snapshot and never runs pr-ci-wait or another blocking wait.
- Watch terminal success, failure, cancellation, closure, merge and relevant changes. Preserve retry metadata on rate limits. Never convert a read error to unchanged pending.
- No supported notifier means a watch-delivery blocker. Do not claim a shell polling loop is equivalent delivery.
Wake verdicts are `merged`, `closed_unmerged`, `ready_to_merge`, `waiting`, `blocked` or `timed_out`. Failed CI is blocked. Unknown required evidence blocks readiness. Full-stop hands off a ready candidate; full-send may merge only with explicit role authority. Both paths use the same snapshot and exit/error mapping.
Call `unwatch_pull_request` or stop the fallback watch before final handoff. Report URL, head/base/merge SHAs, checks, review verdict and SHA, thread completeness, integration state, blockers, authority and watch ownership. Scheduling success is not a terminal merge result.
Adapted from [t3dotgg/tstack](https://github.com/t3dotgg/tstack/tree/1085050f3baed1ac724caa4a8166145fb2ac88af/skills/babysit-pr) (MIT), revision 1085050f3baed1ac724caa4a8166145fb2ac88af. See [LICENSE](LICENSE).
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Theo Browne
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,19 @@
---
name: ms-council
description: Use when the user asks for a council or an independent panel of reviews on one candidate.
---
# Council
Collect independent reviews of one exact candidate. Follow ms-full-send's identity, proof, storage and repository boundaries. Council grants no merge authority.
1. Pin candidate SHA, scope, acceptance criteria and raw test evidence. Resolve known defects first unless the requested review is about replacing the design.
2. Use the requested membership, subject to available policy and budget. Otherwise choose independent capable reviewers from the live model catalog. Do not hardcode coding/review roles by provider. Report unavailable models and explicit policy fallbacks.
3. In T3, use orchestrator_capabilities for live provider/model IDs and option descriptors. Delegate a separate read-only task per member, with explicit target options and retained taskId/clientRequestId. Elsewhere use supported delegation with equivalent scope. Do not create top-level threads for child reviews. Children neither mutate nor watch the candidate.
4. Give each member the same evidence without other members' findings. Require paths, severity, confidence, reproducible findings, verdict, reviewed SHA and optional score. Scores never replace approval.
5. Reproduce findings, fix accepted defects, explain rejected suggestions and preserve dissent. Changed candidates invalidate previous exact-head approval. Each new T3 round is a fresh delegate_task including original brief, prior findings/responses and unresolved objections, with a new stable round key.
6. Through pr-edit.sh, record reviewed SHA, verdicts, findings, dissent, round count and optional scores while preserving existing PR content. Without a PR return the same evidence to the coordinator. Missing scores remain missing; do not invent them. A co-author is not independent.
Follow the repository's review gates and authority assignments. Budget/access exhaustion leaves an explicit incomplete verdict, never an approval.
Adapted from [t3dotgg/tstack](https://github.com/t3dotgg/tstack/tree/1085050f3baed1ac724caa4a8166145fb2ac88af/skills/council) (MIT), revision 1085050f3baed1ac724caa4a8166145fb2ac88af. See [LICENSE](LICENSE).
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Theo Browne
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,23 @@
---
name: ms-full-send
description: Use when the user explicitly asks for full send or unattended end-to-end delivery.
---
# Full send
Own the requested change through the repository's declared completion gates. Invocation authorizes routine delivery within scope. It never waives independent review, hooks, protected operations or the assigned merge role. Repository instructions and the operator's authority boundary apply.
Use ms-git-operations for every forge action. Inspect existing worktrees and the declared integration branch first. Preserve other authors' work. Use the acting identity's own credential. Keep generated data on operator-approved disk-backed storage and set TMPDIR, TMP and TEMP before tools that create temporary files. Never force-push or bypass hooks.
1. Finish the scoped change and prove its behavior. Record baseline and candidate SHAs, test commands and actual exit codes. Follow the repository's delivery and review guides when present.
2. Obtain independent review on the exact candidate SHA. Select available models according to applicable policy, without hardcoding a provider/model division of labor. A co-author cannot approve the work. Use ms-council when a panel is requested. Changed candidates require fresh review evidence.
3. Run `ci-queue-wait.sh --purpose push` before an ordinary task-branch push. Create or edit the PR through wrappers, with explicit head/base and concise verification evidence.
4. Follow ms-babysit-pr for checks, findings and waiting. Require `pr-status.sh -n NUMBER --expect-head SHA --json` to return a complete, ready snapshot. Enumerate inline findings through `pr-review-comments.sh`; use native reply and resolution wrappers with exact-head pins. Unsupported operations leave explicit missing evidence, never fabricated issue-comment replies.
5. Revalidate the current intended base, independent review SHA, required checks, thread completeness and mergeability immediately before integration. Resolve the declared merge method; default remains squash when no merge policy is declared. `--method` requests a method and cannot override repository policy. Use `pr-merge.sh -n NUMBER --expect-head SHA --json`, within assigned merge authority. Workers return the candidate to the coordinator when the coordinator owns integration.
6. Verify merged SHA and required post-merge CI, then close tracking only when authorized. Merge, deployment and product acceptance are distinct states. Report remaining gates explicitly.
Auto-merge and queue enabling can merge immediately. They require explicit merge authority, declared method resolution and verified server-side protection against stale approvals. They are off by default. Never use scheduling to bypass final-head review or failed gates.
Report URL, head/base/merge SHAs, checks, reviewer verdict and reviewed SHA, thread completeness, auto/queue state, blockers, authority mode and watch ownership. A high review score, scheduling success or an open PR is not delivery completion. Retire any owned watch before final handoff.
Adapted from [t3dotgg/tstack](https://github.com/t3dotgg/tstack/tree/1085050f3baed1ac724caa4a8166145fb2ac88af/skills/full-send) (MIT), revision 1085050f3baed1ac724caa4a8166145fb2ac88af. See [LICENSE](LICENSE).
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Theo Browne
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,18 @@
---
name: ms-full-stop
description: Use when the user says full stop or asks for a reviewed PR ready for them to merge.
---
# Full stop
Follow ms-full-send's preparation, implementation, proof, independent review and ms-babysit-pr workflow. These handoff limits override integration. Never merge, enable auto-merge, enqueue a PR, push the integration/default branch, or delegate any of those actions. Only scoped task-branch commits and ordinary pushes are authorized.
Before editing an existing PR, before every push, and at handoff, inspect `pr-auto-merge.sh -n NUMBER --action inspect --json` and `pr-merge-queue.sh -n NUMBER --action inspect --json`. Establish disabled/absent states from verified readback. An unsupported inspection of a scheduler with raw scheduling support is unknown safety evidence, never proof it is disabled. Unknown, access-denied and unsupported inspection block dependent work identically inside and outside T3. For a new branch without a PR, record that no PR exists and inspect as soon as one is created.
Disable/dequeue only when explicit authority permits cancellation; re-read afterward. Otherwise report the blocker to the operator/coordinator. Inspection and cancellation never authorize enabling. Direct-commit repositories receive a prepared diff and evidence within the declared handoff boundary.
At handoff, fetch the intended base and reread `pr-status.sh -n NUMBER --expect-head SHA --json`. Require independent review on that exact SHA, complete required checks and conversations, open/non-draft state and clean mergeability. Report `ready_to_merge`, `waiting`, `blocked`, `closed_unmerged`, `merged` or `timed_out` from measured evidence. `ready_to_merge` means handoff, never permission to merge. A merge performed by someone else is reported as observed, not performed.
Retire/unwatch before handoff. Include URL or prepared diff, head/base/merge SHAs, checks, reviewer verdict and SHA, thread completeness, auto/queue states, blockers, authority mode and watch ownership. Readiness is not delivery completion.
Adapted from [t3dotgg/tstack](https://github.com/t3dotgg/tstack/tree/1085050f3baed1ac724caa4a8166145fb2ac88af/skills/full-stop) (MIT), revision 1085050f3baed1ac724caa4a8166145fb2ac88af. See [LICENSE](LICENSE).
@@ -0,0 +1,33 @@
---
name: ms-git-operations
description: Use for git-host operations, pull requests, issues, milestones, reviews and CI queue guards.
---
# Git operations
Use installed `tools/git/` wrappers for provider operations. They own API dialects and identity; skills own workflow authority. Never bypass wrappers, hooks, repository protection or exact-head gates. Use the acting identity's own credential without persisting it in shared git config. Missing credentials fail closed. Read wrapper help; do not invent flags.
Authenticated normalized adapters require explicit `MOSAIC_GIT_IDENTITY` and a host-bound token for every provider. Missing identity refuses before requests; mutations verify the authenticated actor. Shared token variables, git config identity and inherited provider logins grant no authority. See the wrapper reference for credential provisioning.
New PR commands accept `-n NUMBER --host HOST --repo OWNER/REPO --json`, or infer an unambiguous origin. Review mutations require `--expect-head FULL_SHA`. Python helper calls use import isolation. Preserve legacy wrapper codes unless using the versioned JSON contract. See [wrapper reference](../../tools/git/README.md) for full schemas and permissions.
| Operation | Wrapper | GitHub | Qualified Gitea/Forgejo |
| ------------------------------ | ----------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------- |
| Capabilities | forge-capabilities.sh | supported | versioned profiles |
| Snapshot / predicate | pr-status.sh / pr-watch-check.sh | supported | partial fields explicit |
| Inline enumeration | pr-review-comments.sh | supported | supported |
| Native reply / resolution | pr-review-reply.sh / pr-review-resolve.sh | supported | Gitea 28.2 supported; older inspected profiles unsupported |
| Merge methods | pr-merge.sh --json | merge, squash, rebase | also rebase-merge and fast-forward-only |
| Auto-merge / queue | pr-auto-merge.sh / pr-merge-queue.sh | conditional on settings and verified protection | unsupported without documented state readback |
| Create / edit / review / close | existing pr wrappers | retained | retained API-compatible dispatch |
| CI guard / wait | ci-queue-wait.sh / pr-ci-wait.sh | retained separate contracts | retained separate contracts |
Envelope v1 contains schema_version, operation, provider, host, repo, number, head_sha, data and error. Exit codes: 0 verified result, 1 unchanged pending predicate, 2 usage, 3 POLICY-REFUSED or gate failure, 4 unsupported method/operation or forge setting, 5 access/auth, 6 malformed/incomplete/unverified, 75 transient/unknown outcome, 124 timeout. Error contains kind, message, retry_after_seconds and write_outcome. A 401/403/404 is not capability evidence. Never blindly retry an ambiguous mutation.
Merge policy is read from the trusted target/base commit pinned by the provider, never from the PR head or uncommitted checkout. Fetch that revision first; missing objects refuse. Head and base ref, SHA and repository must remain unchanged through policy, readiness and the final mutation check.
Merge policy lives in optional schema-v3 repo.json `merge`, validated through repo-decl.sh and its shared validator. allowed*methods is a non-empty subset of squash, merge, rebase, rebase-merge, fast-forward-only; default_method must belong to it. Optional shapes map task, promotion and absorb to allowed methods. No policy means squash only. An explicit method must be allowed and match any shape mapping. Forge allow*\* settings are intersected afterward; never change them. Absorb requires an explicit shape mapping. Task requests cannot invent integration authority.
Inspect auto-merge/queue separately from enabling/cancellation. Enable/enqueue can merge immediately and require explicit merge authority plus server-enforced checks, reviews and stale-approval dismissal. Full-stop never enables. Unknown schedule state blocks full-stop even when raw scheduling endpoints exist.
Use ms-babysit-pr and ms-watch for waiting. The CI queue guard controls push/merge admission, not a provider PR merge queue. Run `ci-queue-wait.sh --purpose push` before ordinary push. Required negative or unknown evidence closes the gate.
@@ -0,0 +1,16 @@
---
name: ms-watch
description: Use before creating a watch or arranging a later check.
---
# Watch
Use an existing owner/notifier first. If one command answers now, run it. Durable recurring work needs a scheduler; this bounded watch is for a wait owned by the current agent.
For PRs use ms-babysit-pr. Prefer a supported T3 watch. Otherwise verify an operator-provisioned notifier such as `tools/agent-watch/agent-watch.sh` with a unique owner-scoped name, absolute condition paths, interval at least ten seconds and a finite timeout. The notifier is an optional runtime prerequisite, not a bundled guarantee. If it is absent or cannot deliver to this agent, report a watch-delivery blocker. Do not create a polling loop in the agent turn.
The condition returns 0 for a relevant event/terminal result, 1 for unchanged pending, and any other code for a broken read. Preserve errors and retry metadata. Never mask errors with grep/test, watch only for green, or call a blocking CI wait inside the predicate.
Use `pr-watch-check.sh -n NUMBER --repo OWNER/REPO --host HOST --baseline ABS_JSON --json`. Initialize the baseline from a successful pr-status envelope in private disk-backed storage. Keep it separate per owner/host/repo/PR. Verify the control case returns 1 before arming. Watch timeout maps to timed_out, provider errors to blocked, and every wake rereads status. A watch notification is not readiness evidence.
Arm exactly one watch, yield, and retire it on final handoff. No competing child watches. Do not retry ambiguous writes, and never use raw API or credential recipes in predicates.
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+193 -1
View File
@@ -21,7 +21,11 @@ A successful provider write command—or a wrapper message based only on that co
## Credential handling
The Gitea API token is **never passed on a curl command line.** An `Authorization: token <value>` argument would be visible to any local process that can read the process table (`ps` / `/proc/<pid>/cmdline`) for the lifetime of the request. Instead, every authenticated curl call writes the header into a private, mode-`0600` config file under `$TMPDIR` and passes it with `curl --config <file>` (`gitea_write_auth_config`), so only the file _path_ — never the token — appears in argv. Each such file is unlinked on every exit path (success and failure) by the caller's `RETURN` trap.
Credential resolution and the complete legacy request workflow run in a private
subprocess with xtrace disabled. Tokens stay out of caller traces and process
arguments. `gitea_curl_with_token` supplies an anonymous config descriptor to curl;
request bodies retain stdin. The normalized adapter uses the same anonymous
transport contract. No named authentication scratch files are created.
## `tea` invocation notes (Gitea)
@@ -200,3 +204,191 @@ Windows' bundled `bash`/`sh` when configured via `credential.helper`, without a
counterpart. A `tea`-login-based per-agent identity for the PowerShell wrappers is a
separate, larger design (mapping identities to `tea login` profiles) and is out of scope
here.
## Versioned PR contract
The provider-neutral entry points below accept `--repo OWNER/REPO --host HOST`, or
infer an unambiguous origin. PR operations require `-n NUMBER`. Use `--host`, not
`-H` (head in older wrappers). `--json` opts existing merge/CI-wait callers into
version 1; new commands always emit one JSON envelope. Diagnostics use stderr.
| Command | Additional arguments |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| forge-capabilities.sh | `--operation status\|review_comments\|review_reply\|review_resolve\|merge\|rebase_merge\|fast_forward_only\|auto_merge\|merge_queue` |
| pr-status.sh | `--expect-head FULL_SHA` optional; one bounded snapshot |
| pr-watch-check.sh | `--baseline ABSOLUTE_ENVELOPE_JSON`; one snapshot, no internal wait |
| pr-review-comments.sh | Complete paginated reviews and inline comments |
| pr-review-reply.sh | `--comment-id OPAQUE_ID --body-file PATH --expect-head FULL_SHA` |
| pr-review-resolve.sh | `--thread-id OPAQUE_ID --resolve\|--unresolve --expect-head FULL_SHA` |
| pr-merge.sh | `--json --expect-head FULL_SHA [-m METHOD]` |
| pr-auto-merge.sh | `--action inspect\|enable\|disable [--method METHOD]`; mutations require expected head |
| pr-merge-queue.sh | `--action inspect\|enqueue\|dequeue [--method METHOD]`; mutations require expected head |
| pr-ci-wait.sh | `[-t SECONDS] [-i SECONDS] [--expect-head FULL_SHA] [--json]` |
Methods are `squash`, `merge`, `rebase`, `rebase-merge`, `fast-forward-only`.
GitHub supports only the first three. Never request force-merge or manually-merged.
Normalized merge requires the local origin/declaration context, even with an explicit
host/repository, and preserves queue, branch, independent-review and exact-head gates.
Legacy squash mode retains its existing flags/output/codes. Explicit non-squash methods
use the verified adapter. Message-trailer and branch-deletion flags belong to legacy mode;
the normalized adapter refuses unknown flags rather than silently dropping them.
The envelope schema is [pr-status.schema.json](pr-status.schema.json). Fields are
`schema_version`, `operation`, `provider`, `host`, `repo`, `number`, `head_sha`, `data`
and nullable `error`. Errors contain `kind`, `message`, nullable
`retry_after_seconds`, and `write_outcome` (`none`, `confirmed`, `unknown`). No blind
write retries. Reconcile an unknown outcome against the exact returned ID/state.
| Code | Version 1 meaning |
| ---- | ----------------------------------------------------------------------- |
| 0 | Successful read or verified mutation; inspect ready/blockers separately |
| 1 | Watch predicate unchanged and pending only |
| 2 | Usage error |
| 3 | Gate failure or POLICY-REFUSED (`error.kind=policy`) |
| 4 | Unsupported API/method or forge allow\_\* setting disabled |
| 5 | Access/auth, including hidden-resource 404; never capability evidence |
| 6 | Malformed, incomplete, unknown capability or failed verification |
| 75 | Transient/rate limit, moved head or unknown transport outcome |
| 124 | Bounded wait timeout |
CI wait consumes normalized checks. Default mode maps gate/provider/read failures to
legacy exit 1, usage to 2 and timeout to 124. `--json` retains version-1 error codes.
Missing checks never become green through repeated empty polls. The CI admission guard
and push guard retain their legacy codes; CI admission is separate from a PR merge queue.
Status includes head/base refs, SHAs and repositories; closure/merge SHA; draft and
mergeability; checks with required/observed/complete; independent current-head reviews;
thread completeness; integration states; capabilities; ready/blockers; observation time
and canonical fingerprint. Head/base/closure are reread after aggregation. Incomplete
reads return nonzero with ready=false. Nullable fields remain unknown, never default false.
Fingerprint excludes observation time and includes review/comment and integration evidence.
The observed snapshot is not an atomic lock: mutations still pin the head at the provider.
Required branch policy must be readable. Empty check history is not a declared no-CI policy.
App-bound GitHub required checks currently remain unknown rather than trusting a same-name
check from another app. Code-owner review additionally requires the provider decision.
Unsupported branch-pattern policy/readback leaves missing evidence and blocks readiness.
## Qualified provider profiles and permissions
GitHub uses documented REST and GraphQL APIs. `GH_HOST` explicitly selects a configured
enterprise host. Other hosts must identify Gitea/Forgejo through public swagger metadata;
unfamiliar hosts are never guessed to be Gitea. Qualified profiles are Gitea 1.24.7 and
28.2.0, and the inspected Forgejo 16.0.0 development schema. Unknown versions expose unknown
capabilities until qualified. Legacy dispatch reports `gitea` for the compatible Forgejo API.
All profiles list every review and review-comment page. GitHub also paginates nested
reviewThreads/comments. Authoritative IDs remain provider-qualified. Gitea lists do not
invent a thread graph: thread_id/reply_to are null without evidence. For Gitea 28.2 resolution,
pass the listed `gitea:comment:ID` as the resolution selector; this names the API's review
comment, not a fabricated thread relationship. Resolved and outdated comments remain listed.
| Operation | GitHub | Gitea 28.2 | Gitea 1.24.7 / inspected Forgejo |
| --------------------------------- | ----------- | ---------------------- | -------------------------------- |
| Snapshot / inline enumeration | supported | partial state explicit | partial state explicit |
| Native reply / resolution | supported | supported | exit 4 before mutation |
| Merge/squash/rebase | supported | supported | supported |
| Rebase-merge / fast-forward-only | exit 4 | supported | supported |
| Verified auto-merge / merge queue | conditional | exit 4 | exit 4 |
| Repository policy refuses method | exit 3 | exit 3 | exit 3 |
| Forge setting disables method | exit 4 | exit 4 | exit 4 |
Public schemas inspected: Gitea swagger.v1.json; GitHub
[REST pulls](https://docs.github.com/en/rest/pulls/pulls#merge-a-pull-request) and
[GraphQL pull operations](https://docs.github.com/en/graphql/reference/pulls).
Gitea/Forgejo expose raw deferred-merge/cancellation endpoints, but the inspected schemas
provide no schedule-state readback. These wrappers therefore inspect/schedule/cancel as
unsupported without making a mutation. Deferred merge is not a native merge queue.
Reads require PR/review/check and branch-protection visibility. Replies/resolution require
conversation write permission. Merge and integration controls require repository merge
permission and applicable product settings. 401/403 is access failure; documented rate-limit
headers yield 75 and a retry delay. Neither permissions nor feature settings are modified.
Enable/enqueue require explicit authority, declared method policy, repository allow\_\* settings,
server-enforced checks and independent reviews, stale-approval dismissal and no admin bypass.
Queue configuration must agree with the policy method. Inspect/disable/dequeue are separate
operations and do not require merge policy. Every mutation requires expected head/readback;
scheduling may merge immediately and returns observed merged state rather than queue success.
## Repository merge policy (schema version 3)
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.
```json
{
"schema_version": 3,
"integration_trunk": "integration",
"release_branch": "stable",
"flow": "trunk-release",
"canonical_remote": "https://git.example.test/team/project",
"canonical_clone": "host:/src/project",
"merge": {
"allowed_methods": ["squash", "merge"],
"default_method": "squash",
"shapes": { "task": "squash", "promotion": "merge", "absorb": "merge" }
}
}
```
`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.
Other targets still fail transition validation. Method requests cannot override a shape mapping.
Invalid declarations never fall back to a wider merge policy.
## Watch transport parity and release
T3 linking/watching is optional notification transport. Read normalized status after every wake.
Fallback watching uses pr-watch-check with one owner-scoped private baseline and a supported
notifier, never pr-ci-wait in a predicate. Unknown schedule state blocks full-stop in both paths.
Children return pending evidence instead of owning watches. Retire watches at handoff.
Run `test-pr-watch-parity.sh`, `test-forge-contract.py`, `test-forge-cli.py`,
`test-merge-policy.py`, `test-pr-skill-contract.py` and all legacy test-\*.sh suites.
Fixtures deny unexpected network calls. Parity tests mock notification transports; they do not
claim live notifier delivery, authenticated provider writes or T3 forge support.
The package manifest includes framework assets. Release/install the reviewed package before
operator-owned tool/skill synchronization. Verify deployed files by hash against that release;
an upgrade alone does not prove owned copies were synchronized. No local rollout is performed
by these tests or by promoting the skill sources.
Set absolute `TMPDIR` to approved scratch storage for response and policy files.
The credential transport contract is described below. Fallback notification requires
an operator-provisioned notifier; its absence is an explicit watch-delivery blocker.
This change packages the status/predicate and skills and does not install a notifier.
### Trusted policy and authentication
The normalized merge and scheduling adapters read `.mosaic/repo.json` from the
PR target's pinned base commit, using the matching local origin's object store.
Fetch the target revision before invoking a mutation. Missing objects refuse;
head commits and uncommitted declarations cannot grant merge authority. Full
head and base identities (ref, SHA, repository) must remain unchanged through
policy evaluation, readiness aggregation, admission, mutation and readback.
Authenticated adapter reads and writes require explicit `MOSAIC_GIT_IDENTITY`.
A mutation first verifies `/user` matches that identity. Inherited client login
sessions and shared token environment variables grant no authority. Existing
provider-specific seat/service token stores remain supported. Other hosts,
including GitHub, use `<brain>/credentials/forge/<host>/<identity>.token`, where
`MOSAIC_BRAIN_HOME` selects `<brain>` (default `~/.mosaic`). Provision the
selected actor's token separately. Authorization reaches curl through an
anonymous pipe; request JSON uses stdin. Tokens never enter named scratch files.
Legacy shell wrappers keep credential resolution and the complete request workflow
in a private subprocess with tracing disabled. Caller tracing never receives a
secret variable; each request consumes authorization through an anonymous descriptor.
The skill firewall accepts optional newline-separated forbidden patterns via
`MOSAIC_OPERATOR_FORBIDDEN_PATTERNS`; its default is empty. Deployment-specific
patterns belong in operator configuration, outside the framework source.
Public GitHub REST requests use `https://api.github.com`, and GraphQL uses
`https://api.github.com/graphql`. Enterprise REST requests use
`https://<host>/api/v3`, and GraphQL uses `https://<host>/api/graphql`.
Credentials remain bound to the selected forge host, such as `github.com`,
while the adapter selects its documented API authority. CLI fixtures match
complete request URLs, including scheme, authority, path and query.
@@ -7,6 +7,14 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
# Credential resolution and all consumers execute inside this private boundary.
# Never restore xtrace while credentials remain in scope.
# All workflow state belongs to this one private subprocess.
# shellcheck disable=SC2030,SC2031
mosaic_private_forge_main() (
set +x
BRANCH=""
TARGET_REPO=""
HEAD_SHA=""
@@ -343,7 +351,7 @@ gitea_get_branch_head_sha() {
# error. A not-yet-pushed feature branch has no in-flight pipeline, so the
# pre-push queue guard must treat 404 as "queue clear", not crash.
local resp code body
resp=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token ${token}" -w $'\n%{http_code}' "$url")
resp=$(gitea_curl_with_token "$token" -sS -H "User-Agent: curl/8" -w $'\n%{http_code}' "$url")
code="${resp##*$'\n'}"
body="${resp%$'\n'*}"
if [[ "$code" == "404" ]]; then
@@ -367,7 +375,7 @@ gitea_get_commit_status_json() {
local sha="$3"
local token="$4"
local url="https://${host}/api/v1/repos/${repo}/commits/${sha}/status"
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
gitea_curl_with_token "$token" -fsSL -H "User-Agent: curl/8" "$url"
}
# Repository-admin state for the acting credential, Gitea flavor. The guard's
@@ -382,7 +390,7 @@ gitea_repo_admin_state() {
local token="$3"
local url="https://${host}/api/v1/repos/${repo}"
local resp code body
resp=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token ${token}" -w $'\n%{http_code}' "$url") || return 2
resp=$(gitea_curl_with_token "$token" -sS -H "User-Agent: curl/8" -w $'\n%{http_code}' "$url") || return 2
code="${resp##*$'\n'}"
body="${resp%$'\n'*}"
if [[ "$code" != "200" ]]; then
@@ -473,6 +481,12 @@ if [[ "$NO_CI_EXPECTED" -eq 1 && "$REQUIRE_STATUS" -eq 1 ]]; then
exit 1
fi
if [[ "$NO_CI_EXPECTED" -eq 1 && -z "${MOSAIC_GIT_IDENTITY:-}" ]]; then
record_assertion_event 'ASSERTION_UNATTRIBUTABLE' 'actor-unattributable' 'unknown' || echo 'Warning: attribution audit unavailable.' >&2
echo "Error: ASSERTION_UNATTRIBUTABLE state=unknown purpose=${PURPOSE} asserted-by=unknown reason=no-ci-expected branch=${BRANCH}; explicit MOSAIC_GIT_IDENTITY is required (exit 78)." >&2
exit 78
fi
OWNER="unknown"
REPO="unknown"
PLATFORM="unknown"
@@ -514,7 +528,7 @@ if git rev-parse --show-toplevel >/dev/null 2>&1 \
repo_decl_load
if [[ "$DECL_STATE" == invalid ]]; then
repo_decl_report_invalid
elif [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
elif [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" -ge 2 ]]; then
route="feature"
if [[ "$BRANCH" == "$DECL_TRUNK" ]]; then
route="trunk (integration head)"
@@ -676,3 +690,6 @@ while true; do
;;
esac
done
)
mosaic_private_forge_main "$@"
@@ -15,22 +15,30 @@ detect_platform() {
return 1
fi
# Check for GitHub
if [[ "$remote_url" == *"github.com"* ]]; then
PLATFORM="github"
export PLATFORM
echo "github"
return 0
# Parse the authority, rather than matching github.com anywhere in a URL.
local host profile rc
host=$(get_remote_host) || return 1
if [[ "$host" == github.com || ( -n "${GH_HOST:-}" && "$host" == "$GH_HOST" ) ]]; then
PLATFORM=github; export PLATFORM; echo github; return 0
fi
# Check for common Gitea indicators
# Gitea URLs typically don't contain github.com, gitlab.com, bitbucket.org
if [[ "$remote_url" != *"gitlab.com"* ]] && \
[[ "$remote_url" != *"bitbucket.org"* ]]; then
# Assume Gitea for self-hosted repos
PLATFORM="gitea"
export PLATFORM
echo "gitea"
profile=$(curl --silent --show-error --fail --max-time 10 --url "https://$host/swagger.v1.json") || {
echo "error: forge metadata could not be verified for $host" >&2
return 1
}
rc=$(printf '%s' "$profile" | python3 -I -c '
import json,sys
try:
info=json.load(sys.stdin)["info"]
title=str(info["title"]).lower(); version=str(info["version"])
print("forgejo" if "forgejo" in title or "gitea" in title and "+gitea-" in version else "gitea" if "gitea" in title else "unknown")
except (ValueError, KeyError, TypeError):
print("unknown")
') || return 1
if [[ "$rc" == gitea || "$rc" == forgejo ]]; then
# Legacy scripts dispatch both API-compatible providers as gitea.
FORGE_PROVIDER="$rc"; PLATFORM=gitea
export FORGE_PROVIDER PLATFORM
echo gitea
return 0
fi
@@ -87,7 +95,7 @@ get_repo_slug() {
gitea_url_matches_host() {
local url="${1:-}" host="${2:-}"
[[ -n "$url" && -n "$host" ]] || return 1
python3 - "$url" "$host" <<'PY'
python3 -I - "$url" "$host" <<'PY'
import sys
from urllib.parse import urlparse
@@ -156,7 +164,7 @@ find_tea_login_for_host() {
command -v tea >/dev/null 2>&1 || return 1
logins_json=$(tea login list --output json 2>/dev/null) || return 1
TEA_LOGINS_JSON="$logins_json" python3 - "$host" <<'PY'
TEA_LOGINS_JSON="$logins_json" python3 -I - "$host" <<'PY'
import json
import os
import sys
@@ -226,7 +234,7 @@ tea_login_exists() {
local logins_json
command -v tea >/dev/null 2>&1 || return 1
logins_json=$(tea login list --output json 2>/dev/null) || return 1
TEA_LOGINS_JSON="$logins_json" python3 - "$login_name" <<'PY_INNER'
TEA_LOGINS_JSON="$logins_json" python3 -I - "$login_name" <<'PY_INNER'
import json, os, sys
want = sys.argv[1]
try:
@@ -246,7 +254,7 @@ tea_login_matches_host() {
command -v tea >/dev/null 2>&1 || return 1
logins_json=$(tea login list --output json 2>/dev/null) || return 1
TEA_LOGINS_JSON="$logins_json" python3 - "$login_name" "$host" <<'PY'
TEA_LOGINS_JSON="$logins_json" python3 -I - "$login_name" "$host" <<'PY'
import json
import os
import sys
@@ -281,7 +289,7 @@ print_gitea_login_diagnostic() {
available=$(
command -v tea >/dev/null 2>&1 || { echo "(tea CLI not installed)"; exit 0; }
logins_json=$(tea login list --output json 2>/dev/null) || { echo "(could not query tea login list)"; exit 0; }
TEA_LOGINS_JSON="$logins_json" python3 - <<'PY'
TEA_LOGINS_JSON="$logins_json" python3 -I - <<'PY'
import json, os
from urllib.parse import urlparse
try:
@@ -390,7 +398,7 @@ get_gitea_authenticated_user() {
command -v tea >/dev/null 2>&1 || return 1
response=$(tea api --login "$login_name" /user 2>/dev/null) || return 1
TEA_AUTHENTICATED_USER_JSON="$response" python3 - <<'PY'
TEA_AUTHENTICATED_USER_JSON="$response" python3 -I - <<'PY'
import json
import os
@@ -412,7 +420,7 @@ get_default_tea_login() {
command -v tea >/dev/null 2>&1 || return 1
logins_json=$(tea login list --output json 2>/dev/null) || return 1
TEA_LOGINS_JSON="$logins_json" python3 - <<'PY'
TEA_LOGINS_JSON="$logins_json" python3 -I - <<'PY'
import json
import os
@@ -501,7 +509,7 @@ get_host_from_url() {
local url="${1:-}"
[[ -n "$url" ]] || return 1
python3 - "$url" <<'PY'
python3 -I - "$url" <<'PY'
import sys
from urllib.parse import urlparse
@@ -531,7 +539,7 @@ get_gitea_repo_slug_for_url() {
remote_url=$(git remote get-url origin 2>/dev/null) || return 1
if [[ "$remote_url" =~ ^https?:// ]]; then
python3 - "$remote_url" "$configured_url" <<'PY'
python3 -I - "$remote_url" "$configured_url" <<'PY'
import sys
from urllib.parse import urlparse
@@ -648,160 +656,55 @@ get_gitea_url_for_host() {
}
# Resolve a Gitea API token for the given host.
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
get_gitea_token() {
local host="$1"
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
local cred_loader="$script_dir/../_lib/credentials.sh"
# 0. Per-agent identity (Gate-16 author≠reviewer). If MOSAIC_GIT_IDENTITY, or the
# per-worktree `git config mosaic.gitIdentity`, resolves to an agent that has a
# stored credential for this host, act AS that agent so API tooling
# (pr-create, issue-create, …) authors under the right identity — matching the
# git credential helper, which this block deliberately mirrors.
local _ident="${MOSAIC_GIT_IDENTITY:-}"
local _ident_src="MOSAIC_GIT_IDENTITY"
if [[ -z "$_ident" ]]; then
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
_ident_src="git config mosaic.gitIdentity"
# Explicit identity and host-bound storage only. Disable tracing before handling
# credentials; the subshell preserves the caller's xtrace state.
get_gitea_token() (
set +x
local host="$1" _ident="${MOSAIC_GIT_IDENTITY:-}"
if [[ ! "$_ident" =~ ^[A-Za-z0-9_.-]+$ || ! "$host" =~ ^[A-Za-z0-9.-]+(:[0-9]+)?$ ]]; then
echo 'Error: explicit MOSAIC_GIT_IDENTITY and a valid host are required.' >&2
return 1
fi
# Recognized Gitea hosts are the ones carrying the per-identity token scheme.
local _idpfx=""
case "$host" in
git.uscllc.com) _idpfx=gitea-usc ;;
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
esac
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
local _brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
if [[ -n "$_ident" && -n "$_idpfx" ]]; then
# Credential store selection: an identity is a SEAT or a SERVICE, and which
# one it is decides where its credential lives. No precedence between the
# two stores and no fallback from one to the other — a seat with an empty
# slot fails loud rather than reading a service credential of the same name.
# One credential, one location: two copies diverge, and the stale copy fails
# in a way that reads as a revoked token rather than as drift.
local _idtok _ident_kind
local _brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}" _idtok
if [[ -n "$_idpfx" ]]; then
if [[ -d "$_brain_home/fleet/agents/$_ident" ]]; then
_ident_kind="seat"
_idtok="$_brain_home/fleet/agents/$_ident/secrets/${_idpfx}-${_ident}.token"
else
_ident_kind="service identity"
_idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
fi
if [[ -r "$_idtok" ]]; then
cat "$_idtok"
return 0
fi
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
# but no credential exists for THAT identity. Refuse to fall through to the
# shared/default credential loader below — silently borrowing another identity's
# token would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review
# attributed to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop
# instead so the caller aborts loudly rather than acting as the wrong identity.
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
echo " Resolved as a ${_ident_kind}; there is no fallback between the seat and service stores." >&2
echo " Refusing to borrow another slot's token. Provision the credential at that path, or unset the identity to use shared credentials." >&2
else
_idtok="$_brain_home/credentials/forge/$host/$_ident.token"
fi
if [[ ! -r "$_idtok" ]]; then
echo 'Error: selected identity has no readable host-bound credential.' >&2
return 1
fi
cat "$_idtok"
)
# FAIL LOUD: no identity resolved, on a host that HAS a fleet. Where seats exist,
# every record must name the agent that made it, so an unattributable request is
# refused rather than handed the shared account. `fleet/agents` existing is the
# same signal brain-home.ts uses to decide a brain is active. A host with no fleet
# keeps the shared path below unchanged: there the shared account is the operator's
# own and there is no attribution to lose.
if [[ -z "$_ident" && -n "$_idpfx" && -d "$_brain_home/fleet/agents" ]]; then
echo "Error: no git identity resolved for host '$host', but this host runs a fleet ($_brain_home/fleet/agents)." >&2
echo " Refusing to fall back to the shared account: records it creates cannot be attributed to the agent that made them." >&2
echo " Set MOSAIC_GIT_IDENTITY=<agent-id> or 'git config mosaic.gitIdentity <agent-id>'." >&2
# Consume a credential inside an untraced shell function. Only an anonymous
# config descriptor reaches the curl process; stdin remains the request body.
# Callers must keep credential resolution and this call in their private boundary.
gitea_curl_with_token() {
set +x
local token="$1" escaped auth_fd request_rc=0
shift
if [[ -z "$token" || ${#token} -gt 4096 || "$token" == *[[:cntrl:]]* ]]; then
echo 'Error: invalid authentication credential.' >&2
return 1
fi
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
if [[ -f "$cred_loader" ]]; then
local token
token=$(
# shellcheck source=/dev/null
source "$cred_loader"
# Host-specific wrapper resolution must not inherit caller/global GITEA_*.
# load_credentials intentionally preserves existing env vars for interactive use,
# but metadata/merge wrappers need credentials matching the remote host.
unset GITEA_TOKEN GITEA_URL
case "$host" in
git.mosaicstack.dev) load_credentials gitea-mosaicstack 2>/dev/null ;;
git.uscllc.com) load_credentials gitea-usc 2>/dev/null ;;
*)
local matched=false
for svc in gitea-mosaicstack gitea-usc; do
unset GITEA_TOKEN GITEA_URL
load_credentials "$svc" 2>/dev/null || continue
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
matched=true
break
fi
done
if [[ "$matched" != true ]]; then
unset GITEA_TOKEN GITEA_URL
fi
;;
esac
echo "${GITEA_TOKEN:-}"
)
if [[ -n "$token" ]]; then
echo "$token"
return 0
fi
fi
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
if [[ -n "${GITEA_TOKEN:-}" ]]; then
if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then
echo "$GITEA_TOKEN"
return 0
fi
fi
# 3. ~/.git-credentials file
local creds="$HOME/.git-credentials"
if [[ -f "$creds" ]]; then
local token
token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1)
if [[ -n "$token" ]]; then
echo "$token"
return 0
fi
fi
return 1
}
# Stage the Gitea bearer credential for curl OUTSIDE the process argument vector.
# Passing "-H 'Authorization: token <value>'" on the curl command line exposes
# the token to anyone who can read the process table (ps / /proc/<pid>/cmdline)
# for the lifetime of the request. Instead, write the header into a private
# (mode 0600) curl config file and have callers pass it with `curl --config`, so
# only the FILE PATH — never the token — appears in argv. Prints the temp file
# path on success; the caller OWNS the file and MUST remove it on every exit
# path (success and failure). $1 = bearer token. Callers must not log the token.
gitea_write_auth_config() {
local token="$1" auth_file
auth_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-gitea-auth.XXXXXX") || return 1
# mktemp already creates the file with 0600; be explicit in case of an
# unusual umask so the credential is never briefly group/other readable.
chmod 600 "$auth_file" 2>/dev/null || true
# `header = "..."` is curl's config syntax for an extra request header. Only
# this filename reaches curl's argv; the token stays on disk, readable solely
# by this user, and is unlinked by the caller's trap after the request.
if ! printf 'header = "Authorization: token %s"\n' "$token" > "$auth_file"; then
rm -f "$auth_file"
return 1
fi
printf '%s' "$auth_file"
escaped="${token//\\/\\\\}"
escaped="${escaped//\"/\\\"}"
exec {auth_fd}< <(printf 'header = "Authorization: token %s"\n' "$escaped")
curl --config "/dev/fd/$auth_fd" "$@" || request_rc=$?
exec {auth_fd}<&-
return "$request_rc"
}
# Resolve the API token for a SPECIFIC tea login name from tea's own config
@@ -828,7 +731,7 @@ get_gitea_token_for_login() {
config_file="${XDG_CONFIG_HOME:-$HOME/.config}/tea/config.yml"
[[ -f "$config_file" ]] || return 1
LOGIN_NAME="$login_name" REPO_HOST="$repo_host" python3 - "$config_file" <<'PY'
LOGIN_NAME="$login_name" REPO_HOST="$repo_host" python3 -I - "$config_file" <<'PY'
import datetime
import os
import re
@@ -1656,7 +1559,7 @@ get_gitea_basic_auth() {
return 1
fi
python3 - "$host" "$creds" <<'PY'
python3 -I - "$host" "$creds" <<'PY'
import sys
from pathlib import Path
from urllib.parse import unquote, urlparse
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Provision synthetic, actor/host-bound credentials for offline wrapper suites."""
import json
import re
import pathlib
import sys
home, brain, actor, host, token = sys.argv[1:]
root = pathlib.Path(brain)
p = root / 'credentials/forge' / host / (actor + '.token')
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(token)
# Existing provider-specific stores derive their prefixes from configured
# service keys. The fixture never imports an operator's credential loader.
credentials = pathlib.Path(home) / '.config/mosaic/credentials.json'
if credentials.is_file():
for service, value in json.loads(credentials.read_text()).get('gitea', {}).items():
if value.get('url', '').removeprefix('https://').rstrip('/') == host:
p = pathlib.Path(home) / '.config/mosaic/secrets/gitea-tokens' / ('gitea-' + service + '-' + actor + '.token')
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(token)
# Provider store layout is an inherited compatibility contract. Reuse its
# declared mapping without embedding deployment names in new fixture code.
source=(pathlib.Path(__file__).parent/'detect-platform.sh').read_text()
for mapped_host, prefix in re.findall(r'^\s*([A-Za-z0-9.-]+)\)\s+_idpfx=([A-Za-z0-9-]+)',source,re.MULTILINE):
if mapped_host == host:
p=pathlib.Path(home)/'.config/mosaic/secrets/gitea-tokens'/(prefix+'-'+actor+'.token')
p.parent.mkdir(parents=True,exist_ok=True)
p.write_text(token)
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Public schema fixture; all other network calls fail closed.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
printf 'Unexpected network request in offline suite\n' >&2
exit 97
@@ -0,0 +1,860 @@
#!/usr/bin/env python3
"""Provider adapters for the versioned forge wrapper contract.
Run with Python -I. No repository modules, provider code or fixture files are imported.
All network operations have finite timeouts and pagination bounds. Writes are never retried.
"""
import argparse
import datetime
import hashlib
import json
import os
import pathlib
import re
import subprocess
import sys
import time
import tempfile
import urllib.parse
HERE = pathlib.Path(__file__).resolve().parent
LIMIT = 100
MAX_PAGES = 100
class Failure(Exception):
def __init__(self, code, kind, message, retry=None, outcome='none'):
super().__init__(message)
self.code, self.kind, self.retry, self.outcome = code, kind, retry, outcome
def capabilities(provider, version):
names = ('status', 'review_comments', 'review_reply', 'review_resolve', 'merge',
'rebase_merge', 'fast_forward_only', 'auto_merge', 'merge_queue')
result = {n: {'support': 'unknown', 'reason': 'No qualified API profile', 'schema_source': None} for n in names}
def mark(names, support, reason, source):
for n in names:
result[n] = dict(support=support, reason=reason, schema_source=source)
if provider == 'github':
source = 'https://docs.github.com/en/graphql/reference/objects#pullrequest'
mark(names, 'supported', 'Documented API; repository permissions and settings still apply', source)
mark(('rebase_merge', 'fast_forward_only'), 'unsupported', 'GitHub merge API has no such method', 'https://docs.github.com/en/rest/pulls/pulls#merge-a-pull-request')
elif (provider, version) in (('gitea', '28.2.0'), ('gitea', '1.24.7'), ('forgejo', '16.0.0-dev')):
source = 'swagger.v1.json:' + provider + ':' + version
mark(('status', 'review_comments', 'merge', 'rebase_merge', 'fast_forward_only'), 'supported', 'Qualified versioned schema', source)
mark(('review_reply', 'review_resolve'), 'supported' if version == '28.2.0' else 'unsupported', 'Native conversation routes present' if version == '28.2.0' else 'No native conversation mutation routes in qualified schema', source)
mark(('auto_merge', 'merge_queue'), 'unsupported', 'No documented schedule readback or native merge queue; raw scheduling/cancellation is not safely verifiable', source)
return result
def fingerprint(data):
stable = {k: v for k, v in data.items() if k not in ('observed_at', 'fingerprint')}
return hashlib.sha256(json.dumps(stable, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def checks(statuses, runs, required, allow_none=False):
observed = {}
for s in sorted(statuses, key=lambda s: s.get('id', 0)):
name = s.get('context')
if not name:
raise Failure(6, 'malformed', 'Status context missing')
observed[name] = s.get('state') if s.get('state') in ('success','failure','error','pending','cancelled') else 'unknown'
for r in sorted(runs, key=lambda r: r.get('id', 0)):
name = r.get('name')
if not name:
raise Failure(6, 'malformed', 'Check run name missing')
conclusion = r.get('conclusion')
observed[name] = ('pending' if r.get('status') != 'completed' else
'success' if conclusion in ('success', 'neutral', 'skipped') else
'cancelled' if conclusion == 'cancelled' else 'failure' if conclusion in ('failure', 'timed_out', 'action_required', 'startup_failure') else 'unknown')
state = 'unknown'
if required is not None:
selected = [observed.get(n, 'missing') for n in required] if required else list(observed.values())
if not selected:
state = 'not_required' if allow_none and required == [] else 'missing'
else:
state = next((s for s in ('failure', 'error', 'cancelled', 'missing', 'unknown', 'pending', 'in_progress', 'queued') if s in selected), 'success')
state = {'error': 'failure', 'in_progress': 'pending', 'queued': 'pending'}.get(state, state)
return dict(state=state, required=required, observed=observed, complete=True)
def reviews(items, head, author, required_count=1):
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'):
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
return dict(decision='changes_requested' if rejected else 'approved' if approved else 'missing', reviewed_sha=head if approved else None, complete=True, approved=approved)
def run(argv, data=None, cwd=None, env=None, pass_fds=()):
try:
return subprocess.run(argv, input=data, text=True, capture_output=True, timeout=45, cwd=cwd, env=env, pass_fds=pass_fds)
except (OSError, subprocess.TimeoutExpired):
raise Failure(75, 'transient', 'Provider client unavailable or timed out')
class Client:
def __init__(self, host, repo):
self.host, self.repo = host, repo
self.provider, self.version, self.token = 'unknown', '', None
self.write_attempted = False
self.actor_verified = False
self.root = '/repos/' + repo
def detect(self):
if self.host == 'github.com' or self.host == os.environ.get('GH_HOST'):
self.provider, self.version = 'github', 'documented'
return
schema = self.http('/swagger.v1.json', public=True)
info = schema.get('info', {})
title, version = str(info.get('title', '')).lower(), str(info.get('version', ''))
if 'forgejo' in title or '+gitea-' in version:
self.provider = 'forgejo'
# This profile is the inspected 16.0.0 development schema only.
self.version = '16.0.0-dev' if version.startswith('16.0.0-dev') else version
elif 'gitea' in title:
self.provider, self.version = 'gitea', version
else:
raise Failure(4, 'unsupported', 'Host metadata does not identify a qualified forge')
def credential(self):
if self.token is None:
# Fixed script/arguments; no user input becomes shell source.
p = run(['bash', '-c', 'source "$1/detect-platform.sh"; get_gitea_token "$2"', 'forge-auth', str(HERE), self.host], cwd='/')
if p.returncode or not p.stdout.strip():
raise Failure(5, 'access', 'Acting identity has no credential for this host')
self.token = p.stdout.strip()
return self.token
def http(self, path, method='GET', body=None, public=False):
writing = method != 'GET' and not (path == 'graphql' and body and body.get('query', '').lstrip().startswith('query'))
if not public:
actor = os.environ.get('MOSAIC_GIT_IDENTITY', '')
if not re.fullmatch(r'[A-Za-z0-9_.-]+', actor):
raise Failure(5, 'access', 'Explicit MOSAIC_GIT_IDENTITY is required')
token = self.credential()
if writing and not self.actor_verified:
user = self.http('/user')
if not isinstance(user, dict) or user.get('login') != actor:
raise Failure(5, 'access', 'Authenticated actor differs from explicit identity')
self.actor_verified = True
prefix = '/api/v3' if self.provider == 'github' and self.host != 'github.com' else '' if self.provider == 'github' else '/api/v1'
route = ('/graphql' if self.host == 'github.com' else '/api/graphql') if path == 'graphql' else prefix + path
authority = 'api.github.com' if self.provider == 'github' and self.host == 'github.com' and not public else self.host
url = 'https://' + authority + (path if public else route)
args = ['curl', '--silent', '--show-error', '--include', '--max-time', '30', '--request', method, '--header', 'Accept: application/json', '--header', 'User-Agent: mosaic-forge-wrapper']
if body is not None:
args += ['--header', 'Content-Type: application/json', '--data-binary', '@-']
args += ['--url', url]
if not public:
if len(token) > 4096 or any(ord(c) < 32 or ord(c) == 127 for c in token):
raise Failure(5, 'access', 'Credential contains invalid control characters')
escaped = token.replace('\\', '\\\\').replace('"', '\\"')
# Anonymous pipe: no credential pathname survives termination. The body
# retains stdin, while curl reads authentication from the inherited fd.
read_fd, write_fd = os.pipe()
try:
os.write(write_fd, ('header = "Authorization: token ' + escaped + '"\n').encode())
os.close(write_fd)
write_fd = None
self.auth_fd = read_fd
p = self.transport(args + ['--config', '/dev/fd/' + str(read_fd)], body, writing)
finally:
self.auth_fd = None
os.close(read_fd)
if write_fd is not None:
os.close(write_fd)
else:
p = self.transport(args, body, writing)
if p.returncode:
raise Failure(75, 'transient', 'Provider transport failed; reconcile before retrying a mutation', outcome='unknown' if writing else 'none')
raw = p.stdout.replace('\r\n', '\n')
# Proxies can emit an initial CONNECT response. Only consume header blocks.
status, headers = None, {}
while raw.startswith('HTTP/'):
block, sep, raw = raw.partition('\n\n')
if not sep:
raise Failure(6, 'malformed', 'Provider response headers incomplete')
lines = block.splitlines()
try:
status = int(lines[0].split()[1])
except (ValueError, IndexError):
raise Failure(6, 'malformed', 'Provider HTTP status malformed')
headers = {k.lower().strip(): v.strip() for line in lines[1:] if ':' in line for k, v in [line.split(':', 1)]}
if status is None:
raise Failure(6, 'malformed', 'Provider did not return HTTP metadata')
if status == 429 or status == 403 and (headers.get('x-ratelimit-remaining') == '0' or 'retry-after' in headers):
retry = headers.get('retry-after')
if retry is None:
reset = headers.get('x-ratelimit-reset','')
retry = str(max(1,int(reset)-int(time.time()))) if reset.isdigit() else '60'
raise Failure(75, 'rate_limit', 'Provider rate limit; use a bounded later wake', int(retry) if retry.isdigit() else 60, 'unknown' if writing else 'none')
if status in (401, 403, 404):
raise Failure(5, 'access', 'Provider access denied or resource hidden (HTTP %s)' % status, outcome='unknown' if writing else 'none')
if status >= 500:
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:
return None
try:
result = json.loads(raw)
except ValueError:
raise Failure(6, 'malformed', 'Provider returned invalid JSON', outcome='unknown' if writing else 'none')
return result
def transport(self, args, body, writing):
if writing:
self.write_attempted = True
try:
return run(args, json.dumps(body) if body is not None else None, pass_fds=(self.auth_fd,) if getattr(self, 'auth_fd', None) is not None else ())
except Failure as e:
if writing:
e.outcome = 'unknown'
raise
def pages(self, path, key=None):
result = []
expected_total = 0
for page in range(1, MAX_PAGES + 1):
query = ('&' if '?' in path else '?') + ('per_page' if self.provider == 'github' else 'limit') + '=100&page=' + str(page)
value = self.http(path + query)
if key:
total = value.get('total_count')
if not isinstance(total,int) or isinstance(total,bool) or total < 0:
raise Failure(6,'incomplete','Check-run total_count missing or invalid')
expected_total = max(expected_total,total)
value = value.get(key)
if not isinstance(value, list):
raise Failure(6, 'malformed', 'Provider collection is not a list')
result.extend(value)
if len(value) < LIMIT:
if len(result) < expected_total:
raise Failure(6,'incomplete','Provider returned fewer records than total_count')
return result
raise Failure(6, 'incomplete', 'Pagination bound reached; collection incomplete')
def gql(self, query, variables):
result = self.http('graphql', 'POST', {'query': query, 'variables': variables})
if not isinstance(result, dict) or result.get('errors') or not isinstance(result.get('data'), dict):
raise Failure(6, 'incomplete', 'GraphQL returned errors or incomplete data', outcome='unknown' if query.lstrip().startswith('mutation') else 'none')
return result['data']
def pr(self, number):
value = self.http(self.root + '/pulls/' + str(number))
if not isinstance(value, dict) or value.get('number') != number or not re.fullmatch('[0-9a-fA-F]{40}', (value.get('head') or {}).get('sha', '')):
raise Failure(6, 'malformed', 'PR identity or head SHA missing')
return value
def require(client, operation):
cap = capabilities(client.provider, client.version)[operation]
if cap['support'] != 'supported':
raise Failure(4 if cap['support'] == 'unsupported' else 6, 'unsupported' if cap['support'] == 'unsupported' else 'incomplete', cap['reason'])
def base_identity(p):
b = p['base']
h = p['head']
return (b.get('ref'), b.get('sha'), (b.get('repo') or {}).get('full_name'), h.get('ref'), h.get('sha'), (h.get('repo') or {}).get('full_name'))
def same_base(p, original):
if base_identity(p) != base_identity(original):
raise Failure(75, 'base_moved', 'PR target changed; discard policy and readiness')
return p
def same_head(client, number, expected):
p = client.pr(number)
if p['head']['sha'] != expected:
raise Failure(75, 'head_moved', 'PR head changed; discard readiness and refresh review')
return p
def github_conversations(client, number):
owner, repo = client.repo.split('/')
cursor, seen, threads, integration = None, set(), [], None
fields = '''id isResolved isOutdated comments(first:100) { nodes { databaseId id replyTo { databaseId } } pageInfo { hasNextPage endCursor } }'''
for _ in range(MAX_PAGES):
q = '''query($owner:String!,$repo:String!,$number:Int!,$cursor:String) {
repository(owner:$owner,name:$repo) { pullRequest(number:$number) {
id reviewDecision autoMergeRequest { mergeMethod } mergeQueueEntry { id }
reviewThreads(first:100,after:$cursor) { nodes { ''' + fields + ''' } pageInfo { hasNextPage endCursor } }
} } }'''
pr = client.gql(q, dict(owner=owner, repo=repo, number=number, cursor=cursor))['repository']['pullRequest']
if not isinstance(pr, dict) or not pr.get('id') or 'autoMergeRequest' not in pr or 'mergeQueueEntry' not in pr:
raise Failure(6, 'incomplete', 'Missing integration state')
auto, queue = pr['autoMergeRequest'], pr['mergeQueueEntry']
if auto is not None and (not isinstance(auto, dict) or auto.get('mergeMethod') not in ('SQUASH', 'MERGE', 'REBASE')):
raise Failure(6, 'malformed', 'Unknown auto-merge state')
if queue is not None and (not isinstance(queue, dict) or not isinstance(queue.get('id'), str) or not queue['id']):
raise Failure(6, 'malformed', 'Unknown merge-queue state')
if integration is None:
integration = dict(id=pr['id'], review_decision=pr.get('reviewDecision'), auto_merge={'state':'enabled' if pr['autoMergeRequest'] else 'disabled', 'method':((pr['autoMergeRequest'] or {}).get('mergeMethod') or '').lower() or None}, queue={'state':'queued' if pr['mergeQueueEntry'] else 'absent', 'entry_id':(pr['mergeQueueEntry'] or {}).get('id')})
connection = pr['reviewThreads']
for thread in connection['nodes']:
if type(thread.get('isResolved')) is not bool:
raise Failure(6, 'malformed', 'Review thread resolution must be boolean')
comments = thread['comments']
nodes = list(comments['nodes'])
nested_seen = set()
for _ in range(MAX_PAGES):
if comments['pageInfo']['hasNextPage'] is False:
break
nested_cursor = comments['pageInfo']['endCursor']
if not nested_cursor or nested_cursor in nested_seen:
raise Failure(6, 'incomplete', 'Review comment cursor missing or repeated')
nested_seen.add(nested_cursor)
comments = client.gql('''query($id:ID!,$cursor:String!) { node(id:$id) { ... on PullRequestReviewThread { comments(first:100,after:$cursor) { nodes { databaseId id replyTo { databaseId } } pageInfo { hasNextPage endCursor } } } } }''', {'id':thread['id'], 'cursor':nested_cursor})['node']['comments']
nodes.extend(comments['nodes'])
else:
raise Failure(6, 'incomplete', 'Nested comment pagination bound reached')
thread['comments'] = nodes
threads.append(thread)
if connection['pageInfo']['hasNextPage'] is False:
return threads, integration
cursor = connection['pageInfo']['endCursor']
if not cursor or cursor in seen:
raise Failure(6, 'incomplete', 'Review thread cursor missing or repeated')
seen.add(cursor)
raise Failure(6, 'incomplete', 'Review thread pagination bound reached')
def review_comments(client, number):
require(client, 'review_comments')
root = client.root + '/pulls/' + str(number)
revs = client.pages(root + '/reviews')
comments, threads, integration = [], [], None
if client.provider == 'github':
raw = client.pages(root + '/comments')
threads, integration = github_conversations(client, number)
mapping = {}
for t in threads:
for c in t['comments']:
cid = c.get('databaseId')
if cid is None or cid in mapping:
raise Failure(6, 'incomplete', 'Thread comment identity missing or duplicated')
mapping[cid] = t
for c in raw:
if c.get('id') not in mapping:
raise Failure(6, 'incomplete', 'REST comment absent from authoritative thread graph')
if set(mapping) != {c.get('id') for c in raw}:
raise Failure(6, 'incomplete', 'Comment collections disagree')
else:
raw = []
mapping = {}
for r in revs:
if r.get('id') is None:
raise Failure(6, 'malformed', 'Review ID missing')
page = client.pages(root + '/reviews/' + str(r['id']) + '/comments')
for c in page:
c = dict(c, pull_request_review_id=r['id'])
raw.append(c)
seen = set()
for c in raw:
cid = c.get('id')
if cid is None or cid in seen:
raise Failure(6, 'incomplete', 'Comment ID missing or duplicated')
seen.add(cid)
t = mapping.get(cid)
parent = c.get('in_reply_to_id')
# A resolver proves a resolved comment; it does not prove a thread graph.
resolved = t['isResolved'] if t else bool(c.get('resolver')) if 'resolver' in c else None
comments.append(dict(id=f'{client.provider}:comment:{cid}', review_id=c.get('pull_request_review_id'), thread_id=f"github:thread:{t['id']}" if t else None, reply_to=f'{client.provider}:comment:{parent}' if parent else None, author=(c.get('user') or {}).get('login'), body=c.get('body'), path=c.get('path'), line=c.get('line') or c.get('position') or c.get('new_position'), commit_sha=c.get('commit_id'), outdated=t['isOutdated'] if t else c.get('outdated'), resolved=resolved, url=c.get('html_url') or c.get('url')))
return dict(complete=True, reviews=revs, comments=comments), threads, integration
def protection(client, base):
if client.provider == 'github':
p = client.http(client.root + '/branches/' + urllib.parse.quote(base, safe='') + '/protection')
status = p.get('required_status_checks')
required = sorted(set((status or {}).get('contexts', []) + [c['context'] for c in (status or {}).get('checks', [])])) if status is not None else []
review = p.get('required_pull_request_reviews') or {}
client.review_count = review.get('required_approving_review_count', 1)
client.codeowners_required = review.get('require_code_owner_reviews') is True
enforced = bool(required and review.get('required_approving_review_count', 0) > 0 and review.get('dismiss_stale_reviews') is True and (p.get('enforce_admins') or {}).get('enabled') is True)
if status is not None and any(c.get('app_id') not in (None,-1) for c in status.get('checks',[])):
# App-bound contexts need source-specific matching; unknown never clears a gate.
required = None
return required, enforced
rules = client.pages(client.root + '/branch_protections')
matching = [r for r in rules if r.get('branch_name') == base]
if len(matching) != 1:
return None, False
r = matching[0]
client.review_count = r.get('required_approvals',1)
return (r.get('status_check_contexts') if r.get('enable_status_check') is True else None), False
def snapshot(client, number, expected):
require(client, 'status')
p = client.pr(number)
head = p['head']['sha']
if expected and head != expected:
raise Failure(3, 'gate', 'Expected head differs from PR head')
base = p['base']
required, enforced = protection(client, base['ref'])
root = client.root
statuses = client.pages(root + '/commits/' + head + '/statuses')
runs = client.pages(root + '/commits/' + head + '/check-runs', 'check_runs') if client.provider == 'github' else []
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))
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
thread_state = dict(unresolved=unresolved, complete=comments['complete'] and unresolved is not None)
state = 'merged' if p.get('merged') is True else p['state']
if state not in ('open','closed','merged'):
raise Failure(6,'malformed','Provider PR state is unknown')
mergeability = 'conflict' if p.get('mergeable') is False else 'clean' if p.get('mergeable') is True else 'unknown'
blockers = []
if not re.fullmatch('[0-9a-fA-F]{40}',base.get('sha','')):
blockers.append('base_sha_unknown')
if not (p['head'].get('repo') or {}).get('full_name') or not (base.get('repo') or {}).get('full_name'):
blockers.append('repository_unknown')
if state != 'open':
blockers.append(state)
if p.get('draft') is not False:
blockers.append('draft_or_unknown')
if check['state'] not in ('success', 'not_required'):
blockers.append('checks_' + check['state'])
if not review['approved']:
blockers.append('review_' + review['decision'])
if unresolved != 0:
blockers.append('review_threads_unresolved_or_unknown')
if mergeability != 'clean':
blockers.append('mergeability_' + mergeability)
data = dict(url=p.get('html_url'), state=state, draft=p.get('draft'), head_ref=p['head'].get('ref'), head_sha=head, base_ref=base.get('ref'), base_sha=base.get('sha'), head_repository=(p['head'].get('repo') or {}).get('full_name'), base_repository=(base.get('repo') or {}).get('full_name'), merge_sha=p.get('merge_commit_sha') if state == 'merged' else None, mergeability=mergeability, checks=check, reviews=review, threads=thread_state, auto_merge=integration['auto_merge'] if integration else {'state':'unsupported'}, queue=integration['queue'] if integration else {'state':'unsupported'}, capabilities=capabilities(client.provider, client.version), ready=not blockers, blockers=blockers, comments=comments['comments'], review_records=comments['reviews'], issue_comments=issue_comments, protection_enforced=enforced, observed_at=datetime.datetime.now(datetime.timezone.utc).isoformat())
after = same_head(client, number, head)
same_base(after, p)
if after.get('state') != p.get('state') or after.get('merged') != p.get('merged'):
raise Failure(75, 'snapshot_changed', 'PR base or closure changed during aggregation')
data['fingerprint'] = fingerprint(data)
return data
def owned_comment(client, number, opaque):
data, threads, integration = review_comments(client, number)
matches = [c for c in data['comments'] if c['id'] == opaque]
if len(matches) != 1:
raise Failure(3, 'gate', 'Comment is not uniquely owned by the requested PR')
return matches[0]
def reply(client, number, opaque, body, expected):
require(client, 'review_reply')
same_head(client, number, expected)
target = owned_comment(client, number, opaque)
if not re.fullmatch(re.escape(client.provider) + r':comment:[1-9][0-9]*', opaque):
raise Failure(2, 'usage', 'Use the provider-qualified comment ID from pr-review-comments')
actor = client.http('/user').get('login')
if not actor:
raise Failure(5, 'access', 'Acting user could not be verified')
same_head(client, number, expected)
cid = opaque.rsplit(':', 1)[1]
path = client.root + '/pulls/' + str(number) + '/comments/' + cid + '/replies'
created = client.http(path, 'POST', {'body':body})
if not isinstance(created, dict) or not isinstance(created.get('id'), int):
raise Failure(6, 'verification', 'Reply response has no created ID; reconcile before retry', outcome='unknown')
new_id = f"{client.provider}:comment:{created['id']}"
try:
if client.provider == 'github':
item = client.http(client.root + '/pulls/comments/' + str(created['id']))
expected_url = '/repos/' + client.repo + '/pulls/' + str(number)
parsed = urllib.parse.urlparse(item.get('pull_request_url', ''))
authority = urllib.parse.urlparse('https://' + ('api.github.com' if client.host == 'github.com' else client.host))
actual_path = parsed.path.removeprefix('/api/v3') if client.host != 'github.com' else parsed.path
if actual_path != expected_url or parsed.scheme != 'https' or parsed.hostname != authority.hostname or (parsed.port or 443) != (authority.port or 443) or parsed.username or parsed.password or item.get('in_reply_to_id') != int(cid):
raise Failure(6, 'verification', 'Reply repository or parent readback differs', outcome='unknown')
actual = {'id':f"github:comment:{item.get('id')}", 'body':item.get('body'), 'author':(item.get('user') or {}).get('login'), 'commit_sha':item.get('commit_id')}
else:
actual = owned_comment(client, number, new_id)
if actual.get('id') != new_id or actual.get('body') != body or actual.get('author') != actor or target.get('commit_sha') and actual.get('commit_sha') != target['commit_sha']:
raise Failure(6, 'verification', 'Created reply identity, author, body or commit readback differs', outcome='unknown')
same_head(client, number, expected)
except Failure as e:
e.outcome = 'unknown'
raise
return dict(id=new_id, reply_to=opaque, thread_id=target.get('thread_id'), verified=True, head_sha=expected)
def resolve(client, number, opaque, desired, expected):
require(client, 'review_resolve')
same_head(client, number, expected)
data, threads, integration = review_comments(client, number)
if client.provider == 'github':
matches = [t for t in threads if 'github:thread:' + t['id'] == opaque]
if len(matches) != 1:
raise Failure(3, 'gate', 'Thread is not uniquely owned by the requested PR')
target = matches[0]
if target.get('isResolved') is desired:
return dict(thread_id=opaque, resolved=desired, verified=True, changed=False)
same_head(client, number, expected)
mutation = 'resolveReviewThread' if desired else 'unresolveReviewThread'
client.gql('mutation($id:ID!) { ' + mutation + '(input:{threadId:$id}) { thread { id isResolved } } }', {'id':target['id']})
else:
# This API resolves a review comment. Its ID is a provider selector,
# never an invented path/line-based thread graph.
matches = [c for c in data['comments'] if c['id'] == opaque]
if len(matches) != 1 or not re.fullmatch('gitea:comment:[1-9][0-9]*', opaque):
raise Failure(3, 'gate', 'Resolution selector is not owned by this PR')
if matches[0]['resolved'] is desired:
return dict(thread_id=opaque, resolved=desired, verified=True, changed=False)
same_head(client, number, expected)
client.http(client.root + '/pulls/comments/' + opaque.rsplit(':',1)[1] + ('/resolve' if desired else '/unresolve'), 'POST')
try:
new_data, new_threads, _ = review_comments(client, number)
states = [t['isResolved'] for t in new_threads if 'github:thread:' + t['id'] == opaque] if client.provider == 'github' else [c['resolved'] for c in new_data['comments'] if c['id'] == opaque]
if states != [desired]:
raise Failure(6, 'verification', 'Resolution readback does not prove requested state', outcome='unknown')
same_head(client, number, expected)
except Failure as e:
e.outcome = 'unknown'
raise
return dict(thread_id=opaque, resolved=desired, verified=True, changed=True)
def forge_method(client, method):
if method not in ('squash','merge','rebase','rebase-merge','fast-forward-only'):
raise Failure(2, 'usage', 'Unknown merge method')
if client.provider == 'github' and method in ('rebase-merge','fast-forward-only'):
raise Failure(4, 'unsupported', 'GitHub has no ' + method + ' merge method')
settings = client.http(client.root)
key = ({'squash':'allow_squash_merge','merge':'allow_merge_commit','rebase':'allow_rebase_merge'} if client.provider == 'github' else {'squash':'allow_squash_merge','merge':'allow_merge_commits','rebase':'allow_rebase','rebase-merge':'allow_rebase_explicit','fast-forward-only':'allow_fast_forward_only_merge'})[method]
if settings.get(key) is False:
raise Failure(4, 'unsupported', 'Forge repository setting ' + key + ' disables method')
if settings.get(key) is not True:
raise Failure(6, 'incomplete', 'Forge repository setting ' + key + ' could not be verified')
return settings
def declared_method(client, p, requested):
root = run(['git','rev-parse','--show-toplevel'])
if root.returncode:
raise Failure(3,'policy','POLICY-REFUSED: mutation requires a local repository declaration context')
origin = run(['git','remote','get-url','origin']).stdout.strip()
parsed = urllib.parse.urlparse(origin if not origin.startswith('git@') else 'ssh://' + origin.replace(':','/',1))
if parsed.hostname != client.host.split(':')[0] or parsed.path.strip('/').removesuffix('.git') != client.repo:
raise Failure(3,'policy','POLICY-REFUSED: explicit target differs from local origin')
base = p['base']
if (base.get('repo') or {}).get('full_name') != client.repo or not re.fullmatch('[0-9a-fA-F]{40}', base.get('sha', '')):
raise Failure(3, 'policy', 'POLICY-REFUSED: trusted target revision unavailable')
revision = base['sha']
if run(['git', 'cat-file', '-e', revision + '^{commit}']).returncode:
raise Failure(3, 'policy', 'POLICY-REFUSED: fetch the pinned target revision before merging')
listing = run(['git', 'ls-tree', revision, '--', '.mosaic/repo.json'])
if listing.returncode:
raise Failure(6, 'verification', 'Cannot inspect pinned target declaration')
if not listing.stdout.strip():
if requested not in (None, 'squash'):
raise Failure(3, 'policy', 'POLICY-REFUSED: absent merge policy permits squash only')
if base['ref'] != client.http(client.root).get('default_branch'):
raise Failure(3, 'policy', 'POLICY-REFUSED: absent declaration requires the repository default base')
return 'squash'
content = run(['git', 'show', revision + ':.mosaic/repo.json'])
if content.returncode:
raise Failure(6, 'verification', 'Cannot read pinned target declaration')
# This temporary file contains public policy, never authentication material.
with tempfile.TemporaryDirectory() as directory:
declaration = pathlib.Path(directory) / 'repo.json'
declaration.write_text(content.stdout)
validator = run([str(HERE.parent/'structure/validate-repo-json.sh'), str(declaration), '--mode', 'display'])
if validator.returncode:
raise Failure(6, 'malformed', 'Invalid target repository declaration: ' + validator.stdout.strip())
doc = json.loads(content.stdout)
canonical = doc.get('canonical_remote', '').removesuffix('.git').rstrip('/')
normal = run([str(HERE.parent/'structure/validate-repo-json.sh'), '--normalize-remote', canonical])
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']]
if requested:
args += ['--method', requested]
result = run(args)
value = json.loads(result.stdout)
if result.returncode:
raise Failure(result.returncode, 'policy' if result.returncode == 3 else 'malformed', value.get('error', 'Merge policy refused'))
return value['method']
def queue_guard(client, p):
# This is the CI push/merge guard, never GitHub's PR merge queue.
git_dir = run(['git','rev-parse','--absolute-git-dir'])
work_tree = run(['git','rev-parse','--show-toplevel'])
if git_dir.returncode or work_tree.returncode:
raise Failure(3,'gate','CI admission needs the verified local repository context')
guard_env = dict(os.environ,GIT_DIR=git_dir.stdout.strip(),GIT_WORK_TREE=work_tree.stdout.strip())
result = run([str(HERE/'ci-queue-wait.sh'),'--purpose','merge','--repo',client.repo,'--sha',p['head']['sha'],'-B',p['head']['ref'],'-t','1','-i','1'],cwd='/',env=guard_env)
if result.returncode:
mapping = {75:75, 78:3, 124:124}
raise Failure(mapping.get(result.returncode,3),'gate','CI queue guard refused (legacy exit %s)' % result.returncode)
def merge(client, number, requested, expected):
require(client, 'merge')
p = same_head(client,number,expected)
method = declared_method(client,p,requested)
forge_method(client,method)
status = snapshot(client,number,expected)
if (status['base_ref'], status['base_sha'], status['base_repository'], status['head_ref'], status['head_sha'], status['head_repository']) != base_identity(p):
raise Failure(75, 'base_moved', 'PR identity changed after policy evaluation')
if not status['ready']:
raise Failure(3,'gate','Merge readiness failed: ' + ', '.join(status['blockers']))
queue_guard(client,p)
same_base(same_head(client,number,expected), p)
if client.provider == 'github':
client.http(client.root+'/pulls/'+str(number)+'/merge','PUT',{'merge_method':method,'sha':expected})
else:
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'):
raise Failure(6,'verification','Merge not proven by PR readback',outcome='unknown')
except Failure as e:
e.outcome='unknown'
raise
return dict(requested_method=requested, effective_method=method, merged=True, merge_sha=after['merge_commit_sha'],head_sha=expected,base_ref=after['base']['ref'])
def integration(client, number, operation, action, requested, expected):
require(client,operation)
if action not in (('inspect','enable','disable') if operation == 'auto_merge' else ('inspect','enqueue','dequeue')):
raise Failure(2,'usage','Invalid action for integration operation')
if action != 'inspect' and not expected:
raise Failure(2,'usage','Mutation requires --expect-head')
p = same_head(client,number,expected) if expected else client.pr(number)
_, state = github_conversations(client,number)
key = 'auto_merge' if operation == 'auto_merge' else 'queue'
before = state[key]
if action == 'inspect':
same_base(same_head(client,number,p['head']['sha']), p)
return dict(supported=True,state=before['state'],method=before.get('method'),entry_id=before.get('entry_id'),expected_head=expected,head_sha=p['head']['sha'],verified=True)
enabling = action in ('enable','enqueue')
method = before.get('method')
if enabling:
method = declared_method(client,p,requested)
settings = forge_method(client,method)
_, enforced = protection(client,p['base']['ref'])
if not enforced:
raise Failure(3,'gate','Scheduling requires enforced checks, independent reviews, stale approval dismissal and no administrator bypass')
if p.get('state') != 'open' or p.get('draft') is not False:
raise Failure(3,'gate','Scheduling requires an open non-draft PR')
owner, repo = client.repo.split('/')
queue = client.gql('query($owner:String!,$repo:String!,$branch:String!) { repository(owner:$owner,name:$repo) { mergeQueue(branch:$branch) { configuration { mergeMethod } } } }',{'owner':owner,'repo':repo,'branch':p['base']['ref']})['repository']['mergeQueue']
queue_method = ((queue or {}).get('configuration') or {}).get('mergeMethod')
if action == 'enqueue' and not queue_method:
raise Failure(4,'unsupported','No configured merge queue method available')
if queue_method and queue_method.lower() != method:
raise Failure(3,'policy','POLICY-REFUSED: queue effective method conflicts with requested/declaration method')
if action == 'enable' and settings.get('allow_auto_merge') is not True:
raise Failure(4,'unsupported','Repository allow_auto_merge is disabled or unknown')
queue_guard(client,p)
same_base(same_head(client,number,expected), p)
variables = {'id':state['id']}
if action == 'enable':
query = 'mutation($id:ID!,$head:GitObjectID!,$method:PullRequestMergeMethod!) { enablePullRequestAutoMerge(input:{pullRequestId:$id,expectedHeadOid:$head,mergeMethod:$method}) { pullRequest { id } } }'
variables.update(head=expected,method=method.upper())
elif action == 'disable':
query = 'mutation($id:ID!) { disablePullRequestAutoMerge(input:{pullRequestId:$id}) { pullRequest { id } } }'
elif action == 'enqueue':
query = 'mutation($id:ID!,$head:GitObjectID!) { enqueuePullRequest(input:{pullRequestId:$id,expectedHeadOid:$head}) { mergeQueueEntry { id } } }'
variables['head']=expected
else:
query = 'mutation($id:ID!) { dequeuePullRequest(input:{id:$id}) { mergeQueueEntry { id } } }'
client.gql(query,variables)
try:
after = same_base(same_head(client,number,expected), p)
if after.get('merged') is True:
if not after.get('merge_commit_sha'):
raise Failure(6,'verification','Merged PR lacks merge SHA',outcome='unknown')
return dict(supported=True,state='merged',method=method,entry_id=None,expected_head=expected,verified=True,merge_sha=after['merge_commit_sha'])
_, readback = github_conversations(client,number)
desired = ('enabled' if enabling else 'disabled') if operation == 'auto_merge' else ('queued' if enabling else 'absent')
actual = readback[key]
if actual['state'] != desired or enabling and operation == 'auto_merge' and actual.get('method','').lower() != method or desired == 'queued' and not actual.get('entry_id'):
raise Failure(6,'verification','Integration readback does not prove requested state',outcome='unknown')
same_base(same_head(client,number,expected), p)
except Failure as e:
e.outcome='unknown'
raise
return dict(supported=True,state=desired,method=method,entry_id=actual.get('entry_id'),expected_head=expected,verified=True)
def workflow_verdict(data, mode):
if data.get('state') == 'merged':
return 'merged'
if data.get('state') == 'closed':
return 'closed_unmerged'
if mode == 'full-stop' and (data.get('auto_merge',{}).get('state') != 'disabled' or data.get('queue',{}).get('state') != 'absent'):
return 'blocked'
if data.get('reviews',{}).get('decision') in ('changes_requested','codeowner_approval_unknown'):
return 'blocked'
if data.get('checks',{}).get('state') in ('failure','cancelled','unknown','missing'):
return 'blocked'
if data.get('ready') is True:
return 'ready_to_merge'
if data.get('mergeability') in ('conflict','unknown') or any(b.startswith('review_threads') or 'unknown' in b for b in data.get('blockers',[])):
return 'blocked'
return 'waiting'
def ci_wait(client, number, expected, timeout, interval):
if timeout <= 0 or interval <= 0:
raise Failure(2,'usage','Timeout and interval must be positive')
deadline = time.monotonic() + timeout
while True:
data = snapshot(client,number,expected)
state = data['checks']['state']
if state in ('success','not_required'):
return data
if state in ('failure','cancelled'):
raise Failure(3,'gate','CI reached terminal ' + state)
if time.monotonic() >= deadline:
raise Failure(124,'timeout','CI wait timed out')
time.sleep(min(interval,max(0,deadline-time.monotonic())))
def main():
class Parser(argparse.ArgumentParser):
def error(self, message):
print(message, file=sys.stderr)
print(json.dumps(dict(schema_version=1,operation=sys.argv[1] if len(sys.argv)>1 else 'unknown',provider='unknown',host=None,repo=None,number=None,head_sha=None,data=None,error=dict(kind='usage',message=message,retry_after_seconds=None,write_outcome='none'))))
raise SystemExit(2)
parser = Parser(description='Versioned forge adapter. See README for provider limitations.')
parser.add_argument('operation', choices=['capabilities', 'status', 'watch-check', 'review-comments', 'review-reply', 'review-resolve', 'merge', 'auto-merge', 'merge-queue', 'ci-wait'])
parser.add_argument('-n', '--number', type=int)
parser.add_argument('-r', '--repo')
parser.add_argument('--host')
parser.add_argument('--json', action='store_true')
parser.add_argument('--operation', dest='requested_operation')
parser.add_argument('--expect-head')
parser.add_argument('--baseline')
parser.add_argument('-m', '--method')
parser.add_argument('--action')
parser.add_argument('-t','--timeout',type=int,default=1800)
parser.add_argument('-i','--interval',type=int,default=15)
parser.add_argument('--comment-id')
parser.add_argument('--thread-id')
parser.add_argument('--body-file')
resolution = parser.add_mutually_exclusive_group()
resolution.add_argument('--resolve', action='store_true')
resolution.add_argument('--unresolve', action='store_true')
args = parser.parse_args()
client, data, head, error, code = None, None, None, None, 0
try:
if args.number is not None and args.number < 1:
raise Failure(2, 'usage', 'PR number must be positive')
if args.operation != 'capabilities' and not args.number:
raise Failure(2, 'usage', '-n NUMBER is required')
if args.expect_head and not re.fullmatch('[0-9a-fA-F]{40}', args.expect_head):
raise Failure(2, 'usage', '--expect-head requires a full SHA')
if args.operation in ('review-reply','review-resolve','merge') and not args.expect_head:
raise Failure(2,'usage','Mutation requires --expect-head')
if args.operation == 'review-reply' and (not args.comment_id or not args.body_file or not pathlib.Path(args.body_file).is_file()):
raise Failure(2,'usage','Reply requires --comment-id and a readable --body-file')
if args.operation == 'review-resolve' and (not args.thread_id or not (args.resolve or args.unresolve)):
raise Failure(2,'usage','Resolution requires --thread-id and --resolve or --unresolve')
if args.operation in ('auto-merge','merge-queue') and args.action not in (('inspect','enable','disable') if args.operation == 'auto-merge' else ('inspect','enqueue','dequeue')):
raise Failure(2,'usage','Explicit --action is required')
if args.operation in ('auto-merge','merge-queue') and args.action != 'inspect' and not args.expect_head:
raise Failure(2,'usage','Mutation requires --expect-head')
if args.operation == 'watch-check' and (not args.baseline or not os.path.isabs(args.baseline)):
raise Failure(2,'usage','--baseline requires an absolute path')
if not args.host or not args.repo:
p = run(['git', 'remote', 'get-url', 'origin'])
remote = p.stdout.strip()
if remote.startswith('git@'):
remote = 'ssh://' + remote.replace(':', '/', 1)
parsed = urllib.parse.urlparse(remote)
args.host = args.host or parsed.hostname
args.repo = args.repo or parsed.path.strip('/').removesuffix('.git')
if args.host:
args.host = args.host.lower()
if not args.host or not re.fullmatch(r'[A-Za-z0-9.-]+(?::[0-9]+)?', args.host) or not args.repo or not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', args.repo) or any(part in ('.','..') for part in args.repo.split('/')):
raise Failure(2, 'usage', 'Use an unambiguous origin or --host HOST --repo OWNER/REPO')
if args.operation != 'capabilities' and not re.fullmatch(r'[A-Za-z0-9_.-]+', os.environ.get('MOSAIC_GIT_IDENTITY', '')):
raise Failure(5, 'access', 'Explicit MOSAIC_GIT_IDENTITY is required before provider requests')
client = Client(args.host, args.repo)
client.detect()
if args.operation == 'capabilities':
data = {'version': client.version, 'operations': capabilities(client.provider, client.version)}
if args.requested_operation:
if args.requested_operation not in data['operations']:
raise Failure(2, 'usage', 'Unknown capability operation')
require(client, args.requested_operation)
elif args.operation == 'ci-wait':
data = ci_wait(client,args.number,args.expect_head,args.timeout,args.interval)
head = data['head_sha']
elif args.operation in ('merge','auto-merge','merge-queue'):
if args.operation == 'merge':
if not args.expect_head:
raise Failure(2,'usage','Merge requires --expect-head')
data = merge(client,args.number,args.method,args.expect_head)
else:
data = integration(client,args.number,'auto_merge' if args.operation == 'auto-merge' else 'merge_queue',args.action,args.method,args.expect_head)
head = data.get('head_sha') or args.expect_head
elif args.operation == 'review-comments':
before = client.pr(args.number)['head']['sha']
data, _, _ = review_comments(client, args.number)
same_head(client, args.number, before)
head = before
elif args.operation in ('review-reply', 'review-resolve'):
if not args.expect_head:
raise Failure(2, 'usage', 'Mutation requires --expect-head')
head = args.expect_head
if args.operation == 'review-reply':
if not args.comment_id or not args.body_file:
raise Failure(2, 'usage', 'Reply requires --comment-id and --body-file')
try:
body = pathlib.Path(args.body_file).read_text()
except OSError:
raise Failure(2, 'usage', 'Body file unreadable')
if not body.strip():
raise Failure(2, 'usage', 'Reply body must not be empty')
data = reply(client, args.number, args.comment_id, body, args.expect_head)
else:
if not args.thread_id or not (args.resolve or args.unresolve):
raise Failure(2, 'usage', 'Resolution requires --thread-id and --resolve or --unresolve')
data = resolve(client, args.number, args.thread_id, args.resolve, args.expect_head)
else:
data = snapshot(client, args.number, args.expect_head)
head = data['head_sha']
if args.operation == 'watch-check':
if not args.baseline or not os.path.isabs(args.baseline):
raise Failure(2, 'usage', '--baseline must be an absolute JSON path')
try:
baseline = json.loads(pathlib.Path(args.baseline).read_text())
except (OSError, ValueError):
raise Failure(6, 'malformed', 'Baseline cannot be read as JSON')
if baseline.get('schema_version') != 1 or baseline.get('error') is not None or not re.fullmatch('[0-9a-f]{64}',(baseline.get('data') or {}).get('fingerprint','')):
raise Failure(6,'malformed','Baseline is not a successful version-1 snapshot')
if any(baseline.get(k) != v for k, v in [('provider', client.provider), ('host', args.host), ('repo', args.repo), ('number', args.number)]):
raise Failure(6, 'malformed', 'Baseline belongs to a different PR/watch owner')
code = 0 if data['state'] != 'open' or data['ready'] or workflow_verdict(data,'full-send') == 'blocked' or data['fingerprint'] != (baseline.get('data') or {}).get('fingerprint') else 1
except Failure as e:
if data is None and args.operation in ('status','watch-check','ci-wait'):
data = dict(ready=False,checks={'state':'unknown','complete':False},reviews={'decision':'unknown','complete':False},threads={'unresolved':None,'complete':False})
code = e.code
if client and client.write_attempted and e.outcome == 'none' and code in (6,75,124):
e.outcome = 'unknown'
error = dict(kind=e.kind, message=str(e), retry_after_seconds=e.retry, write_outcome=e.outcome)
print(str(e), file=sys.stderr)
except (ValueError, TypeError, KeyError, AttributeError) as e:
code = 6
error = dict(kind='malformed', message='Provider evidence malformed: ' + str(e), retry_after_seconds=None, write_outcome='unknown' if client and client.write_attempted else 'none')
print(json.dumps(dict(schema_version=1, operation=args.operation, provider=client.provider if client else 'unknown', host=args.host, repo=args.repo, number=args.number, head_sha=head, data=data, error=error), sort_keys=True))
if args.operation == 'ci-wait' and not args.json and code not in (0,2,124):
return 1
return code
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/forge-api.py" capabilities "$@"
@@ -48,6 +48,12 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
# Credential resolution and all consumers execute inside this private boundary.
# Never restore xtrace while credentials remain in scope.
mosaic_private_forge_main() (
set +x
usage() {
echo "Usage: grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]"
echo ""
@@ -170,12 +176,7 @@ ORG="${REPO_SLUG%%/*}"
REPO_NAME="${REPO_SLUG#*/}"
RESPONSE_FILE=$(mktemp "${TMPDIR:-/tmp}/mosaic-grant-reviewer-resp.XXXXXX")
AUTH_CONFIG=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$RESPONSE_FILE"
echo "Error: could not stage Gitea credential for reviewer grant" >&2
exit 1
}
trap 'rm -f "$RESPONSE_FILE" "$AUTH_CONFIG"' EXIT
trap 'rm -f "$RESPONSE_FILE"' EXIT
# gitea_api <step> <method> <path> [json-payload]
# Runs one API call with the staged credential (token never in argv). Sets
@@ -189,9 +190,8 @@ gitea_api() {
if [[ -n "$payload" ]]; then
payload_args=(-H 'Content-Type: application/json' -d "$payload")
fi
if ! GITEA_API_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' \
if ! GITEA_API_STATUS=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$RESPONSE_FILE" -w '%{http_code}' \
-X "$method" \
--config "$AUTH_CONFIG" \
"${payload_args[@]}" \
"$GITEA_API_ROOT$path"); then
echo "Error: Gitea transport failed during $step" >&2
@@ -341,3 +341,6 @@ fi
echo "Granted: '$REVIEWER' is a member of team '$TEAM' (id $TEAM_ID) with access to '$REPO_SLUG' (code read, issues/pulls write) — verified by read-back"
echo "Note: under branch protection with required approvals this reviewer's approvals are official=false unless the branch's approvals whitelist includes the team (operator decision; reviews submitted before whitelisting must be re-submitted)."
)
mosaic_private_forge_main "$@"
@@ -9,6 +9,12 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
# Credential resolution and all consumers execute inside this private boundary.
# Never restore xtrace while credentials remain in scope.
mosaic_private_forge_main() (
set +x
# Parse arguments
ISSUE_NUMBER=""
COMMENT=""
@@ -96,9 +102,8 @@ import os
print(json.dumps({"body": os.environ["COMMENT"]}))
PY
)
curl -fsS -X POST \
gitea_curl_with_token "$token" -fsS -X POST \
-H "User-Agent: curl/8" \
-H "Authorization: token ${token}" \
-H "Content-Type: application/json" \
-d "$payload" \
"$url" >/dev/null
@@ -109,9 +114,8 @@ gitea_issue_close_api() {
host=$(get_remote_host) || return 1
token=$(get_gitea_token "$host") || return 1
url="https://${host}/api/v1/repos/${OWNER}/${REPO}/issues/${ISSUE_NUMBER}"
curl -fsS -X PATCH \
gitea_curl_with_token "$token" -fsS -X PATCH \
-H "User-Agent: curl/8" \
-H "Authorization: token ${token}" \
-H "Content-Type: application/json" \
-d '{"state":"closed"}' \
"$url" >/dev/null
@@ -175,3 +179,6 @@ else
echo "Error: Unknown platform"
exit 1
fi
)
mosaic_private_forge_main "$@"
@@ -28,6 +28,12 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
# Credential resolution and all consumers execute inside this private boundary.
# Never restore xtrace while credentials remain in scope.
mosaic_private_forge_main() (
set +x
# Parse arguments
ISSUE_NUMBER=""
COMMENT=""
@@ -184,18 +190,12 @@ gitea_resolve_api_for_login() {
# a concurrent write from a DIFFERENT identity cannot satisfy verification.
# Prints the login on success.
gitea_authenticated_login() {
local response_file auth_config status
local response_file status
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-whoami.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$response_file"
echo "Error: could not stage Gitea credential for identity read" >&2
return 1
}
trap 'rm -f "$response_file" "$auth_config"' RETURN
trap 'rm -f "$response_file"' RETURN
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
--config "$auth_config" \
if ! status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$response_file" -w '%{http_code}' \
"$GITEA_API_ROOT/user"); then
echo "Error: Gitea authenticated-identity read transport failed" >&2
return 1
@@ -237,7 +237,7 @@ PY
# Args: $1 = issue number, $2 = comment body, $3 = acting identity login.
gitea_create_comment_verified() {
local issue_number="$1" comment_body="$2" acting_login="$3"
local payload write_file readback_file auth_config write_status readback_status created_id
local payload write_file readback_file write_status readback_status created_id
payload=$(COMMENT_BODY="$comment_body" python3 -c '
import json
@@ -247,16 +247,10 @@ print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
')
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-write.XXXXXX")
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-getid.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$write_file" "$readback_file"
echo "Error: could not stage Gitea credential for comment write" >&2
return 1
}
trap 'rm -f "$write_file" "$readback_file" "$auth_config"' RETURN
trap 'rm -f "$write_file" "$readback_file"' RETURN
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
if ! write_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$write_file" -w '%{http_code}' \
-X POST \
--config "$auth_config" \
-H 'Content-Type: application/json' \
-d "$payload" \
"$GITEA_API_BASE/issues/$issue_number/comments"); then
@@ -285,8 +279,7 @@ print(created_id)
PY
) || return 1
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
--config "$auth_config" \
if ! readback_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$readback_file" -w '%{http_code}' \
"$GITEA_API_BASE/issues/comments/$created_id"); then
echo "Error: Gitea comment read-back transport failed" >&2
return 1
@@ -421,3 +414,6 @@ else
echo "Error: Unknown platform"
exit 1
fi
)
mosaic_private_forge_main "$@"
@@ -7,6 +7,12 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
# Credential resolution and all consumers execute inside this private boundary.
# Never restore xtrace while credentials remain in scope.
mosaic_private_forge_main() (
set +x
# Default values
TITLE=""
BODY=""
@@ -49,9 +55,8 @@ PY
)
url="https://${host}/api/v1/repos/${repo}/issues"
curl -fsS -X POST \
gitea_curl_with_token "$token" -fsS -X POST \
-H "User-Agent: curl/8" \
-H "Authorization: token ${token}" \
-H "Content-Type: application/json" \
-d "$payload" \
"$url"
@@ -200,3 +205,6 @@ case "$PLATFORM" in
exit 1
;;
esac
)
mosaic_private_forge_main "$@"
@@ -8,6 +8,12 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
# Credential resolution and all consumers execute inside this private boundary.
# Never restore xtrace while credentials remain in scope.
mosaic_private_forge_main() (
set +x
# Parse arguments
ISSUE_NUMBER=""
COMMENT=""
@@ -94,9 +100,8 @@ import os
print(json.dumps({"body": os.environ["COMMENT"]}))
PY
)
curl -fsS -X POST \
gitea_curl_with_token "$token" -fsS -X POST \
-H "User-Agent: curl/8" \
-H "Authorization: token ${token}" \
-H "Content-Type: application/json" \
-d "$payload" \
"$url" >/dev/null
@@ -107,9 +112,8 @@ gitea_issue_reopen_api() {
host=$(get_remote_host) || return 1
token=$(get_gitea_token "$host") || return 1
url="https://${host}/api/v1/repos/${OWNER}/${REPO}/issues/${ISSUE_NUMBER}"
curl -fsS -X PATCH \
gitea_curl_with_token "$token" -fsS -X PATCH \
-H "User-Agent: curl/8" \
-H "Authorization: token ${token}" \
-H "Content-Type: application/json" \
-d '{"state":"open"}' \
"$url" >/dev/null
@@ -156,3 +160,6 @@ else
echo "Error: Unknown platform"
exit 1
fi
)
mosaic_private_forge_main "$@"
@@ -7,6 +7,12 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
# Credential resolution and all consumers execute inside this private boundary.
# Never restore xtrace while credentials remain in scope.
mosaic_private_forge_main() (
set +x
# Parse arguments
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
@@ -36,12 +42,12 @@ gitea_issue_view_api() {
}
url="https://${host}/api/v1/repos/${repo}/issues/${ISSUE_NUMBER}"
local -a curl_args=(-fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}")
local -a curl_args=(-fsS -H "User-Agent: curl/8")
if ! command -v python3 >/dev/null 2>&1; then
# No renderer: raw JSON is all this path can give. Comments are a
# second resource, so fetch them too rather than only the count.
curl "${curl_args[@]}" "$url"
curl "${curl_args[@]}" "${url}/comments"
gitea_curl_with_token "$token" "${curl_args[@]}" "$url"
gitea_curl_with_token "$token" "${curl_args[@]}" "${url}/comments"
return
fi
# Render issue + comments as text (#1357 F2). The old fallback dumped the
@@ -49,10 +55,10 @@ gitea_issue_view_api() {
# invisible on this path and the wrapper could never show what
# `tea issues --comments` shows.
{
curl "${curl_args[@]}" "$url"
gitea_curl_with_token "$token" "${curl_args[@]}" "$url"
echo
echo "__MOSAIC_COMMENTS__"
curl "${curl_args[@]}" "${url}/comments"
gitea_curl_with_token "$token" "${curl_args[@]}" "${url}/comments"
} | python3 -c '
import json, sys
raw = sys.stdin.read()
@@ -145,3 +151,6 @@ else
echo "Error: Unknown platform"
exit 1
fi
)
mosaic_private_forge_main "$@"
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Validate and resolve the optional v3 merge declaration. No provider writes."""
import argparse
import json
import pathlib
import sys
METHODS = {'squash', 'merge', 'rebase', 'rebase-merge', 'fast-forward-only'}
SHAPES = {'task', 'promotion', 'absorb'}
def policy(doc):
if 'merge' not in doc:
return {'allowed_methods':['squash'], 'default_method':'squash', 'shapes':{}}
if doc.get('schema_version') != 3:
raise ValueError('merge requires schema_version 3')
p = doc['merge']
if not isinstance(p, dict) or set(p) - {'allowed_methods','default_method','shapes'}:
raise ValueError('merge must be an object containing only allowed_methods, default_method and shapes')
allowed = p.get('allowed_methods')
if not isinstance(allowed, list) or not allowed or any(not isinstance(m,str) or m not in METHODS for m in allowed) or len(allowed) != len(set(allowed)):
raise ValueError('merge.allowed_methods must be a non-empty unique subset of supported methods')
if not isinstance(p.get('default_method'), str) or p['default_method'] not in allowed:
raise ValueError('merge.default_method must be in allowed_methods')
shapes = p.get('shapes', {})
if not isinstance(shapes, dict) or any(k not in SHAPES or not isinstance(v,str) or v not in allowed for k,v in shapes.items()):
raise ValueError('merge.shapes accepts task, promotion and absorb, with methods in allowed_methods')
return dict(p, shapes=shapes)
def resolve(doc, head, base, request):
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'
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:
return 3, {'error':'POLICY-REFUSED: requested method conflicts with repository merge policy', 'shape':shape}
if shape == 'absorb' and not mapped:
return 3, {'error':'POLICY-REFUSED: absorb transition requires an explicit shape mapping', 'shape':shape}
if base != trunk and not (shape == 'promotion' and doc.get('flow') == 'trunk-release'):
return 3, {'error':'POLICY-REFUSED: undeclared target transition', 'shape':shape}
return 0, dict(method=selected, shape=shape, allowed_methods=p['allowed_methods'])
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('file')
parser.add_argument('--validate', action='store_true')
parser.add_argument('--head')
parser.add_argument('--base')
parser.add_argument('--method')
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)
except (OSError, ValueError, TypeError) as e:
code, value = 6, {'error':'VALIDATION_ERROR merge: ' + str(e)}
print(json.dumps(value, sort_keys=True))
return code
if __name__ == '__main__':
sys.exit(main())
@@ -221,7 +221,7 @@ fi
repo_decl_warn_absent_irreversible "worktree branch-creation"
;;
valid)
if [ "$DECL_SCHEMA" = 2 ] && [ "$DECL_POLICY" = "orchestrator-precreated" ]; then
if [ "$DECL_SCHEMA" -ge 2 ] && [ "$DECL_POLICY" = "orchestrator-precreated" ]; then
echo "repo-decl: worktree_policy=orchestrator-precreated (TRANSITIONAL, spec 4.5): tasking pre-creates worktrees; creating one directly is the interim path until the J3/#1174 amendment unblocks the wrapper consumer." >&2
fi
if [ -n "${MOSAIC_HOST_ROOT:-}" ] && [ -n "$DECL_WT_ROOT" ]; then
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/forge-api.py" auto-merge "$@"
@@ -1,372 +1,5 @@
#!/bin/bash
# pr-ci-wait.sh - Wait for PR CI status to reach terminal state (GitHub/Gitea)
# Usage: pr-ci-wait.sh -n <pr_number> [-r owner/repo] [-t timeout_sec] [-i interval_sec]
#!/usr/bin/env bash
# CI wait consumes normalized checks; default exits preserve 0/1/2/124.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER=""
TIMEOUT_SEC=1800
INTERVAL_SEC=15
REPO_OVERRIDE=""
HOST_OVERRIDE=""
usage() {
cat <<EOF
Usage: $(basename "$0") -n <pr_number> [-t timeout_sec] [-i interval_sec]
Options:
-n, --number NUMBER PR number (required)
-r, --repo OWNER/REPO Repository slug (default: infer from git origin)
--host HOST Gitea host for --repo API calls (or set GITEA_HOST/GITEA_URL)
-t, --timeout SECONDS Max wait time in seconds (default: 1800)
-i, --interval SECONDS Poll interval in seconds (default: 15)
-h, --help Show this help
Examples:
$(basename "$0") -n 643
$(basename "$0") -n 643 --repo ddk/ai-bma
$(basename "$0") -n 643 -t 900 -i 10
EOF
}
# get_remote_host and get_gitea_token are provided by detect-platform.sh
extract_state_from_status_json() {
# Capture piped JSON BEFORE invoking `python3 - <<PY`. The heredoc binds
# stdin to the Python program text — so json.load(sys.stdin) inside would
# try to re-read stdin after `-` already consumed it for the program,
# yielding EOF and returning "unknown" every time. Pass payload via env.
local payload
payload=$(cat)
PR_CI_STATUS_JSON="$payload" python3 - <<'PY'
import json
import os
import sys
try:
payload = json.loads(os.environ.get("PR_CI_STATUS_JSON", ""))
except Exception:
print("unknown")
raise SystemExit(0)
state = (payload.get("state") or "").lower()
if state in {"success", "pending", "failure", "error"}:
print(state)
raise SystemExit(0)
statuses = payload.get("statuses") or []
values = []
for item in statuses:
if not isinstance(item, dict):
continue
value = (item.get("status") or item.get("state") or "").lower()
if value:
values.append(value)
if any(v in {"failure", "error"} for v in values):
print("failure")
elif values and all(v == "success" for v in values):
print("success")
elif any(v in {"pending", "running", "queued", "waiting"} for v in values):
print("pending")
elif not values and not state:
# No pipeline/status of any kind reported for this commit. Distinct from
# "unknown" (an ambiguous/unrecognized status that should keep polling):
# this signals a repo/commit that simply has no CI configured.
print("no-status")
else:
print("unknown")
PY
}
print_status_summary() {
# Same stdin-collision fix as extract_state_from_status_json above.
local payload
payload=$(cat)
PR_CI_STATUS_JSON="$payload" python3 - <<'PY'
import json
import os
import sys
try:
payload = json.loads(os.environ.get("PR_CI_STATUS_JSON", ""))
except Exception:
print("[pr-ci-wait] status payload unavailable")
raise SystemExit(0)
statuses = payload.get("statuses") or []
if not statuses:
print("[pr-ci-wait] no status contexts reported yet")
raise SystemExit(0)
for item in statuses:
if not isinstance(item, dict):
continue
name = item.get("context") or item.get("name") or "unknown-context"
state = item.get("status") or item.get("state") or "unknown-state"
target = item.get("target_url") or item.get("url") or ""
if target:
print(f"[pr-ci-wait] {name}: {state} ({target})")
else:
print(f"[pr-ci-wait] {name}: {state}")
PY
}
github_get_pr_head_sha() {
gh pr view "$PR_NUMBER" --repo "$OWNER/$REPO" --json headRefOid --jq '.headRefOid'
}
github_get_commit_status_json() {
local owner="$1"
local repo="$2"
local sha="$3"
gh api "repos/${owner}/${repo}/commits/${sha}/status"
}
gitea_get_pr_head_sha() {
local host="$1"
local repo="$2"
local token="$3"
local url="https://${host}/api/v1/repos/${repo}/pulls/${PR_NUMBER}"
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -c '
import json, sys
data = json.load(sys.stdin)
print((data.get("head") or {}).get("sha", ""))
'
}
gitea_get_commit_status_json() {
local host="$1"
local repo="$2"
local token="$3"
local sha="$4"
local url="https://${host}/api/v1/repos/${repo}/commits/${sha}/status"
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
}
gitea_get_default_branch() {
local host="$1"
local repo="$2"
local token="$3"
local url="https://${host}/api/v1/repos/${repo}"
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -c '
import json, sys
print((json.load(sys.stdin) or {}).get("default_branch", ""))
'
}
github_get_default_branch() {
gh api "repos/${OWNER}/${REPO}" --jq '.default_branch'
}
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--number)
PR_NUMBER="$2"
shift 2
;;
-r|--repo)
REPO_OVERRIDE="$2"
shift 2
;;
--host)
HOST_OVERRIDE="$2"
shift 2
;;
-t|--timeout)
TIMEOUT_SEC="$2"
shift 2
;;
-i|--interval)
INTERVAL_SEC="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ -z "$PR_NUMBER" ]]; then
echo "Error: PR number is required (-n)." >&2
usage >&2
exit 1
fi
if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; then
echo "Error: timeout and interval must be integer seconds." >&2
exit 1
fi
if [[ -n "$REPO_OVERRIDE" ]]; then
REPO_INFO="$REPO_OVERRIDE"
PLATFORM=$(detect_platform 2>/dev/null || echo gitea)
else
detect_platform > /dev/null
REPO_INFO=$(get_repo_info)
fi
if [[ -z "$REPO_INFO" || "$REPO_INFO" == error:* || "$REPO_INFO" != */* ]]; then
echo "Error: Could not determine repository from git origin. Run from a repo or pass --repo owner/repo." >&2
exit 1
fi
OWNER=${REPO_INFO%%/*}
REPO=${REPO_INFO##*/}
START_TS=$(date +%s)
DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
if [[ "$PLATFORM" == "github" ]]; then
if ! command -v gh >/dev/null 2>&1; then
echo "Error: gh CLI is required for GitHub CI status polling." >&2
exit 1
fi
HEAD_SHA=$(github_get_pr_head_sha)
if [[ -z "$HEAD_SHA" ]]; then
echo "Error: Could not resolve head SHA for PR #$PR_NUMBER." >&2
exit 1
fi
echo "[pr-ci-wait] Platform=github PR=#${PR_NUMBER} head_sha=${HEAD_SHA}"
elif [[ "$PLATFORM" == "gitea" ]]; then
if [[ -n "$HOST_OVERRIDE" ]]; then
HOST="$HOST_OVERRIDE"
elif [[ -n "$REPO_OVERRIDE" ]]; then
HOST=$(get_gitea_api_host_for_repo_override) || {
echo "Error: Gitea host is required with --repo. Pass --host or set GITEA_HOST/GITEA_URL." >&2
exit 1
}
else
HOST=$(get_remote_host) || {
echo "Error: Could not determine Gitea host from git origin." >&2
exit 1
}
fi
TOKEN=$(get_gitea_token "$HOST") || {
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
exit 1
}
HEAD_SHA=$(gitea_get_pr_head_sha "$HOST" "$OWNER/$REPO" "$TOKEN")
if [[ -z "$HEAD_SHA" ]]; then
echo "Error: Could not resolve head SHA for PR #$PR_NUMBER." >&2
exit 1
fi
echo "[pr-ci-wait] Platform=gitea host=${HOST} repo=${OWNER}/${REPO} PR=#${PR_NUMBER} head_sha=${HEAD_SHA}"
else
echo "Error: Unsupported platform '${PLATFORM}'." >&2
exit 1
fi
# No-CI determination is TWO-TIER (primary: CI history; secondary: empty-poll streak).
#
# PRIMARY — "does this repo run CI at all?" Probed once, up front, from the DEFAULT
# BRANCH's commit status. A repo whose default branch carries CI statuses
# demonstrably runs CI, so an EMPTY status on the PR head means the pipeline simply
# has not registered YET (webhook/queue lag) — NOT that the repo is CI-less. In that
# case we must NEVER fast-green; we keep polling until the pipeline registers or the
# timeout fires (both safe). This closes the webhook-lag false-green: a slow-to-
# register pipeline feeding a merge gate can no longer be mistaken for "no CI".
#
# SECONDARY — the empty-poll streak below applies ONLY to genuinely CI-less repos
# (default branch also has no CI history, e.g. device-imaging class), where burning
# the full timeout would be pure waste. There, NO_CI_MAX empty polls => fast-exit 0.
#
# Probe failure is treated conservatively as REPO_HAS_CI=1 (assume CI present): we
# would rather wait-then-timeout than risk a false-green, per the merge-gate priority.
REPO_HAS_CI=1
detect_repo_ci() {
local def_branch def_status
# Every early exit returns 0: a probe miss must leave the conservative
# REPO_HAS_CI=1 default in place, never abort the caller under `set -e`.
if [[ "$PLATFORM" == "github" ]]; then
def_branch=$(github_get_default_branch 2>/dev/null) || {
echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; }
[[ -n "$def_branch" ]] || return 0
def_status=$(github_get_commit_status_json "$OWNER" "$REPO" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0
else
def_branch=$(gitea_get_default_branch "$HOST" "$OWNER/$REPO" "$TOKEN" 2>/dev/null) || {
echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; }
[[ -n "$def_branch" ]] || return 0
def_status=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$TOKEN" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0
fi
if [[ "$def_status" == "no-status" || -z "$def_status" ]]; then
REPO_HAS_CI=0
echo "[pr-ci-wait] default branch '${def_branch}' has no CI status history — treating repo as CI-less (empty-poll fast-exit enabled)."
else
REPO_HAS_CI=1
echo "[pr-ci-wait] default branch '${def_branch}' has CI history (state=${def_status}) — repo runs CI; empty status on PR head => awaiting registration, will not fast-green."
fi
}
detect_repo_ci || true
NO_CI_STREAK=0
NO_CI_MAX=3
while true; do
NOW_TS=$(date +%s)
if (( NOW_TS > DEADLINE_TS )); then
echo "Error: Timed out waiting for CI status on PR #$PR_NUMBER after ${TIMEOUT_SEC}s." >&2
exit 124
fi
if [[ "$PLATFORM" == "github" ]]; then
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
else
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$TOKEN" "$HEAD_SHA")
fi
STATE=$(printf '%s' "$STATUS_JSON" | extract_state_from_status_json)
echo "[pr-ci-wait] state=${STATE} pr=#${PR_NUMBER} sha=${HEAD_SHA}"
case "$STATE" in
success)
printf '%s' "$STATUS_JSON" | print_status_summary
echo "[pr-ci-wait] CI is green for PR #$PR_NUMBER."
exit 0
;;
failure|error)
printf '%s' "$STATUS_JSON" | print_status_summary
echo "Error: CI reported ${STATE} for PR #$PR_NUMBER." >&2
exit 1
;;
no-status)
if [[ "$REPO_HAS_CI" == "1" ]]; then
# PRIMARY tier: repo demonstrably runs CI but this commit's pipeline
# has not registered yet (webhook/queue lag). Do NOT fast-green — keep
# polling until it registers or the timeout fires. Reset the streak so
# a later genuine CI-less misread can't accumulate across this state.
NO_CI_STREAK=0
echo "[pr-ci-wait] empty status on PR head but repo runs CI — awaiting pipeline registration (webhook lag), not fast-greening."
else
# SECONDARY tier: genuinely CI-less repo (default branch has no CI
# history either). Empty polls => fast-exit green after NO_CI_MAX.
NO_CI_STREAK=$((NO_CI_STREAK + 1))
if (( NO_CI_STREAK >= NO_CI_MAX )); then
echo "[INFO] no CI configured for this repo/commit (PR #$PR_NUMBER, ${NO_CI_STREAK} consecutive empty polls, default branch also CI-less); treating as green."
exit 0
fi
fi
sleep "$INTERVAL_SEC"
;;
pending|unknown)
# A pipeline exists but hasn't reached a terminal state (or is
# transiently ambiguous) — keep waiting, and reset the no-CI streak
# since this commit is not in the "no CI at all" condition.
NO_CI_STREAK=0
sleep "$INTERVAL_SEC"
;;
*)
echo "[pr-ci-wait] Unrecognized state '${STATE}', continuing to poll..."
NO_CI_STREAK=0
sleep "$INTERVAL_SEC"
;;
esac
done
exec python3 -I "$SCRIPT_DIR/forge-api.py" ci-wait "$@"
@@ -7,6 +7,12 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
# Credential resolution and all consumers execute inside this private boundary.
# Never restore xtrace while credentials remain in scope.
mosaic_private_forge_main() (
set +x
# Default values
TITLE=""
BODY=""
@@ -33,9 +39,8 @@ gitea_default_branch() {
# `curl | python` reports only python's status, so an HTTP failure that
# still emits parseable JSON would masquerade as success. curl's own
# exit status is authoritative here.
if ! body=$(curl -fsS \
if ! body=$(gitea_curl_with_token "$token" -fsS \
-H "User-Agent: curl/8" \
-H "Authorization: token ${token}" \
"$url" 2>/dev/null); then
return 1
fi
@@ -105,9 +110,8 @@ PY
)
url="https://${host}/api/v1/repos/${repo}/pulls"
curl -fsS -X POST \
gitea_curl_with_token "$token" -fsS -X POST \
-H "User-Agent: curl/8" \
-H "Authorization: token ${token}" \
-H "Content-Type: application/json" \
-d "$payload" \
"$url"
@@ -243,9 +247,13 @@ if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then
repo_decl_load
else
DECL_STATE=absent; DECL_SCHEMA=""
# Compatibility callbacks are invoked by declaration checks below.
# shellcheck disable=SC2317
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
repo_decl_report_invalid() { :; }
# shellcheck disable=SC2317
repo_decl_warn_absent_reversible() { :; }
# shellcheck disable=SC2317
repo_decl_warn_absent_irreversible() { :; }
repo_decl_remote_matches() { return 0; }
repo_decl_check_transition() { return 2; }
@@ -254,10 +262,10 @@ EFFECTIVE_BASE="$BASE_BRANCH"
case "$DECL_STATE" in
invalid) repo_decl_report_invalid ;;
esac
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" != 2 ]]; then
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" -lt 2 ]]; then
repo_decl_warn "declaration is v$DECL_SCHEMA — carries no consumable flow/trunk fields; legacy behavior"
fi
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" -ge 2 ]]; then
# Write path: a normalized-remote mismatch refuses (spec 5.3).
if ! repo_decl_remote_matches; then
echo "Error: origin remote does not match the declared canonical_remote (spec 5.3, write path) — refusing to create a PR against the wrong forge. Fix the origin remote or the declaration." >&2
@@ -358,3 +366,6 @@ case "$PLATFORM" in
exit 1
;;
esac
)
mosaic_private_forge_main "$@"
+10 -1
View File
@@ -7,6 +7,12 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
# Credential resolution and all consumers execute inside this private boundary.
# Never restore xtrace while credentials remain in scope.
mosaic_private_forge_main() (
set +x
# Parse arguments
PR_NUMBER=""
OUTPUT_FILE=""
@@ -106,7 +112,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
GITEA_API_TOKEN=$(get_gitea_token "$HOST" || true)
if [[ -n "$GITEA_API_TOKEN" ]]; then
DIFF_CONTENT=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token $GITEA_API_TOKEN" "$DIFF_URL")
DIFF_CONTENT=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -H "User-Agent: curl/8" "$DIFF_URL")
else
DIFF_CONTENT=$(curl -sS -H "User-Agent: curl/8" "$DIFF_URL")
fi
@@ -120,3 +126,6 @@ else
echo "Error: Unknown platform" >&2
exit 1
fi
)
mosaic_private_forge_main "$@"
+17 -13
View File
@@ -8,6 +8,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=detect-platform.sh
source "$SCRIPT_DIR/detect-platform.sh"
# Credential resolution and all consumers execute inside this private boundary.
# Never restore xtrace while credentials remain in scope.
mosaic_private_forge_main() (
set +x
PR_NUMBER=""
TITLE=""
BODY=""
@@ -17,16 +23,16 @@ DRAFT_MODE=""
LOGIN_OVERRIDE=""
REPO_OVERRIDE=""
HOST_OVERRIDE=""
AUTH_CONFIG=""
cleanup() {
[[ -z "$AUTH_CONFIG" ]] || rm -f -- "$AUTH_CONFIG"
}
# Signal and EXIT traps invoke these callbacks.
# shellcheck disable=SC2317
cleanup() { :; }
# shellcheck disable=SC2317
terminate() {
local signal="$1"
trap - "$signal"
cleanup
kill -s "$signal" "$$"
kill -s "$signal" "$BASHPID"
}
trap cleanup EXIT
trap 'terminate HUP' HUP
@@ -166,18 +172,13 @@ case "$PLATFORM" in
echo "Error: login '$GITEA_LOGIN_NAME' is not configured for target host '$HOST'" >&2
exit 1
}
AUTH_CONFIG=$(gitea_write_auth_config "$TOKEN") || {
echo "Error: could not stage private Gitea authentication" >&2
exit 1
}
unset TOKEN
API_BASE="https://${HOST}/api/v1"
# Resolve identity through the SAME private curl config used for the
# mutation. Tea login names are globally scoped and can be duplicated
# across hosts; a separate `tea api --login NAME` could validate another
# credential than this host-bound token.
AUTHENTICATED_USER=$(curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$API_BASE/user" \
AUTHENTICATED_USER=$(gitea_curl_with_token "$TOKEN" -fsS -H "User-Agent: mosaic-pr-edit" "$API_BASE/user" \
| python3 -c 'import json,sys; value=json.load(sys.stdin).get("login"); print(value) if isinstance(value,str) and value else sys.exit(1)') || {
echo "Error: could not authenticate the host-bound credential for '$GITEA_LOGIN_NAME'" >&2
exit 1
@@ -188,7 +189,7 @@ case "$PLATFORM" in
}
REPO_API="$API_BASE/repos/${REPO_SLUG}"
curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$REPO_API" >/dev/null || {
gitea_curl_with_token "$TOKEN" -fsS -H "User-Agent: mosaic-pr-edit" "$REPO_API" >/dev/null || {
echo "Error: target repository preflight failed for https://${HOST}/${REPO_SLUG}" >&2
exit 1
}
@@ -204,10 +205,13 @@ if os.environ["DRAFT_MODE"]: payload["draft"] = os.environ["DRAFT_MODE"] == "dra
print(json.dumps(payload))
PY
)
curl -fsS --config "$AUTH_CONFIG" -X PATCH \
gitea_curl_with_token "$TOKEN" -fsS -X PATCH \
-H "User-Agent: mosaic-pr-edit" -H "Content-Type: application/json" \
-d "$PAYLOAD" "$REPO_API/pulls/${PR_NUMBER}"
echo "Updated Gitea pull request #$PR_NUMBER as '$AUTHENTICATED_USER'" >&2
;;
*) echo "Error: Could not detect git platform" >&2; exit 1 ;;
esac
)
mosaic_private_forge_main "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/forge-api.py" merge-queue "$@"
@@ -8,6 +8,28 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
source "$SCRIPT_DIR/detect-platform.sh"
# Credential resolution and all consumers execute inside this private boundary.
# Never restore xtrace while credentials remain in scope.
mosaic_private_forge_main() (
set +x
# Versioned JSON mode and explicit non-squash methods use the normalized adapter.
for ((arg_i=1; arg_i<=$#; arg_i++)); do
if [[ "${!arg_i}" == --json ]]; then
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
# Default values
PR_NUMBER=""
MERGE_METHOD="squash"
@@ -26,7 +48,7 @@ Merge a pull request on the current repository (Gitea or GitHub).
Options:
-n, --number NUMBER PR number to merge (required)
-m, --method METHOD Merge method: squash only (default: squash)
-m, --method METHOD Merge method (default: declared policy, legacy squash); --json uses verified v1 contract
-d, --delete-branch Delete the head branch after merge
--dry-run Run metadata/login preflight without merging
--expect-head SHA Refuse unless the PR head matches this full commit SHA
@@ -121,8 +143,8 @@ if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
fi
if [[ "$MERGE_METHOD" != "squash" ]]; then
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
exit 1
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
@@ -159,8 +181,11 @@ if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then
repo_decl_load
else
DECL_STATE=absent; DECL_SCHEMA=""
# Compatibility callbacks are invoked by declaration checks below.
# shellcheck disable=SC2317
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
repo_decl_report_invalid() { :; }
# shellcheck disable=SC2317
repo_decl_warn_absent_reversible() { :; }
repo_decl_warn_absent_irreversible() { :; }
repo_decl_remote_matches() { return 0; }
@@ -169,7 +194,7 @@ fi
if [[ "$DECL_STATE" == invalid ]]; then
repo_decl_report_invalid
fi
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" -ge 2 ]]; then
if ! repo_decl_remote_matches; then
echo "Error: origin remote does not match the declared canonical_remote (spec 5.3, write path) — refusing to merge against the wrong forge. Fix the origin remote or the declaration." >&2
exit 1
@@ -298,6 +323,8 @@ 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
@@ -716,3 +743,6 @@ case "$PLATFORM" in
esac
echo "PR #$PR_NUMBER merged successfully"
)
mosaic_private_forge_main "$@"
@@ -8,6 +8,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
source "$SCRIPT_DIR/detect-platform.sh"
# Credential resolution and all consumers execute inside this private boundary.
# Never restore xtrace while credentials remain in scope.
mosaic_private_forge_main() (
set +x
# Parse arguments
PR_NUMBER=""
OUTPUT_FILE=""
@@ -64,7 +70,7 @@ write_metadata() {
curl_gitea_pull() {
local api_url="$1"
local token basic_auth raw_code body_file http_code
local token raw_code body_file http_code
body_file=$(mktemp)
# shellcheck disable=SC2329 # Invoked by the RETURN trap below.
@@ -76,30 +82,16 @@ curl_gitea_pull() {
}
trap cleanup_gitea_pull_body RETURN
token=$(get_gitea_token "$HOST" || true)
if [[ -n "$token" ]]; then
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" -H "Authorization: token $token" "$api_url" || true)
if [[ "$raw_code" =~ ^2 ]]; then
cat "$body_file" || return $?
return 0
fi
http_code="$raw_code"
fi
basic_auth=$(get_gitea_basic_auth "$HOST" || true)
if [[ -n "$basic_auth" ]]; then
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -u "$basic_auth" -H "User-Agent: curl/8" "$api_url" || true)
if [[ "$raw_code" =~ ^2 ]]; then
cat "$body_file" || return $?
return 0
fi
http_code="$raw_code"
fi
if [[ -z "${http_code:-}" ]]; then
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" "$api_url" || true)
http_code="$raw_code"
token=$(get_gitea_token "$HOST") || {
echo 'Error: identity-bound metadata credential unavailable.' >&2
return 1
}
raw_code=$(gitea_curl_with_token "$token" -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" "$api_url") || return $?
if [[ "$raw_code" =~ ^2 ]]; then
cat "$body_file" || return $?
return 0
fi
http_code="$raw_code"
python3 - "$http_code" "$body_file" <<'PY' >&2
import json
@@ -262,3 +254,6 @@ else
echo "Error: Unknown platform" >&2
exit 1
fi
)
mosaic_private_forge_main "$@"
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/forge-api.py" review-comments "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/forge-api.py" review-reply "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/forge-api.py" review-resolve "$@"
@@ -35,6 +35,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
source "$SCRIPT_DIR/detect-platform.sh"
# Credential resolution and all consumers execute inside this private boundary.
# Never restore xtrace while credentials remain in scope.
mosaic_private_forge_main() (
set +x
# Parse arguments
PR_NUMBER=""
ACTION=""
@@ -223,7 +229,7 @@ PY
# Args: $1 = PR number, $2 = comment body, $3 = acting identity login.
gitea_create_comment_verified() {
local pr_number="$1" comment_body="$2" acting_login="$3"
local payload write_file readback_file auth_config write_status readback_status created_id
local payload write_file readback_file write_status readback_status created_id
payload=$(COMMENT_BODY="$comment_body" python3 -c '
import json
@@ -233,16 +239,10 @@ print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
')
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-write.XXXXXX")
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$write_file" "$readback_file"
echo "Error: could not stage Gitea credential for comment write" >&2
return 1
}
trap 'rm -f "$write_file" "$readback_file" "$auth_config"' RETURN
trap 'rm -f "$write_file" "$readback_file"' RETURN
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
if ! write_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$write_file" -w '%{http_code}' \
-X POST \
--config "$auth_config" \
-H 'User-Agent: mosaic-pr-review' \
-H 'Content-Type: application/json' \
-d "$payload" \
@@ -272,8 +272,7 @@ print(created_id)
PY
) || return 1
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
--config "$auth_config" \
if ! readback_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$readback_file" -w '%{http_code}' \
-H 'User-Agent: mosaic-pr-review' \
"$GITEA_API_BASE/issues/comments/$created_id"); then
echo "Error: Gitea comment read-back transport failed" >&2
@@ -411,7 +410,7 @@ PY
# residue.
gitea_resolve_api_for_login() {
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
local preflight_auth_config preflight_status
local preflight_status
if [[ -n "$HOST_OVERRIDE" ]]; then
host="$HOST_OVERRIDE"
@@ -459,15 +458,9 @@ gitea_resolve_api_for_login() {
GITEA_WEB_BASE="${configured_url%/}"
if [[ -n "$REPO_OVERRIDE" ]]; then
preflight_auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
echo "Error: could not stage Gitea credential for --repo preflight" >&2
return 1
}
preflight_status=$(curl -sS -o /dev/null -w '%{http_code}' \
--config "$preflight_auth_config" \
preflight_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o /dev/null -w '%{http_code}' \
-H 'User-Agent: mosaic-pr-review' \
"$GITEA_API_BASE") || preflight_status="000"
rm -f "$preflight_auth_config"
if [[ "$preflight_status" != "200" ]]; then
echo "Error: repo '$repo' not reachable at $configured_url (HTTP $preflight_status) — wrong host? pass -H/--host <gitea-host> or cd into the target checkout" >&2
return 1
@@ -481,18 +474,12 @@ gitea_resolve_api_for_login() {
# concurrent review from a DIFFERENT identity cannot satisfy verification.
# Prints the login on success.
gitea_authenticated_login() {
local response_file auth_config status
local response_file status
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-whoami.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$response_file"
echo "Error: could not stage Gitea credential for identity read" >&2
return 1
}
trap 'rm -f "$response_file" "$auth_config"' RETURN
trap 'rm -f "$response_file"' RETURN
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
--config "$auth_config" \
if ! status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$response_file" -w '%{http_code}' \
-H 'User-Agent: mosaic-pr-review' \
"$GITEA_API_ROOT/user"); then
echo "Error: Gitea authenticated-identity read transport failed" >&2
@@ -521,15 +508,14 @@ PY
}
# GET /pulls/{n} into a caller-owned response file and print its head commit
# SHA. This core sets NO RETURN trap and reuses a caller-provided auth config +
# SHA. This core sets NO RETURN trap and reuses a caller-provided
# response file, so it is safe to call from INSIDE another trapped function
# (the post-verify re-read below) without clobbering that function's cleanup
# trap. $1 = PR number, $2 = response file, $3 = curl auth config file.
# trap. $1 = PR number, $2 = response file.
gitea_read_pr_head_into() {
local pr_number="$1" pr_file="$2" auth_config="$3" status
local pr_number="$1" pr_file="$2" status
if ! status=$(curl -sS -o "$pr_file" -w '%{http_code}' \
--config "$auth_config" \
if ! status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$pr_file" -w '%{http_code}' \
-H 'User-Agent: mosaic-pr-review' \
"$GITEA_API_BASE/pulls/$pr_number"); then
echo "Error: Gitea PR head read transport failed" >&2
@@ -561,17 +547,12 @@ PY
# stale review left over from an earlier push cannot be mistaken for this one.
# Prints the head SHA on success.
gitea_pr_head_sha() {
local pr_number="$1" pr_file auth_config
local pr_number="$1" pr_file
pr_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-head.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$pr_file"
echo "Error: could not stage Gitea credential for PR head read" >&2
return 1
}
trap 'rm -f "$pr_file" "$auth_config"' RETURN
trap 'rm -f "$pr_file"' RETURN
gitea_read_pr_head_into "$pr_number" "$pr_file" "$auth_config"
gitea_read_pr_head_into "$pr_number" "$pr_file"
}
# Submit a review to a Gitea PR via the supported REST API and verify it against
@@ -591,7 +572,7 @@ gitea_pr_head_sha() {
# $5 = PR head sha.
gitea_submit_review_verified() {
local pr_number="$1" event="$2" review_body="$3" acting_login="$4" head_sha="$5"
local payload write_file readback_file recheck_file auth_config
local payload write_file readback_file recheck_file
local write_status readback_status created_id live_head
payload=$(REVIEW_EVENT="$event" REVIEW_BODY="$review_body" REVIEW_COMMIT="$head_sha" python3 -c '
@@ -607,16 +588,10 @@ print(json.dumps({
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-submit.XXXXXX")
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
recheck_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-recheck.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$write_file" "$readback_file" "$recheck_file"
echo "Error: could not stage Gitea credential for review submit" >&2
return 1
}
trap 'rm -f "$write_file" "$readback_file" "$recheck_file" "$auth_config"' RETURN
trap 'rm -f "$write_file" "$readback_file" "$recheck_file"' RETURN
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
if ! write_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$write_file" -w '%{http_code}' \
-X POST \
--config "$auth_config" \
-H 'User-Agent: mosaic-pr-review' \
-H 'Content-Type: application/json' \
-d "$payload" \
@@ -647,8 +622,7 @@ print(created_id)
PY
) || return 1
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
--config "$auth_config" \
if ! readback_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$readback_file" -w '%{http_code}' \
-H 'User-Agent: mosaic-pr-review' \
"$GITEA_API_BASE/pulls/$pr_number/reviews/$created_id"); then
echo "Error: Gitea review read-back transport failed" >&2
@@ -713,9 +687,9 @@ PY
# live tip carries unreviewed code — yet the wrapper would still report
# success. Re-read the LIVE PR head and require it STILL equals the submitted
# SHA; if it advanced, fail closed (nonzero, no created id emitted, no
# success line). This reuses the submit-scoped auth config + recheck file so
# success line). This reuses the submit-scoped recheck file so
# it neither leaks the token to argv nor clobbers this function's cleanup.
live_head=$(gitea_read_pr_head_into "$pr_number" "$recheck_file" "$auth_config") || {
live_head=$(gitea_read_pr_head_into "$pr_number" "$recheck_file") || {
echo "Error: could not re-read Gitea PR head after review verification" >&2
return 1
}
@@ -856,3 +830,6 @@ else
echo "Error: Unknown platform"
exit 1
fi
)
mosaic_private_forge_main "$@"
@@ -0,0 +1,251 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Forge wrapper envelope v1",
"type": "object",
"required": [
"schema_version",
"operation",
"provider",
"host",
"repo",
"number",
"head_sha",
"data",
"error"
],
"additionalProperties": false,
"properties": {
"schema_version": {
"const": 1
},
"operation": {
"type": "string"
},
"provider": {
"enum": ["github", "gitea", "forgejo", "unknown"]
},
"host": {
"type": ["string", "null"]
},
"repo": {
"type": ["string", "null"]
},
"number": {
"type": ["integer", "null"]
},
"head_sha": {
"type": ["string", "null"]
},
"data": {
"type": ["object", "null"]
},
"error": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"required": ["kind", "message", "retry_after_seconds", "write_outcome"],
"additionalProperties": false,
"properties": {
"kind": {
"type": "string"
},
"message": {
"type": "string"
},
"retry_after_seconds": {
"type": ["integer", "null"]
},
"write_outcome": {
"enum": ["none", "confirmed", "unknown"]
}
}
}
]
}
},
"$defs": {
"snapshot": {
"type": "object",
"required": [
"url",
"head_ref",
"base_ref",
"head_repository",
"base_repository",
"head_sha",
"base_sha",
"merge_sha",
"state",
"draft",
"mergeability",
"ready",
"blockers",
"observed_at",
"fingerprint",
"capabilities",
"checks",
"reviews",
"threads",
"auto_merge",
"queue"
],
"properties": {
"url": {
"type": ["string", "null"]
},
"head_ref": {
"type": ["string", "null"]
},
"base_ref": {
"type": ["string", "null"]
},
"head_repository": {
"type": ["string", "null"]
},
"base_repository": {
"type": ["string", "null"]
},
"head_sha": {
"type": ["string", "null"],
"pattern": "^[0-9a-fA-F]{40}$"
},
"base_sha": {
"type": ["string", "null"],
"pattern": "^[0-9a-fA-F]{40}$"
},
"merge_sha": {
"type": ["string", "null"],
"pattern": "^[0-9a-fA-F]{40}$"
},
"state": {
"enum": ["open", "closed", "merged"]
},
"draft": {
"type": ["boolean", "null"]
},
"mergeability": {
"enum": ["clean", "conflict", "unknown"]
},
"ready": {
"type": "boolean"
},
"blockers": {
"type": "array",
"items": {
"type": "string"
}
},
"observed_at": {
"type": "string"
},
"fingerprint": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"capabilities": {
"type": "object"
},
"checks": {
"type": "object",
"required": ["state", "required", "observed", "complete"],
"properties": {
"state": {
"enum": [
"pending",
"success",
"failure",
"cancelled",
"missing",
"not_required",
"unknown"
]
},
"required": {
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"observed": {
"type": "object"
},
"complete": {
"type": "boolean"
}
}
},
"reviews": {
"type": "object",
"required": ["decision", "reviewed_sha", "complete"],
"properties": {
"decision": {
"type": "string"
},
"reviewed_sha": {
"type": ["string", "null"],
"pattern": "^[0-9a-fA-F]{40}$"
},
"complete": {
"type": "boolean"
}
}
},
"threads": {
"type": "object",
"required": ["unresolved", "complete"],
"properties": {
"unresolved": {
"type": ["integer", "null"],
"minimum": 0
},
"complete": {
"type": "boolean"
}
}
},
"auto_merge": {
"type": "object",
"required": ["state"],
"properties": {
"state": {
"enum": ["enabled", "disabled", "unsupported", "unknown"]
}
}
},
"queue": {
"type": "object",
"required": ["state"],
"properties": {
"state": {
"enum": ["queued", "absent", "unsupported", "unknown"]
}
}
}
}
}
},
"allOf": [
{
"if": {
"properties": {
"operation": {
"enum": ["status", "watch-check", "ci-wait"]
},
"error": {
"type": "null"
}
}
},
"then": {
"properties": {
"data": {
"$ref": "#/$defs/snapshot"
}
}
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/forge-api.py" status "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/forge-api.py" watch-check "$@"
@@ -73,21 +73,21 @@ repo_decl_load() {
# Valid: extract the consumed fields via the same python stdlib the
# ecosystem already uses. Field-level grammar was the validator's job.
eval "$(python3 - "$DECL_FILE" <<'PY'
import json, sys
eval "$(python3 -I - "$DECL_FILE" <<'PY'
import json, sys, shlex
d = json.load(open(sys.argv[1]))
def q(k):
v = d.get(k, "")
return v if isinstance(v, str) else ""
sv = d.get("schema_version", 1)
print(f"DECL_SCHEMA={sv if isinstance(sv, int) and not isinstance(sv, bool) else 0!r}")
print(f"DECL_TRUNK={q('integration_trunk')!r}")
print(f"DECL_RELEASE={q('release_branch')!r}")
print(f"DECL_FLOW={q('flow')!r}")
print(f"DECL_REMOTE={q('canonical_remote')!r}")
print(f"DECL_POLICY={q('worktree_policy')!r}")
print(f"DECL_WT_ROOT={q('worktree_root')!r}")
print(f"DECL_CLONE={q('canonical_clone')!r}")
print(f"DECL_TRUNK={shlex.quote(q('integration_trunk'))}")
print(f"DECL_RELEASE={shlex.quote(q('release_branch'))}")
print(f"DECL_FLOW={shlex.quote(q('flow'))}")
print(f"DECL_REMOTE={shlex.quote(q('canonical_remote'))}")
print(f"DECL_POLICY={shlex.quote(q('worktree_policy'))}")
print(f"DECL_WT_ROOT={shlex.quote(q('worktree_root'))}")
print(f"DECL_CLONE={shlex.quote(q('canonical_clone'))}")
PY
)" || {
DECL_STATE=invalid
@@ -177,6 +177,14 @@ repo_decl_check_transition() { # $1 head, $2 base
return 1
;;
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
return 0
fi
repo_decl_warn "absorb transition requires explicit merge policy"
return 1
fi
if [ "$base" = "$DECL_TRUNK" ] && [ "$head" != "$DECL_TRUNK" ] && [ "$head" != "$DECL_RELEASE" ]; then
return 0 # feature -> trunk
fi
@@ -196,3 +204,9 @@ repo_decl_check_transition() { # $1 head, $2 base
;;
esac
}
# Optional merge policy. Caller must first require DECL_STATE=valid and matching origin.
repo_decl_merge_method() {
[ "$DECL_STATE" = valid ] || { repo_decl_warn "merge policy requires a valid declaration"; return 3; }
python3 -I "$REPO_DECL_SCRIPT_DIR/merge-policy.py" "$DECL_FILE" --head "$1" --base "$2" "${@:3}"
}
@@ -17,6 +17,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-branch-absent}"
REPO_DIR="$WORK_DIR/repo"
@@ -37,6 +38,12 @@ git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
# post-fix branch-lookup code paths faithfully.
cat > "$STUB_DIR/curl" <<'SH'
#!/usr/bin/env bash
# Public schema fixture for fail-closed provider detection.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
set -euo pipefail
has_w=0
@@ -89,6 +96,9 @@ run_ci_queue_wait() {
cd "$REPO_DIR"
export PATH="$STUB_DIR:$PATH"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain"
export MOSAIC_GIT_IDENTITY=test-identity
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" git.example.test stub-token
export GITEA_TOKEN="stub-token"
export GITEA_URL="https://git.example.test"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
@@ -1,4 +1,6 @@
#!/usr/bin/env bash
# shellcheck disable=SC2030,SC2031
# Each fixture subshell intentionally isolates its credential environment.
# Regression harness for ci-queue-wait.sh's --no-ci-expected assertion:
# the sanctioned merge path for a repository with no CI configured at all.
#
@@ -25,6 +27,7 @@
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-no-ci-expected}"
REPO_DIR="$WORK_DIR/repo"
@@ -41,6 +44,12 @@ git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
# repository-object endpoint (admin state) selected by MOSAIC_STUB_ADMIN_MODE.
cat > "$STUB_DIR/curl" <<'SH'
#!/usr/bin/env bash
# Public schema fixture for fail-closed provider detection.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
set -euo pipefail
has_w=0
@@ -105,6 +114,9 @@ run_guard() {
cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain"
export MOSAIC_GIT_IDENTITY=test-identity
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" git.example.test stub-token
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit-$name.jsonl"
export MOSAIC_STUB_URL_LOG="$URL_LOG"
export GITEA_TOKEN="stub-token"
@@ -123,6 +135,9 @@ run_guard_no_identity() {
cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain"
export MOSAIC_GIT_IDENTITY=test-identity
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" git.example.test stub-token
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit-$name.jsonl"
export MOSAIC_STUB_URL_LOG="$URL_LOG"
export GITEA_TOKEN="stub-token"
@@ -303,7 +318,7 @@ out_h=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=admin run_guard
rc_h=$?
set -u
if expect_rc h 78 "$rc_h"; then
expect_text h "ASSERTION_UNATTRIBUTABLE state=no-status purpose=merge asserted-by=unknown reason=no-ci-expected branch=main" "$out_h"
expect_text h "ASSERTION_UNATTRIBUTABLE state=unknown purpose=merge asserted-by=unknown reason=no-ci-expected branch=main" "$out_h"
expect_text h "queue-clear" "$out_h" absent
if ! grep -q '"outcome":"ASSERTION_UNATTRIBUTABLE"' "$WORK_DIR/audit-h.jsonl" 2>/dev/null; then
echo "FAIL h: expected an ASSERTION_UNATTRIBUTABLE JSONL audit record" >&2
@@ -32,6 +32,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-no-status}"
REPO_DIR="$WORK_DIR/repo"
@@ -48,6 +49,12 @@ git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
# is selected by MOSAIC_STUB_STATUS_MODE.
cat > "$STUB_DIR/curl" <<'SH'
#!/usr/bin/env bash
# Public schema fixture for fail-closed provider detection.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
set -euo pipefail
has_w=0
@@ -103,6 +110,9 @@ run_ci_queue_wait() {
cd "$REPO_DIR"
export PATH="$STUB_DIR:$PATH"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain"
export MOSAIC_GIT_IDENTITY=test-identity
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" git.example.test stub-token
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
export GITEA_TOKEN="stub-token"
export GITEA_URL="https://git.example.test"
@@ -4,6 +4,7 @@
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}"
REPO_DIR="$WORK_DIR/repo"
@@ -60,6 +61,12 @@ git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
cat > "$STUB_DIR/curl" <<'SH'
#!/usr/bin/env bash
# Public schema fixture for fail-closed provider detection.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
set -euo pipefail
url=""
@@ -143,6 +150,9 @@ run_guard() {
cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain"
export MOSAIC_GIT_IDENTITY=test-identity
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" git.example.test stub-token
if [[ "$status_mode" == "credential-unresolvable" ]]; then
export HOME="$WORK_DIR/empty-home"
mkdir -p "$HOME"
@@ -20,6 +20,10 @@ fail=0
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
mkdir -p "$TMP/bin"
cp "$HERE/fixture-schema-client.sh" "$TMP/bin/curl"
export PATH="$TMP/bin:$PATH"
run_outside() { # $1=function name -> "rc:sawmessage"
local fn="$1" out rc
out=$( cd "$TMP" && bash -c "set -e; source '$HERE/detect-platform.sh'; $fn" 2>&1 ); rc=$?
@@ -0,0 +1,213 @@
"""Synthetic-only credential, xtrace, and termination controls."""
import importlib.util
import os
import pathlib
import signal
import subprocess
import tempfile
import time
import unittest
HERE=pathlib.Path(__file__).resolve().parent
LEGACY_CURL = '''#!/usr/bin/env -S python3 -I
import json,os,pathlib,sys,urllib.parse
a=sys.argv[1:]; secret=os.environ['TEST_AUTH_SECRET']; url=next((x for x in reversed(a) if x.startswith(('https://','http://'))),'')
u=urllib.parse.urlsplit(url)
if u.scheme!='https' or u.netloc!='forge.example.test' or not (u.path.startswith('/api/v1/') or u.path=='/swagger.v1.json'): sys.exit(98)
method='GET'
for flag in ['-X','--request']:
if flag in a: method=a[a.index(flag)+1]
config=''; anonymous=False
for flag in ['--config','-K']:
if flag in a:
value=a[a.index(flag)+1]; anonymous=value=='-' or value.startswith('/dev/fd/')
config=sys.stdin.read() if value=='-' else pathlib.Path(value).read_text()
observed={'path':u.path,'method':method,'argv_leak':any(secret in x for x in a),'authenticated':config=='header = '+json.dumps('Authorization: token '+secret)+chr(10),'anonymous':anonymous}
with open(os.environ['TEST_AUTH_OBSERVATIONS'],'a') as f: f.write(json.dumps(observed)+chr(10))
if observed['argv_leak']: sys.exit(97)
head='a'*40; base='b'*40
pull=dict(number=1,state='open',draft=False,merged=False,mergeable=True,title='Title',head={'sha':head,'ref':'task','repo':{'full_name':'owner/repo'}},base={'sha':base,'ref':'main','repo':{'full_name':'owner/repo'}},user={'login':'author'})
if u.path=='/swagger.v1.json': body={'info':{'title':'Gitea API','version':'1.24.7'}}
elif u.path.endswith('/user'): body={'login':'fixture-user'}
elif '/branches/' in u.path: body={'commit':{'id':head}}
elif u.path.endswith('/status'): body={'state':'success','statuses':[{'context':'build','status':'success'}]}
elif u.path.endswith('/pulls/1'): body=pull
elif u.path.endswith('/pulls/1.diff'): body='diff --git a/example b/example'
elif u.path.endswith('/issues/1'): body=dict(number=1,title='Title',state='open',user={'login':'author'},created_at='',body='Body',comments=0)
elif u.path.endswith('/comments'): body=[] if method=='GET' else {'id':7,'body':'body','user':{'login':'fixture-user'},'issue_url':'https://forge.example.test/owner/repo/issues/1'}
elif u.path.endswith('/repos/owner/repo'): body={'default_branch':'main','permissions':{'admin':True}}
else: body={'id':7,'number':1,'login':'fixture-user','name':'reviewers','merged':True}
text=body if isinstance(body,str) else json.dumps(body)
if '-o' in a: pathlib.Path(a[a.index('-o')+1]).write_text(text)
else: print(text,end='')
if '-w' in a:
fmt=a[a.index('-w')+1].replace('%{http_code}','200').replace(chr(92)+'n',chr(10))
print(fmt,end='')
'''
LEGACY_CASES = {
'ci-queue-wait.sh': (['--purpose','push','-B','task','-t','1','-i','1'], 'GET', '/commits/'+'a'*40+'/status'),
'grant-reviewer.sh': (['-u','reviewer'], 'GET', '/orgs/owner'),
'issue-close.sh': (['-i','1'], 'PATCH', '/issues/1'),
'issue-comment.sh': (['-i','1','-b','body'], 'POST', '/issues/1/comments'),
'issue-create.sh': (['-t','Title','-b','body'], 'POST', '/issues'),
'issue-reopen.sh': (['-i','1'], 'PATCH', '/issues/1'),
'issue-view.sh': (['-i','1'], 'GET', '/issues/1'),
'pr-create.sh': (['-t','Title','-H','task','-B','main'], 'POST', '/pulls'),
'pr-diff.sh': (['-n','1'], 'GET', '/pulls/1.diff'),
'pr-edit.sh': (['-n','1','--login','fixture-user','--title','Title'], 'PATCH', '/pulls/1'),
'pr-merge.sh': (['-n','1','--expect-head','a'*40], 'POST', '/pulls/1/merge'),
'pr-metadata.sh': (['-n','1'], 'GET', '/pulls/1'),
'pr-review.sh': (['-n','1','-a','comment','-c','body'], 'POST', '/issues/1/comments'),
}
class Auth(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup)
self.root=pathlib.Path(self.tmp.name)
self.env=dict(os.environ,HOME=str(self.root),MOSAIC_BRAIN_HOME=str(self.root/'brain'),GIT_CONFIG_GLOBAL='/dev/null',GITEA_TOKEN='shared-synthetic',GITEA_URL='https://forge.example.test',TMPDIR=str(self.root),TMP=str(self.root),TEMP=str(self.root))
self.env.pop('MOSAIC_GIT_IDENTITY',None)
self.token=self.root/'brain/credentials/forge/forge.example.test/fixture-user.token'
self.token.parent.mkdir(parents=True); self.token.write_text('private-synthetic-token')
def credential(self,trace=False):
return subprocess.run(['bash',*(['-x'] if trace else []),'-c','source "$1/detect-platform.sh"; get_gitea_token forge.example.test','fixture',str(HERE)],env=self.env,cwd=self.root,capture_output=True,text=True)
def test_no_identity_cannot_borrow_shared_or_git_config(self):
subprocess.run(['git','init','-q',str(self.root)],check=True,env=self.env)
subprocess.run(['git','-C',str(self.root),'config','mosaic.gitIdentity','fixture-user'],check=True,env=self.env)
p=self.credential(); self.assertNotEqual(p.returncode,0); self.assertEqual(p.stdout,'')
def test_host_bound_credential_and_xtrace(self):
self.env['MOSAIC_GIT_IDENTITY']='fixture-user'
p=self.credential(True); self.assertEqual(p.returncode,0,p.stderr)
self.assertEqual(p.stdout.strip(),'private-synthetic-token')
self.assertNotIn('private-synthetic-token',p.stderr)
self.token.unlink(); p=self.credential(); self.assertNotEqual(p.returncode,0); self.assertEqual(p.stdout,'')
def test_provider_store_separates_seat_and_service(self):
import re
mappings=re.findall(r'^\s*([A-Za-z0-9.-]+)\)\s+_idpfx=([A-Za-z0-9-]+)',(HERE/'detect-platform.sh').read_text(),re.MULTILINE)
self.assertTrue(mappings)
self.env['MOSAIC_GIT_IDENTITY']='fixture-user'
for host,prefix in mappings:
service=self.root/'.config/mosaic/secrets/gitea-tokens'/(prefix+'-fixture-user.token')
service.parent.mkdir(parents=True,exist_ok=True); service.write_text('service-synthetic')
seat=self.root/'brain/fleet/agents/fixture-user/secrets'
seat.mkdir(parents=True,exist_ok=True)
command=['bash','-c','source "$1/detect-platform.sh"; get_gitea_token "$2"','fixture',str(HERE),host]
p=subprocess.run(command,env=self.env,capture_output=True,text=True)
self.assertNotEqual(p.returncode,0,'seat borrowed service token')
token=seat/(prefix+'-fixture-user.token'); token.write_text('seat-synthetic')
p=subprocess.run(command,env=self.env,capture_output=True,text=True)
self.assertEqual(p.returncode,0,p.stderr); self.assertEqual(p.stdout,'seat-synthetic')
def legacy_fixture(self):
import shutil
tools=self.root/'tools'
shutil.copytree(HERE,tools,ignore=shutil.ignore_patterns('__pycache__','.work'))
repo=self.root/'repo'; repo.mkdir()
subprocess.run(['git','init','-q','-b','task',str(repo)],check=True,env=self.env)
subprocess.run(['git','-C',str(repo),'remote','add','origin','https://forge.example.test/owner/repo'],check=True,env=self.env)
bin_dir=self.root/'bin';bin_dir.mkdir()
(bin_dir/'curl').write_text(LEGACY_CURL);(bin_dir/'curl').chmod(0o755)
(bin_dir/'tea').write_text('#!/bin/sh\nexit 1\n');(bin_dir/'tea').chmod(0o755)
for name in ['gh','glab','wget','ssh']:
(bin_dir/name).write_text('#!/bin/sh\nexit 99\n');(bin_dir/name).chmod(0o755)
config=self.root/'.config/tea';config.mkdir(parents=True)
(config/'config.yml').write_text('logins:\n - name: fixture-user\n url: https://forge.example.test\n token: private-synthetic-token\n')
credentials=self.root/'credentials.json';credentials.write_text('{}')
self.env.update(PATH=str(bin_dir)+os.pathsep+os.environ['PATH'],MOSAIC_GIT_IDENTITY='fixture-user',XDG_CONFIG_HOME=str(self.root/'.config'),MOSAIC_CREDENTIALS_FILE=str(credentials),GITEA_LOGIN='fixture-user',GITEA_URL='https://forge.example.test',TEST_AUTH_SECRET='private-synthetic-token',TEST_AUTH_OBSERVATIONS=str(self.root/'observations.jsonl'),GIT_CONFIG_NOSYSTEM='1')
for key in ['GIT_DIR','GIT_WORK_TREE','BASH_ENV']: self.env.pop(key,None)
return tools,repo
def legacy_call(self,tools,repo,name):
import json
log=self.root/'observations.jsonl'
if log.exists(): log.unlink()
args,method,suffix=LEGACY_CASES[name]
p=subprocess.run(['bash','-x',str(tools/name),*args],cwd=repo,env=self.env,capture_output=True,text=True,timeout=30)
observations=[json.loads(line) for line in log.read_text().splitlines()] if log.exists() else []
# Never put captured traces, argv or credential values in assertion output.
leaked=self.env['TEST_AUTH_SECRET'] in p.stdout+p.stderr
authenticated=any(o['authenticated'] and o['method']==method and o['path'].endswith(suffix) for o in observations)
return dict(exit_code=p.returncode,trace_leak=leaked,argv_leak=any(o['argv_leak'] for o in observations),authenticated=authenticated,anonymous=all(o['anonymous'] for o in observations if o['authenticated']))
def test_all_legacy_callers_keep_credentials_private_under_xtrace(self):
tools,repo=self.legacy_fixture()
for name in LEGACY_CASES:
with self.subTest(wrapper=name):
observed=self.legacy_call(tools,repo,name)
self.assertTrue(observed['authenticated'],name+' did not reach its credential-consuming request; exit '+str(observed['exit_code']))
self.assertFalse(observed['trace_leak'],name+' exposed credential in captured trace')
self.assertFalse(observed['argv_leak'],name+' exposed credential in argv')
self.assertTrue(observed['anonymous'],name+' did not use anonymous authentication')
def test_legacy_anonymous_config_escapes_credential(self):
tools,repo=self.legacy_fixture()
secret='quoted"'+chr(92)+'credential'
self.token.write_text(secret)
self.env['TEST_AUTH_SECRET']=secret
observed=self.legacy_call(tools,repo,'pr-metadata.sh')
self.assertTrue(observed['authenticated'],'credential config escaping failed')
self.assertTrue(observed['anonymous'])
self.assertFalse(observed['trace_leak'] or observed['argv_leak'])
def test_metadata_xtrace_mutant_is_detected(self):
tools,repo=self.legacy_fixture()
metadata=tools/'pr-metadata.sh'
source=metadata.read_text()
marker='mosaic_private_forge_main() (\n set +x'
self.assertIn(marker,source)
metadata.write_text(source.replace(marker,'mosaic_private_forge_main() (\n :',1))
observed=self.legacy_call(tools,repo,'pr-metadata.sh')
self.assertTrue(observed['authenticated'])
self.assertTrue(observed['trace_leak'],'removing caller privacy was not detected')
def test_legacy_argv_mutant_is_detected(self):
tools,repo=self.legacy_fixture()
helper=tools/'detect-platform.sh'
source=helper.read_text()
marker='curl --config "/dev/fd/$auth_fd" "$@"'
self.assertIn(marker,source)
helper.write_text(source.replace(marker,'curl -H "Authorization: token $token" "$@"',1))
observed=self.legacy_call(tools,repo,'pr-metadata.sh')
self.assertTrue(observed['argv_leak'],'raw-header mutation was not detected')
self.assertFalse(observed['trace_leak'])
self.assertNotEqual(observed['exit_code'],0)
def test_legacy_consumer_inventory_has_wrapper_controls(self):
import re
consumers={p.name for p in HERE.glob('*.sh') if not p.name.startswith(('test-','fixture-')) and p.name!='detect-platform.sh' and re.search(r'\$\(get_gitea_token(?:_for_login)?\b',p.read_text())}
self.assertEqual(consumers,set(LEGACY_CASES))
def test_sigterm_leaves_no_credential_files(self):
# Child blocks inside curl's transport; only anonymous fds hold auth.
self.env['MOSAIC_GIT_IDENTITY']='fixture-user'
script='''import importlib.util,os,pathlib,signal,sys,time
s=importlib.util.spec_from_file_location('a',sys.argv[1]); a=importlib.util.module_from_spec(s); s.loader.exec_module(a)
c=a.Client('forge.example.test','owner/repo'); c.provider='gitea'
def transport(args,body,writing):
assert all('private-synthetic-token' not in x for x in args)
config=args[args.index('--config')+1]
assert config.startswith('/dev/fd/')
assert 'private-synthetic-token' in pathlib.Path(config).read_text()
pathlib.Path(sys.argv[2]).write_text('ready')
while True: time.sleep(1)
c.transport=transport
c.http('/repos/owner/repo')
'''
ready=self.root/'ready'
before=set(self.root.rglob('*'))
child=subprocess.Popen(['python3','-I','-c',script,str(HERE/'forge-api.py'),str(ready)],env=self.env,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
try:
deadline=time.monotonic()+10
while not ready.exists() and child.poll() is None and time.monotonic()<deadline: time.sleep(.02)
self.assertTrue(ready.exists(),'transport did not receive auth fd')
child.terminate(); child.communicate(timeout=5)
self.assertEqual(child.returncode,-signal.SIGTERM)
self.assertEqual(set(self.root.rglob('*'))-before,{ready})
finally:
if child.poll() is None: child.kill(); child.communicate()
if __name__=='__main__': unittest.main()
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/test-forge-contract.py"
@@ -0,0 +1,232 @@
"""Shell entry points with network-denying fake provider executables and isolated HOME."""
import json
import os
import pathlib
import subprocess
import tempfile
import unittest
HERE=pathlib.Path(__file__).resolve().parent
FAKE='''#!/usr/bin/env -S python3 -I
import json,os,pathlib,sys,urllib.parse
args=sys.argv[1:]
if any('fixture-token' in a for a in args):
print('credential exposed in argv',file=sys.stderr); sys.exit(97)
if '--config' in args:
config=pathlib.Path(args[args.index('--config')+1])
if config.read_text() != 'header = "Authorization: token fixture-token"\\n':
print('invalid protected auth config',file=sys.stderr); sys.exit(96)
method='GET'; body=None
if '--method' in args: method=args[args.index('--method')+1]
if '--request' in args: method=args[args.index('--request')+1]
if '--url' not in args:
print('fake provider requires a full URL',file=sys.stderr); sys.exit(98)
url=args[args.index('--url')+1]
fixtures=json.loads(pathlib.Path(os.environ['FAKE_RESPONSES']).read_text())
entry=fixtures.get(method+' '+url)
if entry is None:
print('unexpected provider URL '+url,file=sys.stderr); sys.exit(98)
response=entry['response']; path=entry['path']
query=urllib.parse.urlsplit(url).query
if '--input' in args or '--data-binary' in args:
body=json.load(sys.stdin)
with open(os.environ['FAKE_LOG'],'a') as f: f.write(json.dumps({'method':method,'url':url,'path':path,'query':query,'body':body})+'\\n')
status=response.get('status',200)
print('HTTP/2 '+str(status))
for k,v in response.get('headers',{}).items(): print(k+': '+str(v))
print()
print(json.dumps(response.get('data')))
sys.exit(1 if pathlib.Path(sys.argv[0]).name=='gh' and status>=400 else 0)
'''
class CLI(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory()
self.root=pathlib.Path(self.tmp.name)
(self.root/'home').mkdir()
for client in ['curl','gh']:
p=self.root/client; p.write_text(FAKE); p.chmod(0o755)
self.env=dict(os.environ,HOME=str(self.root/'home'),TMPDIR=str(self.root),TMP=str(self.root),TEMP=str(self.root),PATH=str(self.root)+os.pathsep+os.environ['PATH'],GIT_CONFIG_GLOBAL='/dev/null',MOSAIC_GIT_IDENTITY='',GITEA_TOKEN='fixture-token',GITEA_URL='https://git.example.test',FAKE_RESPONSES=str(self.root/'responses.json'),FAKE_LOG=str(self.root/'calls.jsonl'))
for key in ['BASH_ENV','GH_TOKEN','GITHUB_TOKEN','GH_ENTERPRISE_TOKEN','GH_HOST','GITEA_LOGIN','GIT_DIR','GIT_WORK_TREE']:
self.env.pop(key,None)
self.env.update(MOSAIC_GIT_IDENTITY='fixture-user',MOSAIC_BRAIN_HOME=str(self.root/'home/.mosaic'),XDG_CONFIG_HOME=str(self.root/'home/.config'),MOSAIC_CREDENTIALS_FILE=str(self.root/'fixture-credentials.json'))
(self.root/'fixture-credentials.json').write_text('{}')
for host in ['github.com', 'git.example.test']:
token_dir=self.root/'home/.mosaic/credentials/forge'/host
token_dir.mkdir(parents=True)
(token_dir/'fixture-user.token').write_text('fixture-token')
self.fixtures={}
def tearDown(self): self.tmp.cleanup()
def write_responses(self, host='github.com'):
# Test-owned endpoint table. Never derive expected URLs from the adapter.
github=host=='github.com' or host==self.env.get('GH_HOST')
rest=('https://api.github.com' if host=='github.com' else 'https://'+host+'/api/v3') if github else 'https://'+host+'/api/v1'
graphql='https://api.github.com/graphql' if host=='github.com' else 'https://'+host+'/api/graphql'
expected={}
for key,response in self.fixtures.items():
method,path=key.split(' ',1)
url=graphql if path=='graphql' else 'https://'+host+path if path.startswith('/swagger.v1.json') else rest+path
entry={'path':path.split('?',1)[0],'response':response}
expected[method+' '+url]=entry
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
(self.root/'responses.json').write_text(json.dumps(expected))
def run_wrapper(self,name,*args,host='github.com'):
self.write_responses(host)
p=subprocess.run(['bash',str(HERE/(name+'.sh')),'--host',host,'--repo','owner/repo','--json',*args],cwd=self.root,env=self.env,capture_output=True,text=True)
self.assertNotIn('fixture-token',p.stdout+p.stderr)
self.assertEqual(list(self.root.glob('mosaic-forge-auth-*')),[], 'credential config leaked')
return p,json.loads(p.stdout) if p.stdout.strip() else {}
def calls(self):
p=self.root/'calls.jsonl'
return [json.loads(l) for l in p.read_text().splitlines()] if p.exists() else []
def gitea(self,version='1.24.7',title='Gitea API'):
self.fixtures['GET /swagger.v1.json']={'data':{'info':{'title':title,'version':version}}}
def test_missing_identity_refuses_before_requests(self):
self.env.pop('MOSAIC_GIT_IDENTITY')
p,v=self.run_wrapper('pr-auto-merge','-n','1','--action','disable','--expect-head','a'*40)
self.assertEqual(p.returncode,5)
self.assertEqual(self.calls(),[])
def test_mutation_verifies_actor(self):
self.fixtures['GET /user']={'data':{'login':'other-actor'}}
# Trigger transport directly so a malformed PR cannot hide the actor gate.
script = "import importlib.util; s=importlib.util.spec_from_file_location('a', %r); a=importlib.util.module_from_spec(s); s.loader.exec_module(a); c=a.Client('github.com','owner/repo'); c.provider='github'; c.http('/mutation','PUT',{})" % str(HERE/'forge-api.py')
self.write_responses()
r=subprocess.run(['python3','-I','-c',script],env=self.env,capture_output=True,text=True)
self.assertNotEqual(r.returncode,0)
self.assertEqual([(c['method'],c['path']) for c in self.calls()],[('GET','/user')])
def test_enterprise_auth_uses_explicit_host_store(self):
host='enterprise.example.test'
self.env['GH_HOST']=host
directory=self.root/'home/.mosaic/credentials/forge'/host
directory.mkdir(parents=True); (directory/'fixture-user.token').write_text('fixture-token')
self.fixtures['GET /repos/owner/repo/pulls/1']={'status':403,'data':{'message':'denied'}}
p,_=self.run_wrapper('pr-status','-n','1',host=host)
self.assertEqual(p.returncode,5)
self.assertEqual(self.calls()[0]['path'],'/repos/owner/repo/pulls/1')
def test_public_and_enterprise_snapshot_use_exact_api_urls(self):
for host, rest, graphql in [
('github.com','https://api.github.com','https://api.github.com/graphql'),
('enterprise.example.test','https://enterprise.example.test/api/v3','https://enterprise.example.test/api/graphql'),
]:
with self.subTest(host=host):
if host!='github.com':
self.env['GH_HOST']=host
directory=self.root/'home/.mosaic/credentials/forge'/host
directory.mkdir(parents=True,exist_ok=True)
(directory/'fixture-user.token').write_text('fixture-token')
self.fixtures={
'GET /repos/owner/repo/pulls/1':{'data':dict(number=1,state='open',draft=False,merged=False,mergeable=True,head={'sha':'a'*40,'ref':'task','repo':{'full_name':'fork/repo'}},base={'sha':'b'*40,'ref':'integration','repo':{'full_name':'owner/repo'}},user={'login':'author'})},
'GET /repos/owner/repo/branches/integration/protection':{'data':{'required_status_checks':{'contexts':['build']},'required_pull_request_reviews':{'required_approving_review_count':1,'dismiss_stale_reviews':True}}},
'GET /repos/owner/repo/commits/'+'a'*40+'/statuses':{'data':[{'context':'build','state':'success','id':1}]},
'GET /repos/owner/repo/commits/'+'a'*40+'/check-runs':{'data':{'total_count':0,'check_runs':[]}},
'GET /repos/owner/repo/pulls/1/reviews':{'data':[{'id':1,'state':'APPROVED','commit_id':'a'*40,'user':{'login':'reviewer'}}]},
'GET /repos/owner/repo/pulls/1/comments':{'data':[]},
'GET /repos/owner/repo/issues/1/comments':{'data':[]},
'POST graphql':{'data':{'data':{'repository':{'pullRequest':{'id':'PR','autoMergeRequest':None,'mergeQueueEntry':None,'reviewThreads':{'nodes':[],'pageInfo':{'hasNextPage':False,'endCursor':None}}}}}}},
}
log=self.root/'calls.jsonl'
if log.exists(): log.unlink()
p,value=self.run_wrapper('pr-status','-n','1',host=host)
self.assertEqual(p.returncode,0,p.stderr)
self.assertTrue(value['data']['ready'])
self.assertEqual(self.calls()[0]['url'],rest+'/repos/owner/repo/pulls/1')
self.assertIn(graphql,[c['url'] for c in self.calls()])
self.assertTrue(all(c['url'].startswith(rest+'/repos/') or c['url']==graphql for c in self.calls()))
def test_fake_rejects_unexpected_full_urls(self):
self.fixtures={'GET /repos/owner/repo':{'data':{}}}
self.write_responses()
for url in ['https://github.com/repos/owner/repo','http://api.github.com/repos/owner/repo','https://other.example.test/repos/owner/repo','https://api.github.com/wrong','https://api.github.com/repos/owner/repo?unexpected=1']:
p=subprocess.run([str(self.root/'curl'),'--request','GET','--url',url],env=self.env,capture_output=True,text=True)
self.assertEqual(p.returncode,98,url)
def test_profiles_and_unknown_host_fail_closed(self):
for version,title,provider in [('28.2.0','Gitea API','gitea'),('1.24.7','Gitea API','gitea'),('16.0.0-dev','Forgejo API','forgejo')]:
self.gitea(version,title)
p,v=self.run_wrapper('forge-capabilities',host='git.example.test')
self.assertEqual(p.returncode,0,p.stderr); self.assertEqual(v['provider'],provider)
self.gitea('1','Unrelated application')
p,v=self.run_wrapper('forge-capabilities',host='git.example.test')
self.assertEqual(p.returncode,4); self.assertEqual(v['error']['kind'],'unsupported')
def test_unsupported_reply_has_no_mutation(self):
self.gitea()
body=self.root/'body'; body.write_text('reply')
p,v=self.run_wrapper('pr-review-reply','-n','1','--comment-id','gitea:comment:2','--body-file',str(body),'--expect-head','a'*40,host='git.example.test')
self.assertEqual(p.returncode,4,p.stderr)
self.assertTrue(all(c['method']=='GET' for c in self.calls()))
def test_auth_denial_and_rate_limit_codes(self):
for status,headers,expected in [(401,{},5),(403,{},5),(404,{},5),(429,{'Retry-After':23},75)]:
self.fixtures['GET /repos/owner/repo/pulls/1']={'status':status,'headers':headers,'data':{'message':'refused'}}
p,v=self.run_wrapper('pr-status','-n','1')
self.assertEqual(p.returncode,expected,p.stderr)
self.assertFalse(v['data']['ready'])
if status==429: self.assertEqual(v['error']['retry_after_seconds'],23)
def test_hostile_python_modules_are_not_imported(self):
(self.root/'json.py').write_text("raise RuntimeError('repository module executed')")
self.gitea()
p,v=self.run_wrapper('forge-capabilities',host='git.example.test')
self.assertEqual(p.returncode,0,p.stderr)
def test_gitea_review_pages_preserve_resolved_outdated(self):
self.gitea('28.2.0')
self.fixtures['GET /repos/owner/repo/pulls/1']={'data':{'number':1,'head':{'sha':'a'*40}}}
self.fixtures['GET /repos/owner/repo/pulls/1/reviews']={'data':[{'id':1},{'id':2}]}
for rid in [1,2]:
self.fixtures[f'GET /repos/owner/repo/pulls/1/reviews/{rid}/comments']={'data':[{'id':rid,'body':'same body','resolver':{'login':'reviewer'} if rid==1 else None,'outdated':rid==1,'user':{'login':'reviewer'}}]}
p,v=self.run_wrapper('pr-review-comments','-n','1',host='git.example.test')
self.assertEqual(p.returncode,0,p.stderr)
self.assertEqual(len(v['data']['comments']),2)
self.assertEqual(v['data']['comments'][0]['resolved'],True)
self.assertIsNone(v['data']['comments'][0]['thread_id'])
self.assertEqual(v['data']['comments'][1]['resolved'],False)
def test_gitea_status_and_ci_wait_use_exact_head(self):
self.gitea()
head='a'*40
self.fixtures['GET /repos/owner/repo/pulls/1']={'data':{'number':1,'state':'open','draft':False,'merged':False,'mergeable':True,'head':{'sha':head,'ref':'task','repo':{'full_name':'fork/repo'}},'base':{'sha':'b'*40,'ref':'integration','repo':{'full_name':'owner/repo'}},'user':{'login':'author'}}}
self.fixtures['GET /repos/owner/repo/branch_protections']={'data':[{'branch_name':'integration','enable_status_check':True,'status_check_contexts':['build'],'required_approvals':1}]}
self.fixtures['GET /repos/owner/repo/commits/'+head+'/statuses']={'data':[{'id':1,'context':'build','state':'success'}]}
self.fixtures['GET /repos/owner/repo/pulls/1/reviews']={'data':[{'id':1,'state':'APPROVED','commit_id':head,'user':{'login':'reviewer'}}]}
self.fixtures['GET /repos/owner/repo/pulls/1/reviews/1/comments']={'data':[]}
self.fixtures['GET /repos/owner/repo/issues/1/comments']={'data':[]}
p,v=self.run_wrapper('pr-ci-wait','-n','1','-t','1','-i','1','--expect-head',head,host='git.example.test')
self.assertEqual(p.returncode,0,p.stderr)
self.assertEqual(v['data']['checks']['state'],'success')
self.assertEqual(v['data']['head_repository'],'fork/repo')
self.assertTrue(any(c['path'].endswith('/commits/'+head+'/statuses') for c in self.calls()))
self.fixtures['GET /repos/owner/repo/commits/'+head+'/statuses']={'data':[]}
p,v=self.run_wrapper('pr-ci-wait','-n','1','-t','1','-i','1',host='git.example.test')
self.assertEqual(p.returncode,124,p.stderr)
self.assertFalse(v['data']['ready'])
def test_watch_predicate_preserves_baseline_and_read_errors(self):
self.test_gitea_status_and_ci_wait_use_exact_head()
key='GET /repos/owner/repo/commits/'+'a'*40+'/statuses'
self.fixtures[key]={'data':[{'id':1,'context':'build','state':'pending'}]}
p,baseline=self.run_wrapper('pr-status','-n','1',host='git.example.test')
self.assertEqual(p.returncode,0,p.stderr)
path=self.root/'baseline.json'; path.write_text(json.dumps(baseline))
before=path.read_text()
p,value=self.run_wrapper('pr-watch-check','-n','1','--baseline',str(path),host='git.example.test')
self.assertEqual(p.returncode,1,p.stderr)
self.assertEqual(path.read_text(),before)
self.fixtures[key]={'data':[{'id':1,'context':'build','state':'failure'}]}
p,value=self.run_wrapper('pr-watch-check','-n','1','--baseline',str(path),host='git.example.test')
self.assertEqual(p.returncode,0,p.stderr)
self.assertEqual(value['data']['checks']['state'],'failure')
self.fixtures[key]={'status':403,'data':{'message':'denied'}}
p,value=self.run_wrapper('pr-watch-check','-n','1','--baseline',str(path),host='git.example.test')
self.assertEqual(p.returncode,5,p.stderr)
self.assertFalse(value['data']['checks']['complete'])
self.assertEqual(path.read_text(),before)
if __name__=='__main__': unittest.main()
@@ -0,0 +1,446 @@
"""Offline contracts. Fake responses replace the provider HTTP boundary."""
import importlib.util
import pathlib
import unittest
PATH = pathlib.Path(__file__).with_name('forge-api.py')
class Contract(unittest.TestCase):
@classmethod
def setUpClass(cls):
if PATH.is_file():
spec = importlib.util.spec_from_file_location('forge_api', PATH)
cls.api = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cls.api)
def setUp(self):
self.assertTrue(PATH.is_file(), 'normalized adapter is missing')
def test_profiles_require_schema_evidence(self):
for provider, version, reply in [('gitea', '28.2.0', 'supported'), ('gitea', '1.24.7', 'unsupported'), ('forgejo', '16.0.0-dev', 'unsupported')]:
p = self.api.capabilities(provider, version)
self.assertEqual(p['review_reply']['support'], reply)
self.assertEqual(p['auto_merge']['support'], 'unsupported')
self.assertEqual(self.api.capabilities('gitea', '99.0.0')['review_reply']['support'], 'unknown')
self.assertEqual(self.api.capabilities('unknown', '')['status']['support'], 'unknown')
def test_fingerprint_ignores_clock_but_tracks_evidence(self):
a = {'head_sha': 'a', 'observed_at': 'one', 'checks': {'state': 'pending'}}
b = dict(a, observed_at='two')
self.assertEqual(self.api.fingerprint(a), self.api.fingerprint(b))
b['checks'] = {'state': 'failure'}
self.assertNotEqual(self.api.fingerprint(a), self.api.fingerprint(b))
def test_empty_checks_are_not_green(self):
result = self.api.checks([], [], ['build'])
self.assertEqual(result['state'], 'missing')
self.assertTrue(result['complete'])
self.assertEqual(self.api.checks([], [], None)['state'], 'unknown')
self.assertEqual(self.api.checks([], [], [], allow_none=True)['state'], 'not_required')
self.assertEqual(self.api.checks([], [], [])['state'], 'missing')
def test_required_checks_include_check_runs_and_latest_context(self):
statuses = [{'context':'build','state':'failure','id':1}, {'context':'build','state':'success','id':2}]
runs = [{'name':'test','status':'completed','conclusion':'success','id':3}]
self.assertEqual(self.api.checks(statuses, runs, ['build', 'test'])['state'], 'success')
runs[0]['conclusion'] = 'cancelled'
self.assertEqual(self.api.checks(statuses, runs, ['build', 'test'])['state'], 'cancelled')
self.assertEqual(self.api.checks(statuses, [], ['test'])['state'], 'missing')
def test_unrecognized_check_is_never_green(self):
self.assertEqual(self.api.checks([{'id':1,'context':'build','state':'surprise'}], [], ['build'])['state'], 'unknown')
def test_required_review_count(self):
r=[{'id':1,'state':'APPROVED','user':{'login':'reviewer'},'commit_id':'a'}]
self.assertFalse(self.api.reviews(r,'a','author',2)['approved'])
def test_review_must_be_independent_and_current(self):
reviews = [{'id':1,'user':{'login':'reviewer'},'state':'APPROVED','commit_id':'old'}]
self.assertFalse(self.api.reviews(reviews, 'new', 'author')['approved'])
reviews[0]['commit_id'] = 'new'
self.assertTrue(self.api.reviews(reviews, 'new', 'author')['approved'])
reviews[0]['user']['login'] = 'author'
self.assertFalse(self.api.reviews(reviews, 'new', 'author')['approved'])
class SnapshotContract(Contract):
def fake(self, race=False, missing=False):
api = self.api
class Fake(api.Client):
def __init__(self):
super().__init__('github.com', 'owner/repo')
self.provider, self.version, self.reads = 'github', 'documented', 0
def pr(self, number):
self.reads += 1
return {'number':number,'state':'open','draft':False,'merged':False,'html_url':'https://github.com/owner/repo/pull/1','head':{'sha':('b' if race and self.reads > 1 else 'a')*40,'ref':'task','repo':{'full_name':'fork/repo'}},'base':{'sha':'c'*40,'ref':'integration','repo':{'full_name':'owner/repo'}},'user':{'login':'author'},'mergeable':True,'mergeable_state':'clean'}
def http(self, path, method='GET', body=None, public=False):
if '/protection' in path:
return {'required_status_checks':{'contexts':['build']},'required_pull_request_reviews':{'required_approving_review_count':1,'dismiss_stale_reviews':True}}
raise AssertionError(path)
def pages(self, path, key=None):
if '/statuses/' in path or path.endswith('/statuses'):
return [] if missing else [{'context':'build','state':'success','id':1}]
if '/check-runs' in path:
return []
if '/reviews' in path:
return [{'id':1,'state':'APPROVED','user':{'login':'reviewer'},'commit_id':'a'*40}]
if '/comments' in path:
return []
raise AssertionError(path)
def gql(self, query, variables):
return {'repository':{'pullRequest':{'id':'PR_one','autoMergeRequest':None,'mergeQueueEntry':None,'reviewThreads':{'nodes':[],'pageInfo':{'hasNextPage':False,'endCursor':None}}}}}
return Fake()
def test_ready_needs_all_evidence(self):
result = self.api.snapshot(self.fake(), 1, None)
self.assertTrue(result['ready'])
self.assertEqual(result['head_repository'], 'fork/repo')
result = self.api.snapshot(self.fake(missing=True), 1, None)
self.assertFalse(result['ready'])
self.assertIn('checks_missing', result['blockers'])
def test_head_race_discards_readiness(self):
with self.assertRaises(self.api.Failure) as ctx:
self.api.snapshot(self.fake(race=True), 1, None)
self.assertEqual(ctx.exception.code, 75)
def test_partial_collections_fail(self):
c = self.fake()
def broken(path, key=None):
raise self.api.Failure(5, 'access', 'denied')
c.pages = broken
with self.assertRaises(self.api.Failure) as ctx:
self.api.snapshot(c, 1, None)
self.assertEqual(ctx.exception.code, 5)
class TransportContract(Contract):
def test_access_and_rate_limit_remain_distinct(self):
import types
original = self.api.run
c = self.api.Client('github.com', 'owner/repo')
c.provider = 'github'
c.token = 'synthetic'
import os
from unittest.mock import patch
actor_patch = patch.dict(os.environ, MOSAIC_GIT_IDENTITY='fixture-user')
actor_patch.start()
try:
for status, headers, expected in [(403, '', 5), (404, '', 5), (429, 'Retry-After: 37\n', 75)]:
self.api.run = lambda *a, **kw: types.SimpleNamespace(returncode=0, stdout=f'HTTP/2 {status}\n{headers}\n{{"message":"denied"}}')
with self.assertRaises(self.api.Failure) as ctx:
c.http('/repos/owner/repo')
self.assertEqual(ctx.exception.code, expected)
if status == 429:
self.assertEqual(ctx.exception.retry, 37)
finally:
self.api.run = original
actor_patch.stop()
def test_curl_errors_never_prove_reads_or_mutations(self):
import types
import os
from unittest.mock import patch
for provider,host in [('github','github.com'),('github','enterprise.example.test'),('gitea','git.example.test'),('forgejo','forge.example.test')]:
for code in [0,28,56]:
for method in ['GET','PUT']:
with self.subTest(provider=provider,host=host,code=code,method=method):
c=self.api.Client(host,'owner/repo'); c.provider=provider
c.token='synthetic'; c.actor_verified=True
c.transport=lambda *a: types.SimpleNamespace(returncode=code,stdout='HTTP/2 200\n\n{"ok":true}')
with patch.dict(os.environ,MOSAIC_GIT_IDENTITY='fixture-user'):
if code==0:
self.assertEqual(c.http('/repos/owner/repo',method),{'ok':True})
else:
with self.assertRaises(self.api.Failure) as caught:
c.http('/repos/owner/repo',method)
self.assertEqual(caught.exception.code,75)
self.assertEqual(caught.exception.outcome,'unknown' if method=='PUT' else 'none')
def test_pagination_failure_is_not_empty_success(self):
c = self.api.Client('github.com', 'owner/repo')
c.provider = 'github'
calls = []
def response(path):
calls.append(path)
if 'page=2' in path:
raise self.api.Failure(5, 'access', 'second page denied')
return [{'id':i} for i in range(100)]
c.http = response
with self.assertRaises(self.api.Failure):
c.pages('/collection')
self.assertEqual(len(calls), 2)
class ReviewContract(SnapshotContract):
def test_unsupported_reply_makes_no_write(self):
c = self.fake()
c.provider, c.version = 'forgejo', '16.0.0-dev'
calls = []
c.http = lambda *args, **kwargs: calls.append(args)
with self.assertRaises(self.api.Failure) as ctx:
self.api.reply(c, 1, 'forgejo:comment:3', 'body', 'a'*40)
self.assertEqual(ctx.exception.code, 4)
self.assertEqual(calls, [])
def test_wrong_pr_comment_refuses_write(self):
c = self.fake()
with self.assertRaises(self.api.Failure) as ctx:
self.api.reply(c, 1, 'github:comment:99', 'body', 'a'*40)
self.assertEqual(ctx.exception.code, 3)
def test_reply_verifies_exact_returned_identity(self):
c = self.fake()
original = self.api.review_comments
calls = []
def collection(*args):
return {'complete':True,'comments':[{'id':'github:comment:3','thread_id':'github:thread:T','resolved':False}], 'reviews':[]}, [], None
def request(path, method='GET', body=None, public=False):
calls.append((path,method,body))
if path == '/user':
return {'login':'acting-user'}
if method == 'POST':
return {'id':8}
if path.endswith('/8'):
return {'id':8,'body':'body','user':{'login':'acting-user'},'in_reply_to_id':3,'pull_request_url':'https://api.github.com/repos/owner/repo/pulls/1','commit_id':'a'*40}
raise AssertionError(path)
c.http = request
try:
self.api.review_comments = collection
result = self.api.reply(c, 1, 'github:comment:3', 'body', 'a'*40)
self.assertEqual(result['id'], 'github:comment:8')
self.assertTrue(result['verified'])
finally:
self.api.review_comments = original
self.assertEqual(sum(method == 'POST' for _,method,_ in calls), 1)
def test_enterprise_reply_readback_uses_api_prefix(self):
c=self.fake(); c.host='git.example.test'
original=self.api.review_comments
self.api.review_comments=lambda *args: ({'complete':True,'reviews':[],'comments':[{'id':'github:comment:3','thread_id':'github:thread:T'}]},[],None)
def request(path,method='GET',body=None,public=False):
if path=='/user': return {'login':'reviewer'}
if method=='POST': return {'id':8}
return {'id':8,'body':'body','user':{'login':'reviewer'},'in_reply_to_id':3,'pull_request_url':'https://git.example.test/api/v3/repos/owner/repo/pulls/1'}
c.http=request
try:
self.assertTrue(self.api.reply(c,1,'github:comment:3','body','a'*40)['verified'])
finally:
self.api.review_comments=original
def test_resolve_requires_thread_owned_by_pr(self):
c = self.fake()
with self.assertRaises(self.api.Failure) as ctx:
self.api.resolve(c, 1, 'github:thread:foreign', True, 'a'*40)
self.assertEqual(ctx.exception.code, 3)
class IntegrationContract(SnapshotContract):
def test_unsupported_integration_performs_no_mutation(self):
for provider, version in [('gitea','28.2.0'), ('gitea','1.24.7'), ('forgejo','16.0.0-dev')]:
c = self.fake()
c.provider, c.version = provider, version
calls = []
c.http = lambda *args, **kwargs: calls.append((args,kwargs))
for op in ['auto_merge','merge_queue']:
with self.assertRaises(self.api.Failure) as ctx:
self.api.integration(c,1,op,'inspect',None,None)
self.assertEqual(ctx.exception.code,4)
self.assertEqual(calls,[])
def test_integration_inspect_honors_optional_head_pin(self):
c=self.fake()
with self.assertRaises(self.api.Failure) as ctx:
self.api.integration(c,1,'auto_merge','inspect',None,'b'*40)
self.assertEqual(ctx.exception.code,75)
result=self.api.integration(c,1,'auto_merge','inspect',None,'a'*40)
self.assertEqual(result['head_sha'],'a'*40)
def test_forge_disallowed_method_is_unsupported(self):
c = self.fake()
c.http = lambda *args, **kwargs: {'allow_squash_merge':False}
with self.assertRaises(self.api.Failure) as ctx:
self.api.forge_method(c,'squash')
self.assertEqual(ctx.exception.code,4)
def test_github_method_limits(self):
c = self.fake()
for method in ['rebase-merge','fast-forward-only']:
with self.assertRaises(self.api.Failure) as ctx:
self.api.forge_method(c,method)
self.assertEqual(ctx.exception.code,4)
class WorkflowContract(Contract):
def test_stop_unknown_integration_blocks(self):
d=dict(state='open',ready=True,checks={'state':'success'},auto_merge={'state':'unsupported'},queue={'state':'unsupported'})
self.assertEqual(self.api.workflow_verdict(d,'full-stop'),'blocked')
self.assertEqual(self.api.workflow_verdict(d,'full-send'),'ready_to_merge')
def test_red_ci_and_terminal_states(self):
d=dict(state='open',ready=False,checks={'state':'failure'},auto_merge={'state':'disabled'},queue={'state':'absent'})
self.assertEqual(self.api.workflow_verdict(d,'full-send'),'blocked')
d['state']='closed'
self.assertEqual(self.api.workflow_verdict(d,'full-stop'),'closed_unmerged')
d['state']='merged'
self.assertEqual(self.api.workflow_verdict(d,'full-stop'),'merged')
class IntegrationMutations(SnapshotContract):
def integration_client(self,noop=False,immediate=False):
c=self.fake(); state={'enabled':False,'queued':False,'writes':0}
def http(path,method='GET',body=None,public=False):
if '/protection' in path:
return {'required_status_checks':{'contexts':['build']},'required_pull_request_reviews':{'required_approving_review_count':1,'dismiss_stale_reviews':True},'enforce_admins':{'enabled':True}}
return {'allow_squash_merge':True,'allow_auto_merge':True}
def gql(query,variables):
if query.startswith('mutation'):
state['writes']+=1
self.assertEqual(variables.get('head'),'a'*40)
if not noop:
state['enabled']='enablePullRequestAutoMerge' in query
state['queued']='enqueuePullRequest' in query
return {}
if 'mergeQueue(branch:' in query:
return {'repository':{'mergeQueue':{'configuration':{'mergeMethod':'SQUASH'}}}}
return {'repository':{'pullRequest':{'id':'PR_one','autoMergeRequest':{'mergeMethod':'SQUASH'} if state['enabled'] else None,'mergeQueueEntry':{'id':'entry_one'} if state['queued'] else None,'reviewThreads':{'nodes':[],'pageInfo':{'hasNextPage':False,'endCursor':None}}}}}
c.http=http; c.gql=gql
if immediate:
original=c.pr
def pr(number):
value=original(number)
if state['writes']:
value.update(merged=True,merge_commit_sha='d'*40)
return value
c.pr=pr
return c,state
def test_enable_and_enqueue_verify_readback(self):
original_policy,original_guard=self.api.declared_method,self.api.queue_guard
self.api.declared_method=lambda *a:'squash'
self.api.queue_guard=lambda *a:None
try:
for op,action,expected in [('auto_merge','enable','enabled'),('merge_queue','enqueue','queued')]:
c,state=self.integration_client()
result=self.api.integration(c,1,op,action,'squash','a'*40)
self.assertEqual(result['state'],expected)
self.assertEqual(state['writes'],1)
finally:
self.api.declared_method,self.api.queue_guard=original_policy,original_guard
def test_noop_write_fails_without_retry(self):
original_policy,original_guard=self.api.declared_method,self.api.queue_guard
self.api.declared_method=lambda *a:'squash'; self.api.queue_guard=lambda *a:None
try:
c,state=self.integration_client(noop=True)
with self.assertRaises(self.api.Failure) as ctx:
self.api.integration(c,1,'auto_merge','enable','squash','a'*40)
self.assertEqual(ctx.exception.code,6)
self.assertEqual(ctx.exception.outcome,'unknown')
self.assertEqual(state['writes'],1)
c,state=self.integration_client(immediate=True)
result=self.api.integration(c,1,'merge_queue','enqueue','squash','a'*40)
self.assertEqual(result['state'],'merged')
finally:
self.api.declared_method,self.api.queue_guard=original_policy,original_guard
def test_gitea_method_settings_are_not_github_names(self):
c=self.fake(); c.provider='gitea'; c.version='28.2.0'
c.http=lambda *a,**kw:{'allow_merge_commits':True,'allow_rebase':True}
self.api.forge_method(c,'merge'); self.api.forge_method(c,'rebase')
class ResolutionContract(SnapshotContract):
def test_native_thread_resolution_and_noop_readback(self):
for noop in [False,True]:
c=self.fake(); state={'resolved':False,'writes':0}
pages=c.pages
c.pages=lambda path,key=None: [{'id':3,'pull_request_review_id':1,'user':{'login':'reviewer'},'body':'finding'}] if path.endswith('/pulls/1/comments') else pages(path,key)
def gql(query,variables):
if query.startswith('mutation'):
state['writes']+=1
self.assertEqual(variables['id'],'T_one')
if not noop: state['resolved']=True
return {}
return {'repository':{'pullRequest':{'id':'PR_one','autoMergeRequest':None,'mergeQueueEntry':None,'reviewThreads':{'nodes':[{'id':'T_one','isResolved':state['resolved'],'isOutdated':True,'comments':{'nodes':[{'databaseId':3,'id':'C_three','replyTo':None}],'pageInfo':{'hasNextPage':False,'endCursor':None}}}],'pageInfo':{'hasNextPage':False,'endCursor':None}}}}}
c.gql=gql
if noop:
with self.assertRaises(self.api.Failure) as ctx:
self.api.resolve(c,1,'github:thread:T_one',True,'a'*40)
self.assertEqual(ctx.exception.code,6)
else:
result=self.api.resolve(c,1,'github:thread:T_one',True,'a'*40)
self.assertTrue(result['verified'])
self.assertEqual(state['writes'],1)
def test_nested_comment_and_thread_pagination(self):
c=self.fake(); queries=[]
def gql(query,variables):
queries.append(variables)
if 'node(id:' in query:
return {'node':{'comments':{'nodes':[{'databaseId':2,'id':'C2','replyTo':{'databaseId':1}}],'pageInfo':{'hasNextPage':False,'endCursor':None}}}}
page={'nodes':[],'pageInfo':{'hasNextPage':False,'endCursor':None}}
if variables['cursor'] is None:
page={'nodes':[{'id':'T','isResolved':True,'isOutdated':True,'comments':{'nodes':[{'databaseId':1,'id':'C1','replyTo':None}],'pageInfo':{'hasNextPage':True,'endCursor':'nested'}}}],'pageInfo':{'hasNextPage':True,'endCursor':'outer'}}
return {'repository':{'pullRequest':{'id':'PR','autoMergeRequest':None,'mergeQueueEntry':None,'reviewThreads':page}}}
c.gql=gql
threads,_=self.api.github_conversations(c,1)
self.assertEqual(len(threads[0]['comments']),2)
self.assertEqual(len(queries),3)
self.assertEqual(queries[1]['cursor'],'nested')
self.assertEqual(queries[2]['cursor'],'outer')
class ReviewRegressions(SnapshotContract):
def test_same_sha_retarget_snapshot_refused(self):
for field in ['ref','repo','head_ref','head_repo']:
c=self.fake(); original=c.pr
def retarget(n):
p=original(n)
if c.reads>1:
side='head' if field.startswith('head_') else 'base'
key=field.removeprefix('head_')
p[side][key]='forbidden' if key=='ref' else {'full_name':'other/repo'}
return p
c.pr=retarget
with self.assertRaises(self.api.Failure) as caught:
self.api.snapshot(c,1,None)
self.assertEqual(caught.exception.code,75)
def test_retarget_merge_never_writes(self):
from unittest.mock import patch
c=self.fake(); original=c.pr; writes=[]
def retarget(n):
p=original(n)
if c.reads>1: p['base']['ref']='forbidden-target'
return p
c.pr=retarget
http=c.http
def request(path,method='GET',body=None,public=False):
if method!='GET': writes.append(path)
if path==c.root: return {'allow_squash_merge':True}
return http(path,method,body,public)
c.http=request
with patch.object(self.api,'declared_method',return_value='squash'), patch.object(self.api,'queue_guard'):
with self.assertRaises(self.api.Failure) as caught:
self.api.merge(c,1,'squash','a'*40)
self.assertEqual(caught.exception.code,75)
self.assertEqual(writes,[])
def test_null_thread_is_not_resolved(self):
c=self.fake()
gql=c.gql
def response(q,v):
data=gql(q,v)
data['repository']['pullRequest']['reviewThreads']['nodes']=[{'id':'T','isResolved':None,'comments':{'nodes':[],'pageInfo':{'hasNextPage':False}}}]
return data
c.gql=response
with self.assertRaises(self.api.Failure) as caught:
self.api.snapshot(c,1,None)
self.assertEqual(caught.exception.code,6)
def test_malformed_integration_is_unknown(self):
for key in ['autoMergeRequest','mergeQueueEntry']:
for value in [{},False,'',{'mergeMethod':None},{'id':False}]:
c=self.fake(); gql=c.gql
def response(q,v):
data=gql(q,v); data['repository']['pullRequest'][key]=value; return data
c.gql=response
with self.assertRaises(self.api.Failure) as caught:
self.api.integration(c,1,'auto_merge','inspect',None,None)
self.assertEqual(caught.exception.code,6)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,23 @@
import json
import pathlib
import subprocess
import unittest
HERE=pathlib.Path(__file__).resolve().parent
class Guard(unittest.TestCase):
def check(self,command,expected):
p=subprocess.run(['bash',str(HERE/'wrapper-guard.sh')],input=json.dumps({'tool_input':{'command':command}}),text=True,capture_output=True)
self.assertEqual(p.returncode,expected,p.stderr)
def test_native_review_writes_refused(self):
for route in ['pulls/1/comments/2/replies','pulls/comments/2/resolve','pulls/comments/2/unresolve']:
self.check('curl -X POST https://git.example.test/api/v1/repos/owner/repo/'+route,2)
def test_named_graphql_writes_refused(self):
for mutation in ['resolveReviewThread','unresolveReviewThread','enablePullRequestAutoMerge','disablePullRequestAutoMerge','enqueuePullRequest','dequeuePullRequest']:
self.check("gh api graphql -f query='mutation { "+mutation+"(input:{id:123}) { clientMutationId } }'",2)
def test_opaque_graphql_payload_cannot_hide_mutation(self):
self.check('gh api graphql --input request.json',2)
self.check('curl --data-binary @request.json https://api.github.com/graphql',2)
def test_graphql_reads_and_wrappers_allowed(self):
self.check("gh api graphql -f query='query { repository(owner:123,name:123) { name } }'",0)
self.check('/opt/mosaic/tools/git/pr-review-resolve.sh -n 1 --thread-id github:thread:T --resolve --expect-head abc --json',0)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,40 @@
// Use the repository's existing ESLint/Ajv dependency, without adding a package.
const assert = require('node:assert/strict');
const { createRequire } = require('node:module');
const { readFileSync } = require('node:fs');
const { join } = require('node:path');
const { spawnSync } = require('node:child_process');
const eslintRequire = createRequire(require.resolve('eslint/package.json'));
const Ajv = eslintRequire('ajv');
const schema = JSON.parse(readFileSync(join(__dirname, 'pr-status.schema.json'), 'utf8'));
const ajv = new Ajv({ allErrors: true });
assert(ajv.validateSchema(schema), JSON.stringify(ajv.errors));
const validate = ajv.compile(schema);
const fixture = spawnSync(
'python3',
[
'-I',
'-c',
`
import importlib.util,json,pathlib,sys
p=pathlib.Path(sys.argv[1])/'test-forge-contract.py'
spec=importlib.util.spec_from_file_location('fixture',p)
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
m.SnapshotContract.setUpClass()
c=m.SnapshotContract().fake()
s=m.SnapshotContract.api.snapshot(c,1,None)
print(json.dumps(dict(schema_version=1,operation='status',provider=c.provider,host=c.host,repo=c.repo,number=1,head_sha=s['head_sha'],data=s,error=None)))
`,
__dirname,
],
{ encoding: 'utf8' },
);
assert.equal(fixture.status, 0, fixture.stderr);
const snapshot = JSON.parse(fixture.stdout);
assert(validate(snapshot), JSON.stringify(validate.errors));
delete snapshot.data.threads;
assert.equal(validate(snapshot), false, 'missing thread evidence must fail schema');
snapshot.data.threads = { unresolved: 0, complete: true };
snapshot.data.checks.state = 'invented-green';
assert.equal(validate(snapshot), false, 'invented checks state must fail schema');
console.log('Schema and negative controls passed');
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec node "$SCRIPT_DIR/test-forge-schema.cjs"
@@ -3,6 +3,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-login-resolution}"
REPO_DIR="$WORK_DIR/repo"
@@ -86,9 +87,22 @@ SH
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
# Public schema fixture for fail-closed provider detection.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
set -euo pipefail
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
args=("$@")
for ((i=0; i<${#args[@]}; i++)); do
if [[ "${args[i]}" == --config && "${args[i+1]:-}" == /dev/fd/* ]]; then
grep -q '^header = "Authorization: token ' "${args[i+1]}" || exit 96
printf '%s\n' 'anonymous-auth-config' >> "$MOSAIC_TEST_LOG"
fi
done
url="${*: -1}"
case "$url" in
*/pulls/*.diff)
@@ -261,22 +275,31 @@ run_in_repo env GITEA_URL=https://git.uscllc.com "$SCRIPT_DIR/pr-diff.sh" --repo
grep -q -- 'curl .*https://git.uscllc.com/api/v1/repos/USC/uconnect/pulls/7.diff' "$LOG_FILE"
: > "$LOG_FILE"
run_in_repo "$SCRIPT_DIR/pr-ci-wait.sh" --repo USC/uconnect --host git.uscllc.com -n 9 -t 2 -i 1
grep -q -- 'curl .*https://git.uscllc.com/api/v1/repos/USC/uconnect/pulls/9' "$LOG_FILE"
grep -q -- 'curl .*https://git.uscllc.com/api/v1/repos/USC/uconnect/commits/abc123/status' "$LOG_FILE"
# CI wait now consumes the normalized contract rather than this legacy aggregate
# fixture. Its host/head/status and empty-check controls live in test-forge-cli.py.
python3 -I "$SCRIPT_DIR/test-forge-cli.py" CLI.test_gitea_status_and_ci_wait_use_exact_head
fixture_host=$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname)')
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" fixture-user "$fixture_host" "$(python3 -I -c 'import json,sys; d=json.load(open(sys.argv[1])); print(next(v["token"] for v in d["gitea"].values() if v["url"].endswith(sys.argv[2])))' "$CREDENTIALS_FILE" "$fixture_host")"
: > "$LOG_FILE"
run_in_repo env MOSAIC_TEA_FAIL_PR_CREATE=1 GITEA_TOKEN=usc-token GITEA_URL=https://git.uscllc.com "$SCRIPT_DIR/pr-create.sh" -t "USC API fallback" -H feature/pr-create
run_in_repo env MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" GITEA_LOGIN="$(python3 -I -c 'import json,sys; d=json.load(open(sys.argv[1])); print(next(k for k,v in d["gitea"].items() if v["url"].endswith(sys.argv[2])))' "$CREDENTIALS_FILE" "$fixture_host")" MOSAIC_TEA_FAIL_PR_CREATE=1 GITEA_TOKEN=usc-token GITEA_URL=https://git.uscllc.com "$SCRIPT_DIR/pr-create.sh" -t "USC API fallback" -H feature/pr-create
grep -q -- 'tea pr create --repo USC/uconnect --login usc --title USC API fallback --head feature/pr-create' "$LOG_FILE"
grep -q -- 'curl .*Authorization: token usc-token .*https://git.uscllc.com/api/v1/repos/USC/uconnect/pulls' "$LOG_FILE"
grep -q '^anonymous-auth-config$' "$LOG_FILE"
if grep -q 'Authorization:' "$LOG_FILE"; then
echo 'Credential header appeared in provider argv' >&2
exit 1
fi
if grep -q -- 'git.mosaicstack.dev/api/v1/repos/USC/uconnect/pulls' "$LOG_FILE"; then
echo "pr-create.sh API fallback defaulted USC repo to git.mosaicstack.dev" >&2
exit 1
fi
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
fixture_host=$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname)')
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" fixture-user "$fixture_host" "$(python3 -I -c 'import json,sys; d=json.load(open(sys.argv[1])); print(next(v["token"] for v in d["gitea"].values() if v["url"].endswith(sys.argv[2])))' "$CREDENTIALS_FILE" "$fixture_host")"
: > "$LOG_FILE"
run_in_repo env GITEA_TOKEN=mosaic-token GITEA_URL=https://git.mosaicstack.dev "$SCRIPT_DIR/issue-close.sh" -i 536
run_in_repo env MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" GITEA_TOKEN=mosaic-token GITEA_URL=https://git.mosaicstack.dev "$SCRIPT_DIR/issue-close.sh" -i 536
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/536' "$LOG_FILE"
if grep -q -- 'tea issue close 536 .*--login mosaicstack' "$LOG_FILE"; then
echo "issue-close.sh invented a mosaicstack tea login instead of using API fallback" >&2
@@ -517,15 +540,18 @@ git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
FIXTURE_XDG="$WORK_DIR/tokenfix"
NOYAML_DIR="$WORK_DIR/noyaml"
mkdir -p "$FIXTURE_XDG/tea" "$NOYAML_DIR"
# A shadow `yaml` module that raises ImportError, forcing the fallback path.
printf 'raise ImportError("forced-absent for #865 fallback regression")\n' > "$NOYAML_DIR/yaml.py"
if python3 -c 'import yaml' >/dev/null 2>&1; then HAVE_PYYAML=true; else HAVE_PYYAML=false; fi
# Confirm the shim really does force ImportError, so the fallback is exercised.
if python3 -c 'import yaml' >/dev/null 2>&1; then
if PYTHONPATH="$NOYAML_DIR" python3 -c 'import yaml' >/dev/null 2>&1; then
echo "FAIL: PyYAML-absence shim did not force ImportError (fallback not exercised)" >&2
exit 1
fi
# -I deliberately ignores PYTHONPATH. Use -S in a fixture interpreter to
# exercise missing third-party modules without relaxing production isolation.
REAL_PYTHON="$(command -v python3)"
cat > "$NOYAML_DIR/python3" <<SH
#!/usr/bin/env bash
exec "$REAL_PYTHON" -I -S "\$@"
SH
chmod +x "$NOYAML_DIR/python3"
if python3 -I -c 'import yaml' >/dev/null 2>&1; then HAVE_PYYAML=true; else HAVE_PYYAML=false; fi
if PATH="$NOYAML_DIR:$PATH" python3 -I -c 'import yaml' >/dev/null 2>&1; then
echo "FAIL: isolated no-site interpreter still imports PyYAML" >&2
exit 1
fi
write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
@@ -534,7 +560,7 @@ write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
token_fallback() {
(
cd "$REPO_DIR"
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" PATH="$NOYAML_DIR:$PATH" bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_token_for_login "$1" "$2"
' _ "$1" "$2"
@@ -1,316 +1,5 @@
#!/usr/bin/env bash
# Regression harness for detect-platform.sh's get_gitea_token() per-agent
# identity resolution (Gate-16 author≠reviewer separation) — the API-tooling
# counterpart to git-credential-mosaic, so pr-create.sh/issue-create.sh/etc.
# open records under the resolved agent identity, not the shared account.
#
# Covers:
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
# mosaic.gitIdentity (per-worktree).
# 2. Correct per-slot token file path chosen per host
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
# 3. Per-slot token present -> that token is returned (agent-authored calls).
# 4. No identity requested -> shared credential-loader token (backward
# compat, unchanged).
# 5. Patch 2b — explicit identity + recognized Gitea host + ABSENT per-slot
# token for that identity -> FAIL LOUD (nonzero return, empty stdout, a
# stderr diagnostic naming identity/source/host/expected path). Must NOT
# fall through to the shared/default token (Gate-16 author≠reviewer
# integrity — never silently borrow another slot's credentials). Covered
# for both identity sources (git config, MOSAIC_GIT_IDENTITY env) and
# for a same-identity cross-host case (token exists for one host, not
# the other).
# 6. Scope containment: identity requested + an UNRECOGNIZED Gitea host (no
# per-slot token scheme) -> Patch 2b does not apply; existing
# fall-through behavior is unchanged.
# 7. Credential store selection: an identity with a directory under
# <brain>/fleet/agents/ is a SEAT and is read ONLY from its own secrets/
# slot; any other identity is a SERVICE and is read from the framework
# store. No precedence between them and NO fallback from one to the
# other — a seat with an empty slot is REFUSED even when a same-named
# token sits in the framework store.
# 8. Fail loud when NO identity resolves on a host that runs a fleet: where
# seats exist, an unattributable API call is refused rather than made
# under the shared account. On a host with no fleet the same call still
# returns the shared token (case 1), which is what keeps this change a
# no-op for non-fleet operators of the framework.
#
# Uses a stubbed credentials.json + stubbed per-slot token files under a fake
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
# Explicit actor/host-bound credentials, shared/config refusal and trace controls.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}"
FAKE_HOME="$WORK_DIR/home"
REPO_DIR="$WORK_DIR/repo"
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
rm -rf "$WORK_DIR"
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$REPO_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
cat > "$CREDENTIALS_FILE" <<'JSON'
{
"gitea": {
"mosaicstack": {
"url": "https://git.mosaicstack.dev",
"token": "shared-mosaicstack-token"
},
"usc": {
"url": "https://git.uscllc.com",
"token": "shared-usc-token"
}
}
}
JSON
fail=0
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" != "$actual" ]]; then
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
fail=1
fi
}
# Runs get_gitea_token for $1=host inside REPO_DIR (per-worktree git config
# resolves there) with a fake HOME + the stub credentials.json, plus any
# extra env passed in $@.
call_get_gitea_token() {
local host="$1"; shift
(
cd "$REPO_DIR"
# shellcheck disable=SC2016 # deliberately deferred: $DETECT_PLATFORM_SH is
# expanded by the INNER bash -c (via the exported env var below), not here.
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
DETECT_PLATFORM_SH="$SCRIPT_DIR/detect-platform.sh" "$@" \
bash -c 'source "$DETECT_PLATFORM_SH"; get_gitea_token "$1"' _ "$host"
)
}
# ---------------------------------------------------------------------------
# 1. No identity resolvable -> shared credential-loader token (unchanged).
# ---------------------------------------------------------------------------
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
out=$(call_get_gitea_token "git.mosaicstack.dev")
assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out"
# ---------------------------------------------------------------------------
# 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot
# token -> that token wins over the shared account.
# ---------------------------------------------------------------------------
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
git -C "$REPO_DIR" config mosaic.gitIdentity agentA
out=$(call_get_gitea_token "git.mosaicstack.dev")
assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out"
# ---------------------------------------------------------------------------
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
# ---------------------------------------------------------------------------
echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token"
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentB)
assert_eq "env beats git-config identity token" "agentB-mosaicstack-token" "$out"
# ---------------------------------------------------------------------------
# 4. FAIL LOUD (Patch 2b): an identity is explicitly requested (via git config
# mosaic.gitIdentity, and separately via MOSAIC_GIT_IDENTITY env) for a
# RECOGNIZED Gitea host, but no per-slot token exists for THAT identity.
# Must NOT fall through to the shared/default token — silently borrowing
# another slot's credentials would post PRs/issues/reviews as the WRONG
# agent (Gate-16 author≠reviewer integrity break). Expect: nonzero return,
# EMPTY stdout (no token — shared or otherwise — leaked), and a stderr
# diagnostic naming the identity, its source, the host, and the expected
# per-slot token path.
# ---------------------------------------------------------------------------
assert_failloud() {
local desc="$1" host="$2" ident="$3" expected_tok_path="$4"; shift 4
local stderr_file="$WORK_DIR/stderr.tmp"
: > "$stderr_file"
set +e
local stdout
stdout=$(call_get_gitea_token "$host" "$@" 2>"$stderr_file")
local rc=$?
set -e
local stderr
stderr=$(cat "$stderr_file")
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: $desc — expected nonzero return, got 0 (stdout='$stdout')" >&2
fail=1
fi
if [[ -n "$stdout" ]]; then
echo "FAIL: $desc — expected empty stdout (no token leaked), got '$stdout'" >&2
fail=1
fi
if [[ "$stderr" != *"$ident"* ]]; then
echo "FAIL: $desc — stderr does not name the requested identity '$ident':" >&2
echo "$stderr" >&2
fail=1
fi
if [[ "$stderr" != *"$host"* ]]; then
echo "FAIL: $desc — stderr does not name the host '$host':" >&2
echo "$stderr" >&2
fail=1
fi
if [[ "$stderr" != *"$expected_tok_path"* ]]; then
echo "FAIL: $desc — stderr does not name the expected per-slot token path '$expected_tok_path':" >&2
echo "$stderr" >&2
fail=1
fi
if [[ "$stderr" == *"shared"*"token"* ]]; then
echo "FAIL: $desc — stderr unexpectedly mentions a shared token value:" >&2
echo "$stderr" >&2
fail=1
fi
}
# 4a. git config mosaic.gitIdentity source, recognized host (mosaicstack),
# shared token IS present but must not be borrowed.
git -C "$REPO_DIR" config mosaic.gitIdentity no-such-agent
assert_failloud "fail-loud via git-config identity (recognized host)" \
"git.mosaicstack.dev" "no-such-agent" \
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-no-such-agent.token"
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
# 4b. MOSAIC_GIT_IDENTITY env source (takes priority over git config), same
# recognized-host / absent-token scenario -> also fails loud.
assert_failloud "fail-loud via MOSAIC_GIT_IDENTITY env (recognized host)" \
"git.mosaicstack.dev" "no-such-agent-env" \
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-no-such-agent-env.token" \
MOSAIC_GIT_IDENTITY=no-such-agent-env
# ---------------------------------------------------------------------------
# 5. Correct per-slot token PATH per host: same agent id, only a usc token
# exists. usc host returns it (happy path, unchanged). mosaicstack host
# has NO per-slot token for this identity -> Patch 2b fail-loud applies
# there too (must NOT fall back to the shared mosaicstack token, and must
# NOT leak the agent's usc token either).
# ---------------------------------------------------------------------------
echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token"
git -C "$REPO_DIR" config mosaic.gitIdentity agentD
out=$(call_get_gitea_token "git.uscllc.com")
assert_eq "host-scoped token path (usc)" "agentD-usc-token" "$out"
assert_failloud "fail-loud on cross-host absence (no fallback, no cross-host leak)" \
"git.mosaicstack.dev" "agentD" \
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentD.token"
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
# ---------------------------------------------------------------------------
# 6. Scope containment: identity explicitly requested, but the host is NOT a
# recognized Gitea host (no per-slot token scheme at all) -> Patch 2b does
# NOT apply; existing fall-through behavior is unchanged (ends in the
# pre-existing generic failure since no shared credentials match either,
# NOT the fail-loud diagnostic path).
# ---------------------------------------------------------------------------
git -C "$REPO_DIR" config mosaic.gitIdentity no-such-agent
set +e
out=$(call_get_gitea_token "github.com" 2>"$WORK_DIR/stderr-scope.tmp")
rc=$?
set -e
err=$(cat "$WORK_DIR/stderr-scope.tmp")
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: unrecognized host + identity — expected nonzero (no credentials configured), got 0" >&2
fail=1
fi
if [[ "$err" == *"no per-slot token at"* ]]; then
echo "FAIL: unrecognized host + identity — fail-loud diagnostic must not fire for a host with no per-slot scheme:" >&2
echo "$err" >&2
fail=1
fi
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
# ---------------------------------------------------------------------------
# 7. SEAT identity: an id with a directory under <brain>/fleet/agents/ is read
# from its OWN secrets/ slot, not from the framework store. The brain home
# is resolved exactly as packages/mosaic/src/fleet/brain-home.ts does it:
# MOSAIC_BRAIN_HOME, else ~/.mosaic.
# ---------------------------------------------------------------------------
BRAIN_DIR="$WORK_DIR/brain"
mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets"
echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatE MOSAIC_BRAIN_HOME="$BRAIN_DIR")
assert_eq "seat reads its own slot" "seatE-slot-token" "$out"
# ---------------------------------------------------------------------------
# 8. NO CROSS-STORE FALLBACK. seatF is a seat (it has a directory) with an
# EMPTY slot, while a framework-store token of the identical name is
# present and readable. It must be REFUSED rather than served that token:
# one credential, one location. A seat that silently reads a same-named
# service credential is the same substitution failure as the shared-account
# fallback, one store further down.
# ---------------------------------------------------------------------------
mkdir -p "$BRAIN_DIR/fleet/agents/seatF/secrets"
echo -n "seatF-SERVICE-STORE-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-seatF.token"
assert_failloud "seat with empty slot does NOT fall back to the framework store" \
"git.mosaicstack.dev" "seatF" \
"$BRAIN_DIR/fleet/agents/seatF/secrets/gitea-mosaicstack-seatF.token" \
MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR"
# assert_failloud only screens stderr for the word "shared"; this store's token
# is not named that, so check for its value explicitly.
set +e
xstore_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-xstore.tmp")
set -e
if [[ "$xstore_out$(cat "$WORK_DIR/stderr-xstore.tmp")" == *"seatF-SERVICE-STORE-token"* ]]; then
echo "FAIL: cross-store fallback — a seat was served the framework store's same-named token" >&2
fail=1
fi
# Control: that framework-store token IS readable, so the refusal above is the
# store rule firing and not an unreadable file. The same id, resolved against a
# brain home where it is not a seat, gets it.
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$WORK_DIR/no-such-brain")
assert_eq "control — same file IS readable for a non-seat identity" "seatF-SERVICE-STORE-token" "$out"
# ---------------------------------------------------------------------------
# 9. FAIL LOUD — no identity resolvable, but this host runs a fleet. Contrast
# with case 1: the identical call on a host with no fleet still returns the
# shared token.
# ---------------------------------------------------------------------------
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
set +e
noid_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-noid.tmp")
noid_rc=$?
set -e
noid_err=$(cat "$WORK_DIR/stderr-noid.tmp")
if [[ "$noid_rc" -eq 0 ]]; then
echo "FAIL: no identity on a fleet host — expected nonzero return, got 0 (stdout='$noid_out')" >&2
fail=1
fi
if [[ -n "$noid_out" ]]; then
echo "FAIL: no identity on a fleet host — expected empty stdout, got '$noid_out'" >&2
fail=1
fi
if [[ "$noid_out" == *"shared-mosaicstack-token"* || "$noid_err" == *"shared-mosaicstack-token"* ]]; then
echo "FAIL: no identity on a fleet host — the shared token was served anyway:" >&2
echo "$noid_out$noid_err" >&2
fail=1
fi
if [[ "$noid_err" != *"MOSAIC_GIT_IDENTITY"* ]]; then
echo "FAIL: no identity on a fleet host — stderr does not say how to set an identity:" >&2
echo "$noid_err" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 10. The brain home defaults to ~/.mosaic when MOSAIC_BRAIN_HOME is unset —
# the fleet gate must fire on the default path too, not only on an
# explicitly injected one. Case 1 ran before this directory existed; the
# same call now refuses, which also proves case 1 was measuring the
# no-fleet branch rather than passing for an unrelated reason.
# ---------------------------------------------------------------------------
mkdir -p "$FAKE_HOME/.mosaic/fleet/agents"
set +e
dflt_out=$(call_get_gitea_token "git.mosaicstack.dev" 2>"$WORK_DIR/stderr-dflt.tmp")
dflt_rc=$?
set -e
if [[ "$dflt_rc" -eq 0 || -n "$dflt_out" ]]; then
echo "FAIL: fleet gate did not fire on the default ~/.mosaic brain home (rc=$dflt_rc stdout='$dflt_out')" >&2
fail=1
fi
if [[ "$fail" -eq 0 ]]; then
echo "get_gitea_token identity resolution regression passed"
fi
exit "$fail"
exec python3 -I "$SCRIPT_DIR/test-forge-auth.py"
@@ -29,6 +29,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/grant-reviewer}"
REPO_DIR="$WORK_DIR/repo"
@@ -111,6 +112,9 @@ with open(sys.argv[1], "w", encoding="utf-8") as credentials:
}, credentials)
PY
mkdir -p "$HOME_DIR/.config/mosaic"
cp "$CREDENTIALS_FILE" "$HOME_DIR/.config/mosaic/credentials.json"
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" "$DEFAULT_IDENTITY" "$(python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.argv[1]).hostname)' "$API_ROOT")" "$DEFAULT_TOKEN"
# tea stub: grant-reviewer.sh must never shell out to tea at all.
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
@@ -131,6 +135,12 @@ chmod +x "$BIN_DIR/tea"
# GET /teams/{id}/repos/{org}/{repo} -> answers from persisted state only
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
# Public schema fixture for fail-closed provider detection.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
set -euo pipefail
# Record the FULL argv exactly as spawned, before consumption. The bearer token
@@ -162,7 +172,7 @@ done
# Resolve the bearer token from the curl --config file (its real, secure
# source). The config line is `header = "Authorization: token <value>"`.
if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then
if [[ -z "$auth_token" && -n "$config_file" && -r "$config_file" ]]; then
config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)"
if [[ "$config_hdr" == *"token "* ]]; then
auth_token="${config_hdr##*token }"
@@ -379,7 +389,7 @@ run_grant() {
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" \
MOSAIC_GIT_IDENTITY= \
MOSAIC_GIT_IDENTITY="$DEFAULT_IDENTITY" \
GITEA_LOGIN= \
GITEA_TOKEN= \
GITEA_URL= \
@@ -16,6 +16,7 @@
# invoke the real, provider-mutating issue-close.sh. Setup now fails closed, and both
# `tea` and `curl` are asserted to resolve INSIDE $MOCK_BIN before any target run.
set -euo pipefail
# NOTE: with `set -e`, `grep -q X && fail "..."` is a trap -- the ABSENT case (grep rc=1,
# which is the PASSING case for a must-not-appear assertion) is the last command of an &&
# list and silently terminates the script with no message. Every must-not-appear check
@@ -43,9 +44,20 @@ git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git || fail
export PATH="$MOCK_BIN:$PATH" CALLS
export GITEA_URL="https://git.mosaicstack.dev"
export GITEA_TOKEN="redacted-test-token"
export HOME="$SANDBOX/home"
export MOSAIC_BRAIN_HOME="$HOME/.mosaic"
export MOSAIC_GIT_IDENTITY=fixture-user
mkdir -p "$HOME"
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "${GITEA_URL#https://}" "$GITEA_TOKEN"
cat > "$MOCK_BIN/curl" <<'EOF'
#!/bin/bash
# Public schema fixture for fail-closed provider detection.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
method=GET; url=""
while [ $# -gt 0 ]; do
case "$1" in
@@ -76,17 +88,8 @@ exit 0
EOF
chmod +x "$MOCK_BIN/tea"
}
# #1356: login resolution is now identity-aware, so the tea-branch fixture must
# offer the login the RUNNER's identity resolves to; otherwise every case below
# fails closed before reaching the branch under test.
#
# This does NOT make the suite hermetic, and it is not trying to. The API-path
# cases (5-7) need a usable Gitea token, and with an identity set the token path
# reads that seat's credential file rather than the GITEA_TOKEN exported above.
# So this suite passes only where the runner owns a real credential for its own
# identity, and fails with no identity at all -- on this branch and on its base
# alike. That is a pre-existing hole in the fixture, filed separately; pinning a
# synthetic identity here would only convert it into a confident-looking green.
# Match the synthetic actor in both tea and API fixture arms. No arm reads an
# operator credential; the API token is provisioned in this fixture's own HOME.
_LOGIN_IDENT="${MOSAIC_GIT_IDENTITY:-}"
LOGIN_JSON='[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
if [[ -n "$_LOGIN_IDENT" ]]; then
@@ -55,6 +55,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-comment-readback}"
REPO_DIR="$WORK_DIR/repo"
@@ -223,6 +224,12 @@ chmod +x "$BIN_DIR/tea"
# guard and fails the test.
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
# Public schema fixture for fail-closed provider detection.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
set -euo pipefail
# Record the FULL argv exactly as spawned, before consumption. The bearer token
@@ -255,7 +262,7 @@ done
# Resolve the bearer token from the curl --config file (its real, secure source);
# fall back to an -H header only for defense in depth. The config line is
# `header = "Authorization: token <value>"`.
if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then
if [[ -z "$auth_token" && -n "$config_file" && -r "$config_file" ]]; then
config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)"
if [[ "$config_hdr" == *"token "* ]]; then
auth_token="${config_hdr##*token }"
@@ -437,6 +444,8 @@ run_comment() {
: > "$AUTH_LOG"
: > "$OUTPUT_FILE"
seed_state "$mode"
fixture_host="${API_ROOT#https://}"; fixture_host="${fixture_host%%/*}"
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" "$ACTING_LOGIN" "$fixture_host" "$DEFAULT_TOKEN"
(
cd "$REPO_DIR"
# Provisioned seats export MOSAIC_GIT_IDENTITY and MOSAIC_BRAIN_HOME
@@ -459,8 +468,8 @@ run_comment() {
HOME="$HOME_DIR" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_GIT_IDENTITY="" \
MOSAIC_BRAIN_HOME="" \
MOSAIC_GIT_IDENTITY="$ACTING_LOGIN" \
MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" \
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
@@ -497,6 +506,8 @@ run_comment_seat() {
: > "$AUTH_LOG"
: > "$OUTPUT_FILE"
seed_state "$mode"
fixture_host="${API_ROOT#https://}"; fixture_host="${fixture_host%%/*}"
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" "$ACTING_LOGIN" "$fixture_host" "$DEFAULT_TOKEN"
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
@@ -505,7 +516,7 @@ run_comment_seat() {
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$EMPTY_CREDENTIALS_FILE" \
MOSAIC_GIT_IDENTITY="$SEAT_IDENTITY" \
MOSAIC_BRAIN_HOME="" \
MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" \
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
@@ -82,6 +82,7 @@ fi
exit 0
SH
chmod +x "$BIN_DIR/tea"
cp "$SCRIPT_DIR/fixture-schema-client.sh" "$BIN_DIR/curl"
(
cd "$REPO_DIR"
@@ -3,6 +3,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
REPO_DIR="$WORK_DIR/repo"
@@ -65,6 +66,12 @@ SH
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
# Public schema fixture for fail-closed provider detection.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
set -euo pipefail
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
# Repo roots: the pr-create API fallback resolves its base from the forge
@@ -81,6 +88,13 @@ case "$url" in
esac
SH
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
export MOSAIC_GIT_IDENTITY=current-user
GITEA_LOGIN="$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname.split(".")[1])')"
export GITEA_LOGIN
export MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic"
mkdir -p "$HOME_DIR/.config/mosaic"
cp "$CREDENTIALS_FILE" "$HOME_DIR/.config/mosaic/credentials.json"
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname)')" test-token
run_wrapper() {
(
@@ -15,6 +15,7 @@
# Hermetic: mock tea and curl on PATH, sandboxed repo. Resolves no real credentials.
set -euo pipefail
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
SANDBOX="$WORK_ROOT/issue-view-comments-test-$$"
MOCK_BIN="$SANDBOX/bin"; REPO_DIR="$SANDBOX/repo"; CALLS="$SANDBOX/calls.log"
@@ -36,12 +37,21 @@ export GITEA_URL="https://git.mosaicstack.dev"
export GITEA_TOKEN="redacted-test-token"
# The identity ladder must not reach for this seat's real login; the mock tea below
# defines the only login that exists in this sandbox.
unset MOSAIC_GIT_IDENTITY
export MOSAIC_GIT_IDENTITY=fixture-user
GITEA_LOGIN="$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname.split(".")[1])')"
export GITEA_LOGIN
# No fleet in the sandbox: on a host that runs one, get_gitea_token fails closed for an
# identity-less caller (by design), which would make this test measure the host, not
# the wrapper. An empty brain home makes the sandbox the same on every host.
export MOSAIC_BRAIN_HOME="$SANDBOX/brain"
mkdir -p "$MOSAIC_BRAIN_HOME" || fail "setup: cannot create sandbox brain home"
mkdir -p "$SANDBOX/home/.config/mosaic"
export HOME="$SANDBOX/home"
python3 -I - "$HOME/.config/mosaic/credentials.json" "$GITEA_URL" <<'PYFIX'
import json,sys
open(sys.argv[1],'w').write(json.dumps({'gitea':{sys.argv[2].split('//')[1].split('.')[1]:{'url':sys.argv[2]}}}))
PYFIX
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "${GITEA_URL#https://}" "$GITEA_TOKEN"
# Distinctive strings: a comment body that appears nowhere else, and an issue title
# that contains the word "comment" so a count-of-the-word instrument would misread.
@@ -51,6 +61,12 @@ ISSUE_TITLE="wrapper never shows a comment"
# --- mock curl: serves the issue and its comments; logs every call --------------
cat > "$MOCK_BIN/curl" <<EOF
#!/bin/bash
# Public schema fixture for fail-closed provider detection.
if [[ "\$*" == *'/swagger.v1.json'* ]]; then
[[ " \$* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
url=""
while [ \$# -gt 0 ]; do
case "\$1" in
@@ -0,0 +1,99 @@
import json
import pathlib
import subprocess
import tempfile
import unittest
HERE = pathlib.Path(__file__).resolve().parent
class Policy(unittest.TestCase):
def resolve(self, policy=None, method=None, head='task', base='integration'):
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')
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]
if method:
args += ['--method',method]
p = subprocess.run(args,capture_output=True,text=True)
return p.returncode, json.loads(p.stdout) if p.stdout else {}
def test_absence_is_squash_only(self):
self.assertEqual(self.resolve()[1].get('method'),'squash')
self.assertEqual(self.resolve(method='merge')[0],3)
def test_absorb_shape_controls_request(self):
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_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)
def test_shared_validator_and_shell_quoting(self):
with tempfile.TemporaryDirectory() as directory:
root=pathlib.Path(directory)
doc=dict(schema_version=3,integration_trunk='integration',release_branch='stable',flow='trunk-release',canonical_remote='https://git.example.test/owner/repo',canonical_clone='host:/src/project',merge=dict(allowed_methods=['squash','merge'],default_method='squash',shapes={'absorb':'merge'}))
(root/'.mosaic').mkdir()
path=root/'.mosaic/repo.json'; path.write_text(json.dumps(doc))
validator=HERE.parent/'structure/validate-repo-json.sh'
p=subprocess.run(['bash',str(validator),str(path),'--mode','display'],capture_output=True,text=True)
self.assertEqual(p.returncode,0,p.stdout+p.stderr)
doc['merge']['shapes']['invented']='merge'; path.write_text(json.dumps(doc))
p=subprocess.run(['bash',str(validator),str(path),'--mode','display'],capture_output=True,text=True)
self.assertNotEqual(p.returncode,0)
del doc['merge']['shapes']['invented']
branch="feature/'$(touch${IFS}marker)'"
doc['integration_trunk']=branch; path.write_text(json.dumps(doc))
subprocess.run(['git','init','-q',str(root)],check=True)
p=subprocess.run(['bash','-c','source "$1/repo-decl.sh"; repo_decl_load; [[ "$DECL_STATE" == valid ]] || exit 1; printf %s "$DECL_TRUNK"','fixture',str(HERE)],cwd=root,capture_output=True,text=True)
self.assertEqual(p.returncode,0,p.stderr)
self.assertEqual(p.stdout,branch)
self.assertFalse((root/'marker').exists(),'declaration executed shell substitution')
def test_self_transition_refused(self):
self.assertEqual(self.resolve(head='integration')[0],3)
def test_head_and_uncommitted_policy_cannot_authorize_merge(self):
import os
import importlib.util
spec=importlib.util.spec_from_file_location('trusted_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):
r=subprocess.run(['git',*args],cwd=root,capture_output=True,text=True,check=True)
return r.stdout.strip()
git('init','-q'); git('remote','add','origin','https://github.com/owner/repo.git')
git('-c','user.name=Fixture','-c','[email protected]','commit','--allow-empty','-qm','absent policy target')
absent=git('rev-parse','HEAD')
doc=dict(schema_version=3,integration_trunk='integration',release_branch='stable',flow='trunk-release',canonical_remote='https://github.com/owner/repo',canonical_clone='host:/src/project',merge=dict(allowed_methods=['squash'],default_method='squash'))
path=root/'.mosaic/repo.json'; path.write_text(json.dumps(doc)); git('add','.mosaic/repo.json')
git('-c','user.name=Fixture','-c','[email protected]','commit','-qm','trusted target')
base=git('rev-parse','HEAD')
doc['merge']['allowed_methods'].append('merge'); path.write_text(json.dumps(doc))
git('add','.mosaic/repo.json'); git('-c','user.name=Fixture','-c','[email protected]','commit','-qm','weaken head')
doc['merge']['default_method']='merge'; path.write_text(json.dumps(doc))
p={'head':{'ref':'task'},'base':{'ref':'integration','sha':base,'repo':{'full_name':'owner/repo'}}}
client=api.Client('github.com','owner/repo')
old=os.getcwd()
try:
os.chdir(root)
with self.assertRaises(api.Failure) as caught: api.declared_method(client,p,'merge')
self.assertEqual(caught.exception.code,3)
self.assertEqual(api.declared_method(client,p,None),'squash')
p['base']['sha']=absent
client.http=lambda *a, **kw: {'default_branch':'integration','allow_squash_merge':False}
self.assertEqual(api.declared_method(client,p,None),'squash')
with self.assertRaises(api.Failure) as caught: api.declared_method(client,p,'merge')
self.assertEqual(caught.exception.code,3)
with self.assertRaises(api.Failure) as caught: api.forge_method(client,'squash')
self.assertEqual(caught.exception.code,4)
p['base']['sha']='e'*40
with self.assertRaises(api.Failure) as caught: api.declared_method(client,p,None)
self.assertEqual(caught.exception.code,3)
finally: os.chdir(old)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/test-forge-contract.py" IntegrationContract IntegrationMutations
@@ -16,6 +16,7 @@
# shellcheck disable=SC2317
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-create-fallback-base}"
@@ -45,10 +46,20 @@ git -C "$ROOT/repo" init -q -b fix/e4
git -C "$ROOT/repo" -c user.name=fixture -c user.email=fixture@test commit -q --allow-empty -m base
git -C "$ROOT/repo" remote add origin https://git.example.test/acme/widgets.git
export MOSAIC_GIT_IDENTITY=fixture-user
export MOSAIC_BRAIN_HOME="$ROOT/home/.mosaic"
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$ROOT/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" git.example.test stub-token
# curl stub: GET repo -> default_branch JSON (or failure mode); POST pulls ->
# capture payload, answer with a minimal PR JSON. Every call is logged.
cat > "$ROOT/stub/curl" <<STUB
#!/usr/bin/env bash
# Public schema fixture for fail-closed provider detection.
if [[ "\$*" == *'/swagger.v1.json'* ]]; then
[[ " \$* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
set -u
mode="\${CURL_STUB_GET_MODE:-ok}"
printf '%s\n' "\$*" >> "$ROOT/curl-calls.log"
@@ -87,7 +98,7 @@ run_pr_create() { # args... -> sets RC/OUT/ERR
RC=0
OUT=$(cd "$ROOT/repo" && env -i \
PATH="$ROOT/stub:/usr/bin:/bin" \
HOME="$ROOT/home" \
HOME="$ROOT/home" MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$ROOT/home/.mosaic" \
GITEA_TOKEN=stub-token \
bash "$TOOLS/pr-create.sh" "$@" 2>"$ROOT/err.txt")
RC=$?
@@ -119,7 +130,7 @@ echo "== (3) default-branch lookup failure: loud refusal, no POST =="
RC=0
OUT=$(cd "$ROOT/repo" && env -i \
PATH="$ROOT/stub:/usr/bin:/bin" \
HOME="$ROOT/home" \
HOME="$ROOT/home" MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$ROOT/home/.mosaic" \
GITEA_TOKEN=stub-token \
CURL_STUB_GET_MODE=fail \
bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt")
@@ -141,7 +152,7 @@ echo "== (5) B2: HTTP failure with parseable JSON on stdout is a FAILED resoluti
RC=0
OUT=$(cd "$ROOT/repo" && env -i \
PATH="$ROOT/stub:/usr/bin:/bin" \
HOME="$ROOT/home" \
HOME="$ROOT/home" MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$ROOT/home/.mosaic" \
GITEA_TOKEN=stub-token \
CURL_STUB_GET_MODE=fail-json \
bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt")
@@ -158,7 +169,7 @@ for bad in null numeric blank; do
RC=0
OUT=$(cd "$ROOT/repo" && env -i \
PATH="$ROOT/stub:/usr/bin:/bin" \
HOME="$ROOT/home" \
HOME="$ROOT/home" MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$ROOT/home/.mosaic" \
GITEA_TOKEN=stub-token \
CURL_STUB_GET_MODE="$bad" \
bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt")
@@ -3,6 +3,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-edit}"
REPO_DIR="$WORK_DIR/repo"; BIN_DIR="$WORK_DIR/bin"; HOME_DIR="$WORK_DIR/home"
@@ -35,6 +36,12 @@ exit 1
SH
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
# Public schema fixture for fail-closed provider detection.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
set -euo pipefail
printf 'curl' >> "$MOSAIC_TEST_LOG"; printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG"; printf '\n' >> "$MOSAIC_TEST_LOG"
if [[ "${*: -1}" == */user ]]; then
@@ -3,6 +3,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_ROOT="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$"
@@ -38,6 +39,12 @@ chmod +x "$MOCK_BIN/tea"
cat > "$MOCK_BIN/curl" <<'EOF'
#!/bin/bash
# Public schema fixture for fail-closed provider detection.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
set -euo pipefail
printf 'curl %q ' "$@" >> "$PR_MERGE_TEST_LOG"
printf '\n' >> "$PR_MERGE_TEST_LOG"
@@ -157,6 +164,14 @@ export PR_MERGE_TEST_LOG="$LOG_FILE"
export GITEA_LOGIN="git.mosaicstack.dev"
export GITEA_URL="https://git.mosaicstack.dev"
export GITEA_TOKEN="redacted-test-token"
export MOSAIC_GIT_IDENTITY=fixture-user
export MOSAIC_BRAIN_HOME="$SANDBOX/brain"
mkdir -p "$HOME/.config/mosaic"
python3 -I - "$HOME/.config/mosaic/credentials.json" "$GITEA_URL" <<'PYFIX'
import json,sys
open(sys.argv[1],'w').write(json.dumps({'gitea':{sys.argv[2].split('//')[1].split('.')[1]:{'url':sys.argv[2]}}}))
PYFIX
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "${GITEA_URL#https://}" "$GITEA_TOKEN"
OUTPUT="$SANDBOX/output.log"
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
@@ -4,6 +4,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-head-pin}"
SHA=0123456789abcdef0123456789abcdef01234567
@@ -35,6 +36,12 @@ make_fixture github https://github.com/acme/widgets.git main
cat > "$WORK_DIR/gitea/curl" <<'SH'
#!/usr/bin/env bash
# Public schema fixture for fail-closed provider detection.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
set -euo pipefail
payload=""
out_file=""
@@ -71,9 +78,12 @@ set +e
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_GIT_IDENTITY=fixture-user
export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain"
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "${GITEA_URL#https://}" "$GITEA_TOKEN"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123
env MOSAIC_GIT_IDENTITY=fixture-user "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123
) >"$WORK_DIR/gitea.out" 2>&1
gitea_rc=$?
set -e
@@ -100,9 +110,12 @@ set +e
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_GIT_IDENTITY=fixture-user
export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain"
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "${GITEA_URL#https://}" "$GITEA_TOKEN"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-stale.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --expect-head "$wrong_sha"
env MOSAIC_GIT_IDENTITY=fixture-user "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --expect-head "$wrong_sha"
) >"$WORK_DIR/gitea-stale.out" 2>&1
stale_rc=$?
set -e
@@ -125,9 +138,12 @@ set +e
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_GIT_IDENTITY=fixture-user
export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain"
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "${GITEA_URL#https://}" "$GITEA_TOKEN"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-bypass.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --skip-queue-guard
env MOSAIC_GIT_IDENTITY=fixture-user "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --skip-queue-guard
) >"$WORK_DIR/gitea-bypass.out" 2>&1
bypass_rc=$?
set -e
@@ -144,9 +160,12 @@ rm -f "$WORK_DIR/gitea-payload-dry-run.json"
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_GIT_IDENTITY=fixture-user
export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain"
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "${GITEA_URL#https://}" "$GITEA_TOKEN"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-dry-run.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --dry-run
env MOSAIC_GIT_IDENTITY=fixture-user "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --dry-run
) >"$WORK_DIR/gitea-dry-run.out" 2>&1
if [[ -e "$WORK_DIR/gitea-payload-dry-run.json" ]]; then
echo "FAIL dry-run: non-merging preflight reached the provider merge path" >&2
@@ -3,6 +3,7 @@
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SUBJECT="${MOSAIC_TEST_SUBJECT:-$SCRIPT_DIR/pr-merge.sh}"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-message-field}"
@@ -83,6 +84,12 @@ SH
cat > "$case_dir/bin/curl" <<'SH'
#!/usr/bin/env bash
# Public schema fixture for fail-closed provider detection.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
set -eu
for arg in "$@"; do
@@ -530,7 +537,7 @@ method_output=$(run_case "$method_dir" single -m merge 2>&1)
method_rc=$?
set -e
[[ "$method_rc" -ne 0 ]] || fail "non-squash method unexpectedly passed"
[[ "$method_output" == *"enforces squash merge only"* ]] || fail "non-squash refusal lost its policy diagnostic"
[[ "$method_output" == *"POLICY-REFUSED"* ]] || fail "non-squash refusal lost its policy diagnostic"
[[ ! -e "$method_dir/merge-payload.json" ]] || fail "non-squash refusal reached the merge API"
if [[ "$failures" -ne 0 ]]; then
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/test-merge-policy.py"
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/test-forge-contract.py" IntegrationContract IntegrationMutations
@@ -3,6 +3,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}"
REPO_DIR="$WORK_DIR/repo"
@@ -71,6 +72,13 @@ cat > "$CREDENTIALS_FILE" <<'JSON'
}
JSON
export MOSAIC_GIT_IDENTITY=fixture-user
export MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic"
mkdir -p "$HOME_DIR/.config/mosaic"
cp "$CREDENTIALS_FILE" "$HOME_DIR/.config/mosaic/credentials.json"
fixture_host=$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname)')
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "$fixture_host" stub-token
cat > "$FIXTURE_DIR/gitea-standard.json" <<'JSON'
{
"number": 1905,
@@ -115,6 +123,12 @@ JSON
cat > "$STUB_DIR/curl" <<'SH'
#!/usr/bin/env bash
# Public schema fixture for fail-closed provider detection.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
set -euo pipefail
output_file=""
@@ -265,7 +279,7 @@ run_case() {
local fixture="$1" expected_number="$2" expected_head="$3"
local output
output=$(cd "$REPO_DIR" && HOME="$HOME_DIR" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number")
PATH="$STUB_DIR:$PATH" MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number")
PR_METADATA_OUTPUT="$output" python3 - "$expected_number" "$expected_head" <<'PY'
import json
import os
@@ -286,7 +300,7 @@ run_case "$FIXTURE_DIR/gitea-refs-pull-label.json" 1908 fix/t_23fa9e1d-portal-he
run_curl_success_case
run_curl_early_exit_cleanup_case
if cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$FIXTURE_DIR/gitea-error.json" "$SCRIPT_DIR/pr-metadata.sh" -n 1909 >/dev/null 2>"$WORK_DIR/error.log"; then
if cd "$REPO_DIR" && PATH="$STUB_DIR:$PATH" MOSAIC_GITEA_PR_METADATA_RAW_FILE="$FIXTURE_DIR/gitea-error.json" "$SCRIPT_DIR/pr-metadata.sh" -n 1909 >/dev/null 2>"$WORK_DIR/error.log"; then
echo "Expected API error fixture to fail" >&2
exit 1
fi
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/test-forge-cli.py" CLI.test_gitea_review_pages_preserve_resolved_outdated
@@ -36,6 +36,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-gitea-comment}"
REPO_DIR="$WORK_DIR/repo"
@@ -158,6 +159,12 @@ chmod +x "$BIN_DIR/tea"
# state.
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
# Public schema fixture for fail-closed provider detection.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
set -euo pipefail
# Record the FULL argv exactly as spawned, BEFORE any consumption. The bearer
@@ -190,7 +197,7 @@ done
# Resolve the bearer token from the curl --config file (its real, secure source);
# only fall back to an -H header for defense in depth. The config line is
# `header = "Authorization: token <value>"`.
if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then
if [[ -z "$auth_token" && -n "$config_file" && -r "$config_file" ]]; then
config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)"
if [[ "$config_hdr" == *"token "* ]]; then
auth_token="${config_hdr##*token }"
@@ -553,6 +560,10 @@ run_review() {
local expected_web_base="${configured_url%/}/$expected_repo"
git -C "$REPO_DIR" remote set-url origin "$remote_url"
write_credentials "$configured_url"
fixture_host=$(python3 -I -c 'import sys,urllib.parse; print(urllib.parse.urlsplit(sys.argv[1]).netloc)' "$configured_url")
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" "$ACTING_LOGIN" "$fixture_host" "$DEFAULT_TOKEN"
fixture_remote_host=$(python3 -I -c 'import sys,urllib.parse; print(urllib.parse.urlsplit(sys.argv[1]).netloc if sys.argv[1].startswith(("https://", "http://")) else sys.argv[2])' "$remote_url" "$fixture_host")
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" "$ACTING_LOGIN" "$fixture_remote_host" "$DEFAULT_TOKEN"
: > "$TEA_LOG"
: > "$CURL_LOG"
: > "$CURL_ARGV_LOG"
@@ -564,7 +575,7 @@ run_review() {
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
HOME="$HOME_DIR" \
MOSAIC_GIT_IDENTITY="" \
MOSAIC_GIT_IDENTITY="$ACTING_LOGIN" MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
PR_REVIEW_TEA_LOG="$TEA_LOG" \
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/test-forge-contract.py" ReviewContract.test_reply_verifies_exact_returned_identity ReviewContract.test_unsupported_reply_makes_no_write ReviewContract.test_wrong_pr_comment_refuses_write
@@ -29,6 +29,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-repo-host-override}"
BIN_DIR="$WORK_DIR/bin"
@@ -72,6 +73,12 @@ chmod +x "$BIN_DIR/tea"
# inference instead of the override) is unexpected and fails the run.
cat > "$BIN_DIR/curl" <<SH
#!/usr/bin/env bash
# Public schema fixture for fail-closed provider detection.
if [[ "\$*" == *'/swagger.v1.json'* ]]; then
[[ " \$* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
set -euo pipefail
output_file=""
@@ -160,6 +167,7 @@ chmod +x "$BIN_DIR/curl"
run() {
local repo_dir="$1"; shift
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$WORK_DIR/home/.mosaic" fixture-user "$HOST_OVERRIDE_VAL" "$TOKEN_VAL"
: > "$CURL_LOG"
: > "$UA_LOG"
: > "$OUTPUT_FILE"
@@ -167,6 +175,7 @@ run() {
cd "$repo_dir"
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
HOME="$WORK_DIR/home" MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$WORK_DIR/home/.mosaic" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
GITEA_TOKEN="$TOKEN_VAL" \
GITEA_URL="https://$HOST_OVERRIDE_VAL" \
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/test-forge-contract.py" ReviewContract.test_resolve_requires_thread_owned_by_pr ResolutionContract
@@ -0,0 +1,35 @@
import json
import os
import pathlib
import unittest
ROOT=pathlib.Path(__file__).resolve().parents[2]
SKILLS=ROOT/'skills'
class Skills(unittest.TestCase):
def test_packaged_workflows_and_attribution(self):
for name in ['ms-full-send','ms-full-stop','ms-babysit-pr','ms-council','ms-git-operations','ms-watch']:
p=SKILLS/name/'SKILL.md'
self.assertTrue(p.is_file(),name+' not packaged')
text=p.read_text()
for forbidden in os.environ.get('MOSAIC_OPERATOR_FORBIDDEN_PATTERNS', '').splitlines():
self.assertNotIn(forbidden,text)
if name in ['ms-full-send','ms-full-stop','ms-babysit-pr','ms-council']:
self.assertTrue((p.parent/'LICENSE').is_file())
self.assertIn('t3dotgg/tstack',text)
def test_optional_firewall_patterns(self):
fixture = 'example operator placeholder'
self.assertIn('operator placeholder', fixture)
self.assertNotIn('operator placeholder', (SKILLS/'ms-full-stop/SKILL.md').read_text())
def test_stop_fails_closed_and_never_schedules(self):
text=(SKILLS/'ms-full-stop/SKILL.md').read_text()
self.assertIn('unsupported',text)
self.assertIn('pr-auto-merge.sh',text)
self.assertIn('pr-merge-queue.sh',text)
self.assertIn('Never merge',text)
def test_watch_reads_normalized_truth(self):
text=(SKILLS/'ms-babysit-pr/SKILL.md').read_text()
for required in ['pr-status.sh','pr-watch-check.sh','list_thread_pull_requests','unwatch_pull_request','pr-review-comments.sh']:
self.assertIn(required,text)
def test_framework_is_in_package(self):
package=json.loads((ROOT.parent/'package.json').read_text())
self.assertIn('framework',package['files'])
if __name__ == '__main__': unittest.main()
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/test-forge-contract.py" SnapshotContract
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/test-forge-contract.py" WorkflowContract
@@ -0,0 +1,169 @@
"""Independent wake and notifier entry paths, with a literal verdict oracle.
Provider delivery is simulated; both paths reread through real shell wrappers.
Every fake provider request is recorded and writes are rejected by fixtures.
"""
import importlib.util
import pathlib
import tempfile
import unittest
HERE = pathlib.Path(__file__).resolve().parent
def load(name, path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
api = load('watch_api', HERE/'forge-api.py')
cli = load('watch_cli', HERE/'test-forge-cli.py')
class T3Wake:
def __init__(self, fixture):
self.fixture, self.calls, self.active = fixture, [], False
def arm(self):
assert not self.active
self.calls.append('watch_pull_request')
self.active = True
def wake(self, authority):
self.calls.append('wake')
self.active = False
process, envelope = self.fixture.run_wrapper('pr-status', '-n', '1')
self.calls.append('pr-status')
return api.workflow_verdict(envelope['data'], authority) if process.returncode == 0 else 'blocked'
def retire(self):
self.calls.append('unwatch_pull_request')
self.active = False
class FallbackNotifier:
def __init__(self, fixture, available=True):
self.fixture, self.calls, self.active = fixture, [], False
self.available = available
def arm(self, baseline):
assert not self.active
if not self.available:
self.calls.append('watch-delivery-blocker')
return 'blocked'
self.baseline = baseline
self.active = True
self.calls.append('notifier:arm')
def wake(self, authority):
process, _ = self.fixture.run_wrapper('pr-watch-check', '-n', '1', '--baseline', str(self.baseline))
self.calls.append(('predicate', process.returncode))
if process.returncode not in (0, 1):
self.active = False
return 'blocked'
# A delivered notification always causes a fresh read; it is not proof.
process, envelope = self.fixture.run_wrapper('pr-status', '-n', '1')
self.calls.append('notifier:reread')
self.active = False
return api.workflow_verdict(envelope['data'], authority) if process.returncode == 0 else 'blocked'
def retire(self):
self.calls.append('notifier:cancel')
self.active = False
class Parity(unittest.TestCase):
def fixture(self, state='success', auto=None, queue=None, closed=False, conflict=False, rejected=False):
fixture = cli.CLI(); fixture.setUp(); self.addCleanup(fixture.tearDown)
root = '/repos/owner/repo'
pr = dict(number=1,state='closed' if closed else 'open',draft=False,merged=False,mergeable=not conflict,head={'sha':'a'*40,'ref':'task','repo':{'full_name':'fork/repo'}},base={'sha':'c'*40,'ref':'integration','repo':{'full_name':'owner/repo'}},user={'login':'author'})
fixture.fixtures = {
'GET '+root+'/pulls/1': {'data':pr},
'GET '+root+'/branches/integration/protection': {'data':{'required_status_checks':{'contexts':['build']},'required_pull_request_reviews':{'required_approving_review_count':1,'dismiss_stale_reviews':True}}},
'GET '+root+'/commits/'+'a'*40+'/statuses': {'data':[] if state=='missing' else [{'context':'build','state':state,'id':1}]},
'GET '+root+'/commits/'+'a'*40+'/check-runs': {'data':{'total_count':0,'check_runs':[]}},
'GET '+root+'/pulls/1/reviews': {'data':[{'id':1,'state':'CHANGES_REQUESTED' if rejected else 'APPROVED','user':{'login':'reviewer'},'commit_id':'a'*40}]},
'GET '+root+'/pulls/1/comments': {'data':[]},
'GET '+root+'/issues/1/comments': {'data':[]},
'POST graphql': {'data':{'data':{'repository':{'pullRequest':{'id':'PR','autoMergeRequest':auto,'mergeQueueEntry':queue,'reviewThreads':{'nodes':[],'pageInfo':{'hasNextPage':False,'endCursor':None}}}}}}},
}
return fixture
def test_independent_paths_match_expected_timeline_verdicts(self):
cases = [({},'ready_to_merge','ready_to_merge'),({'state':'pending'},'waiting','waiting'),({'state':'failure'},'blocked','blocked'),({'state':'cancelled'},'blocked','blocked'),({'state':'missing'},'blocked','blocked'),({'closed':True},'closed_unmerged','closed_unmerged'),({'conflict':True},'blocked','blocked'),({'rejected':True},'blocked','blocked'),({'auto':{'mergeMethod':'SQUASH'}},'blocked','ready_to_merge'),({'queue':{'id':'entry'}},'blocked','ready_to_merge'),({'auto':{}},'blocked','blocked')]
for options, stop_expected, send_expected in cases:
for authority, expected in [('full-stop',stop_expected),('full-send',send_expected)]:
with self.subTest(options=options,authority=authority):
fixture=self.fixture(**options)
# A valid pending baseline exists before the notifier is armed.
baseline_fixture=self.fixture(state='pending')
process, envelope=baseline_fixture.run_wrapper('pr-status','-n','1')
self.assertEqual(process.returncode,0)
baseline=fixture.root/'baseline.json'
import json
baseline.write_text(json.dumps(envelope))
t3, fallback=T3Wake(fixture), FallbackNotifier(fixture)
t3.arm(); self.assertEqual(t3.wake(authority),expected); t3.retire()
fallback.arm(baseline); self.assertEqual(fallback.wake(authority),expected); fallback.retire()
self.assertFalse(t3.active or fallback.active)
self.assertEqual(t3.calls,['watch_pull_request','wake','pr-status','unwatch_pull_request'])
self.assertEqual(fallback.calls[0],'notifier:arm')
if options.get('auto') != {}:
self.assertIn('notifier:reread',fallback.calls)
for call in fixture.calls():
self.assertTrue(call['method']=='GET' or call['path']=='graphql' and call['body']['query'].lstrip().startswith('query'), call)
def test_profiles_use_same_independent_entry_paths(self):
for title, version in [('Gitea API','28.2.0'),('Gitea API','1.24.7'),('Forgejo API','16.0.0-dev')]:
fixture=self.fixture()
fixture.gitea(version,title)
root='/repos/owner/repo'
fixture.fixtures['GET '+root+'/branch_protections']={'data':[{'branch_name':'integration','enable_status_check':True,'status_check_contexts':['build'],'required_approvals':1}]}
fixture.fixtures['GET '+root+'/pulls/1/reviews/1/comments']={'data':[]}
original=fixture.run_wrapper
fixture.run_wrapper=lambda *a, **kw: original(*a,host='git.example.test',**kw)
process,envelope=fixture.run_wrapper('pr-status','-n','1')
self.assertEqual(process.returncode,0)
import json
baseline=fixture.root/'baseline.json'; baseline.write_text(json.dumps(envelope))
for authority,expected in [('full-stop','blocked'),('full-send','ready_to_merge')]:
t3,fallback=T3Wake(fixture),FallbackNotifier(fixture)
t3.arm(); self.assertEqual(t3.wake(authority),expected); t3.retire()
fallback.arm(baseline); self.assertEqual(fallback.wake(authority),expected); fallback.retire()
self.assertFalse(t3.active or fallback.active)
self.assertTrue(all(c['method']=='GET' for c in fixture.calls()))
def test_missing_notifier_cannot_arm(self):
fallback=FallbackNotifier(None,available=False)
self.assertEqual(fallback.arm(None),'blocked')
self.assertFalse(fallback.active)
self.assertEqual(fallback.calls,['watch-delivery-blocker'])
def test_ready_and_stop_oracle(self):
ready=dict(state='open',ready=True,checks={'state':'success'},reviews={'decision':'approved'},auto_merge={'state':'disabled'},queue={'state':'absent'})
self.assertEqual(api.workflow_verdict(ready,'full-stop'),'ready_to_merge')
ready['auto_merge']['state']='unsupported'
self.assertEqual(api.workflow_verdict(ready,'full-stop'),'blocked')
def test_mutants_are_rejected_by_independent_oracle(self):
source=(HERE/'forge-api.py').read_text()
for mutant in [source.replace("return 'ready_to_merge'", "return 'waiting'"),source.replace("if mode == 'full-stop' and (", "if False and (")]:
with tempfile.TemporaryDirectory() as directory:
p=pathlib.Path(directory)/'api.py'; p.write_text(mutant)
module=load('mutant_api',p)
ready=dict(state='open',ready=True,checks={'state':'success'},reviews={'decision':'approved'},auto_merge={'state':'disabled'},queue={'state':'absent'})
outcomes=[module.workflow_verdict(ready,'full-stop')]
ready['auto_merge']['state']='unsupported'
outcomes.append(module.workflow_verdict(ready,'full-stop'))
self.assertNotEqual(outcomes,['ready_to_merge','blocked'])
def test_predicate_never_calls_blocking_wait(self):
source=(HERE/'forge-api.py').read_text()
branch=source.split("if args.operation == 'watch-check':",1)[1].split(' except Failure',1)[0]
self.assertNotIn('ci_wait(',branch)
self.assertNotIn('sleep(',branch)
if __name__=='__main__': unittest.main()
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/test-pr-watch-parity.py"
@@ -14,7 +14,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GUARD="$SCRIPT_DIR/push-guard.sh"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$SCRIPT_DIR/.work}"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$(mktemp -d)}"
trap 'rm -rf "${WORK_DIR:?}"' EXIT
PASS=0
FAIL=0
@@ -17,14 +17,15 @@
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TOOLS_SRC="${WP5B_TOOLS:-$SCRIPT_DIR}"
TROOT="${TMPDIR:-/tmp}"
TROOT="$(mktemp -d)"
PASS=0 FAIL=0 FAILED_CASES=""
FIXTURES=()
cleanup_all() { local f; for f in "${FIXTURES[@]:-}"; do rm -rf -- "$f"; done; }
trap cleanup_all EXIT INT TERM
trap 'cleanup_all; rmdir "$TROOT" 2>/dev/null || true' EXIT INT TERM
ok() { PASS=$((PASS + 1)); }
bad() { FAIL=$((FAIL + 1)); FAILED_CASES="$FAILED_CASES $1"; printf 'FAIL: %s\n' "$1" >&2; }
@@ -41,6 +42,7 @@ new_sb() { SB="$(mktemp -d "$TROOT/wp5b-test.XXXXXX")"; FIXTURES+=("$SB"); }
mkrepo() {
local d="$1" decl_origin="${2:-}"
mkdir -p "$d/tools/git" "$d/tools/structure" "$d/home"
cp "$TOOLS_SRC/merge-policy.py" "$d/tools/git/"
cp "$TOOLS_SRC/repo-decl.sh" "$d/tools/git/" 2>/dev/null || true
cp "$TOOLS_SRC/pr-create.sh" "$TOOLS_SRC/pr-merge.sh" "$TOOLS_SRC/mosaic-worktree.sh" "$TOOLS_SRC/ci-queue-wait.sh" "$TOOLS_SRC/detect-platform.sh" "$d/tools/git/" 2>/dev/null || true
cp "$SCRIPT_DIR/../structure/validate-repo-json.sh" "$d/tools/structure/"
@@ -157,6 +159,12 @@ mkpr() { # $1 dir: install the curl stub + run env; sets PR_RC/PR_OUT/PR_ERR/PR_
mkdir -p "$1/stub"
cat > "$1/stub/curl" <<'STUB'
#!/usr/bin/env bash
# Public schema fixture for fail-closed provider detection.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
url="${*: -1}"
printf 'curl %s\n' "$*" >> "${STUB_DIR:?}/calls.log"
case "$url" in
@@ -173,8 +181,9 @@ STUB
}
run_pr() { # $1 dir, rest args -> pr-create
local prdir="$1"; shift
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$prdir/home" "$prdir/home/.mosaic" fixture-user git.example.test stub-token
PR_RC=0
PR_OUT="$(cd "$prdir" && env -i PATH="$prdir/stub:/usr/bin:/bin" HOME="$prdir/home" \
PR_OUT="$(cd "$prdir" && env -i PATH="$prdir/stub:/usr/bin:/bin" HOME="$prdir/home" MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$prdir/home/.mosaic" \
GITEA_TOKEN=stub-token STUB_DIR="$prdir" \
bash "$prdir/tools/git/pr-create.sh" "$@" < /dev/null 2>"$prdir/err.txt")" || PR_RC=$?
PR_ERR="$(cat "$prdir/err.txt")"
@@ -234,6 +243,19 @@ assert_contains "B8 v1 note present" "$PR_ERR" "no consumable flow/trunk fields"
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
assert_eq "B8 legacy forge default used" "forge-default" "$base"
mkmetadata() {
mkdir -p "$1/stub"
cat > "$1/stub/curl" <<'STUB'
#!/usr/bin/env bash
if [[ "$*" == *'/swagger.v1.json'* ]]; then
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
exit 1
STUB
chmod +x "$1/stub/curl"
}
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
@@ -248,7 +270,7 @@ exit 0
EOF
chmod +x "$1/tools/git/ci-queue-wait.sh"
mkdir -p "$1/stub"
printf '#!/usr/bin/env bash\nexit 0\n' > "$1/stub/curl"; chmod +x "$1/stub/curl"
mkmetadata "$1"
M_RC=0
M_OUT="$(cd "$1" && env -i PATH="$1/stub:/usr/bin:/bin" HOME="$1/home" GITEA_TOKEN=stub-token \
bash "$1/tools/git/pr-merge.sh" -n 7 --dry-run < /dev/null 2>"$1/merr.txt")" || M_RC=$?
@@ -308,18 +330,20 @@ assert_contains "D3 placement stays derived (no decl path used)" "$(cat "$SB/d3/
echo "== E. ci-queue-wait route context (C4: context only, never a gate) =="
new_sb; mkrepo "$SB/e1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"
git -C "$SB/e1" checkout -q -b dev-trunk 2>/dev/null || { git -C "$SB/e1" branch -q dev-trunk; git -C "$SB/e1" checkout -q dev-trunk; }
mkmetadata "$SB/e1"
E_RC=0
E_OUT="$(cd "$SB/e1" && env -i PATH="/usr/bin:/bin" HOME="$SB/e1/home" \
E_OUT="$(cd "$SB/e1" && env -i PATH="$SB/e1/stub:/usr/bin:/bin" HOME="$SB/e1/home" \
bash "$SB/e1/tools/git/ci-queue-wait.sh" < /dev/null 2>"$SB/e1/eerr.txt")" || E_RC=$?
E_ERR="$(cat "$SB/e1/eerr.txt")"
assert_contains "E1 route context names trunk head" "$E_ERR" "'dev-trunk' is a trunk (integration head) head"
assert_contains "E1 context names the flow" "$E_ERR" "flow=trunk-release"
new_sb; mkrepo "$SB/e2" <<< "" "https://git.example.test/acme/widgets"
mkmetadata "$SB/e2"
E_RC=0
E_OUT="$(cd "$SB/e2" && env -i PATH="/usr/bin:/bin" HOME="$SB/e2/home" \
E_OUT="$(cd "$SB/e2" && env -i PATH="$SB/e2/stub:/usr/bin:/bin" HOME="$SB/e2/home" \
bash "$SB/e2/tools/git/ci-queue-wait.sh" < /dev/null 2>"$SB/e2/eerr.txt")" || E_RC=$?
assert_not_contains "E2 absence is SILENT for the guard (N4)" "$(cat "$SB/e2/eerr.txt")" "repo-decl"
assert_not_contains "E2 absence is SILENT for the guard (N4)" "$(cat "$SB/e2/eerr.txt")" "repo-decl:"
cleanup_all
assert_count "final: zero scratch residue" 0 "$(ls -d "$TROOT"/wp5b-test.* 2>/dev/null | wc -l | tr -d ' ')"
@@ -17,6 +17,7 @@
set -uo pipefail
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
GUARD="${1:-$HERE/wrapper-guard.sh}"
[ -x "$GUARD" ] || { printf 'test-wrapper-guard: not executable: %s\n' "$GUARD" >&2; exit 2; }
@@ -743,4 +744,5 @@ little is decoration. Both directions are failures here, which is why the
allowed cases are asserted as hard as the blocked ones.
EOF
fi
python3 -I "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/test-forge-guard.py" || fail=1
exit "$fail"
@@ -803,6 +803,16 @@ if printf '%s' "$CMD" | grep -Eq "$API_SHAPED" \
'\.(post|put|patch|delete)\(' && is_write=1
if [ "$is_write" -eq 1 ]; then
# Opaque GraphQL request files cannot be established as reads by this hook.
if printf '%s' "$CMD_NAMES" | grep -Eq '(^|[[:space:]/])graphql([[:space:]]|$)' && printf '%s' "$CMD" | grep -Eq -- '--input[[:space:]=]|--data(-binary)?[[:space:]=]+@|-d@'; then
echo "BLOCKED: opaque GraphQL payload; use the corresponding git wrapper or spell a read query inline." >&2
exit 2
fi
# Named GraphQL mutations are writes even though the URL is just graphql.
if printf '%s' "$CMD" | grep -Eq '(resolveReviewThread|unresolveReviewThread|enablePullRequestAutoMerge|disablePullRequestAutoMerge|enqueuePullRequest|dequeuePullRequest)[[:space:]]*\('; then
echo "BLOCKED: use pr-review-resolve.sh, pr-auto-merge.sh or pr-merge-queue.sh for this GraphQL mutation." >&2
exit 2
fi
# A percent-escape makes the endpoint unreadable HERE and perfectly readable
# to the PROVIDER, which is the whole hazard. Measured against the live
# forge, read-only: GET /repos/mosaicstack/stack/issues/1174 and
@@ -900,6 +910,8 @@ EOF
# -a <action> -c <comment> and files a verdict; nothing in the tree adds a
# requested reviewer. Unowned, so it flows through — placed above the
# reviews arm so it cannot be refused with "use pr-review.sh".
*"/pulls/"*"/comments/"*"/replies"*) endpoint="native review reply"; wrapper="pr-review-reply.sh" ;;
*"/pulls/comments/"*"/resolve"*|*"/pulls/comments/"*"/unresolve"*) endpoint="review conversation resolution"; wrapper="pr-review-resolve.sh" ;;
*"/pulls/"*"/requested_reviewers"*) : ;;
*"/pulls/"*"/reviews"*) endpoint="pull-request review"; wrapper="pr-review.sh" ;;
*"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="pr-merge.sh" ;;
@@ -72,8 +72,8 @@ fx nan.json '{"schema_version":NaN}'
expect_err_notrace b4 "malformed JSON" MOSAIC_HOST_ROOT=$ROOT -- "$SB/nan.json"
echo "== (3) unknown schema_version = ABSENT-loud =="
fx v3.json "${STACK/schema_version\":2/schema_version\":3}"
expect_err c1 "schema_version" MOSAIC_HOST_ROOT=$ROOT -- "$SB/v3.json"
fx v4.json "${STACK/schema_version\":2/schema_version\":4}"
expect_err c1 "schema_version" MOSAIC_HOST_ROOT=$ROOT -- "$SB/v4.json"
echo "== (4) v1 mode + authoring rule (v1 consumes no paths: no root needed) =="
fx v1.json '{"integration_trunk":"next","release_branch":"main"}'
@@ -42,8 +42,9 @@ VERSION="1.1.0+t51spec-r3+t51p2rw1"
if [ "${1:-}" = "--version" ]; then echo "validate-repo-json $VERSION"; exit 0; fi
exec python3 - "$@" <<'PYEOF'
MOSAIC_MERGE_POLICY_HELPER="$(cd "$(dirname "${BASH_SOURCE[0]}")/../git" && pwd)/merge-policy.py" exec python3 -I - "$@" <<'PYEOF'
import json, os, re, subprocess, sys, urllib.parse
from pathlib import Path
def err(key, reason):
print(f"VALIDATION_ERROR {key}: {reason}")
@@ -281,14 +282,14 @@ def main():
if "schema_version" in doc:
if not isinstance(sv, int) or isinstance(sv, bool):
err("schema_version", "must be an integer")
if sv not in (1, 2):
if sv not in (1, 2, 3):
err("schema_version", f"unknown schema_version {sv} — treated as ABSENT per keep-list K3; update tooling")
version = sv
else:
version = 1
warn("schema_version absent → v1 compatibility mode (two keys only)")
if REQUIRE_V2 and version != 2:
if REQUIRE_V2 and version not in (2, 3):
err("schema_version", "CI authoring rule: new or edited declarations must declare schema_version 2")
# ---- v1 ----
@@ -313,6 +314,13 @@ def main():
if not isinstance(doc["flow"], str) or doc["flow"] not in ENUM_FLOW:
err("flow", f"must be one of {sorted(ENUM_FLOW)} (§1.2, required — no defaulting, R7)")
if version == 3:
V2_OPTIONAL.add("merge")
# The shell supplies a fixed framework helper path, never repository code.
result = subprocess.run(["python3", "-I", os.environ["MOSAIC_MERGE_POLICY_HELPER"], path, "--validate"], capture_output=True, text=True)
if result.returncode:
err("merge", result.stdout.strip() or "merge policy validation failed")
unknown = set(doc) - set(V2_REQUIRED) - V2_OPTIONAL
if unknown:
err("x_extensions", f"unknown top-level keys {sorted(unknown)} — place extensions inside x_extensions")