chore: sync local Mosaic changes

This commit is contained in:
Jason Woltje
2026-02-21 09:55:34 -06:00
parent 1e4eefeca3
commit e3ec3e32e5
82 changed files with 5398 additions and 1969 deletions

View File

@@ -9,10 +9,10 @@
# agent-lint.sh --fix-hint # Show fix commands for failures
#
# Checks per project:
# 1. Has CLAUDE.md?
# 1. Has runtime context file (CLAUDE.md or RUNTIME.md)?
# 2. Has AGENTS.md?
# 3. CLAUDE.md references conditional context/guides?
# 4. CLAUDE.md has quality gates?
# 3. Runtime context file references conditional context/guides?
# 4. Runtime context file has quality gates?
# 5. For monorepos: sub-directories have AGENTS.md?
set -euo pipefail
@@ -92,9 +92,23 @@ is_monorepo() {
(grep -q '"workspaces"' "$dir/package.json" 2>/dev/null)
}
# Check for CLAUDE.md
check_claude_md() {
[[ -f "$1/CLAUDE.md" ]]
# Resolve runtime context file (CLAUDE.md or RUNTIME.md)
runtime_context_file() {
local dir="$1"
if [[ -f "$dir/CLAUDE.md" ]]; then
echo "$dir/CLAUDE.md"
return
fi
if [[ -f "$dir/RUNTIME.md" ]]; then
echo "$dir/RUNTIME.md"
return
fi
echo ""
}
# Check for runtime context file
check_runtime_context() {
[[ -n "$(runtime_context_file "$1")" ]]
}
# Check for AGENTS.md
@@ -104,14 +118,16 @@ check_agents_md() {
# Check conditional loading/context (references guides or conditional section)
check_conditional_loading() {
local claude_md="$1/CLAUDE.md"
[[ -f "$claude_md" ]] && grep -qi "agent-guides\|~/.config/mosaic/guides\|conditional.*loading\|conditional.*documentation\|conditional.*context" "$claude_md" 2>/dev/null
local ctx
ctx="$(runtime_context_file "$1")"
[[ -n "$ctx" ]] && grep -qi "agent-guides\|~/.config/mosaic/guides\|conditional.*loading\|conditional.*documentation\|conditional.*context" "$ctx" 2>/dev/null
}
# Check quality gates
check_quality_gates() {
local claude_md="$1/CLAUDE.md"
[[ -f "$claude_md" ]] && grep -qi "quality.gates\|must pass before\|lint\|typecheck\|test" "$claude_md" 2>/dev/null
local ctx
ctx="$(runtime_context_file "$1")"
[[ -n "$ctx" ]] && grep -qi "quality.gates\|must pass before\|lint\|typecheck\|test" "$ctx" 2>/dev/null
}
# Check monorepo sub-AGENTS.md
@@ -152,16 +168,16 @@ lint_project() {
local name
name=$(basename "$dir")
local has_claude has_agents has_guides has_quality mono_status
local has_runtime has_agents has_guides has_quality mono_status
local score=0 max_score=4
check_claude_md "$dir" && has_claude="OK" || has_claude="MISS"
check_runtime_context "$dir" && has_runtime="OK" || has_runtime="MISS"
check_agents_md "$dir" && has_agents="OK" || has_agents="MISS"
check_conditional_loading "$dir" && has_guides="OK" || has_guides="MISS"
check_quality_gates "$dir" && has_quality="OK" || has_quality="MISS"
mono_status=$(check_monorepo_sub_agents "$dir")
[[ "$has_claude" == "OK" ]] && ((score++)) || true
[[ "$has_runtime" == "OK" ]] && ((score++)) || true
[[ "$has_agents" == "OK" ]] && ((score++)) || true
[[ "$has_guides" == "OK" ]] && ((score++)) || true
[[ "$has_quality" == "OK" ]] && ((score++)) || true
@@ -171,7 +187,7 @@ lint_project() {
{
"project": "$name",
"path": "$dir",
"claude_md": "$has_claude",
"runtime_context": "$has_runtime",
"agents_md": "$has_agents",
"conditional_loading": "$has_guides",
"quality_gates": "$has_quality",
@@ -182,8 +198,8 @@ lint_project() {
JSONEOF
else
# Color-code the status
local c_claude c_agents c_guides c_quality
[[ "$has_claude" == "OK" ]] && c_claude="${GREEN} OK ${NC}" || c_claude="${RED} MISS ${NC}"
local c_runtime c_agents c_guides c_quality
[[ "$has_runtime" == "OK" ]] && c_runtime="${GREEN} OK ${NC}" || c_runtime="${RED} MISS ${NC}"
[[ "$has_agents" == "OK" ]] && c_agents="${GREEN} OK ${NC}" || c_agents="${RED} MISS ${NC}"
[[ "$has_guides" == "OK" ]] && c_guides="${GREEN} OK ${NC}" || c_guides="${RED} MISS ${NC}"
[[ "$has_quality" == "OK" ]] && c_quality="${GREEN} OK ${NC}" || c_quality="${RED} MISS ${NC}"
@@ -193,7 +209,7 @@ JSONEOF
[[ $score -eq 4 ]] && score_color="$GREEN"
printf " %-35s %b %b %b %b ${score_color}%d/%d${NC}" \
"$name" "$c_claude" "$c_agents" "$c_guides" "$c_quality" "$score" "$max_score"
"$name" "$c_runtime" "$c_agents" "$c_guides" "$c_quality" "$score" "$max_score"
# Show monorepo status if applicable
if [[ "$mono_status" != "N/A" && "$mono_status" != "OK" ]]; then
@@ -203,7 +219,7 @@ JSONEOF
fi
if $VERBOSE && ! $JSON_OUTPUT; then
[[ "$has_claude" == "MISS" ]] && echo " ${DIM} CLAUDE.md missing${NC}"
[[ "$has_runtime" == "MISS" ]] && echo " ${DIM} Runtime context file missing (CLAUDE.md or RUNTIME.md)${NC}"
[[ "$has_agents" == "MISS" ]] && echo " ${DIM} AGENTS.md missing${NC}"
[[ "$has_guides" == "MISS" ]] && echo " ${DIM} No conditional context/loading section detected${NC}"
[[ "$has_quality" == "MISS" ]] && echo " ${DIM} No quality gates section${NC}"
@@ -213,7 +229,7 @@ JSONEOF
fi
if $FIX_HINT && ! $JSON_OUTPUT; then
if [[ "$has_claude" == "MISS" || "$has_agents" == "MISS" ]]; then
if [[ "$has_runtime" == "MISS" || "$has_agents" == "MISS" ]]; then
echo " ${DIM}Fix: ~/.config/mosaic/rails/bootstrap/init-project.sh --name \"$name\" --type auto${NC}"
elif [[ "$has_guides" == "MISS" ]]; then
echo " ${DIM}Fix: ~/.config/mosaic/rails/bootstrap/agent-upgrade.sh $dir --section conditional-loading${NC}"
@@ -258,7 +274,7 @@ main() {
echo -e "${BOLD}Agent Configuration Audit — $(date +%Y-%m-%d)${NC}"
echo "========================================================"
printf " %-35s %s %s %s %s %s\n" \
"Project" "CLAUDE" "AGENTS" "Guides" "Quality" "Score"
"Project" "RUNTIME" "AGENTS" "Guides" "Quality" "Score"
echo " -----------------------------------------------------------------------"
for dir in "${projects[@]}"; do

View File

@@ -113,6 +113,19 @@ has_section() {
[[ -f "$file" ]] && grep -qi "$pattern" "$file" 2>/dev/null
}
runtime_context_file() {
local project_dir="$1"
if [[ -f "$project_dir/CLAUDE.md" ]]; then
echo "$project_dir/CLAUDE.md"
return
fi
if [[ -f "$project_dir/RUNTIME.md" ]]; then
echo "$project_dir/RUNTIME.md"
return
fi
echo "$project_dir/CLAUDE.md"
}
backup_file() {
local file="$1"
if [[ -f "$file" ]] && ! $DRY_RUN; then
@@ -124,7 +137,8 @@ backup_file() {
inject_fragment() {
local project_dir="$1"
local fragment_name="$2"
local claude_md="$project_dir/CLAUDE.md"
local ctx_file
ctx_file="$(runtime_context_file "$project_dir")"
local fragment_file="$FRAGMENTS_DIR/$fragment_name.md"
if [[ ! -f "$fragment_file" ]]; then
@@ -144,12 +158,12 @@ inject_fragment() {
*) echo "Unknown fragment: $fragment_name"; return 1 ;;
esac
if [[ ! -f "$claude_md" ]]; then
echo -e " ${YELLOW}No CLAUDE.md — skipping fragment injection${NC}"
if [[ ! -f "$ctx_file" ]]; then
echo -e " ${YELLOW}No runtime context file (CLAUDE.md/RUNTIME.md) — skipping fragment injection${NC}"
return 0
fi
if has_section "$claude_md" "$detect_pattern"; then
if has_section "$ctx_file" "$detect_pattern"; then
echo -e " ${DIM}$fragment_name already present${NC}"
return 0
fi
@@ -157,10 +171,10 @@ inject_fragment() {
if $DRY_RUN; then
echo -e " ${GREEN}Would inject: $fragment_name${NC}"
else
backup_file "$claude_md"
echo "" >> "$claude_md"
cat "$fragment_file" >> "$claude_md"
echo "" >> "$claude_md"
backup_file "$ctx_file"
echo "" >> "$ctx_file"
cat "$fragment_file" >> "$ctx_file"
echo "" >> "$ctx_file"
echo -e " ${GREEN}Injected: $fragment_name${NC}"
fi
}
@@ -257,8 +271,8 @@ upgrade_project() {
# Always try conditional-loading (highest impact)
inject_fragment "$dir" "conditional-loading"
# Try other fragments if CLAUDE.md exists
if [[ -f "$dir/CLAUDE.md" ]]; then
# Try other fragments if runtime context exists
if [[ -f "$dir/CLAUDE.md" || -f "$dir/RUNTIME.md" ]]; then
inject_fragment "$dir" "commit-format"
inject_fragment "$dir" "secrets"
inject_fragment "$dir" "multi-agent"

View File

@@ -10,6 +10,7 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMPLATE_DIR="$HOME/.config/mosaic/templates/agent"
GIT_SCRIPT_DIR="$HOME/.config/mosaic/rails/git"
SEQUENTIAL_MCP_SCRIPT="$HOME/.config/mosaic/bin/mosaic-ensure-sequential-thinking"
# Defaults
PROJECT_NAME=""
@@ -320,10 +321,14 @@ fi
if [[ "$DRY_RUN" == true ]]; then
echo "[DRY RUN] Would create:"
echo " - Validate sequential-thinking MCP hard requirement"
echo " - CLAUDE.md (from $STACK_TEMPLATE_DIR/CLAUDE.md.template)"
echo " - AGENTS.md (from $STACK_TEMPLATE_DIR/AGENTS.md.template)"
echo " - docs/scratchpads/"
echo " - docs/reports/"
echo " - docs/reports/qa-automation/{pending,in-progress,done,escalated}"
echo " - docs/reports/deferred/"
echo " - docs/tasks/"
echo " - docs/releases/"
echo " - docs/templates/"
if [[ "$SKIP_CI" != true ]]; then
echo " - .woodpecker/codex-review.yml"
@@ -331,7 +336,8 @@ if [[ "$DRY_RUN" == true ]]; then
fi
if [[ "$SKIP_LABELS" != true ]]; then
echo " - Standard git labels (epic, feature, bug, task, documentation, security, breaking)"
echo " - Milestone: 0.1.0 - MVP"
echo " - Milestone: 0.0.1 - Pre-MVP Foundation"
echo " - Milestone policy: 0.0.x pre-MVP, 0.1.0 for MVP release"
fi
if [[ "$CICD_DOCKER" == true ]]; then
echo " - Docker build/push/link steps appended to .woodpecker.yml"
@@ -343,6 +349,21 @@ if [[ "$DRY_RUN" == true ]]; then
exit 0
fi
# Enforce sequential-thinking MCP hard requirement.
if [[ ! -x "$SEQUENTIAL_MCP_SCRIPT" ]]; then
echo "Error: Missing sequential-thinking setup helper: $SEQUENTIAL_MCP_SCRIPT" >&2
echo "Install/repair Mosaic at ~/.config/mosaic before bootstrapping projects." >&2
exit 1
fi
if "$SEQUENTIAL_MCP_SCRIPT" >/dev/null 2>&1; then
echo "Verified sequential-thinking MCP configuration"
else
echo "Error: sequential-thinking MCP setup failed (hard requirement)." >&2
echo "Run: $SEQUENTIAL_MCP_SCRIPT" >&2
exit 1
fi
# Create CLAUDE.md
if [[ -f "CLAUDE.md" ]]; then
echo "CLAUDE.md already exists — skipping (rename or delete to recreate)"
@@ -368,8 +389,17 @@ else
fi
# Create directories
mkdir -p docs/scratchpads docs/reports docs/templates
echo "Created docs/scratchpads/, docs/reports/, docs/templates/"
mkdir -p \
docs/scratchpads \
docs/reports/qa-automation/pending \
docs/reports/qa-automation/in-progress \
docs/reports/qa-automation/done \
docs/reports/qa-automation/escalated \
docs/reports/deferred \
docs/tasks \
docs/releases \
docs/templates
echo "Created docs/scratchpads/, docs/reports/*, docs/tasks/, docs/releases/, docs/templates/"
# Set up CI/CD pipeline
if [[ "$SKIP_CI" != true ]]; then

View File

@@ -24,7 +24,7 @@ while [[ $# -gt 0 ]]; do
echo "Create standard labels and initial milestone for the current repository."
echo ""
echo "Options:"
echo " --skip-milestone Skip creating the 0.1.0 MVP milestone"
echo " --skip-milestone Skip creating the 0.0.1 pre-MVP milestone"
echo " -h, --help Show this help"
exit 0
;;
@@ -107,14 +107,16 @@ done
echo ""
# Create initial milestone
# Create initial pre-MVP milestone
if [[ "$SKIP_MILESTONE" != true ]]; then
echo "Creating initial milestone..."
echo "Creating initial pre-MVP milestone..."
"$GIT_SCRIPT_DIR/milestone-create.sh" -t "0.1.0" -d "MVP - Minimum Viable Product" 2>/dev/null && \
echo " [created] Milestone '0.1.0 - MVP'" || \
"$GIT_SCRIPT_DIR/milestone-create.sh" -t "0.0.1" -d "Pre-MVP - Foundation Sprint" 2>/dev/null && \
echo " [created] Milestone '0.0.1 - Pre-MVP'" || \
echo " [skip] Milestone may already exist or creation failed"
echo " [note] Reserve 0.1.0 for MVP release milestone"
echo ""
fi

View File

@@ -260,6 +260,6 @@ For best results, use `gpt-5.2-codex` or newer for strongest review accuracy.
## See Also
- `~/.config/mosaic/guides/code-review.md` — Manual code review checklist
- `~/.config/mosaic/guides/CODE-REVIEW.md` — Manual code review checklist
- `~/.config/mosaic/rails/git/` — Git helper scripts (issue/PR management)
- OpenAI Codex CLI docs: https://developers.openai.com/codex/cli/

247
rails/git/ci-queue-wait.ps1 Normal file
View File

@@ -0,0 +1,247 @@
# ci-queue-wait.ps1 - Wait until project CI queue is clear (no running/queued pipeline on branch head)
# Usage: .\ci-queue-wait.ps1 [-Branch main] [-TimeoutSeconds 900] [-IntervalSeconds 15] [-Purpose merge] [-RequireStatus]
[CmdletBinding()]
param(
[Alias("B")]
[string]$Branch = "main",
[Alias("t")]
[int]$TimeoutSeconds = 900,
[Alias("i")]
[int]$IntervalSeconds = 15,
[ValidateSet("push", "merge")]
[string]$Purpose = "merge",
[switch]$RequireStatus,
[Alias("h")]
[switch]$Help
)
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. "$ScriptDir\detect-platform.ps1"
function Show-Usage {
@"
Usage: ci-queue-wait.ps1 [-Branch main] [-TimeoutSeconds 900] [-IntervalSeconds 15] [-Purpose push|merge] [-RequireStatus]
Options:
-Branch, -B BRANCH Branch head to inspect (default: main)
-TimeoutSeconds, -t SECONDS Max wait time (default: 900)
-IntervalSeconds, -i SECONDS Poll interval (default: 15)
-Purpose VALUE push or merge (default: merge)
-RequireStatus Fail if no CI status contexts are present
-Help, -h Show help
"@
}
if ($Help) {
Show-Usage
exit 0
}
if ($TimeoutSeconds -lt 1 -or $IntervalSeconds -lt 1) {
Write-Error "TimeoutSeconds and IntervalSeconds must be positive integers."
exit 1
}
function Get-RemoteHost {
$remoteUrl = git remote get-url origin 2>$null
if ([string]::IsNullOrEmpty($remoteUrl)) { return $null }
if ($remoteUrl -match "^https?://([^/]+)/") { return $Matches[1] }
if ($remoteUrl -match "^git@([^:]+):") { return $Matches[1] }
return $null
}
function Get-GiteaToken {
param([string]$Host)
if ($env:GITEA_TOKEN) { return $env:GITEA_TOKEN }
$credPath = Join-Path $HOME ".git-credentials"
if (-not (Test-Path $credPath)) { return $null }
$line = Get-Content $credPath | Where-Object { $_ -like "*$Host*" } | Select-Object -First 1
if (-not $line) { return $null }
if ($line -match 'https?://[^@]*:([^@/]+)@') {
return $Matches[1]
}
return $null
}
function Get-QueueState {
param([object]$Payload)
$pending = @("pending", "queued", "running", "waiting")
$failure = @("failure", "error", "failed")
$success = @("success")
$state = ""
if ($null -ne $Payload.state) {
$state = "$($Payload.state)".ToLowerInvariant()
}
if ($pending -contains $state) { return "pending" }
if ($failure -contains $state) { return "terminal-failure" }
if ($success -contains $state) { return "terminal-success" }
$values = @()
$statuses = @()
if ($null -ne $Payload.statuses) { $statuses = @($Payload.statuses) }
foreach ($s in $statuses) {
if ($null -eq $s) { continue }
$v = ""
if ($null -ne $s.status) { $v = "$($s.status)".ToLowerInvariant() }
elseif ($null -ne $s.state) { $v = "$($s.state)".ToLowerInvariant() }
if (-not [string]::IsNullOrEmpty($v)) { $values += $v }
}
if ($values.Count -eq 0 -and [string]::IsNullOrEmpty($state)) { return "no-status" }
if (($values | Where-Object { $pending -contains $_ }).Count -gt 0) { return "pending" }
if (($values | Where-Object { $failure -contains $_ }).Count -gt 0) { return "terminal-failure" }
if ($values.Count -gt 0 -and ($values | Where-Object { -not ($success -contains $_) }).Count -eq 0) { return "terminal-success" }
return "unknown"
}
function Print-PendingContexts {
param([object]$Payload)
$pending = @("pending", "queued", "running", "waiting")
$statuses = @()
if ($null -ne $Payload.statuses) { $statuses = @($Payload.statuses) }
if ($statuses.Count -eq 0) {
Write-Host "[ci-queue-wait] no status contexts reported"
return
}
$found = $false
foreach ($s in $statuses) {
if ($null -eq $s) { continue }
$name = if ($s.context) { $s.context } elseif ($s.name) { $s.name } else { "unknown-context" }
$value = if ($s.status) { "$($s.status)".ToLowerInvariant() } elseif ($s.state) { "$($s.state)".ToLowerInvariant() } else { "unknown" }
$target = if ($s.target_url) { $s.target_url } elseif ($s.url) { $s.url } else { "" }
if ($pending -contains $value) {
$found = $true
if ($target) {
Write-Host "[ci-queue-wait] pending: $name=$value ($target)"
}
else {
Write-Host "[ci-queue-wait] pending: $name=$value"
}
}
}
if (-not $found) {
Write-Host "[ci-queue-wait] no pending contexts"
}
}
$platform = Get-GitPlatform
$owner = Get-GitRepoOwner
$repo = Get-GitRepoName
if ([string]::IsNullOrEmpty($owner) -or [string]::IsNullOrEmpty($repo)) {
Write-Error "Could not determine repository owner/name from git remote."
exit 1
}
$headSha = $null
$host = $null
$giteaToken = $null
switch ($platform) {
"github" {
if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
Write-Error "gh CLI is required for GitHub CI queue guard."
exit 1
}
$headSha = (& gh api "repos/$owner/$repo/branches/$Branch" --jq ".commit.sha").Trim()
if ([string]::IsNullOrEmpty($headSha)) {
Write-Error "Could not resolve $Branch head SHA."
exit 1
}
Write-Host "[ci-queue-wait] platform=github purpose=$Purpose branch=$Branch sha=$headSha"
}
"gitea" {
$host = Get-RemoteHost
if ([string]::IsNullOrEmpty($host)) {
Write-Error "Could not determine remote host."
exit 1
}
$giteaToken = Get-GiteaToken -Host $host
if ([string]::IsNullOrEmpty($giteaToken)) {
Write-Error "Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials."
exit 1
}
try {
$branchUrl = "https://$host/api/v1/repos/$owner/$repo/branches/$Branch"
$branchPayload = Invoke-RestMethod -Method Get -Uri $branchUrl -Headers @{ Authorization = "token $giteaToken" }
$headSha = ($branchPayload.commit.id | Out-String).Trim()
}
catch {
Write-Error "Could not resolve $Branch head SHA from Gitea API."
exit 1
}
if ([string]::IsNullOrEmpty($headSha)) {
Write-Error "Could not resolve $Branch head SHA."
exit 1
}
Write-Host "[ci-queue-wait] platform=gitea purpose=$Purpose branch=$Branch sha=$headSha"
}
default {
Write-Error "Unsupported platform '$platform'."
exit 1
}
}
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ($true) {
if ((Get-Date) -gt $deadline) {
Write-Error "Timed out waiting for CI queue to clear on $Branch after ${TimeoutSeconds}s."
exit 124
}
try {
if ($platform -eq "github") {
$statusJson = & gh api "repos/$owner/$repo/commits/$headSha/status"
$payload = $statusJson | ConvertFrom-Json
}
else {
$statusUrl = "https://$host/api/v1/repos/$owner/$repo/commits/$headSha/status"
$payload = Invoke-RestMethod -Method Get -Uri $statusUrl -Headers @{ Authorization = "token $giteaToken" }
}
}
catch {
Write-Error "Failed to query commit status for queue guard."
exit 1
}
$state = Get-QueueState -Payload $payload
Write-Host "[ci-queue-wait] state=$state purpose=$Purpose branch=$Branch"
switch ($state) {
"pending" {
Print-PendingContexts -Payload $payload
Start-Sleep -Seconds $IntervalSeconds
}
"no-status" {
if ($RequireStatus) {
Write-Error "No CI status contexts found while -RequireStatus is set."
exit 1
}
Write-Host "[ci-queue-wait] no status contexts present; proceeding."
exit 0
}
"terminal-success" { exit 0 }
"terminal-failure" { exit 0 }
"unknown" { exit 0 }
default { exit 0 }
}
}

307
rails/git/ci-queue-wait.sh Executable file
View File

@@ -0,0 +1,307 @@
#!/bin/bash
# ci-queue-wait.sh - Wait until project CI queue is clear (no running/queued pipeline on branch head)
# Usage: ci-queue-wait.sh [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
BRANCH="main"
TIMEOUT_SEC=900
INTERVAL_SEC=15
PURPOSE="merge"
REQUIRE_STATUS=0
usage() {
cat <<EOF
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
Options:
-B, --branch BRANCH Branch head to inspect (default: main)
-t, --timeout SECONDS Max wait time in seconds (default: 900)
-i, --interval SECONDS Poll interval in seconds (default: 15)
--purpose VALUE Log context: push|merge (default: merge)
--require-status Fail if no CI status contexts are present
-h, --help Show this help
Examples:
$(basename "$0")
$(basename "$0") --purpose push -B main -t 600 -i 10
EOF
}
get_remote_host() {
local remote_url
remote_url=$(git remote get-url origin 2>/dev/null || true)
if [[ -z "$remote_url" ]]; then
return 1
fi
if [[ "$remote_url" =~ ^https?://([^/]+)/ ]]; then
echo "${BASH_REMATCH[1]}"
return 0
fi
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
echo "${BASH_REMATCH[1]}"
return 0
fi
return 1
}
get_gitea_token() {
local host="$1"
if [[ -n "${GITEA_TOKEN:-}" ]]; then
echo "$GITEA_TOKEN"
return 0
fi
local creds="$HOME/.git-credentials"
if [[ -f "$creds" ]]; then
local token
token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1)
if [[ -n "$token" ]]; then
echo "$token"
return 0
fi
fi
return 1
}
get_state_from_status_json() {
python3 - <<'PY'
import json
import sys
try:
payload = json.load(sys.stdin)
except Exception:
print("unknown")
raise SystemExit(0)
statuses = payload.get("statuses") or []
state = (payload.get("state") or "").lower()
pending_values = {"pending", "queued", "running", "waiting"}
failure_values = {"failure", "error", "failed"}
success_values = {"success"}
if state in pending_values:
print("pending")
raise SystemExit(0)
if state in failure_values:
print("terminal-failure")
raise SystemExit(0)
if state in success_values:
print("terminal-success")
raise SystemExit(0)
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 not values and not state:
print("no-status")
elif any(v in pending_values for v in values):
print("pending")
elif any(v in failure_values for v in values):
print("terminal-failure")
elif values and all(v in success_values for v in values):
print("terminal-success")
else:
print("unknown")
PY
}
print_pending_contexts() {
python3 - <<'PY'
import json
import sys
try:
payload = json.load(sys.stdin)
except Exception:
print("[ci-queue-wait] unable to decode status payload")
raise SystemExit(0)
statuses = payload.get("statuses") or []
if not statuses:
print("[ci-queue-wait] no status contexts reported")
raise SystemExit(0)
pending_values = {"pending", "queued", "running", "waiting"}
found = False
for item in statuses:
if not isinstance(item, dict):
continue
name = item.get("context") or item.get("name") or "unknown-context"
value = (item.get("status") or item.get("state") or "unknown").lower()
target = item.get("target_url") or item.get("url") or ""
if value in pending_values:
found = True
if target:
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
else:
print(f"[ci-queue-wait] pending: {name}={value}")
if not found:
print("[ci-queue-wait] no pending contexts")
PY
}
github_get_branch_head_sha() {
local owner="$1"
local repo="$2"
local branch="$3"
gh api "repos/${owner}/${repo}/branches/${branch}" --jq '.commit.sha'
}
github_get_commit_status_json() {
local owner="$1"
local repo="$2"
local sha="$3"
gh api "repos/${owner}/${repo}/commits/${sha}/status"
}
gitea_get_branch_head_sha() {
local host="$1"
local repo="$2"
local branch="$3"
local token="$4"
local url="https://${host}/api/v1/repos/${repo}/branches/${branch}"
curl -fsS -H "Authorization: token ${token}" "$url" | python3 -c '
import json, sys
data = json.load(sys.stdin)
commit = data.get("commit") or {}
print((commit.get("id") or "").strip())
'
}
gitea_get_commit_status_json() {
local host="$1"
local repo="$2"
local sha="$3"
local token="$4"
local url="https://${host}/api/v1/repos/${repo}/commits/${sha}/status"
curl -fsS -H "Authorization: token ${token}" "$url"
}
while [[ $# -gt 0 ]]; do
case "$1" in
-B|--branch)
BRANCH="$2"
shift 2
;;
-t|--timeout)
TIMEOUT_SEC="$2"
shift 2
;;
-i|--interval)
INTERVAL_SEC="$2"
shift 2
;;
--purpose)
PURPOSE="$2"
shift 2
;;
--require-status)
REQUIRE_STATUS=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; then
echo "Error: timeout and interval must be integer seconds." >&2
exit 1
fi
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
detect_platform > /dev/null
PLATFORM="${PLATFORM:-unknown}"
if [[ "$PLATFORM" == "github" ]]; then
if ! command -v gh >/dev/null 2>&1; then
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
exit 1
fi
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
if [[ -z "$HEAD_SHA" ]]; then
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
exit 1
fi
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
elif [[ "$PLATFORM" == "gitea" ]]; then
HOST=$(get_remote_host) || {
echo "Error: Could not determine remote host." >&2
exit 1
}
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_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
if [[ -z "$HEAD_SHA" ]]; then
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
exit 1
fi
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
else
echo "Error: Unsupported platform '${PLATFORM}'." >&2
exit 1
fi
START_TS=$(date +%s)
DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
while true; do
NOW_TS=$(date +%s)
if (( NOW_TS > DEADLINE_TS )); then
echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} 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" "$HEAD_SHA" "$TOKEN")
fi
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
echo "[ci-queue-wait] state=${STATE} purpose=${PURPOSE} branch=${BRANCH}"
case "$STATE" in
pending)
printf '%s' "$STATUS_JSON" | print_pending_contexts
sleep "$INTERVAL_SEC"
;;
no-status)
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
exit 1
fi
echo "[ci-queue-wait] no status contexts present; proceeding."
exit 0
;;
terminal-success|terminal-failure|unknown)
# Queue guard only blocks on pending/running/queued states.
exit 0
;;
*)
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
exit 0
;;
esac
done

View File

@@ -13,6 +13,80 @@ BODY=""
LABELS=""
MILESTONE=""
get_remote_host() {
local remote_url
remote_url=$(git remote get-url origin 2>/dev/null || true)
if [[ -z "$remote_url" ]]; then
return 1
fi
if [[ "$remote_url" =~ ^https?://([^/]+)/ ]]; then
echo "${BASH_REMATCH[1]}"
return 0
fi
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
echo "${BASH_REMATCH[1]}"
return 0
fi
return 1
}
get_gitea_token() {
local host="$1"
if [[ -n "${GITEA_TOKEN:-}" ]]; then
echo "$GITEA_TOKEN"
return 0
fi
local creds="$HOME/.git-credentials"
if [[ -f "$creds" ]]; then
local token
token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1)
if [[ -n "$token" ]]; then
echo "$token"
return 0
fi
fi
return 1
}
gitea_issue_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" ]]; then
echo "Warning: API fallback currently applies title/body only; labels/milestone require authenticated tea setup." >&2
fi
payload=$(TITLE="$TITLE" BODY="$BODY" python3 - <<'PY'
import json
import os
payload = {"title": os.environ["TITLE"]}
body = os.environ.get("BODY", "")
if body:
payload["body"] = body
print(json.dumps(payload))
PY
)
url="https://${host}/api/v1/repos/${repo}/issues"
curl -fsS -X POST \
-H "Authorization: token ${token}" \
-H "Content-Type: application/json" \
-d "$payload" \
"$url"
}
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
@@ -78,12 +152,18 @@ case "$PLATFORM" in
eval "$CMD"
;;
gitea)
CMD="tea issue create --title \"$TITLE\""
[[ -n "$BODY" ]] && CMD="$CMD --description \"$BODY\""
[[ -n "$LABELS" ]] && CMD="$CMD --labels \"$LABELS\""
# tea accepts milestone by name directly (verified 2026-02-05)
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\""
eval "$CMD"
if command -v tea >/dev/null 2>&1; then
CMD="tea issue create --title \"$TITLE\""
[[ -n "$BODY" ]] && CMD="$CMD --description \"$BODY\""
[[ -n "$LABELS" ]] && CMD="$CMD --labels \"$LABELS\""
# tea accepts milestone by name directly (verified 2026-02-05)
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\""
if eval "$CMD"; then
exit 0
fi
echo "Warning: tea issue create failed, trying Gitea API fallback..." >&2
fi
gitea_issue_create_api
;;
*)
echo "Error: Could not detect git platform" >&2

View File

@@ -10,6 +10,64 @@ source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments
ISSUE_NUMBER=""
get_remote_host() {
local remote_url
remote_url=$(git remote get-url origin 2>/dev/null || true)
if [[ -z "$remote_url" ]]; then
return 1
fi
if [[ "$remote_url" =~ ^https?://([^/]+)/ ]]; then
echo "${BASH_REMATCH[1]}"
return 0
fi
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
echo "${BASH_REMATCH[1]}"
return 0
fi
return 1
}
get_gitea_token() {
local host="$1"
if [[ -n "${GITEA_TOKEN:-}" ]]; then
echo "$GITEA_TOKEN"
return 0
fi
local creds="$HOME/.git-credentials"
if [[ -f "$creds" ]]; then
local token
token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1)
if [[ -n "$token" ]]; then
echo "$token"
return 0
fi
fi
return 1
}
gitea_issue_view_api() {
local host repo token url
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
}
url="https://${host}/api/v1/repos/${repo}/issues/${ISSUE_NUMBER}"
if command -v python3 >/dev/null 2>&1; then
curl -fsS -H "Authorization: token ${token}" "$url" | python3 -m json.tool
else
curl -fsS -H "Authorization: token ${token}" "$url"
fi
}
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
@@ -41,7 +99,13 @@ detect_platform
if [[ "$PLATFORM" == "github" ]]; then
gh issue view "$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
tea issue "$ISSUE_NUMBER"
if command -v tea >/dev/null 2>&1; then
if tea issue "$ISSUE_NUMBER"; then
exit 0
fi
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
fi
gitea_issue_view_api
else
echo "Error: Unknown platform"
exit 1

View File

@@ -28,12 +28,12 @@ Create or list milestones on the current repository (Gitea or GitHub).
Versioning Convention:
- Features get dedicated milestones
- Pre-release: 0.X.0 for breaking changes, 0.X.Y for patches
- Post-release: X.0.0 for breaking changes
- MVP starts at 0.1.0
- Pre-MVP milestones MUST use 0.0.x and MUST start at 0.0.1
- 0.1.0 is reserved for MVP release
- After MVP, continue semantic progression (0.1.x, 0.2.x, ...)
Options:
-Title, -t TITLE Milestone title/version (e.g., "0.2.0")
-Title, -t TITLE Milestone title/version (e.g., "0.0.1")
-Description, -d DESC Milestone description
-Due DATE Due date (YYYY-MM-DD format)
-List List existing milestones
@@ -41,8 +41,8 @@ Options:
Examples:
.\milestone-create.ps1 -List
.\milestone-create.ps1 -t "0.1.0" -d "MVP Release"
.\milestone-create.ps1 -t "0.2.0" -d "User Authentication Feature" -Due "2025-03-01"
.\milestone-create.ps1 -t "0.0.1" -d "Pre-MVP Foundation Sprint"
.\milestone-create.ps1 -t "0.1.0" -d "MVP Release" -Due "2025-03-01"
"@
exit 1
}

View File

@@ -21,12 +21,12 @@ Create or list milestones on the current repository (Gitea or GitHub).
Versioning Convention:
- Features get dedicated milestones
- Pre-release: 0.X.0 for breaking changes, 0.X.Y for patches
- Post-release: X.0.0 for breaking changes
- MVP starts at 0.1.0
- Pre-MVP milestones MUST use 0.0.x and MUST start at 0.0.1
- 0.1.0 is reserved for MVP release
- After MVP, continue semantic progression (0.1.x, 0.2.x, ...)
Options:
-t, --title TITLE Milestone title/version (e.g., "0.2.0")
-t, --title TITLE Milestone title/version (e.g., "0.0.1")
-d, --desc DESCRIPTION Milestone description
--due DATE Due date (YYYY-MM-DD format)
--list List existing milestones
@@ -34,8 +34,8 @@ Options:
Examples:
$(basename "$0") --list
$(basename "$0") -t "0.1.0" -d "MVP Release"
$(basename "$0") -t "0.2.0" -d "User Authentication Feature" --due "2025-03-01"
$(basename "$0") -t "0.0.1" -d "Pre-MVP Foundation Sprint"
$(basename "$0") -t "0.1.0" -d "MVP Release" --due "2025-03-01"
EOF
exit 1
}

273
rails/git/pr-ci-wait.sh Executable file
View File

@@ -0,0 +1,273 @@
#!/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> [-t timeout_sec] [-i interval_sec]
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
usage() {
cat <<EOF
Usage: $(basename "$0") -n <pr_number> [-t timeout_sec] [-i interval_sec]
Options:
-n, --number NUMBER PR number (required)
-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 -t 900 -i 10
EOF
}
get_remote_host() {
local remote_url
remote_url=$(git remote get-url origin 2>/dev/null || true)
if [[ -z "$remote_url" ]]; then
return 1
fi
if [[ "$remote_url" =~ ^https?://([^/]+)/ ]]; then
echo "${BASH_REMATCH[1]}"
return 0
fi
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
echo "${BASH_REMATCH[1]}"
return 0
fi
return 1
}
get_gitea_token() {
local host="$1"
if [[ -n "${GITEA_TOKEN:-}" ]]; then
echo "$GITEA_TOKEN"
return 0
fi
local creds="$HOME/.git-credentials"
if [[ -f "$creds" ]]; then
local token
token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1)
if [[ -n "$token" ]]; then
echo "$token"
return 0
fi
fi
return 1
}
extract_state_from_status_json() {
python3 - <<'PY'
import json
import sys
try:
payload = json.load(sys.stdin)
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")
else:
print("unknown")
PY
}
print_status_summary() {
python3 - <<'PY'
import json
import sys
try:
payload = json.load(sys.stdin)
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" --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 -fsS -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 -fsS -H "Authorization: token ${token}" "$url"
}
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--number)
PR_NUMBER="$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
detect_platform > /dev/null
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
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
HOST=$(get_remote_host) || {
echo "Error: Could not determine remote host." >&2
exit 1
}
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} PR=#${PR_NUMBER} head_sha=${HEAD_SHA}"
else
echo "Error: Unsupported platform '${PLATFORM}'." >&2
exit 1
fi
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
;;
pending|unknown)
sleep "$INTERVAL_SEC"
;;
*)
echo "[pr-ci-wait] Unrecognized state '${STATE}', continuing to poll..."
sleep "$INTERVAL_SEC"
;;
esac
done

47
rails/git/pr-merge.ps1 Normal file → Executable file
View File

@@ -1,5 +1,5 @@
# pr-merge.ps1 - Merge pull requests on Gitea or GitHub
# Usage: .\pr-merge.ps1 -Number PR_NUMBER [-Method method] [-DeleteBranch]
# Usage: .\pr-merge.ps1 -Number PR_NUMBER [-Method squash] [-DeleteBranch]
[CmdletBinding()]
param(
@@ -8,12 +8,13 @@ param(
[int]$Number,
[Alias("m")]
[ValidateSet("merge", "squash", "rebase")]
[string]$Method = "merge",
[string]$Method = "squash",
[Alias("d")]
[switch]$DeleteBranch,
[switch]$SkipQueueGuard,
[Alias("h")]
[switch]$Help
)
@@ -29,14 +30,15 @@ Merge a pull request on the current repository (Gitea or GitHub).
Options:
-Number, -n NUMBER PR number to merge (required)
-Method, -m METHOD Merge method: merge, squash, rebase (default: merge)
-Method, -m METHOD Merge method: squash only (default: squash)
-DeleteBranch, -d Delete the head branch after merge
-SkipQueueGuard Skip CI queue guard wait before merge
-Help, -h Show this help message
Examples:
.\pr-merge.ps1 -n 42 # Merge PR #42
.\pr-merge.ps1 -n 42 -m squash # Squash merge
.\pr-merge.ps1 -n 42 -m rebase -d # Rebase and delete branch
.\pr-merge.ps1 -n 42 -d # Squash merge and delete branch
"@
exit 1
}
@@ -45,27 +47,42 @@ if ($Help) {
Show-Usage
}
if ($Method -ne "squash") {
Write-Error "Mosaic policy enforces squash merge only. Received '$Method'."
exit 1
}
$platform = Get-GitPlatform
switch ($platform) {
"github" {
$cmd = @("gh", "pr", "merge", $Number)
switch ($Method) {
"merge" { $cmd += "--merge" }
"squash" { $cmd += "--squash" }
"rebase" { $cmd += "--rebase" }
$baseRef = (& gh pr view $Number --json baseRefName --jq ".baseRefName").Trim()
if ($baseRef -ne "main") {
Write-Error "Mosaic policy allows merges only for PRs targeting 'main' (found '$baseRef')."
exit 1
}
if (-not $SkipQueueGuard) {
$timeout = if ($env:MOSAIC_CI_QUEUE_TIMEOUT_SEC) { [int]$env:MOSAIC_CI_QUEUE_TIMEOUT_SEC } else { 900 }
$interval = if ($env:MOSAIC_CI_QUEUE_POLL_SEC) { [int]$env:MOSAIC_CI_QUEUE_POLL_SEC } else { 15 }
& "$ScriptDir\ci-queue-wait.ps1" -Purpose merge -Branch $baseRef -TimeoutSeconds $timeout -IntervalSeconds $interval
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
$cmd = @("gh", "pr", "merge", $Number, "--squash")
if ($DeleteBranch) { $cmd += "--delete-branch" }
& $cmd[0] $cmd[1..($cmd.Length-1)]
}
"gitea" {
$cmd = @("tea", "pr", "merge", $Number)
switch ($Method) {
"merge" { $cmd += @("--style", "merge") }
"squash" { $cmd += @("--style", "squash") }
"rebase" { $cmd += @("--style", "rebase") }
if (-not $SkipQueueGuard) {
$timeout = if ($env:MOSAIC_CI_QUEUE_TIMEOUT_SEC) { [int]$env:MOSAIC_CI_QUEUE_TIMEOUT_SEC } else { 900 }
$interval = if ($env:MOSAIC_CI_QUEUE_POLL_SEC) { [int]$env:MOSAIC_CI_QUEUE_POLL_SEC } else { 15 }
& "$ScriptDir\ci-queue-wait.ps1" -Purpose merge -Branch "main" -TimeoutSeconds $timeout -IntervalSeconds $interval
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
$cmd = @("tea", "pr", "merge", $Number, "--style", "squash")
if ($DeleteBranch) {
Write-Warning "Branch deletion after merge may need to be done separately with tea"
}

View File

@@ -1,6 +1,6 @@
#!/bin/bash
# pr-merge.sh - Merge pull requests on Gitea or GitHub
# Usage: pr-merge.sh -n PR_NUMBER [-m method] [-d]
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
set -e
@@ -9,8 +9,9 @@ source "$SCRIPT_DIR/detect-platform.sh"
# Default values
PR_NUMBER=""
MERGE_METHOD="merge" # merge, squash, rebase
MERGE_METHOD="squash"
DELETE_BRANCH=false
SKIP_QUEUE_GUARD=false
usage() {
cat <<EOF
@@ -20,14 +21,16 @@ 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: merge, squash, rebase (default: merge)
-m, --method METHOD Merge method: squash only (default: squash)
-d, --delete-branch Delete the head branch after merge
--skip-queue-guard Skip CI queue guard wait before merge
-h, --help Show this help message
Examples:
$(basename "$0") -n 42 # Merge PR #42
$(basename "$0") -n 42 -m squash # Squash merge
$(basename "$0") -n 42 -m rebase -d # Rebase and delete branch
$(basename "$0") -n 42 -d # Squash merge and delete branch
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
EOF
exit 1
}
@@ -47,6 +50,10 @@ while [[ $# -gt 0 ]]; do
DELETE_BRANCH=true
shift
;;
--skip-queue-guard)
SKIP_QUEUE_GUARD=true
shift
;;
-h|--help)
usage
;;
@@ -62,37 +69,36 @@ if [[ -z "$PR_NUMBER" ]]; then
usage
fi
if [[ "$MERGE_METHOD" != "squash" ]]; then
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
exit 1
fi
BASE_BRANCH="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
if [[ "$BASE_BRANCH" != "main" ]]; then
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
exit 1
fi
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
"$SCRIPT_DIR/ci-queue-wait.sh" \
--purpose merge \
-B "$BASE_BRANCH" \
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
fi
PLATFORM=$(detect_platform)
case "$PLATFORM" in
github)
CMD="gh pr merge $PR_NUMBER"
case "$MERGE_METHOD" in
merge) CMD="$CMD --merge" ;;
squash) CMD="$CMD --squash" ;;
rebase) CMD="$CMD --rebase" ;;
*)
echo "Error: Invalid merge method '$MERGE_METHOD'" >&2
exit 1
;;
esac
CMD="gh pr merge $PR_NUMBER --squash"
[[ "$DELETE_BRANCH" == true ]] && CMD="$CMD --delete-branch"
eval "$CMD"
;;
gitea)
# tea pr merge syntax
CMD="tea pr merge $PR_NUMBER"
# tea merge style flags
case "$MERGE_METHOD" in
merge) CMD="$CMD --style merge" ;;
squash) CMD="$CMD --style squash" ;;
rebase) CMD="$CMD --style rebase" ;;
*)
echo "Error: Invalid merge method '$MERGE_METHOD'" >&2
exit 1
;;
esac
CMD="tea pr merge $PR_NUMBER --style squash"
# Delete branch after merge if requested
if [[ "$DELETE_BRANCH" == true ]]; then

View File

@@ -46,7 +46,7 @@ Continuous loop:
~/.config/mosaic/bin/mosaic-orchestrator-run --poll-sec 10
```
Sync from `docs/tasks.md` to queue:
Sync from `docs/TASKS.md` to queue:
```bash
~/.config/mosaic/bin/mosaic-orchestrator-sync-tasks --apply

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Sync docs/tasks.md rows into .mosaic/orchestrator/tasks.json."""
"""Sync docs/TASKS.md rows into .mosaic/orchestrator/tasks.json."""
from __future__ import annotations
@@ -89,7 +89,12 @@ def parse_depends(raw: str) -> list[str]:
return [x.strip() for x in raw.split(",") if x.strip()]
def build_task(row: dict[str, str], existing: dict[str, Any], runtime_default: str) -> dict[str, Any]:
def build_task(
row: dict[str, str],
existing: dict[str, Any],
runtime_default: str,
source_path: str,
) -> dict[str, Any]:
task_id = row.get("id", "").strip()
description = row.get("description", "").strip()
issue = row.get("issue", "").strip()
@@ -109,7 +114,7 @@ def build_task(row: dict[str, str], existing: dict[str, Any], runtime_default: s
metadata = dict(task.get("metadata") or {})
metadata.update(
{
"source": "docs/tasks.md",
"source": source_path,
"issue": issue,
"repo": repo,
"branch": branch,
@@ -120,26 +125,35 @@ def build_task(row: dict[str, str], existing: dict[str, Any], runtime_default: s
def main() -> int:
parser = argparse.ArgumentParser(description="Sync docs/tasks.md into .mosaic/orchestrator/tasks.json")
parser = argparse.ArgumentParser(description="Sync docs/TASKS.md into .mosaic/orchestrator/tasks.json")
parser.add_argument("--repo", default=os.getcwd(), help="Repository root (default: cwd)")
parser.add_argument("--docs", default="docs/tasks.md", help="Path to tasks markdown (repo-relative)")
parser.add_argument("--docs", default="docs/TASKS.md", help="Path to tasks markdown (repo-relative)")
parser.add_argument(
"--tasks-json",
default=".mosaic/orchestrator/tasks.json",
help="Path to orchestrator tasks JSON (repo-relative)",
)
parser.add_argument("--keep-unlisted", action="store_true", help="Retain tasks already in JSON but missing from docs/tasks.md")
parser.add_argument("--keep-unlisted", action="store_true", help="Retain tasks already in JSON but missing from docs/TASKS.md")
parser.add_argument("--apply", action="store_true", help="Write changes (default is dry-run)")
args = parser.parse_args()
repo = pathlib.Path(args.repo).resolve()
docs_path = (repo / args.docs).resolve()
# Backward compatibility: fall back to legacy lowercase path when default path is absent.
if args.docs == "docs/TASKS.md" and not docs_path.exists():
legacy_docs_path = (repo / "docs/tasks.md").resolve()
if legacy_docs_path.exists():
docs_path = legacy_docs_path
tasks_path = (repo / args.tasks_json).resolve()
config_path = repo / ".mosaic" / "orchestrator" / "config.json"
config = load_json(config_path, {})
runtime_default = str(config.get("worker", {}).get("runtime") or "codex")
rows = parse_tasks_markdown(docs_path)
try:
source_path = str(docs_path.relative_to(repo))
except ValueError:
source_path = str(docs_path)
existing_payload = load_json(tasks_path, {"tasks": []})
existing_tasks = existing_payload.get("tasks", [])
if not isinstance(existing_tasks, list):
@@ -153,7 +167,14 @@ def main() -> int:
if not task_id:
continue
seen.add(task_id)
out_tasks.append(build_task(row, existing_by_id.get(task_id, {}), runtime_default))
out_tasks.append(
build_task(
row,
existing_by_id.get(task_id, {}),
runtime_default,
source_path,
)
)
if args.keep_unlisted:
for task in existing_tasks: