chore: sync local Mosaic changes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
247
rails/git/ci-queue-wait.ps1
Normal 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
307
rails/git/ci-queue-wait.sh
Executable 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
273
rails/git/pr-ci-wait.sh
Executable 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
47
rails/git/pr-merge.ps1
Normal file → Executable 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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user