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

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