diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md index b209b42..1d55db3 100644 --- a/BOOTSTRAP.md +++ b/BOOTSTRAP.md @@ -139,6 +139,51 @@ Verify no root-owned files exist in agent temp directories: find /tmp/dev-* /tmp/harb-* /tmp/review-* -not -user debian 2>/dev/null ``` +## 4b. Woodpecker CI + Forgejo Integration + +`disinto init` automatically configures Woodpecker to use the local Forgejo instance as its forge backend if `WOODPECKER_SERVER` is set in `.env`. This includes: + +1. Creating an OAuth2 application on Forgejo for Woodpecker +2. Writing `WOODPECKER_FORGEJO_*` env vars to `.env` +3. Activating the repo in Woodpecker + +### Manual setup (if Woodpecker runs outside of `disinto init`) + +If you manage Woodpecker separately, configure these env vars in its server config: + +```bash +WOODPECKER_FORGEJO=true +WOODPECKER_FORGEJO_URL=http://localhost:3000 +WOODPECKER_FORGEJO_CLIENT= +WOODPECKER_FORGEJO_SECRET= +``` + +To create the OAuth2 app on Forgejo: + +```bash +# Create OAuth2 application (redirect URI = Woodpecker authorize endpoint) +curl -X POST \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "http://localhost:3000/api/v1/user/applications/oauth2" \ + -d '{"name":"woodpecker-ci","redirect_uris":["http://localhost:8000/authorize"],"confidential_client":true}' +``` + +The response contains `client_id` and `client_secret` for `WOODPECKER_FORGEJO_CLIENT` / `WOODPECKER_FORGEJO_SECRET`. + +To activate the repo in Woodpecker: + +```bash +woodpecker-cli repo add / +# Or via API: +curl -X POST \ + -H "Authorization: Bearer ${WOODPECKER_TOKEN}" \ + "http://localhost:8000/api/repos" \ + -d '{"forge_remote_id":"/"}' +``` + +Woodpecker will now trigger pipelines on pushes to Forgejo and push commit status back. Disinto queries Woodpecker directly for CI status (with a forge API fallback), so pipeline results are visible even if Woodpecker's status push to Forgejo is delayed. + ## 5. Prepare the Target Repo ### Required: CI pipeline diff --git a/bin/disinto b/bin/disinto index 259ffcd..c73c8cd 100755 --- a/bin/disinto +++ b/bin/disinto @@ -551,6 +551,129 @@ install_cron() { echo "Cron entries installed" } +# Set up Woodpecker CI to use Forgejo as its forge backend. +# Creates an OAuth2 app on Forgejo for Woodpecker, activates the repo. +setup_woodpecker() { + local forge_url="$1" repo_slug="$2" + local wp_server="${WOODPECKER_SERVER:-}" + + if [ -z "$wp_server" ]; then + echo "Woodpecker: not configured (WOODPECKER_SERVER not set), skipping" + return + fi + + # Check if Woodpecker is reachable + if ! curl -sf --max-time 5 "${wp_server}/api/version" >/dev/null 2>&1; then + echo "Woodpecker: not reachable at ${wp_server}, skipping" + return + fi + + echo "" + echo "── Woodpecker CI setup ────────────────────────────────" + echo "Server: ${wp_server}" + + # Create OAuth2 application on Forgejo for Woodpecker + local oauth2_name="woodpecker-ci" + local redirect_uri="${wp_server}/authorize" + local existing_app client_id client_secret + + # Check if OAuth2 app already exists + existing_app=$(curl -sf \ + -H "Authorization: token ${FORGE_TOKEN}" \ + "${forge_url}/api/v1/user/applications/oauth2" 2>/dev/null \ + | jq -r --arg name "$oauth2_name" '.[] | select(.name == $name) | .client_id // empty' 2>/dev/null) || true + + if [ -n "$existing_app" ]; then + echo "OAuth2: ${oauth2_name} (already exists, client_id=${existing_app})" + client_id="$existing_app" + else + local oauth2_resp + oauth2_resp=$(curl -sf -X POST \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${forge_url}/api/v1/user/applications/oauth2" \ + -d "{\"name\":\"${oauth2_name}\",\"redirect_uris\":[\"${redirect_uri}\"],\"confidential_client\":true}" \ + 2>/dev/null) || oauth2_resp="" + + if [ -z "$oauth2_resp" ]; then + echo "Warning: failed to create OAuth2 app on Forgejo" >&2 + return + fi + + client_id=$(printf '%s' "$oauth2_resp" | jq -r '.client_id // empty') + client_secret=$(printf '%s' "$oauth2_resp" | jq -r '.client_secret // empty') + + if [ -z "$client_id" ]; then + echo "Warning: OAuth2 app creation returned no client_id" >&2 + return + fi + + echo "OAuth2: ${oauth2_name} created (client_id=${client_id})" + fi + + # Store Woodpecker forge config in .env + local env_file="${FACTORY_ROOT}/.env" + local wp_vars=( + "WOODPECKER_FORGEJO=true" + "WOODPECKER_FORGEJO_URL=${forge_url}" + ) + if [ -n "${client_id:-}" ]; then + wp_vars+=("WOODPECKER_FORGEJO_CLIENT=${client_id}") + fi + if [ -n "${client_secret:-}" ]; then + wp_vars+=("WOODPECKER_FORGEJO_SECRET=${client_secret}") + fi + + for var_line in "${wp_vars[@]}"; do + local var_name="${var_line%%=*}" + if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then + sed -i "s|^${var_name}=.*|${var_line}|" "$env_file" + else + printf '%s\n' "$var_line" >> "$env_file" + fi + done + echo "Config: Woodpecker forge vars written to .env" + + # Activate repo in Woodpecker (if not already) + local wp_token="${WOODPECKER_TOKEN:-}" + if [ -z "$wp_token" ]; then + echo "Warning: WOODPECKER_TOKEN not set — cannot activate repo" >&2 + echo " Activate manually: woodpecker-cli repo add ${repo_slug}" >&2 + return + fi + + local wp_repo_id + wp_repo_id=$(curl -sf \ + -H "Authorization: Bearer ${wp_token}" \ + "${wp_server}/api/repos/lookup/${repo_slug}" 2>/dev/null \ + | jq -r '.id // empty' 2>/dev/null) || true + + if [ -n "$wp_repo_id" ] && [ "$wp_repo_id" != "0" ]; then + echo "Repo: ${repo_slug} already active in Woodpecker (id=${wp_repo_id})" + else + local activate_resp + activate_resp=$(curl -sf -X POST \ + -H "Authorization: Bearer ${wp_token}" \ + -H "Content-Type: application/json" \ + "${wp_server}/api/repos" \ + -d "{\"forge_remote_id\":\"${repo_slug}\"}" 2>/dev/null) || activate_resp="" + + wp_repo_id=$(printf '%s' "$activate_resp" | jq -r '.id // empty' 2>/dev/null) || true + + if [ -n "$wp_repo_id" ] && [ "$wp_repo_id" != "0" ]; then + echo "Repo: ${repo_slug} activated in Woodpecker (id=${wp_repo_id})" + else + echo "Warning: could not activate repo in Woodpecker" >&2 + echo " Activate manually: woodpecker-cli repo add ${repo_slug}" >&2 + fi + fi + + # Store repo ID for later TOML generation + if [ -n "$wp_repo_id" ] && [ "$wp_repo_id" != "0" ]; then + _WP_REPO_ID="$wp_repo_id" + fi +} + # ── init command ───────────────────────────────────────────────────────────── disinto_init() { @@ -684,6 +807,26 @@ p.write_text(text) echo "Created: ${toml_path}" fi + # Set up Woodpecker CI to use Forgejo as forge backend + _WP_REPO_ID="" + setup_woodpecker "$forge_url" "$forge_repo" + + # Use detected Woodpecker repo ID if ci_id was not explicitly set + if [ "$ci_id" = "0" ] && [ -n "${_WP_REPO_ID:-}" ]; then + ci_id="$_WP_REPO_ID" + echo "CI ID: ${ci_id} (from Woodpecker)" + # Update TOML if it already exists + if [ "$toml_exists" = true ] && [ -f "$toml_path" ]; then + python3 -c " +import sys, re, pathlib +p = pathlib.Path(sys.argv[1]) +text = p.read_text() +text = re.sub(r'^woodpecker_repo_id\s*=\s*.*$', 'woodpecker_repo_id = ' + sys.argv[2], text, flags=re.MULTILINE) +p.write_text(text) +" "$toml_path" "$ci_id" + fi + fi + # Create labels on remote create_labels "$forge_repo" "$forge_url" diff --git a/dev/dev-poll.sh b/dev/dev-poll.sh index 5b89297..e304612 100755 --- a/dev/dev-poll.sh +++ b/dev/dev-poll.sh @@ -261,8 +261,7 @@ for i in $(seq 0 $(($(echo "$PL_PRS" | jq 'length') - 1))); do fi fi - PL_CI_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${API}/commits/${PL_PR_SHA}/status" | jq -r '.state // "unknown"') || true + PL_CI_STATE=$(ci_commit_status "$PL_PR_SHA") || true # Non-code PRs may have no CI — treat as passed if ! ci_passed "$PL_CI_STATE" && ! ci_required_for_pr "$PL_PR_NUM"; then @@ -397,8 +396,7 @@ if [ "$ORPHAN_COUNT" -gt 0 ]; then if [ -n "$HAS_PR" ]; then PR_SHA=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ "${API}/pulls/${HAS_PR}" | jq -r '.head.sha') || true - CI_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${API}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"') || true + CI_STATE=$(ci_commit_status "$PR_SHA") || true # Non-code PRs (docs, formulas, evidence) may have no CI — treat as passed if ! ci_passed "$CI_STATE" && ! ci_required_for_pr "$HAS_PR"; then @@ -510,8 +508,7 @@ for i in $(seq 0 $(($(echo "$OPEN_PRS" | jq 'length') - 1))); do fi fi - CI_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${API}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"') || true + CI_STATE=$(ci_commit_status "$PR_SHA") || true # Non-code PRs (docs, formulas, evidence) may have no CI — treat as passed if ! ci_passed "$CI_STATE" && ! ci_required_for_pr "$PR_NUM"; then @@ -652,8 +649,7 @@ for i in $(seq 0 $((BACKLOG_COUNT - 1))); do if [ -n "$EXISTING_PR" ]; then PR_SHA=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ "${API}/pulls/${EXISTING_PR}" | jq -r '.head.sha') || true - CI_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${API}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"') || true + CI_STATE=$(ci_commit_status "$PR_SHA") || true # Non-code PRs (docs, formulas, evidence) may have no CI — treat as passed if ! ci_passed "$CI_STATE" && ! ci_required_for_pr "$EXISTING_PR"; then diff --git a/dev/phase-handler.sh b/dev/phase-handler.sh index 04614fe..92d1833 100644 --- a/dev/phase-handler.sh +++ b/dev/phase-handler.sh @@ -346,8 +346,7 @@ Write PHASE:awaiting_review to the phase file, then stop and wait for review fee # Re-fetch HEAD — Claude may have pushed new commits since loop started CI_CURRENT_SHA=$(git -C "${WORKTREE}" rev-parse HEAD 2>/dev/null || echo "$CI_CURRENT_SHA") - CI_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${API}/commits/${CI_CURRENT_SHA}/status" | jq -r '.state // "unknown"') + CI_STATE=$(ci_commit_status "$CI_CURRENT_SHA") if [ "$CI_STATE" = "success" ] || [ "$CI_STATE" = "failure" ] || [ "$CI_STATE" = "error" ]; then CI_DONE=true [ "$CI_STATE" = "success" ] && CI_FIX_COUNT=0 @@ -370,9 +369,7 @@ Write PHASE:awaiting_review to the phase file, then stop and wait for review fee echo \"PHASE:awaiting_review\" > \"${PHASE_FILE}\"" else # Fetch CI error details - PIPELINE_NUM=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${API}/commits/${CI_CURRENT_SHA}/status" | \ - jq -r '.statuses[0].target_url // ""' | grep -oP 'pipeline/\K[0-9]+' | head -1 || true) + PIPELINE_NUM=$(ci_pipeline_number "$CI_CURRENT_SHA") FAILED_STEP="" FAILED_EXIT="" diff --git a/gardener/gardener-run.sh b/gardener/gardener-run.sh index 48a3d52..1a86364 100755 --- a/gardener/gardener-run.sh +++ b/gardener/gardener-run.sh @@ -429,8 +429,7 @@ Write PHASE:awaiting_review to the phase file, then stop and wait: head_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ "${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.head.sha // empty') || true - ci_state=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${FORGE_API}/commits/${head_sha}/status" | jq -r '.state // "unknown"') || ci_state="unknown" + ci_state=$(ci_commit_status "$head_sha") || ci_state="unknown" case "$ci_state" in success|failure|error) ci_done=true; break ;; @@ -463,9 +462,7 @@ Write PHASE:awaiting_review to the phase file, then stop and wait: # Get error details local pipeline_num ci_error_log - pipeline_num=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${FORGE_API}/commits/${head_sha}/status" | \ - jq -r '.statuses[0].target_url // ""' | grep -oP 'pipeline/\K[0-9]+' | head -1 || true) + pipeline_num=$(ci_pipeline_number "$head_sha") ci_error_log="" if [ -n "$pipeline_num" ]; then diff --git a/lib/ci-helpers.sh b/lib/ci-helpers.sh index 91ad194..fda0071 100644 --- a/lib/ci-helpers.sh +++ b/lib/ci-helpers.sh @@ -4,6 +4,7 @@ set -euo pipefail # # Source from any script: source "$(dirname "$0")/../lib/ci-helpers.sh" # ci_passed() requires: WOODPECKER_REPO_ID (from env.sh / project config) +# ci_commit_status() / ci_pipeline_number() require: woodpecker_api(), forge_api() (from env.sh) # classify_pipeline_failure() requires: woodpecker_api() (defined in env.sh) # ensure_blocked_label_id — look up (or create) the "blocked" label, print its ID. @@ -102,6 +103,55 @@ ci_failed() { return 0 } +# ci_commit_status — get CI state for a commit +# Queries Woodpecker API directly, falls back to forge commit status API. +ci_commit_status() { + local sha="$1" + local state="" + + # Primary: ask Woodpecker directly + if [ -n "${WOODPECKER_REPO_ID:-}" ] && [ "${WOODPECKER_REPO_ID}" != "0" ]; then + state=$(woodpecker_api "/repos/${WOODPECKER_REPO_ID}/pipelines" \ + | jq -r --arg sha "$sha" \ + '[.[] | select(.commit == $sha)] | sort_by(.number) | last | .status // empty' \ + 2>/dev/null) || true + # Map Woodpecker status to Gitea/Forgejo status names + case "$state" in + success) echo "success"; return ;; + failure|error|killed) echo "failure"; return ;; + running|pending|blocked) echo "pending"; return ;; + esac + fi + + # Fallback: forge commit status API (works with any Gitea/Forgejo) + forge_api GET "/commits/${sha}/status" 2>/dev/null \ + | jq -r '.state // "unknown"' +} + +# ci_pipeline_number — get Woodpecker pipeline number for a commit +# Queries Woodpecker API directly, falls back to forge status target_url parsing. +ci_pipeline_number() { + local sha="$1" + + # Primary: ask Woodpecker directly + if [ -n "${WOODPECKER_REPO_ID:-}" ] && [ "${WOODPECKER_REPO_ID}" != "0" ]; then + local num + num=$(woodpecker_api "/repos/${WOODPECKER_REPO_ID}/pipelines" \ + | jq -r --arg sha "$sha" \ + '[.[] | select(.commit == $sha)] | sort_by(.number) | last | .number // empty' \ + 2>/dev/null) || true + if [ -n "$num" ]; then + echo "$num" + return + fi + fi + + # Fallback: extract from forge status target_url + forge_api GET "/commits/${sha}/status" 2>/dev/null \ + | jq -r '.statuses[0].target_url // ""' \ + | grep -oP 'pipeline/\K[0-9]+' | head -1 || true +} + # is_infra_step [log_data] # Checks whether a single CI step failure matches infra heuristics. # Returns 0 (infra) with reason on stdout, or 1 (not infra). diff --git a/review/review-poll.sh b/review/review-poll.sh index 5f595d8..4eb217b 100755 --- a/review/review-poll.sh +++ b/review/review-poll.sh @@ -196,8 +196,7 @@ if [ -n "${REVIEW_SESSIONS:-}" ]; then pr_branch=$(printf '%s' "$pr_json" | jq -r '.head.ref // ""') if [ -z "$current_sha" ] || [ "$current_sha" = "$reviewed_sha" ]; then continue; fi - ci_state=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${API_BASE}/commits/${current_sha}/status" | jq -r '.state // "unknown"') + ci_state=$(ci_commit_status "$current_sha") if ! ci_passed "$ci_state"; then if ci_required_for_pr "$pr_num"; then @@ -227,8 +226,7 @@ while IFS= read -r line; do PR_SHA=$(echo "$line" | awk '{print $2}') PR_BRANCH=$(echo "$line" | awk '{print $3}') - CI_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${API_BASE}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"') + CI_STATE=$(ci_commit_status "$PR_SHA") # Skip if CI is running/failed. Allow "success", no CI configured, or non-code PRs if ! ci_passed "$CI_STATE"; then diff --git a/review/review-pr.sh b/review/review-pr.sh index 458aaac..ec26340 100755 --- a/review/review-pr.sh +++ b/review/review-pr.sh @@ -50,8 +50,7 @@ if [ "$PR_STATE" != "open" ]; then cd "${PROJECT_REPO_ROOT}"; git worktree remove "$WORKTREE" --force 2>/dev/null || true rm -rf "$WORKTREE" "$PHASE_FILE" "$OUTPUT_FILE" 2>/dev/null || true; exit 0 fi -CI_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${API}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"') +CI_STATE=$(ci_commit_status "$PR_SHA") CI_NOTE=""; if ! ci_passed "$CI_STATE"; then ci_required_for_pr "$PR_NUMBER" && { log "SKIP: CI=${CI_STATE}"; exit 0; } CI_NOTE=" (not required — non-code PR)"; fi diff --git a/supervisor/supervisor-poll.sh b/supervisor/supervisor-poll.sh index a530d2d..a183694 100755 --- a/supervisor/supervisor-poll.sh +++ b/supervisor/supervisor-poll.sh @@ -415,7 +415,7 @@ check_project() { PR_SHA=$(echo "$PR_JSON" | jq -r '.head.sha // ""') [ -z "$PR_SHA" ] && continue - CI_STATE=$(forge_api GET "/commits/${PR_SHA}/status" 2>/dev/null | jq -r '.state // "unknown"' 2>/dev/null || true) + CI_STATE=$(ci_commit_status "$PR_SHA" 2>/dev/null || true) MERGEABLE=$(echo "$PR_JSON" | jq -r '.mergeable // true') if [ "$MERGEABLE" = "false" ] && ci_passed "$CI_STATE"; then