Spec of record: docs/plans/2026-08-23_repo-structure-declaration.md (brain repo) sections 4 (consumption contract), 5.1/5.3/5.4, 1.2a. New shared lib repo-decl.sh: one consumption surface for the wrappers. Loads .mosaic/repo.json, classifies absent/invalid/valid via the WP1 validator (5.1: ALL consumers invoke the same script; 5.4 point 1: invalid = ABSENT + loud error naming file/key/reason), extracts the consumed fields, normalizes origin for 5.3 comparisons, validates transitions per 4.2 (a CLI flag is input, not authority), and resolves host:/ paths FAIL-CLOSED while MOSAIC_HOST_ROOT is unset (1.2a — no WP5b consumer resolves a path today; the helper exists so the first that needs one cannot guess). v1 declarations validate but carry no consumable fields: legacy behavior with a note. pr-create.sh: base precedence -B (validated as an allowed transition) -> declared integration_trunk -> legacy WP5a forge-default floor (unmanaged/absent/v1 per 4.3 reversible class, warn + legacy). Remote mismatch vs canonical_remote refuses (write path, 5.3). pr-merge.sh: transition validation per declared flow; the hardcoded main/next target check survives only for undeclared repos during the rollout window (4.3 irreversible class, loud warning). Remote mismatch refuses. ci-queue-wait.sh: ROUTE CONTEXT only (4.1, C4/jarvis F8/DR2 R9) — branch-selection semantics untouched, absence silent, invalid reported per 5.4. mosaic-worktree.sh: staged rule 4.4 — invalid declaration fails branch-creation loud, absent warns and proceeds, valid contributes policy ADVICE only (4.5: placement stays derived; the advisory worktree_root comparison runs only when MOSAIC_HOST_ROOT is set, per 1.2a warn-and-omit). Consuming via a self-located source line and set -u-safe env access. mutate-push-guard.sh: NO change — spec 4.1 names it for push-to-trunk protection, but the tool as shipped is a mutation-coverage meta-tool for push-guard.sh with no trunk-protection logic to consult; the disposition is documented in #1413 rather than force-feeding a fake consumption. All wrappers degrade SILENTLY to legacy behavior when repo-decl.sh is absent from a copied tool subset (a legal deployment shape; a note there broke single-line diagnostic contracts in test-pr-merge-message-field). test-repo-decl-consumption.sh: 67 assertions, green x2, hermetic; runs RED against pre-change tools via WP5B_TOOLS (49 red there — red-first evidence). Covers every 5.4 hostile-input class applicable to consumed fields (missing, malformed, unknown schema_version, v1, unknown key, bad refs, cross-field, userinfo URL, remote mismatch) plus transition validation, base precedence, absence policies, staged worktree rule, route context, and 1.2a fail-closed. Enumerated on the S1 surface (enumeration guard green: population 71, enumerated 57). Neighbor suites green: WP5a fallback suite, all six pr-merge suites, worktree large-repo, help/login/interactive suites. S1 chain failures (fleet-units systemd bus, invariant_r host Pi version, pr-edit credential-helper env) reproduce identically at origin/next — environmental, untouched by this diff. No TS/vitest lane touched (shell tools only).
325 lines
11 KiB
Bash
Executable File
325 lines
11 KiB
Bash
Executable File
#!/bin/bash
|
|
# pr-create.sh - Create pull requests on Gitea or GitHub
|
|
# Usage: pr-create.sh -t "Title" [-b "Body"] [-B base] [-H head] [-l "labels"] [-m "milestone"]
|
|
|
|
set -e
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
source "$SCRIPT_DIR/detect-platform.sh"
|
|
|
|
# Default values
|
|
TITLE=""
|
|
BODY=""
|
|
BASE_BRANCH=""
|
|
HEAD_BRANCH=""
|
|
LABELS=""
|
|
MILESTONE=""
|
|
DRAFT=false
|
|
ISSUE=""
|
|
|
|
# get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh
|
|
|
|
gitea_default_branch() {
|
|
# Forge default branch for the current repo (T51-P2 WP5a / spec E4): the
|
|
# API fallback must not guess a base. Empty output or any lookup failure
|
|
# returns nonzero so the caller fails loud instead of mistargeting a PR.
|
|
local host repo token url body branch
|
|
host=$(get_remote_host) || return 1
|
|
repo=$(get_repo_info) || return 1
|
|
token=$(get_gitea_token "$host") || return 1
|
|
url="https://${host}/api/v1/repos/${repo}"
|
|
# Fetch and parse as separate steps (T51P2WP5AR B2): a piped
|
|
# `curl | python` reports only python's status, so an HTTP failure that
|
|
# still emits parseable JSON would masquerade as success. curl's own
|
|
# exit status is authoritative here.
|
|
if ! body=$(curl -fsS \
|
|
-H "User-Agent: curl/8" \
|
|
-H "Authorization: token ${token}" \
|
|
"$url" 2>/dev/null); then
|
|
return 1
|
|
fi
|
|
# A valid base is a NONBLANK JSON STRING (T51P2WP5AR B3): null, numbers,
|
|
# and whitespace-only values are failed resolution, never a POSTed base.
|
|
branch=$(printf '%s' "$body" | python3 -c '
|
|
import json, sys
|
|
try:
|
|
value = json.load(sys.stdin).get("default_branch")
|
|
except Exception:
|
|
sys.exit(1)
|
|
if not isinstance(value, str) or not value.strip():
|
|
sys.exit(1)
|
|
print(value.strip())
|
|
' 2>/dev/null) || return 1
|
|
[[ -n "$branch" ]] || return 1
|
|
printf '%s' "$branch"
|
|
}
|
|
|
|
gitea_pr_create_api() {
|
|
local host repo token url payload
|
|
host=$(get_remote_host) || {
|
|
echo "Error: could not determine remote host for API fallback" >&2
|
|
return 1
|
|
}
|
|
repo=$(get_repo_info) || {
|
|
echo "Error: could not determine repo owner/name for API fallback" >&2
|
|
return 1
|
|
}
|
|
token=$(get_gitea_token "$host") || {
|
|
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
|
|
return 1
|
|
}
|
|
|
|
if [[ -n "$LABELS" || -n "$MILESTONE" || "$DRAFT" == true ]]; then
|
|
echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2
|
|
fi
|
|
|
|
# Base resolution (spec E4): an explicit -B always wins; with none, the
|
|
# forge default branch is resolved from the provider API -- never the
|
|
# historical "main" literal, which mistargeted every fallback PR on
|
|
# repos whose trunk is not main (e.g. mosaicstack/stack -> next).
|
|
local api_base=""
|
|
if [[ -n "$EFFECTIVE_BASE" ]]; then
|
|
api_base="$EFFECTIVE_BASE"
|
|
else
|
|
api_base=$(gitea_default_branch) || {
|
|
echo "Error: could not resolve the forge default branch for the API-fallback base; pass -B <branch> explicitly" >&2
|
|
return 1
|
|
}
|
|
fi
|
|
|
|
payload=$(TITLE="$TITLE" BODY="$BODY" HEAD_BRANCH="$HEAD_BRANCH" API_BASE="$api_base" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
|
|
payload = {
|
|
"title": os.environ["TITLE"],
|
|
"head": os.environ["HEAD_BRANCH"],
|
|
"base": os.environ["API_BASE"],
|
|
}
|
|
body = os.environ.get("BODY", "")
|
|
if body:
|
|
payload["body"] = body
|
|
print(json.dumps(payload))
|
|
PY
|
|
)
|
|
|
|
url="https://${host}/api/v1/repos/${repo}/pulls"
|
|
curl -fsS -X POST \
|
|
-H "User-Agent: curl/8" \
|
|
-H "Authorization: token ${token}" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$payload" \
|
|
"$url"
|
|
}
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: $(basename "$0") [OPTIONS]
|
|
|
|
Create a pull request on the current repository (Gitea or GitHub).
|
|
|
|
Options:
|
|
-t, --title TITLE PR title (required, or use --issue)
|
|
-b, --body BODY PR description/body
|
|
-B, --base BRANCH Base branch to merge into (default: the forge repository's default branch)
|
|
-H, --head BRANCH Head branch with changes (default: current branch)
|
|
-l, --labels LABELS Comma-separated labels
|
|
-m, --milestone NAME Milestone name
|
|
-i, --issue NUMBER Link to issue (auto-generates title if not provided)
|
|
-d, --draft Create as draft PR
|
|
-h, --help Show this help message
|
|
|
|
Examples:
|
|
$(basename "$0") -t "Add login feature" -b "Implements user authentication"
|
|
$(basename "$0") -t "Fix bug" -B main -H feature/fix-123
|
|
$(basename "$0") -i 42 -b "Implements the feature described in #42"
|
|
$(basename "$0") -t "WIP: New feature" --draft
|
|
EOF
|
|
exit "${1:-1}"
|
|
}
|
|
|
|
# Parse arguments
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
-t|--title)
|
|
TITLE="$2"
|
|
shift 2
|
|
;;
|
|
-b|--body)
|
|
BODY="$2"
|
|
shift 2
|
|
;;
|
|
-B|--base)
|
|
BASE_BRANCH="$2"
|
|
shift 2
|
|
;;
|
|
-H|--head)
|
|
HEAD_BRANCH="$2"
|
|
shift 2
|
|
;;
|
|
-l|--labels)
|
|
LABELS="$2"
|
|
shift 2
|
|
;;
|
|
-m|--milestone)
|
|
MILESTONE="$2"
|
|
shift 2
|
|
;;
|
|
-i|--issue)
|
|
ISSUE="$2"
|
|
shift 2
|
|
;;
|
|
-d|--draft)
|
|
DRAFT=true
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
usage 0
|
|
;;
|
|
*)
|
|
echo "Unknown option: $1" >&2
|
|
usage
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# If no title but issue provided, generate title
|
|
if [[ -z "$TITLE" ]] && [[ -n "$ISSUE" ]]; then
|
|
TITLE="Fixes #$ISSUE"
|
|
fi
|
|
|
|
if [[ -z "$TITLE" ]]; then
|
|
echo "Error: Title is required (-t) or provide an issue (-i)" >&2
|
|
usage
|
|
fi
|
|
|
|
# Default head branch to current branch
|
|
if [[ -z "$HEAD_BRANCH" ]]; then
|
|
HEAD_BRANCH=$(git branch --show-current)
|
|
fi
|
|
|
|
# T51 WP5b: declaration-driven base resolution (spec 4.1). Precedence:
|
|
# explicit -B -> validated as an ALLOWED transition (4.2: a flag is input,
|
|
# not authority) when a consumable declaration exists
|
|
# declared trunk (v2 declarations only) -> used directly
|
|
# legacy -> WP5a forge-default floor (unmanaged/absent/v1, 4.3)
|
|
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
|
|
if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then
|
|
source "$SCRIPT_DIR/repo-decl.sh"
|
|
repo_decl_load
|
|
else
|
|
DECL_STATE=absent; DECL_SCHEMA=""
|
|
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
|
|
repo_decl_report_invalid() { :; }
|
|
repo_decl_warn_absent_reversible() { :; }
|
|
repo_decl_warn_absent_irreversible() { :; }
|
|
repo_decl_remote_matches() { return 0; }
|
|
repo_decl_check_transition() { return 2; }
|
|
fi
|
|
EFFECTIVE_BASE="$BASE_BRANCH"
|
|
case "$DECL_STATE" in
|
|
invalid) repo_decl_report_invalid ;;
|
|
esac
|
|
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" != 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
|
|
# 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
|
|
exit 1
|
|
fi
|
|
if [[ -n "$BASE_BRANCH" ]]; then
|
|
trc=0
|
|
repo_decl_check_transition "$HEAD_BRANCH" "$BASE_BRANCH" || trc=$?
|
|
if [[ "$trc" == 1 ]]; then
|
|
echo "Error: -B '$BASE_BRANCH' is not an allowed transition for head '$HEAD_BRANCH' under the declared flow (spec 4.2). The declaration governs; supply an allowed base." >&2
|
|
exit 1
|
|
fi
|
|
# trc 2 cannot happen here (state=valid): 0 = allowed
|
|
else
|
|
EFFECTIVE_BASE="$DECL_TRUNK"
|
|
fi
|
|
elif [[ -z "$BASE_BRANCH" ]]; then
|
|
repo_decl_warn_absent_reversible "pr-create"
|
|
fi
|
|
|
|
# Add issue reference to body if provided
|
|
if [[ -n "$ISSUE" ]]; then
|
|
if [[ -n "$BODY" ]]; then
|
|
BODY="$BODY
|
|
|
|
Fixes #$ISSUE"
|
|
else
|
|
BODY="Fixes #$ISSUE"
|
|
fi
|
|
fi
|
|
|
|
PLATFORM=$(detect_platform)
|
|
|
|
case "$PLATFORM" in
|
|
github)
|
|
CMD=(gh pr create --title "$TITLE")
|
|
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
|
[[ -n "$EFFECTIVE_BASE" ]] && CMD+=(--base "$EFFECTIVE_BASE")
|
|
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
|
|
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
|
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
|
[[ "$DRAFT" == true ]] && CMD+=(--draft)
|
|
"${CMD[@]}"
|
|
;;
|
|
gitea)
|
|
# tea pull create syntax. Always pass --repo because tea repo inference
|
|
# is unreliable in Mosaic worktrees/profile shells. Use arrays instead
|
|
# of eval so markdown backticks/body content are not shell-executed.
|
|
REPO_SLUG=$(get_repo_slug)
|
|
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
|
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
|
|
gitea_pr_create_api
|
|
exit $?
|
|
}
|
|
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
|
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
|
gitea_pr_create_api
|
|
exit $?
|
|
fi
|
|
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
|
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
|
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
|
[[ -n "$EFFECTIVE_BASE" ]] && CMD+=(--base "$EFFECTIVE_BASE")
|
|
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
|
|
|
|
# Handle labels for tea
|
|
if [[ -n "$LABELS" ]]; then
|
|
# tea may use --labels flag
|
|
CMD+=(--labels "$LABELS")
|
|
fi
|
|
|
|
# Handle milestone for tea
|
|
if [[ -n "$MILESTONE" ]]; then
|
|
MILESTONE_ID=$(tea milestones list "${REPO_ARGS[@]}" 2>/dev/null | grep -E "^\s*[0-9]+" | grep "$MILESTONE" | awk '{print $1}' | head -1)
|
|
if [[ -n "$MILESTONE_ID" ]]; then
|
|
CMD+=(--milestone "$MILESTONE_ID")
|
|
else
|
|
echo "Warning: Could not find milestone '$MILESTONE', creating without milestone" >&2
|
|
fi
|
|
fi
|
|
|
|
# Note: tea may not support --draft flag in all versions
|
|
if [[ "$DRAFT" == true ]]; then
|
|
echo "Note: Draft PR may not be supported by your tea version" >&2
|
|
fi
|
|
|
|
if "${CMD[@]}"; then
|
|
exit 0
|
|
fi
|
|
echo "Warning: tea pr create failed, trying Gitea API fallback..." >&2
|
|
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
|
gitea_pr_create_api
|
|
;;
|
|
*)
|
|
echo "Error: Could not detect git platform" >&2
|
|
exit 1
|
|
;;
|
|
esac
|