Compare commits

...
Author SHA1 Message Date
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
code-be-02 924438016e fix(mosaic): honor seat-owned Gitea slots (#1529)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Mosaic fleet seat code-be-02 <[email protected]>
2026-10-10 00:50:38 +00:00
fred 2101c9b446 fix(git-tools): issue-comment.sh resolves API base without monolith GITEA_URL (#1502)
ci/woodpecker/push/publish Pipeline was successful
2026-09-11 22:50:23 +00:00
82 changed files with 3698 additions and 536 deletions

No files matched your search

@@ -0,0 +1,12 @@
{
"summary": "No important actionable issues found. Helper failures remain terminal, and the previously reported token-export regression is fixed. Bash syntax, package JSON, and caller-token export checks passed. The full regression suite was not run because the sandbox is read-only.",
"verdict": "approve",
"confidence": 0.88,
"findings": [],
"stats": {
"files_reviewed": 6,
"blockers": 0,
"should_fix": 0,
"suggestions": 0
}
}
@@ -0,0 +1,13 @@
{
"summary": "No confident security findings in the six supplied changed files. Seat lookups use the production helper, helper failures remain terminal, and returned credentials are validated. Bash syntax checks passed. Runtime regression tests were not run because the sandbox is read-only.",
"risk_level": "none",
"confidence": 0.87,
"findings": [],
"stats": {
"files_reviewed": 6,
"critical": 0,
"high": 0,
"medium": 0,
"low": 0
}
}
@@ -0,0 +1,23 @@
# #1311 Credential seat-store alignment
## Scope
Restore the missing `load_credentials` Gitea seat-slot behavior in the Stack framework. A known fleet seat must obtain its token through the production credential helper, while its Gitea URL remains provider configuration. A missing seat slot must fail closed and never fall back to the service store.
## Evidence
- Base: `2101c9b4468b22f57b2f02bb2c9da12067225819` (`origin/next`).
- Historical branch `origin/fix/credentials-gitea-seat-slots` contains the pre-refactor direct-loader fix, but it predates the current Python wrapper and ancestry fence.
- Red reproduction: `test-credentials-gitea-seats.sh` failed on base for populated seats, empty-seat no-fallback, and cross-seat ancestry cases.
- Canonical source: `packages/mosaic/framework/`. The package installer and `mosaic-doctor` describe the shipped framework as the source for deployed framework tools. The brain runtime copy is an estate runtime copy, not this framework change's source.
## Decision
The loader delegates seat token resolution to its sibling `git-credential-mosaic` production entrypoint. It does not invoke `.impl` directly or read a seat slot itself. This preserves the wrapper's environment sanitization and the implementation's process-ancestry fence. Non-seat and no-identity service-store behavior, non-Gitea services, and pre-existing `GITEA_TOKEN` precedence remain unchanged. The loader continues to export a caller-supplied token so child tool processes receive it.
## Validation and review
- The final hermetic matrix passes for Mosaic and USC seat slots, empty and cross-seat refusals, service-store paths, explicit and unexported caller tokens, and Woodpecker isolation. It fails against `origin/next` for the expected missing behavior.
- `bash -n` passes for the loader and new suite. The framework test-enumeration guard passes with the new suite enumerated.
- `pnpm --dir packages/mosaic run test:framework-shell` reaches `invariant_r_unittest.py` and stops on its existing host-runtime check: the test expects Pi `0.84.1` while this host reports `1.0.3`. No file in that invariant or its runtime probe changed in this task. The suite passes its earlier systemd check when the user-bus environment is supplied.
- Initial independent code review found that the new conditional stopped exporting a caller-supplied unexported `GITEA_TOKEN`. The fix exports it in both Gitea arms and adds Mosaic/USC child-process regressions. Re-review approved with no findings. Independent security review reported risk level `none`.
@@ -0,0 +1,465 @@
{
"base_sha": "924438016e2eb1c2ca352bfbf9107143b8b342bb",
"shell_suites": [
{
"test": "test-ci-queue-wait-branch-absent.sh",
"exit_code": 0
},
{
"test": "test-ci-queue-wait-github-checks.sh",
"exit_code": 0
},
{
"test": "test-ci-queue-wait-no-ci-expected.sh",
"exit_code": 0
},
{
"test": "test-ci-queue-wait-no-status.sh",
"exit_code": 0
},
{
"test": "test-ci-queue-wait-tristate.sh",
"exit_code": 0
},
{
"test": "test-detect-platform-outside-repo.sh",
"exit_code": 0
},
{
"test": "test-explain-diagnostic-status-neutral.sh",
"exit_code": 0
},
{
"test": "test-forge-capabilities.sh",
"exit_code": 0
},
{
"test": "test-git-credential-mosaic.sh",
"exit_code": 0
},
{
"test": "test-gitea-login-resolution.sh",
"exit_code": 0
},
{
"test": "test-gitea-token-identity.sh",
"exit_code": 0
},
{
"test": "test-grant-reviewer.sh",
"exit_code": 0
},
{
"test": "test-help-exit-code.sh",
"exit_code": 0
},
{
"test": "test-issue-assign-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-issue-close-fail-closed.sh",
"exit_code": 0
},
{
"test": "test-issue-close-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-issue-comment-readback.sh",
"exit_code": 0
},
{
"test": "test-issue-comment-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-issue-create-body-safety.sh",
"exit_code": 0
},
{
"test": "test-issue-create-interactive-auth.sh",
"exit_code": 0
},
{
"test": "test-issue-create-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-issue-edit-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-issue-list-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-issue-reopen-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-issue-view-comments.sh",
"exit_code": 0
},
{
"test": "test-issue-view-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-lane-brief-pr-linkage.sh",
"exit_code": 0
},
{
"test": "test-lane-brief-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-milestone-close-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-milestone-create-usage-contract.sh",
"exit_code": 1,
"baseline": "Same exit 1 reproduced on pinned base: missing-title usage returns provider/no-origin error instead of usage exit 2."
},
{
"test": "test-milestone-list-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-mosaic-worktree-large-repo.sh",
"exit_code": 0
},
{
"test": "test-mutate-push-guard.sh",
"exit_code": 0
},
{
"test": "test-pr-auto-merge.sh",
"exit_code": 0
},
{
"test": "test-pr-close-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-pr-create-fallback-default-base.sh",
"exit_code": 0
},
{
"test": "test-pr-create-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-pr-diff-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-pr-edit-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-pr-edit.sh",
"exit_code": 0
},
{
"test": "test-pr-list-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-pr-merge-baseline.sh",
"exit_code": 0
},
{
"test": "test-pr-merge-fork-ci-status.sh",
"exit_code": 0
},
{
"test": "test-pr-merge-gitea-empty-uid.sh",
"exit_code": 0
},
{
"test": "test-pr-merge-head-pin.sh",
"exit_code": 0
},
{
"test": "test-pr-merge-message-field.sh",
"exit_code": 0
},
{
"test": "test-pr-merge-methods.sh",
"exit_code": 0
},
{
"test": "test-pr-merge-no-ci-expected.sh",
"exit_code": 0
},
{
"test": "test-pr-merge-queue-branch.sh",
"exit_code": 0
},
{
"test": "test-pr-merge-queue.sh",
"exit_code": 0
},
{
"test": "test-pr-metadata-gitea.sh",
"exit_code": 0
},
{
"test": "test-pr-metadata-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-pr-review-comments.sh",
"exit_code": 0
},
{
"test": "test-pr-review-gitea-comment.sh",
"exit_code": 0
},
{
"test": "test-pr-review-reply.sh",
"exit_code": 0
},
{
"test": "test-pr-review-repo-host-override.sh",
"exit_code": 0
},
{
"test": "test-pr-review-resolve.sh",
"exit_code": 0
},
{
"test": "test-pr-review-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-pr-status.sh",
"exit_code": 0
},
{
"test": "test-pr-view-usage-contract.sh",
"exit_code": 0
},
{
"test": "test-pr-watch-check.sh",
"exit_code": 0
},
{
"test": "test-pr-watch-parity.sh",
"exit_code": 0
},
{
"test": "test-push-guard.sh",
"exit_code": 0
},
{
"test": "test-repo-decl-consumption.sh",
"initial_exit_code": 1,
"exit_code": 0,
"rerun_reason": "Assert diagnostic prefix, excluding incidental scratch directory names; isolated rerun passed all 68 assertions."
},
{
"test": "test-verify-clean-clone.sh",
"exit_code": 0
},
{
"test": "test-wrapper-guard.sh",
"exit_code": 0
}
],
"additional_checks": [
{
"command": "python3 -I tools/git/test-forge-contract.py",
"exit_code": 0,
"runner_cases": 89,
"note": "Inherited fixture cases repeat across classes."
},
{
"command": "python3 -I tools/git/test-forge-cli.py",
"exit_code": 0,
"runner_cases": 7
},
{
"command": "python3 -I tools/git/test-merge-policy.py",
"exit_code": 0,
"runner_cases": 5
},
{
"command": "python3 -I tools/git/test-forge-guard.py",
"exit_code": 0,
"runner_cases": 4
},
{
"command": "python3 -I tools/git/test-pr-skill-contract.py",
"exit_code": 0,
"runner_cases": 4
},
{
"command": "bash tools/git/test-pr-watch-parity.sh",
"exit_code": 0,
"note": "Mock transport replay; no live delivery claim."
},
{
"command": "bash tools/git/test-forge-schema.sh",
"exit_code": 0
},
{
"command": "bash tools/structure/test-validate-repo-json.sh",
"exit_code": 0,
"assertions": 101
},
{
"command": "pnpm exec vitest run src/commands/skill.spec.ts src/framework/manifest-parity.spec.ts src/framework/manifest.spec.ts",
"exit_code": 0,
"runner_cases": 97
},
{
"command": "Real loadSkillsCatalog against framework",
"exit_code": 0,
"skills_discovered": 6
},
{
"command": "bash -n all git shell scripts",
"exit_code": 0,
"files": 106
},
{
"command": "ShellCheck new entry points",
"exit_code": 0
},
{
"command": "ShellCheck changed existing wrappers",
"exit_code": 1,
"baseline_exit_code": 1,
"new_diagnostics": 0
},
{
"command": "pnpm preflight",
"exit_code": 0
},
{
"command": "pnpm typecheck",
"exit_code": 0
},
{
"command": "pnpm lint",
"exit_code": 0
},
{
"command": "pnpm format:check",
"exit_code": 0
},
{
"command": "npm pack --dry-run --json and publish-list assertions",
"exit_code": 0,
"skills": 6,
"attribution_licenses": 4,
"bytecode_entries": 0,
"negative_control_before_exclusion": 2
}
],
"limitations": [
"No live provider mutation tests.",
"No authenticated GitHub/Forgejo or new Gitea operation qualification.",
"No live T3 or fallback notifier delivery qualification. Fallback notifier is operator-provisioned.",
"Older inspected forge conversation mutations and scheduled integration state explicitly unsupported.",
"App-bound required GitHub checks remain unknown and cannot establish readiness.",
"Independent exact-head review, PR, CI, integration and release pending."
],
"dependencies": "Installed the locked workspace dependency graph for required push checks; extracted ShellCheck 0.10.0 in task scratch. No runtime services or containers started.",
"files_by_work_item": {
"A": [
"tools/git/detect-platform.sh",
"tools/git/forge-api.py",
"tools/git/forge-capabilities.sh",
"tools/git/pr-status.sh",
"tools/git/pr-status.schema.json",
"tools/git/pr-watch-check.sh"
],
"B": [
"tools/git/pr-review-comments.sh",
"tools/git/pr-review-reply.sh",
"tools/git/pr-review-resolve.sh",
"tools/git/forge-api.py"
],
"C": [
"tools/git/merge-policy.py",
"tools/git/repo-decl.sh",
"tools/structure/validate-repo-json.sh",
"tools/git/pr-merge.sh",
"tools/git/pr-auto-merge.sh",
"tools/git/pr-merge-queue.sh",
"tools/git/pr-create.sh",
"tools/git/ci-queue-wait.sh",
"tools/git/mosaic-worktree.sh"
],
"D": [
"tools/git/wrapper-guard.sh",
"tools/git/pr-ci-wait.sh",
"tools/git/.npmignore",
"tools/git/README.md",
"guides/TOOLS-REFERENCE.md"
],
"skills": [
"skills/ms-full-send/SKILL.md",
"skills/ms-full-stop/SKILL.md",
"skills/ms-babysit-pr/SKILL.md",
"skills/ms-council/SKILL.md",
"skills/ms-git-operations/SKILL.md",
"skills/ms-watch/SKILL.md",
"skills/mosaic-gitea/SKILL.md",
"skills/ms-full-send/LICENSE",
"skills/ms-full-stop/LICENSE",
"skills/ms-babysit-pr/LICENSE",
"skills/ms-council/LICENSE"
],
"fixture_and_parity_tests": [
"packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh",
"packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh",
"packages/mosaic/framework/tools/git/test-ci-queue-wait-no-status.sh",
"packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh",
"packages/mosaic/framework/tools/git/test-forge-capabilities.sh",
"packages/mosaic/framework/tools/git/test-forge-cli.py",
"packages/mosaic/framework/tools/git/test-forge-contract.py",
"packages/mosaic/framework/tools/git/test-forge-guard.py",
"packages/mosaic/framework/tools/git/test-forge-schema.cjs",
"packages/mosaic/framework/tools/git/test-forge-schema.sh",
"packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh",
"packages/mosaic/framework/tools/git/test-grant-reviewer.sh",
"packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh",
"packages/mosaic/framework/tools/git/test-issue-comment-readback.sh",
"packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh",
"packages/mosaic/framework/tools/git/test-issue-view-comments.sh",
"packages/mosaic/framework/tools/git/test-merge-policy.py",
"packages/mosaic/framework/tools/git/test-pr-auto-merge.sh",
"packages/mosaic/framework/tools/git/test-pr-create-fallback-default-base.sh",
"packages/mosaic/framework/tools/git/test-pr-edit.sh",
"packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh",
"packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh",
"packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh",
"packages/mosaic/framework/tools/git/test-pr-merge-methods.sh",
"packages/mosaic/framework/tools/git/test-pr-merge-queue.sh",
"packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh",
"packages/mosaic/framework/tools/git/test-pr-review-comments.sh",
"packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh",
"packages/mosaic/framework/tools/git/test-pr-review-reply.sh",
"packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh",
"packages/mosaic/framework/tools/git/test-pr-review-resolve.sh",
"packages/mosaic/framework/tools/git/test-pr-skill-contract.py",
"packages/mosaic/framework/tools/git/test-pr-status.sh",
"packages/mosaic/framework/tools/git/test-pr-watch-check.sh",
"packages/mosaic/framework/tools/git/test-pr-watch-parity.py",
"packages/mosaic/framework/tools/git/test-pr-watch-parity.sh",
"packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh",
"packages/mosaic/framework/tools/git/test-wrapper-guard.sh",
"packages/mosaic/framework/tools/structure/test-validate-repo-json.sh",
"packages/mosaic/framework/tools/git/test-forge-schema.sh",
"packages/mosaic/framework/tools/git/test-forge-schema.cjs"
]
},
"carried_local_fixes": [
"Python import isolation in the shared detection/auth helper. No wholesale directory replacement."
]
}
@@ -0,0 +1,71 @@
# 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.
@@ -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,29 @@
---
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.
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 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.
@@ -24,6 +24,14 @@
# $HOME points at a per-profile directory that has no credentials file.
# Operators symlink /etc/mosaic/credentials.json to the host's canonical
# file once, instead of exporting MOSAIC_CREDENTIALS_FILE per invocation.
#
# Gitea has one additional identity-aware path. When the resolved git identity
# names a fleet seat, gitea-mosaicstack and gitea-usc obtain the token through
# tools/git/git-credential-mosaic rather than reading a slot directly. That
# production entrypoint enforces the clean-environment and process-ancestry
# fence before it reads a seat slot. A seat-slot miss is terminal: this loader
# never substitutes the service-store token for it. URLs remain provider
# configuration and continue to come from this loader's service store.
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do
@@ -94,6 +102,85 @@ _mosaic_load_woodpecker_legacy() {
_mosaic_sync_woodpecker_env "$WOODPECKER_INSTANCE" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
}
_mosaic_resolve_git_identity() {
local ident="${MOSAIC_GIT_IDENTITY:-}"
if [[ -z "$ident" ]]; then
ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
fi
printf '%s' "$ident"
}
_mosaic_git_identity_is_seat() {
local ident="$1" brain_home
[[ -n "$ident" ]] || return 1
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
[[ -d "$brain_home/fleet/agents/$ident" ]]
}
_mosaic_gitea_seat_token_from_helper() {
# Use the production wrapper, not its Bash implementation. The wrapper
# removes BASH_ENV/function injection before the implementation evaluates
# MOSAIC_AGENT_NAME ancestry, so a loader consumer cannot bypass that fence.
local host="$1" ident="$2" script_dir helper response key value
local username="" password="" username_seen=0 password_seen=0
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
helper="$script_dir/../git/git-credential-mosaic"
if [[ ! -x "$helper" ]]; then
echo "Error: Gitea seat credential helper is unavailable: $helper" >&2
return 1
fi
if ! response="$(printf 'protocol=https\nhost=%s\n\n' "$host" | "$helper" get)"; then
return 1
fi
while IFS='=' read -r key value; do
[[ -n "$key" ]] || continue
case "$key" in
username)
if (( username_seen )); then
echo 'Error: Gitea seat credential helper returned duplicate username fields' >&2
return 1
fi
username="$value"
username_seen=1
;;
password)
if (( password_seen )); then
echo 'Error: Gitea seat credential helper returned duplicate password fields' >&2
return 1
fi
password="$value"
password_seen=1
;;
*)
echo 'Error: Gitea seat credential helper returned an invalid protocol field' >&2
return 1
;;
esac
done <<< "$response"
if [[ "$username_seen" -ne 1 || "$password_seen" -ne 1 || "$username" != "$ident" || -z "$password" ]]; then
echo "Error: Gitea seat credential helper did not return a valid credential for '$ident'" >&2
return 1
fi
printf '%s' "$password"
}
_mosaic_gitea_token() {
# $1 is the Gitea host and $2 is the legacy service-store jq path.
# Seats are delegated to the fenced helper; all other identities retain the
# existing service-store behavior. A failed seat delegation returns nonzero
# to the caller and deliberately cannot fall through to _mosaic_read_cred.
local host="$1" service_jq_path="$2" ident
ident="$(_mosaic_resolve_git_identity)"
if _mosaic_git_identity_is_seat "$ident"; then
_mosaic_gitea_seat_token_from_helper "$host" "$ident"
return
fi
_mosaic_read_cred "$service_jq_path"
}
load_credentials() {
local service="$1"
@@ -183,16 +270,31 @@ EOF
;;
gitea-mosaicstack)
export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.mosaicstack.url')}"
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.mosaicstack.token')}"
GITEA_URL="${GITEA_URL%/}"
[[ -n "$GITEA_URL" ]] || { echo "Error: gitea.mosaicstack.url not found" >&2; return 1; }
# An explicit caller value retains the loader's established precedence.
# Otherwise, a known seat delegates to the ancestry-fenced helper and a
# non-seat identity uses the established service-store lookup.
if [[ -z "${GITEA_TOKEN:-}" ]]; then
local _gitea_token
_gitea_token="$(_mosaic_gitea_token 'git.mosaicstack.dev' '.gitea.mosaicstack.token')" || return 1
GITEA_TOKEN="$_gitea_token"
fi
# Preserve the loader's contract even when the caller supplied an
# unexported shell variable before invoking load_credentials.
export GITEA_TOKEN
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.mosaicstack.token not found" >&2; return 1; }
;;
gitea-usc)
export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.usc.url')}"
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.usc.token')}"
GITEA_URL="${GITEA_URL%/}"
[[ -n "$GITEA_URL" ]] || { echo "Error: gitea.usc.url not found" >&2; return 1; }
if [[ -z "${GITEA_TOKEN:-}" ]]; then
local _gitea_token
_gitea_token="$(_mosaic_gitea_token 'git.uscllc.com' '.gitea.usc.token')" || return 1
GITEA_TOKEN="$_gitea_token"
fi
export GITEA_TOKEN
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.usc.token not found" >&2; return 1; }
;;
woodpecker-*)
@@ -0,0 +1,287 @@
#!/usr/bin/env bash
# Hermetic regression for load_credentials Gitea seat-slot resolution.
#
# It runs against copied framework tools under a fake HOME, fixture credentials,
# and fake seat slots only. No real credential path is read.
#
# Contract pinned here:
# G1-G3 an ancestry-owned seat resolves its own host-scoped token through
# the production git-credential-mosaic entrypoint, whether identity
# comes from env or per-worktree git config.
# G4 an owned seat with no slot fails closed and never uses the service
# token.
# G5 a seat cannot request another seat's slot through load_credentials;
# the helper's ancestry fence remains the authorization boundary.
# G6/G7 no seat identity and a non-seat identity retain service-store
# behavior.
# G8-G10 explicitly supplied GITEA_TOKEN values keep their established
# precedence and are exported for child tool processes.
# G11 non-Gitea services remain unaffected.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/credentials-gitea-seats}"
FAKE_HOME="$WORK_DIR/home"
FRAMEWORK_TOOLS="$FAKE_HOME/.config/mosaic/tools"
BRAIN_DIR="$WORK_DIR/brain"
REPO_DIR="$WORK_DIR/repo"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
SPOOL_DIR="$WORK_DIR/spool"
LOADER="$FRAMEWORK_TOOLS/_lib/credentials.sh"
HELPER="$FRAMEWORK_TOOLS/git/git-credential-mosaic"
rm -rf "$WORK_DIR"
trap 'rm -rf "$WORK_DIR"' EXIT
mkdir -p "$FRAMEWORK_TOOLS/_lib" "$FRAMEWORK_TOOLS/git" "$BRAIN_DIR/fleet/agents" \
"$REPO_DIR" "$SPOOL_DIR"
cp "$SCRIPT_DIR/credentials.sh" "$LOADER"
cp "$SCRIPT_DIR/../git/git-credential-mosaic" "$HELPER"
cp "$SCRIPT_DIR/../git/git-credential-mosaic.impl" "$FRAMEWORK_TOOLS/git/git-credential-mosaic.impl"
chmod +x "$HELPER" "$FRAMEWORK_TOOLS/git/git-credential-mosaic.impl"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" config user.name 'Credential seat test'
git -C "$REPO_DIR" config user.email '[email protected]'
cat > "$CREDENTIALS_FILE" <<'JSON'
{
"gitea": {
"mosaicstack": {
"url": "https://git.mosaicstack.dev",
"token": "fixture-service-token"
},
"usc": {
"url": "https://git.uscllc.com",
"token": "fixture-usc-service-token"
}
},
"woodpecker": {
"default": "mosaic",
"mosaic": {
"url": "https://ci.example.invalid",
"token": "fixture-woodpecker-token"
}
}
}
JSON
for seat in seat-owner seat-config seat-usc seat-victim empty-seat; do
mkdir -p "$BRAIN_DIR/fleet/agents/$seat/secrets"
done
printf '%s' 'fixture-owner-slot-token' > "$BRAIN_DIR/fleet/agents/seat-owner/secrets/gitea-mosaicstack-seat-owner.token"
printf '%s' 'fixture-config-slot-token' > "$BRAIN_DIR/fleet/agents/seat-config/secrets/gitea-mosaicstack-seat-config.token"
printf '%s' 'fixture-usc-slot-token' > "$BRAIN_DIR/fleet/agents/seat-usc/secrets/gitea-usc-seat-usc.token"
printf '%s' 'fixture-victim-slot-token' > "$BRAIN_DIR/fleet/agents/seat-victim/secrets/gitea-mosaicstack-seat-victim.token"
chmod 600 "$BRAIN_DIR"/fleet/agents/*/secrets/*.token
# Establishes a seat identity in an exec-frozen ancestor. The empty-name root
# carries the lineage fence, preventing the helper from seeing this suite's
# real parent process outside its hermetic fixture.
cat > "$WORK_DIR/lineage-root.sh" <<'ROOT'
#!/usr/bin/env bash
set -euo pipefail
caller="$1"
carrier="$2"
shift 2
env MOSAIC_AGENT_NAME="$caller" PATH="$PATH" HOME="$HOME" \
bash "$carrier" "$@"
ROOT
cat > "$WORK_DIR/lineage-carrier.sh" <<'CARRIER'
#!/usr/bin/env bash
set -euo pipefail
loader="$1"
brain="$2"
credentials="$3"
repo="$4"
spool="$5"
identity_source="$6"
target="$7"
preexisting_token="$8"
service="${9:-gitea-mosaicstack}"
cd "$repo"
unset GITEA_URL GITEA_TOKEN MOSAIC_GIT_IDENTITY
git config --unset mosaic.gitIdentity 2>/dev/null || true
case "$identity_source" in
env) export MOSAIC_GIT_IDENTITY="$target" ;;
config) git config mosaic.gitIdentity "$target" ;;
none) ;;
*) echo "unknown identity source: $identity_source" >&2; exit 2 ;;
esac
if [[ "$preexisting_token" != '-' ]]; then
export GITEA_TOKEN="$preexisting_token"
fi
export MOSAIC_BRAIN_HOME="$brain"
export MOSAIC_CREDENTIALS_FILE="$credentials"
export MOSAIC_CREDENTIAL_SPOOL="$spool"
# shellcheck source=/dev/null
source "$loader"
load_credentials "$service"
printf 'url=%s\ntoken=%s\n' "$GITEA_URL" "$GITEA_TOKEN"
CARRIER
chmod +x "$WORK_DIR/lineage-root.sh" "$WORK_DIR/lineage-carrier.sh"
run_lineage() {
local caller="$1" source="$2" target="$3" preset="$4" service="${5:-gitea-mosaicstack}"
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_LINEAGE_FENCE=1 \
bash "$WORK_DIR/lineage-root.sh" "$caller" "$WORK_DIR/lineage-carrier.sh" \
"$LOADER" "$BRAIN_DIR" "$CREDENTIALS_FILE" "$REPO_DIR" "$SPOOL_DIR" \
"$source" "$target" "$preset" "$service"
}
run_plain() {
local source="$1" target="$2" preset="$3" service="${4:-gitea-mosaicstack}"
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_BRAIN_HOME="$BRAIN_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" \
LOADER="$LOADER" REPO_DIR="$REPO_DIR" IDENTITY_SOURCE="$source" TARGET="$target" \
PRESET="$preset" SERVICE="$service" bash -c '
set -euo pipefail
cd "$REPO_DIR"
unset GITEA_URL GITEA_TOKEN MOSAIC_GIT_IDENTITY
git config --unset mosaic.gitIdentity 2>/dev/null || true
case "$IDENTITY_SOURCE" in
env) export MOSAIC_GIT_IDENTITY="$TARGET" ;;
config) git config mosaic.gitIdentity "$TARGET" ;;
none) ;;
*) exit 2 ;;
esac
if [[ "$PRESET" != "-" ]]; then export GITEA_TOKEN="$PRESET"; fi
source "$LOADER"
load_credentials "$SERVICE"
printf "url=%s\\ntoken=%s\\n" "$GITEA_URL" "$GITEA_TOKEN"
'
}
run_unexported_token() {
local service="$1"
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_BRAIN_HOME="$BRAIN_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" LOADER="$LOADER" SERVICE="$service" \
bash -s <<'UNEXPORTED'
set -euo pipefail
unset GITEA_URL GITEA_TOKEN MOSAIC_GIT_IDENTITY
GITEA_TOKEN='fixture-unexported-token'
source "$LOADER"
load_credentials "$SERVICE"
child_token="$(bash -c 'printf "%s" "${GITEA_TOKEN:-}"')"
[[ "$child_token" == "$GITEA_TOKEN" ]] || {
echo 'GITEA_TOKEN was not exported to a child process' >&2
exit 1
}
printf 'url=%s\ntoken=%s\n' "$GITEA_URL" "$GITEA_TOKEN"
UNEXPORTED
}
fail=0
assert_success() {
local desc="$1" expected_token="$2" expected_url="$3"
shift 3
local err="$WORK_DIR/stderr.tmp" out rc token url
: > "$err"
set +e
out=$("$@" 2>"$err")
rc=$?
set -e
if [[ "$rc" -ne 0 ]]; then
echo "FAIL: $desc — expected success, got rc=$rc" >&2
cat "$err" >&2
fail=1
return
fi
token="$(printf '%s\n' "$out" | awk -F= '/^token=/{print substr($0, 7)}')"
url="$(printf '%s\n' "$out" | awk -F= '/^url=/{print substr($0, 5)}')"
if [[ "$token" != "$expected_token" ]]; then
echo "FAIL: $desc — resolved the wrong token source" >&2
fail=1
fi
if [[ "$url" != "$expected_url" ]]; then
echo "FAIL: $desc — URL did not come from provider configuration" >&2
fail=1
fi
}
assert_refused() {
local desc="$1" expected_reason="$2"
shift 2
local err="$WORK_DIR/stderr.tmp" out rc
: > "$err"
set +e
out=$("$@" 2>"$err")
rc=$?
set -e
local diagnostics
diagnostics="$(cat "$err")"
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: $desc — expected refusal, got success" >&2
fail=1
fi
if [[ -n "$out" ]]; then
echo "FAIL: $desc — refusal emitted credential output" >&2
fail=1
fi
if [[ "$diagnostics" != *"$expected_reason"* ]]; then
echo "FAIL: $desc — refusal did not retain helper reason $expected_reason" >&2
fail=1
fi
if [[ "$out$diagnostics" == *'fixture-service-token'* || "$out$diagnostics" == *'fixture-victim-slot-token'* ]]; then
echo "FAIL: $desc — refusal exposed or fell back to another store" >&2
fail=1
fi
}
# G1: env identity, owning seat, populated Mosaic slot.
assert_success 'G1 env-owned seat uses its Mosaic slot through the helper' 'fixture-owner-slot-token' 'https://git.mosaicstack.dev' \
run_lineage seat-owner env seat-owner -
# G2: the same contract when the helper and loader resolve git config identity.
assert_success 'G2 config-owned seat uses its Mosaic slot through the helper' 'fixture-config-slot-token' 'https://git.mosaicstack.dev' \
run_lineage seat-config config seat-config -
# G3: the USC arm remains host-scoped rather than borrowing Mosaic credentials.
assert_success 'G3 USC-owned seat uses its USC slot through the helper' 'fixture-usc-slot-token' 'https://git.uscllc.com' \
run_lineage seat-usc env seat-usc - gitea-usc
# G4: a seat slot miss must be terminal, never service-store fallback.
assert_refused 'G4 empty owned seat refuses without service fallback' 'no-token-for-identity' \
run_lineage empty-seat env empty-seat -
# G5: a child cannot select another seat by rewriting MOSAIC_GIT_IDENTITY.
assert_refused 'G5 cross-seat identity is refused by ancestry fencing' 'cross-seat-identity-refused' \
run_lineage seat-owner env seat-victim -
# G6/G7: non-seat paths retain the existing shared service-store behavior.
assert_success 'G6 no identity keeps the service-store path' 'fixture-service-token' 'https://git.mosaicstack.dev' \
run_plain none '' -
assert_success 'G7 non-seat identity keeps the service-store path' 'fixture-service-token' 'https://git.mosaicstack.dev' \
run_plain env service-automation -
# G8: caller-provided env values retain the established loader precedence.
assert_success 'G8 explicit GITEA_TOKEN remains caller-owned' 'fixture-preexisting-token' 'https://git.mosaicstack.dev' \
run_lineage seat-owner env seat-owner fixture-preexisting-token
# G9/G10: pre-existing shell variables keep the loader's export contract.
assert_success 'G9 unexported Mosaic token reaches child processes' 'fixture-unexported-token' 'https://git.mosaicstack.dev' \
run_unexported_token gitea-mosaicstack
assert_success 'G10 unexported USC token reaches child processes' 'fixture-unexported-token' 'https://git.uscllc.com' \
run_unexported_token gitea-usc
# G11: only Gitea has seat slots; Woodpecker remains service-scoped.
wp_out=$(env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_BRAIN_HOME="$BRAIN_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" MOSAIC_GIT_IDENTITY=seat-owner \
LOADER="$LOADER" bash -c '
set -euo pipefail
unset WOODPECKER_URL WOODPECKER_TOKEN
source "$LOADER"
load_credentials woodpecker
printf "%s|%s" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
')
if [[ "$wp_out" != 'https://ci.example.invalid|fixture-woodpecker-token' ]]; then
echo 'FAIL: G11 Woodpecker changed under a Gitea seat identity' >&2
fail=1
fi
if [[ "$fail" -eq 0 ]]; then
echo 'credentials Gitea seat-store regression passed'
fi
exit "$fail"
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+161 -2
View File
@@ -44,8 +44,8 @@ account/token configured through `tools/_lib/credentials.sh`. That means every a
fleet commits, pushes, and opens PRs under one identity — with no cryptographic
separation between an author and a reviewer.
Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent
identity**:
`git-credential-mosaic`, `get_gitea_token()`, and the `gitea-mosaicstack` /
`gitea-usc` arms of `load_credentials` resolve an optional **per-agent identity**:
1. `MOSAIC_GIT_IDENTITY` environment variable, or
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
@@ -65,6 +65,12 @@ The store is chosen by what the identity **is**, not by which file happens to ex
`<brain>` is `MOSAIC_BRAIN_HOME` if set, else `~/.mosaic` — the same resolution
`packages/mosaic/src/fleet/brain-home.ts` performs.
The Gitea arms of `load_credentials` obtain a seat token through the production
`git-credential-mosaic` entrypoint, rather than reading the slot directly. That retains
the entrypoint's clean-environment and process-ancestry fence. The Gitea URL remains
provider configuration from the service store. A caller-supplied `GITEA_TOKEN` retains
its established environment precedence.
**There is no precedence between the two stores and no fallback from one to the other.**
A seat whose slot is empty is refused even when a same-named token sits in the framework
store. One credential lives in exactly one location: a second copy is drift rather than
@@ -194,3 +200,156 @@ 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.
Authenticated Gitea/Forgejo requests require absolute `TMPDIR` pointing to approved scratch storage. Credentials use mode-0600 temporary client config files, removed after each request, and never appear in client arguments. 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 host notifier.
@@ -514,7 +514,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)"
@@ -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
@@ -828,7 +836,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 +1664,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,810 @@
#!/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):
try:
return subprocess.run(argv, input=data, text=True, capture_output=True, timeout=45, cwd=cwd, env=env)
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.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 self.provider == 'github' and not public:
args = ['gh', 'api', '--hostname', self.host, '--include', '--method', method, path]
if body is not None:
args += ['--input', '-']
p = self.transport(args, body, writing)
else:
url = 'https://' + self.host + (path if public else '/api/v1' + path)
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:
scratch = os.environ.get('TMPDIR')
if not scratch or not os.path.isabs(scratch):
raise Failure(2,'usage','Set TMPDIR to approved absolute scratch storage before authenticated requests')
token = self.credential()
if 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('"', '\\"')
try:
with tempfile.NamedTemporaryFile(mode='w',prefix='mosaic-forge-auth-',dir=scratch) as auth:
auth.write('header = "Authorization: token ' + escaped + '"\n')
auth.flush()
p = self.transport(args + ['--config',auth.name],body,writing)
except OSError:
raise Failure(6,'verification','Cannot create protected credential config in configured scratch storage')
else:
p = self.transport(args, body, writing)
if p.returncode and (self.provider != 'github' or not p.stdout.startswith('HTTP/')):
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)
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 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')
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']:
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)
if after['base'].get('sha') != base.get('sha') or 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')
declaration = pathlib.Path(root.stdout.strip())/'.mosaic/repo.json'
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')
if not declaration.exists():
if requested not in (None, 'squash'):
raise Failure(3,'policy','POLICY-REFUSED: absent merge policy permits squash only')
# Preserve legacy base behavior, requiring the provider default branch.
default = client.http(client.root).get('default_branch')
if p['base']['ref'] != default:
raise Failure(3,'policy','POLICY-REFUSED: absent declaration requires the repository default base')
return 'squash'
validator = run([str(HERE.parent/'structure/validate-repo-json.sh'),str(declaration),'--mode','display'])
if validator.returncode:
raise Failure(6,'malformed','Invalid repository declaration: ' + validator.stdout.strip())
# Validate canonical authority through the existing shared helper as well.
gate = run(['bash','-c','source "$1/repo-decl.sh"; repo_decl_load; [[ "$DECL_STATE" == valid ]] && repo_decl_remote_matches','forge-policy',str(HERE)])
if gate.returncode:
raise Failure(3,'policy','POLICY-REFUSED: declaration canonical remote differs from origin')
args = ['python3','-I',str(HERE/'merge-policy.py'),str(declaration),'--head',p['head']['ref'],'--base',p['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 not status['ready']:
raise Failure(3,'gate','Merge readiness failed: ' + ', '.join(status['blockers']))
queue_guard(client,p)
same_head(client,number,expected)
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_head(client,number,expected)
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_head(client,number,p['head']['sha'])
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_head(client,number,expected)
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_head(client,number,expected)
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_head(client,number,expected)
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')
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 "$@"
@@ -155,8 +155,15 @@ gitea_resolve_api_for_login() {
}
fi
configured_url=$(get_gitea_url_for_host "$host") || {
echo "Error: Configured Gitea URL not found for comment read-back verification" >&2
return 1
# No monolith-configured Gitea URL for this host (#1450): seat-token-only
# hosts carry no gitea-mosaicstack/gitea-usc credentials.sh entry and no
# bare GITEA_URL, so get_gitea_url_for_host has nothing to match against.
# Synthesize the API base directly from the git remote's own host --
# exactly the trust model issue-create.sh's REST fallback already uses
# successfully on these hosts. This is NOT a cross-host guess: $host came
# from get_remote_host() reading THIS repo's own origin remote, so the
# resolved base always matches the repo actually being acted on.
configured_url="https://${host}"
}
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
@@ -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 "$@"
@@ -254,10 +254,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
+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,22 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
source "$SCRIPT_DIR/detect-platform.sh"
# 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 +42,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 +137,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
@@ -169,7 +185,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
@@ -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 "$@"
@@ -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
@@ -25,6 +25,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 +42,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
@@ -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
@@ -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=""
@@ -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,152 @@
"""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.stat().st_mode & 0o077 or 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' in args:
url=args[args.index('--url')+1]; parsed=urllib.parse.urlparse(url); path=parsed.path
if path.startswith('/api/v1'): path=path[7:]
query=parsed.query
else:
path=args[args.index('--method')+2]; parsed=urllib.parse.urlparse(path); path=parsed.path; query=parsed.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,'path':path,'query':query,'body':body})+'\\n')
fixtures=json.loads(pathlib.Path(os.environ['FAKE_RESPONSES']).read_text())
key=method+' '+path+('?' + query if 'page=2' in query else '')
response=fixtures.get(key)
if response is None:
print('unexpected provider call '+key,file=sys.stderr); sys.exit(98)
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('{}')
self.fixtures={}
def tearDown(self): self.tmp.cleanup()
def run_wrapper(self,name,*args,host='github.com'):
(self.root/'responses.json').write_text(json.dumps(self.fixtures))
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_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,361 @@
"""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'
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=1, 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
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')
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,6 +87,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"
@@ -261,9 +268,10 @@ 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
: > "$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
@@ -517,15 +525,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 +545,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"
@@ -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"
@@ -131,6 +132,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
@@ -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
@@ -46,6 +47,12 @@ export GITEA_TOKEN="redacted-test-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
@@ -44,9 +44,18 @@
# clobber each other and every scratch file is removed on all exit paths.
# 11. accepts the canonical -b/--body flag exactly like the -c/--comment alias
# (R1, 2026-08-28): a full verified write via -b alone.
# 12. (#1450, 2026-09-11) on a SEAT-TOKEN-ONLY host — identity resolved purely
# via MOSAIC_GIT_IDENTITY's per-slot token file, no tea login involved, and
# no monolith credentials.json entry for this Gitea host (so
# get_gitea_url_for_host has nothing to match) — the wrapper still
# resolves the API base directly from the git remote's own host (no
# cross-host fallback/guessing) instead of failing closed with
# "Configured Gitea URL not found", and the POST + exact-ID read-back both
# run under that same seat identity, never the (absent) host default.
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"
@@ -129,6 +138,12 @@ OVERRIDE_TOKEN="override-token-placeholder"
# repo host: host-bound selection must fail closed on the host mismatch.
CROSS_HOST_LOGIN="foreign-host-reviewer"
CROSS_HOST_TOKEN="cross-host-token-placeholder"
# A seat-token-only identity (#1450): resolved purely via MOSAIC_GIT_IDENTITY's
# per-slot token file under $HOME/.config/mosaic/secrets/gitea-tokens/ -- no tea
# login, no MOSAIC_CREDENTIALS_FILE entry for this host at all.
SEAT_IDENTITY="seat-only-agent"
SEAT_LOGIN="seat-only-actor"
SEAT_TOKEN="seat-token-placeholder"
# tea config: the override login has its own token here (as tea itself stores
# per-login tokens). The default login name ("mosaicstack") is deliberately NOT
@@ -169,6 +184,19 @@ with open(sys.argv[1], "w", encoding="utf-8") as credentials:
}, credentials)
PY
# A monolith credentials file that EXISTS but carries no gitea.mosaicstack (or
# gitea.usc) entry -- the seat-token-only condition (#1450). Distinct from
# $CREDENTIALS_FILE above, which does carry a configured URL for the other
# cases in this suite.
EMPTY_CREDENTIALS_FILE="$WORK_DIR/credentials-empty.json"
printf '{}' > "$EMPTY_CREDENTIALS_FILE"
# The seat identity's per-slot token file, exactly as a provisioned agent seat
# carries one: $HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-<agent>.token.
# get_gitea_token() resolves this BEFORE ever consulting MOSAIC_CREDENTIALS_FILE.
mkdir -p "$HOME_DIR/.config/mosaic/secrets/gitea-tokens"
printf '%s' "$SEAT_TOKEN" > "$HOME_DIR/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-${SEAT_IDENTITY}.token"
# tea stub: only ever answers the login list (used to resolve the default login
# name). It must NEVER be asked to write a comment — the wrapper writes via REST.
cat > "$BIN_DIR/tea" <<'SH'
@@ -196,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
@@ -250,6 +284,7 @@ case "$auth_token" in
"$ISSUE_COMMENT_DEFAULT_TOKEN") acting_identity="$ISSUE_COMMENT_ACTING_LOGIN" ;;
"$ISSUE_COMMENT_OVERRIDE_TOKEN") acting_identity="$ISSUE_COMMENT_OVERRIDE_LOGIN" ;;
"$ISSUE_COMMENT_CROSS_HOST_TOKEN") acting_identity="$ISSUE_COMMENT_CROSS_HOST_LOGIN" ;;
"$ISSUE_COMMENT_SEAT_TOKEN") acting_identity="$ISSUE_COMMENT_SEAT_LOGIN" ;;
esac
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$ISSUE_COMMENT_AUTH_LOG"
@@ -446,6 +481,53 @@ run_comment() {
ISSUE_COMMENT_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
ISSUE_COMMENT_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
ISSUE_COMMENT_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
ISSUE_COMMENT_SEAT_LOGIN="$SEAT_LOGIN" \
ISSUE_COMMENT_SEAT_TOKEN="$SEAT_TOKEN" \
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
ISSUE_COMMENT_API_BASE="$API_BASE" \
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" "${BODY_FLAG:--c}" "$BODY" "$@"
) > "$OUTPUT_FILE" 2>&1
}
# Seat-token-only variant (#1450): no monolith credentials.json entry for this
# host at all (MOSAIC_CREDENTIALS_FILE points at an empty {}), and identity
# resolves purely via MOSAIC_GIT_IDENTITY's per-slot token file. Everything
# else is identical to run_comment() -- same sandboxing, same always-exported
# constants -- so a diff against run_comment() is exactly these two overrides.
run_comment_seat() {
local mode="$1"
shift
: > "$TEA_LOG"
: > "$CURL_LOG"
: > "$CURL_ARGV_LOG"
: > "$AUTH_LOG"
: > "$OUTPUT_FILE"
seed_state "$mode"
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
HOME="$HOME_DIR" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$EMPTY_CREDENTIALS_FILE" \
MOSAIC_GIT_IDENTITY="$SEAT_IDENTITY" \
MOSAIC_BRAIN_HOME="" \
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
ISSUE_COMMENT_AUTH_LOG="$AUTH_LOG" \
ISSUE_COMMENT_STATE="$STATE_FILE" \
ISSUE_COMMENT_TEST_MODE="$mode" \
ISSUE_COMMENT_ACTING_LOGIN="$ACTING_LOGIN" \
ISSUE_COMMENT_FOREIGN_LOGIN="$FOREIGN_LOGIN" \
ISSUE_COMMENT_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
ISSUE_COMMENT_CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" \
ISSUE_COMMENT_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
ISSUE_COMMENT_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
ISSUE_COMMENT_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
ISSUE_COMMENT_SEAT_LOGIN="$SEAT_LOGIN" \
ISSUE_COMMENT_SEAT_TOKEN="$SEAT_TOKEN" \
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
ISSUE_COMMENT_API_BASE="$API_BASE" \
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
@@ -473,7 +555,7 @@ assert_no_temp_leak() {
# expected path grep matches nothing, so no token value is ever printed.
assert_token_not_in_argv() {
local context="$1"
if grep -qF -e "$DEFAULT_TOKEN" -e "$OVERRIDE_TOKEN" -e "$CROSS_HOST_TOKEN" "$CURL_ARGV_LOG"; then
if grep -qF -e "$DEFAULT_TOKEN" -e "$OVERRIDE_TOKEN" -e "$CROSS_HOST_TOKEN" -e "$SEAT_TOKEN" "$CURL_ARGV_LOG"; then
echo "FAIL: a Gitea bearer token leaked into curl argv ($context)" >&2
exit 1
fi
@@ -650,4 +732,27 @@ assert_no_temp_leak "fresh-success-body-flag"
assert_token_not_in_argv "fresh-success-body-flag"
unset BODY_FLAG
# Case 12 (#1450, 2026-09-11): a SEAT-TOKEN-ONLY host -- no monolith
# credentials.json entry for this Gitea host at all (get_gitea_url_for_host has
# nothing to match), identity resolved purely via MOSAIC_GIT_IDENTITY's
# per-slot token file. The wrapper must still resolve the API base directly
# from the git remote's own host (no cross-host fallback/guessing -- proven by
# reusing this suite's existing $API_BASE/$API_ROOT constants unmodified) and
# run the POST, the /user lookup, and the exact-id read-back all under the
# seat identity, never a host-default identity that does not even exist here.
run_comment_seat fresh-success
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
grep -q "^POST $API_BASE/issues/7/comments $SEAT_LOGIN$" "$AUTH_LOG"
grep -q "^GET $API_ROOT/user $SEAT_LOGIN$" "$AUTH_LOG"
grep -q "^GET $API_BASE/issues/comments/51 $SEAT_LOGIN$" "$AUTH_LOG"
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: seat-token-only run was attributed to the (nonexistent) host-default identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
assert_no_temp_leak "seat-token-no-monolith"
assert_token_not_in_argv "seat-token-no-monolith"
echo "issue-comment.sh REST create + exact-id read-back regression passed"
@@ -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
@@ -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"
@@ -51,6 +52,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,59 @@
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)
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}"
@@ -49,6 +50,12 @@ git -C "$ROOT/repo" remote add origin https://git.example.test/acme/widgets.git
# 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"
@@ -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"
@@ -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=""
@@ -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"
@@ -115,6 +116,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=""
@@ -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
@@ -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=""
@@ -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,30 @@
import json
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 ['git.uscllc.com','git.mosaicstack.dev','jason.woltje','/home/jason','code-infra-01','GraphQL quota']:
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_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,75 @@
"""Replay provider evidence through mocked T3 wakes and a bounded fallback owner.
This proves normalized decisions, not live notifier delivery or T3 forge support.
"""
import copy
import importlib.util
import pathlib
import unittest
HERE=pathlib.Path(__file__).resolve().parent
spec=importlib.util.spec_from_file_location('contract_fixtures', HERE/'test-forge-contract.py')
fixtures=importlib.util.module_from_spec(spec); spec.loader.exec_module(fixtures)
fixtures.SnapshotContract.setUpClass()
api=fixtures.SnapshotContract.api
class Transport:
def __init__(self,mode,notifier=True):
self.mode,self.notifier=mode,notifier
self.active=0; self.max_active=0; self.merges=0
def run(self,timeline,authority):
output=[]
for data,code in timeline:
if self.active: self.active=0 # wake retires the previous subscription
canonical=copy.deepcopy(data)
canonical.pop('observed_at',None)
verdict='timed_out' if code==124 else 'blocked' if code not in (0,1) else api.workflow_verdict(data,authority)
if verdict=='waiting':
if not self.notifier:
verdict='blocked'
else:
self.active+=1; self.max_active=max(self.max_active,self.active)
output.append((canonical,code,verdict))
self.active=0 # handoff retires owned watch
return output
class Parity(unittest.TestCase):
def snapshot(self,provider,version):
c=fixtures.SnapshotContract().fake()
c.provider,c.version=provider,version
if provider!='github':
pages=c.pages
c.pages=lambda path,key=None: [{'branch_name':'integration','enable_status_check':True,'status_check_contexts':['build'],'required_approvals':1}] if path.endswith('/branch_protections') else pages(path,key)
return api.snapshot(c,1,None)
def test_identical_verdicts_and_one_owner_for_all_profiles(self):
for provider,version in [('github','documented'),('gitea','28.2.0'),('gitea','1.24.7'),('forgejo','16.0.0-dev')]:
green=self.snapshot(provider,version)
pending=copy.deepcopy(green); pending['ready']=False; pending['checks']['state']='pending'
red=copy.deepcopy(pending); red['checks']['state']='failure'
cancelled=copy.deepcopy(pending); cancelled['checks']['state']='cancelled'
closed=copy.deepcopy(pending); closed['state']='closed'
merged=copy.deepcopy(pending); merged['state']='merged'; merged['merge_sha']='d'*40
conflict=copy.deepcopy(pending); conflict['mergeability']='conflict'
moved=copy.deepcopy(pending); moved['head_sha']='b'*40; moved['base_sha']='e'*40
comment=copy.deepcopy(pending); comment['comments']=[{'id':provider+':comment:9','body':'finding'}]
review=copy.deepcopy(pending); review['reviews']['decision']='changes_requested'
for final,rc in [(green,0),(red,0),(cancelled,0),(closed,0),(merged,0),(conflict,0),(moved,0),(comment,0),(review,0),(pending,5),(pending,75),(pending,124)]:
for mode in ['full-stop','full-send']:
a,b=Transport('T3'),Transport('fallback')
self.assertEqual(a.run([(pending,1),(final,rc)],mode),b.run([(pending,1),(final,rc)],mode))
self.assertLessEqual(a.max_active,1); self.assertLessEqual(b.max_active,1)
self.assertEqual(a.active+b.active,0)
self.assertEqual(a.merges+b.merges,0)
if mode=='full-stop' and provider!='github' and final['state']=='open' and rc!=124:
self.assertEqual(a.run([(final,rc)],mode)[0][2],'blocked')
# Explicitly unsupported T3 transport selects the fallback owner;
# missing fallback notifier is blocked rather than a competing loop.
fallback=Transport('fallback',notifier=False)
self.assertEqual(fallback.run([(pending,1)],'full-send')[0][2],'blocked')
self.assertEqual(fallback.active,0)
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"
@@ -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
@@ -234,6 +242,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 +269,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 +329,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")
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-baseline.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-issue-comment-usage-contract.sh && bash framework/tools/git/test-issue-comment-readback.sh && bash framework/tools/git/test-issue-close-usage-contract.sh && bash framework/tools/git/test-issue-reopen-usage-contract.sh && bash framework/tools/git/test-pr-close-usage-contract.sh && bash framework/tools/git/test-pr-review-usage-contract.sh && bash framework/tools/git/test-pr-diff-usage-contract.sh && bash framework/tools/git/test-pr-view-usage-contract.sh && bash framework/tools/git/test-pr-metadata-usage-contract.sh && bash framework/tools/git/test-pr-list-usage-contract.sh && bash framework/tools/git/test-issue-edit-usage-contract.sh && bash framework/tools/git/test-issue-create-usage-contract.sh && bash framework/tools/git/test-pr-edit-usage-contract.sh && bash framework/tools/git/test-pr-create-usage-contract.sh && bash framework/tools/git/test-issue-assign-usage-contract.sh && bash framework/tools/git/test-milestone-close-usage-contract.sh && bash framework/tools/git/test-milestone-list-usage-contract.sh && bash framework/tools/git/test-issue-view-usage-contract.sh && bash framework/tools/git/test-issue-list-usage-contract.sh && bash framework/tools/git/test-milestone-create-usage-contract.sh && bash framework/tools/git/test-lane-brief-usage-contract.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash fLine truncated
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-baseline.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_lib/test-credentials-gitea-seats.sh && bash framework/tools/git/test-issue-comment-usage-contract.sh && bash framework/tools/git/test-issue-comment-readback.sh && bash framework/tools/git/test-issue-close-usage-contract.sh && bash framework/tools/git/test-issue-reopen-usage-contract.sh && bash framework/tools/git/test-pr-close-usage-contract.sh && bash framework/tools/git/test-pr-review-usage-contract.sh && bash framework/tools/git/test-pr-diff-usage-contract.sh && bash framework/tools/git/test-pr-view-usage-contract.sh && bash framework/tools/git/test-pr-metadata-usage-contract.sh && bash framework/tools/git/test-pr-list-usage-contract.sh && bash framework/tools/git/test-issue-edit-usage-contract.sh && bash framework/tools/git/test-issue-create-usage-contract.sh && bash framework/tools/git/test-pr-edit-usage-contract.sh && bash framework/tools/git/test-pr-create-usage-contract.sh && bash framework/tools/git/test-issue-assign-usage-contract.sh && bash framework/tools/git/test-milestone-close-usage-contract.sh && bash framework/tools/git/test-milestone-list-usage-contract.sh && bash framework/tools/git/test-issue-view-usage-contract.sh && bash framework/tools/git/test-issue-list-usage-contract.sh && bash framework/tools/git/test-milestone-create-usage-contract.sh && bash framework/tools/git/test-lane-brief-usage-contract.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && basLine truncated
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",