From a54e23828215966ef7db750427c18092961923be Mon Sep 17 00:00:00 2001 From: Agent Date: Tue, 31 Mar 2026 21:16:01 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20feat:=20lib/vault.sh=20=E2=80=94=20h?= =?UTF-8?q?elper=20for=20agents=20to=20create=20vault=20PRs=20on=20ops=20r?= =?UTF-8?q?epo=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/AGENTS.md | 1 + lib/pr-lifecycle.sh | 6 +- lib/vault.sh | 220 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 lib/vault.sh diff --git a/lib/AGENTS.md b/lib/AGENTS.md index fc8ffd0..de31d2e 100644 --- a/lib/AGENTS.md +++ b/lib/AGENTS.md @@ -22,3 +22,4 @@ sourced as needed. | `lib/pr-lifecycle.sh` | Reusable PR lifecycle library: `pr_create()`, `pr_find_by_branch()`, `pr_poll_ci()`, `pr_poll_review()`, `pr_merge()`, `pr_is_merged()`, `pr_walk_to_merge()`, `build_phase_protocol_prompt()`. Requires `lib/ci-helpers.sh`. | dev-agent.sh (future) | | `lib/issue-lifecycle.sh` | Reusable issue lifecycle library: `issue_claim()` (add in-progress, remove backlog), `issue_release()` (remove in-progress, add backlog), `issue_block()` (post diagnostic comment with secret redaction, add blocked label), `issue_close()`, `issue_check_deps()` (parse deps, check transitive closure; sets `_ISSUE_BLOCKED_BY`, `_ISSUE_SUGGESTION`), `issue_suggest_next()` (find next unblocked backlog issue; sets `_ISSUE_NEXT`), `issue_post_refusal()` (structured refusal comment with dedup). Label IDs cached in globals on first lookup. Sources `lib/secret-scan.sh`. | dev-agent.sh (future) | | `lib/agent-session.sh` | Shared tmux + Claude session helpers: `create_agent_session()`, `inject_formula()`, `agent_wait_for_claude_ready()`, `agent_inject_into_session()`, `agent_kill_session()`, `monitor_phase_loop()`, `read_phase()`, `write_compact_context()`. `create_agent_session(session, workdir, [phase_file])` optionally installs a PostToolUse hook (matcher `Bash\|Write`) that detects phase file writes in real-time — when Claude writes to the phase file, the hook writes a marker so `monitor_phase_loop` reacts on the next poll instead of waiting for mtime changes. Also installs a StopFailure hook (matcher `rate_limit\|server_error\|authentication_failed\|billing_error`) that writes `PHASE:failed` with an `api_error` reason to the phase file and touches the phase-changed marker, so the orchestrator discovers API errors within one poll cycle instead of waiting for idle timeout. Also installs a SessionStart hook (matcher `compact`) that re-injects phase protocol instructions after context compaction — callers write the context file via `write_compact_context(phase_file, content)`, and the hook (`on-compact-reinject.sh`) outputs the file content to stdout so Claude retains critical instructions. When `phase_file` is set, passes it to the idle stop hook (`on-idle-stop.sh`) so the hook can **nudge Claude** (up to 2 times) if Claude returns to the prompt without writing to the phase file — the hook injects a tmux reminder asking Claude to signal PHASE:done or PHASE:awaiting_ci. The PreToolUse guard hook (`on-pretooluse-guard.sh`) receives the session name as a third argument — formula agents (`gardener-*`, `planner-*`, `predictor-*`, `supervisor-*`) are identified this way and allowed to access `FACTORY_ROOT` from worktrees (they need env.sh, AGENTS.md, formulas/, lib/). **OAuth flock**: when `DISINTO_CONTAINER=1`, Claude CLI is wrapped in `flock -w 300 ~/.claude/session.lock` to queue concurrent token refresh attempts and prevent rotation races across agents sharing the same credentials. `monitor_phase_loop` sets `_MONITOR_LOOP_EXIT` to one of: `done`, `idle_timeout`, `idle_prompt` (Claude returned to `>` for 3 consecutive polls without writing any phase — callback invoked with `PHASE:failed`, session already dead), `crashed`, or `PHASE:escalate` / other `PHASE:*` string. **Unified escalation**: `PHASE:escalate` is the signal that a session needs human input (renamed from `PHASE:needs_human`). **Callers must handle `idle_prompt`** in both their callback and their post-loop exit handler — see [`docs/PHASE-PROTOCOL.md` idle_prompt](docs/PHASE-PROTOCOL.md#idle_prompt-exit-reason) for the full contract. | dev-agent.sh | +| `lib/vault.sh` | **Vault PR helper** — create vault action PRs on ops repo via Forgejo API (works from containers without SSH). `vault_request ` validates TOML (using `validate_vault_action` from `vault/vault-env.sh`), creates branch `vault/`, writes `vault/actions/.toml`, creates PR targeting `main` with title `vault: ` and body from context field, returns PR number. Idempotent: if PR exists, returns existing number. Requires `FORGE_TOKEN`, `FORGE_URL`, `FORGE_REPO`, `FORGE_OPS_REPO`. Uses agent's own token (not shared) so approval workflow respects individual identities. | dev-agent (vault actions), future vault dispatcher | diff --git a/lib/pr-lifecycle.sh b/lib/pr-lifecycle.sh index ad6f0de..94cbac9 100644 --- a/lib/pr-lifecycle.sh +++ b/lib/pr-lifecycle.sh @@ -110,15 +110,17 @@ pr_create() { # --------------------------------------------------------------------------- # pr_find_by_branch — Find an open PR by head branch name. -# Args: branch +# Args: branch [api_url] # Stdout: PR number # Returns: 0=found, 1=not found +# api_url defaults to FORGE_API if not provided # --------------------------------------------------------------------------- pr_find_by_branch() { local branch="$1" + local api_url="${2:-${FORGE_API}}" local pr_num pr_num=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${FORGE_API}/pulls?state=open&limit=20" | \ + "${api_url}/pulls?state=open&limit=20" | \ jq -r --arg b "$branch" '.[] | select(.head.ref == $b) | .number' \ | head -1) || true if [ -n "$pr_num" ]; then diff --git a/lib/vault.sh b/lib/vault.sh new file mode 100644 index 0000000..584db17 --- /dev/null +++ b/lib/vault.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# vault.sh — Helper for agents to create vault PRs on ops repo +# +# Source after lib/env.sh: +# source "$(dirname "$0")/../lib/env.sh" +# source "$(dirname "$0")/lib/vault.sh" +# +# Required globals: FORGE_TOKEN, FORGE_URL, FORGE_REPO, FORGE_OPS_REPO +# Optional: OPS_REPO_ROOT (local path for ops repo) +# +# Functions: +# vault_request — Create vault PR, return PR number +# +# The function: +# 1. Validates TOML content using validate_vault_action() from vault/vault-env.sh +# 2. Creates a branch on the ops repo: vault/ +# 3. Writes TOML to vault/actions/.toml on that branch +# 4. Creates PR targeting main with title "vault: " +# 5. Body includes context field from TOML +# 6. Returns PR number (existing or newly created) +# +# Idempotent: if PR for same action-id exists, returns its number +# +# Uses Forgejo REST API (not git push) — works from containers without SSH + +set -euo pipefail + +# Internal log helper +_vault_log() { + if declare -f log >/dev/null 2>&1; then + log "vault: $*" + else + printf '[%s] vault: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >&2 + fi +} + +# Get ops repo API URL (encodes hyphens for Forgejo API) +_vault_ops_api() { + local ops_repo_encoded + ops_repo_encoded=$(printf '%s' "$FORGE_OPS_REPO" | sed 's/-/%2D/g') + printf '%s' "${FORGE_URL}/api/v1/repos/${ops_repo_encoded}" +} + +# ----------------------------------------------------------------------------- +# vault_request — Create a vault PR or return existing one +# Args: action_id toml_content +# Stdout: PR number +# Returns: 0=success, 1=validation failed, 2=API error +# ----------------------------------------------------------------------------- +vault_request() { + local action_id="$1" + local toml_content="$2" + + if [ -z "$action_id" ]; then + echo "ERROR: action_id is required" >&2 + return 1 + fi + + if [ -z "$toml_content" ]; then + echo "ERROR: toml_content is required" >&2 + return 1 + fi + + # Check if PR already exists for this action + local existing_pr + existing_pr=$(pr_find_by_branch "vault/${action_id}" "$(_vault_ops_api)") || true + if [ -n "$existing_pr" ]; then + _vault_log "PR already exists for action $action_id: #${existing_pr}" + printf '%s' "$existing_pr" + return 0 + fi + + # Validate TOML content + local tmp_toml + tmp_toml=$(mktemp /tmp/vault-XXXXXX.toml) + trap 'rm -f "$tmp_toml"' RETURN + + printf '%s' "$toml_content" > "$tmp_toml" + + # Source vault-env.sh for validate_vault_action + local vault_env="${FACTORY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/vault/vault-env.sh" + if [ ! -f "$vault_env" ]; then + echo "ERROR: vault-env.sh not found at $vault_env" >&2 + return 1 + fi + + # Source it to get validate_vault_action + # Note: vault-env.sh sources env.sh which sets up log() and other helpers + if ! source "$vault_env"; then + echo "ERROR: failed to source vault-env.sh" >&2 + return 1 + fi + + # Run validation + if ! validate_vault_action "$tmp_toml"; then + echo "ERROR: TOML validation failed" >&2 + return 1 + fi + + # Extract values for PR creation + local pr_title pr_body + pr_title="vault: ${action_id}" + pr_body="Vault action: ${action_id} + +Context: ${VAULT_ACTION_CONTEXT:-No context provided} + +Formula: ${VAULT_ACTION_FORMULA:-} +Secrets: ${VAULT_ACTION_SECRETS:-} + +--- +This vault action has been created by an agent and requires admin approval +before execution. See the TOML file for details." + + # Get ops repo API URL + local ops_api + ops_api="$(_vault_ops_api)" + + # Create branch + local branch="vault/${action_id}" + local branch_exists + + branch_exists=$(curl -sf -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: token ${FORGE_TOKEN}" \ + "${ops_api}/git/branches/${branch}" 2>/dev/null || echo "0") + + if [ "$branch_exists" != "200" ]; then + # Branch doesn't exist, create it from main + _vault_log "Creating branch ${branch} on ops repo" + + # Get the commit SHA of main branch + local main_sha + main_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ + "${ops_api}/git/branches/${PRIMARY_BRANCH:-main}" 2>/dev/null | \ + jq -r '.commit.id // empty' || true) + + if [ -z "$main_sha" ]; then + # Fallback: get from refs + main_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ + "${ops_api}/git/refs/heads/${PRIMARY_BRANCH:-main}" 2>/dev/null | \ + jq -r '.object.sha // empty' || true) + fi + + if [ -z "$main_sha" ]; then + echo "ERROR: could not get main branch SHA" >&2 + return 1 + fi + + # Create the branch + if ! curl -sf -X POST \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${ops_api}/git/branches" \ + -d "{\"ref\":\"${branch}\",\"sha\":\"${main_sha}\"}" >/dev/null 2>&1; then + echo "ERROR: failed to create branch ${branch}" >&2 + return 1 + fi + else + _vault_log "Branch ${branch} already exists" + fi + + # Write TOML file to branch via API + local file_path="vault/actions/${action_id}.toml" + _vault_log "Writing ${file_path} to branch ${branch}" + + # Encode TOML content as base64 + local encoded_content + encoded_content=$(printf '%s' "$toml_content" | base64 -w 0) + + # Upload file using Forgejo content API + if ! curl -sf -X PUT \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${ops_api}/contents/${file_path}" \ + -d "{\"message\":\"vault: add ${action_id}\",\"branch\":\"${branch}\",\"content\":\"${encoded_content}\",\"committer\":{\"name\":\"vault-bot\",\"email\":\"vault-bot@${FORGE_REPO}\"},\"overwrite\":true}" >/dev/null 2>&1; then + echo "ERROR: failed to write ${file_path} to branch ${branch}" >&2 + return 1 + fi + + # Create PR + _vault_log "Creating PR for ${branch}" + + local pr_num + pr_num=$(pr_create "$branch" "$pr_title" "$pr_body") || { + echo "ERROR: failed to create PR" >&2 + return 1 + } + + # Add labels to PR (vault, pending-approval) + _vault_log "PR #${pr_num} created, adding labels" + + # Get label IDs + local vault_label_id pending_label_id + vault_label_id=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ + "${ops_api}/labels" 2>/dev/null | \ + jq -r --arg n "vault" '.[] | select(.name == $n) | .id // empty' || true) + + pending_label_id=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ + "${ops_api}/labels" 2>/dev/null | \ + jq -r --arg n "pending-approval" '.[] | select(.name == $n) | .id // empty' || true) + + # Add labels if they exist + if [ -n "$vault_label_id" ]; then + curl -sf -X POST \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${ops_api}/issues/${pr_num}/labels" \ + -d "[{\"id\":${vault_label_id}}]" >/dev/null 2>&1 || true + fi + + if [ -n "$pending_label_id" ]; then + curl -sf -X POST \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${ops_api}/issues/${pr_num}/labels" \ + -d "[{\"id\":${pending_label_id}}]" >/dev/null 2>&1 || true + fi + + printf '%s' "$pr_num" + return 0 +} From 657b8aff363637abb95eb7abaa5bcdf10dd1196f Mon Sep 17 00:00:00 2001 From: Agent Date: Tue, 31 Mar 2026 21:16:01 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20feat:=20lib/vault.sh=20=E2=80=94=20h?= =?UTF-8?q?elper=20for=20agents=20to=20create=20vault=20PRs=20on=20ops=20r?= =?UTF-8?q?epo=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/AGENTS.md | 1 + lib/pr-lifecycle.sh | 14 ++- lib/vault.sh | 222 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 lib/vault.sh diff --git a/lib/AGENTS.md b/lib/AGENTS.md index fc8ffd0..a01e9ca 100644 --- a/lib/AGENTS.md +++ b/lib/AGENTS.md @@ -22,3 +22,4 @@ sourced as needed. | `lib/pr-lifecycle.sh` | Reusable PR lifecycle library: `pr_create()`, `pr_find_by_branch()`, `pr_poll_ci()`, `pr_poll_review()`, `pr_merge()`, `pr_is_merged()`, `pr_walk_to_merge()`, `build_phase_protocol_prompt()`. Requires `lib/ci-helpers.sh`. | dev-agent.sh (future) | | `lib/issue-lifecycle.sh` | Reusable issue lifecycle library: `issue_claim()` (add in-progress, remove backlog), `issue_release()` (remove in-progress, add backlog), `issue_block()` (post diagnostic comment with secret redaction, add blocked label), `issue_close()`, `issue_check_deps()` (parse deps, check transitive closure; sets `_ISSUE_BLOCKED_BY`, `_ISSUE_SUGGESTION`), `issue_suggest_next()` (find next unblocked backlog issue; sets `_ISSUE_NEXT`), `issue_post_refusal()` (structured refusal comment with dedup). Label IDs cached in globals on first lookup. Sources `lib/secret-scan.sh`. | dev-agent.sh (future) | | `lib/agent-session.sh` | Shared tmux + Claude session helpers: `create_agent_session()`, `inject_formula()`, `agent_wait_for_claude_ready()`, `agent_inject_into_session()`, `agent_kill_session()`, `monitor_phase_loop()`, `read_phase()`, `write_compact_context()`. `create_agent_session(session, workdir, [phase_file])` optionally installs a PostToolUse hook (matcher `Bash\|Write`) that detects phase file writes in real-time — when Claude writes to the phase file, the hook writes a marker so `monitor_phase_loop` reacts on the next poll instead of waiting for mtime changes. Also installs a StopFailure hook (matcher `rate_limit\|server_error\|authentication_failed\|billing_error`) that writes `PHASE:failed` with an `api_error` reason to the phase file and touches the phase-changed marker, so the orchestrator discovers API errors within one poll cycle instead of waiting for idle timeout. Also installs a SessionStart hook (matcher `compact`) that re-injects phase protocol instructions after context compaction — callers write the context file via `write_compact_context(phase_file, content)`, and the hook (`on-compact-reinject.sh`) outputs the file content to stdout so Claude retains critical instructions. When `phase_file` is set, passes it to the idle stop hook (`on-idle-stop.sh`) so the hook can **nudge Claude** (up to 2 times) if Claude returns to the prompt without writing to the phase file — the hook injects a tmux reminder asking Claude to signal PHASE:done or PHASE:awaiting_ci. The PreToolUse guard hook (`on-pretooluse-guard.sh`) receives the session name as a third argument — formula agents (`gardener-*`, `planner-*`, `predictor-*`, `supervisor-*`) are identified this way and allowed to access `FACTORY_ROOT` from worktrees (they need env.sh, AGENTS.md, formulas/, lib/). **OAuth flock**: when `DISINTO_CONTAINER=1`, Claude CLI is wrapped in `flock -w 300 ~/.claude/session.lock` to queue concurrent token refresh attempts and prevent rotation races across agents sharing the same credentials. `monitor_phase_loop` sets `_MONITOR_LOOP_EXIT` to one of: `done`, `idle_timeout`, `idle_prompt` (Claude returned to `>` for 3 consecutive polls without writing any phase — callback invoked with `PHASE:failed`, session already dead), `crashed`, or `PHASE:escalate` / other `PHASE:*` string. **Unified escalation**: `PHASE:escalate` is the signal that a session needs human input (renamed from `PHASE:needs_human`). **Callers must handle `idle_prompt`** in both their callback and their post-loop exit handler — see [`docs/PHASE-PROTOCOL.md` idle_prompt](docs/PHASE-PROTOCOL.md#idle_prompt-exit-reason) for the full contract. | dev-agent.sh | +| `lib/vault.sh` | **Vault PR helper** — create vault action PRs on ops repo via Forgejo API (works from containers without SSH). `vault_request ` validates TOML (using `validate_vault_action` from `vault/vault-env.sh`), creates branch `vault/`, writes `vault/actions/.toml`, creates PR targeting `main` with title `vault: ` and body from context field, returns PR number. Idempotent: if PR exists, returns existing number. Requires `FORGE_TOKEN`, `FORGE_URL`, `FORGE_REPO`, `FORGE_OPS_REPO`. Uses the calling agent's own token (saves/restores `FORGE_TOKEN` around sourcing `vault-env.sh`), so approval workflow respects individual agent identities. | dev-agent (vault actions), future vault dispatcher | diff --git a/lib/pr-lifecycle.sh b/lib/pr-lifecycle.sh index ad6f0de..0ea5125 100644 --- a/lib/pr-lifecycle.sh +++ b/lib/pr-lifecycle.sh @@ -61,13 +61,15 @@ _prl_log() { # --------------------------------------------------------------------------- # pr_create — Create a PR via forge API. -# Args: branch title body [base_branch] +# Args: branch title body [base_branch] [api_url] # Stdout: PR number # Returns: 0=created (or found existing), 1=failed +# api_url defaults to FORGE_API if not provided # --------------------------------------------------------------------------- pr_create() { local branch="$1" title="$2" body="$3" local base="${4:-${PRIMARY_BRANCH:-main}}" + local api_url="${5:-${FORGE_API}}" local tmpfile resp http_code resp_body pr_num tmpfile=$(mktemp /tmp/prl-create-XXXXXX.json) @@ -77,7 +79,7 @@ pr_create() { resp=$(curl -s -w "\n%{http_code}" -X POST \ -H "Authorization: token ${FORGE_TOKEN}" \ -H "Content-Type: application/json" \ - "${FORGE_API}/pulls" \ + "${api_url}/pulls" \ --data-binary @"$tmpfile") || true rm -f "$tmpfile" @@ -92,7 +94,7 @@ pr_create() { return 0 ;; 409) - pr_num=$(pr_find_by_branch "$branch") || true + pr_num=$(pr_find_by_branch "$branch" "$api_url") || true if [ -n "$pr_num" ]; then _prl_log "PR already exists: #${pr_num}" printf '%s' "$pr_num" @@ -110,15 +112,17 @@ pr_create() { # --------------------------------------------------------------------------- # pr_find_by_branch — Find an open PR by head branch name. -# Args: branch +# Args: branch [api_url] # Stdout: PR number # Returns: 0=found, 1=not found +# api_url defaults to FORGE_API if not provided # --------------------------------------------------------------------------- pr_find_by_branch() { local branch="$1" + local api_url="${2:-${FORGE_API}}" local pr_num pr_num=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${FORGE_API}/pulls?state=open&limit=20" | \ + "${api_url}/pulls?state=open&limit=20" | \ jq -r --arg b "$branch" '.[] | select(.head.ref == $b) | .number' \ | head -1) || true if [ -n "$pr_num" ]; then diff --git a/lib/vault.sh b/lib/vault.sh new file mode 100644 index 0000000..8ca4f38 --- /dev/null +++ b/lib/vault.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# vault.sh — Helper for agents to create vault PRs on ops repo +# +# Source after lib/env.sh: +# source "$(dirname "$0")/../lib/env.sh" +# source "$(dirname "$0")/lib/vault.sh" +# +# Required globals: FORGE_TOKEN, FORGE_URL, FORGE_REPO, FORGE_OPS_REPO +# Optional: OPS_REPO_ROOT (local path for ops repo) +# +# Functions: +# vault_request — Create vault PR, return PR number +# +# The function: +# 1. Validates TOML content using validate_vault_action() from vault/vault-env.sh +# 2. Creates a branch on the ops repo: vault/ +# 3. Writes TOML to vault/actions/.toml on that branch +# 4. Creates PR targeting main with title "vault: " +# 5. Body includes context field from TOML +# 6. Returns PR number (existing or newly created) +# +# Idempotent: if PR for same action-id exists, returns its number +# +# Uses Forgejo REST API (not git push) — works from containers without SSH + +set -euo pipefail + +# Internal log helper +_vault_log() { + if declare -f log >/dev/null 2>&1; then + log "vault: $*" + else + printf '[%s] vault: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >&2 + fi +} + +# Get ops repo API URL +_vault_ops_api() { + printf '%s' "${FORGE_URL}/api/v1/repos/${FORGE_OPS_REPO}" +} + +# ----------------------------------------------------------------------------- +# vault_request — Create a vault PR or return existing one +# Args: action_id toml_content +# Stdout: PR number +# Returns: 0=success, 1=validation failed, 2=API error +# ----------------------------------------------------------------------------- +vault_request() { + local action_id="$1" + local toml_content="$2" + + if [ -z "$action_id" ]; then + echo "ERROR: action_id is required" >&2 + return 1 + fi + + if [ -z "$toml_content" ]; then + echo "ERROR: toml_content is required" >&2 + return 1 + fi + + # Check if PR already exists for this action + local existing_pr + existing_pr=$(pr_find_by_branch "vault/${action_id}" "$(_vault_ops_api)") || true + if [ -n "$existing_pr" ]; then + _vault_log "PR already exists for action $action_id: #${existing_pr}" + printf '%s' "$existing_pr" + return 0 + fi + + # Validate TOML content + local tmp_toml + tmp_toml=$(mktemp /tmp/vault-XXXXXX.toml) + trap 'rm -f "$tmp_toml"' RETURN + + printf '%s' "$toml_content" > "$tmp_toml" + + # Source vault-env.sh for validate_vault_action + local vault_env="${FACTORY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/vault/vault-env.sh" + if [ ! -f "$vault_env" ]; then + echo "ERROR: vault-env.sh not found at $vault_env" >&2 + return 1 + fi + + # Save caller's FORGE_TOKEN, source vault-env.sh for validate_vault_action, + # then restore caller's token so PR creation uses agent's identity (not vault-bot) + local _saved_forge_token="${FORGE_TOKEN:-}" + if ! source "$vault_env"; then + FORGE_TOKEN="${_saved_forge_token:-}" + echo "ERROR: failed to source vault-env.sh" >&2 + return 1 + fi + # Restore caller's FORGE_TOKEN after validation + FORGE_TOKEN="${_saved_forge_token:-}" + + # Run validation + if ! validate_vault_action "$tmp_toml"; then + echo "ERROR: TOML validation failed" >&2 + return 1 + fi + + # Extract values for PR creation + local pr_title pr_body + pr_title="vault: ${action_id}" + pr_body="Vault action: ${action_id} + +Context: ${VAULT_ACTION_CONTEXT:-No context provided} + +Formula: ${VAULT_ACTION_FORMULA:-} +Secrets: ${VAULT_ACTION_SECRETS:-} + +--- +This vault action has been created by an agent and requires admin approval +before execution. See the TOML file for details." + + # Get ops repo API URL + local ops_api + ops_api="$(_vault_ops_api)" + + # Create branch + local branch="vault/${action_id}" + local branch_exists + + branch_exists=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: token ${FORGE_TOKEN}" \ + "${ops_api}/git/branches/${branch}" 2>/dev/null || echo "0") + + if [ "$branch_exists" != "200" ]; then + # Branch doesn't exist, create it from main + _vault_log "Creating branch ${branch} on ops repo" + + # Get the commit SHA of main branch + local main_sha + main_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ + "${ops_api}/git/branches/${PRIMARY_BRANCH:-main}" 2>/dev/null | \ + jq -r '.commit.id // empty' || true) + + if [ -z "$main_sha" ]; then + # Fallback: get from refs + main_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ + "${ops_api}/git/refs/heads/${PRIMARY_BRANCH:-main}" 2>/dev/null | \ + jq -r '.object.sha // empty' || true) + fi + + if [ -z "$main_sha" ]; then + echo "ERROR: could not get main branch SHA" >&2 + return 1 + fi + + # Create the branch + if ! curl -sf -X POST \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${ops_api}/git/branches" \ + -d "{\"ref\":\"${branch}\",\"sha\":\"${main_sha}\"}" >/dev/null 2>&1; then + echo "ERROR: failed to create branch ${branch}" >&2 + return 1 + fi + else + _vault_log "Branch ${branch} already exists" + fi + + # Write TOML file to branch via API + local file_path="vault/actions/${action_id}.toml" + _vault_log "Writing ${file_path} to branch ${branch}" + + # Encode TOML content as base64 + local encoded_content + encoded_content=$(printf '%s' "$toml_content" | base64 -w 0) + + # Upload file using Forgejo content API + if ! curl -sf -X PUT \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${ops_api}/contents/${file_path}" \ + -d "{\"message\":\"vault: add ${action_id}\",\"branch\":\"${branch}\",\"content\":\"${encoded_content}\",\"committer\":{\"name\":\"vault-bot\",\"email\":\"vault-bot@${FORGE_REPO}\"},\"overwrite\":true}" >/dev/null 2>&1; then + echo "ERROR: failed to write ${file_path} to branch ${branch}" >&2 + return 1 + fi + + # Create PR + _vault_log "Creating PR for ${branch}" + + local pr_num + pr_num=$(pr_create "$branch" "$pr_title" "$pr_body" "$PRIMARY_BRANCH" "$ops_api") || { + echo "ERROR: failed to create PR" >&2 + return 1 + } + + # Add labels to PR (vault, pending-approval) + _vault_log "PR #${pr_num} created, adding labels" + + # Get label IDs + local vault_label_id pending_label_id + vault_label_id=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ + "${ops_api}/labels" 2>/dev/null | \ + jq -r --arg n "vault" '.[] | select(.name == $n) | .id // empty' || true) + + pending_label_id=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ + "${ops_api}/labels" 2>/dev/null | \ + jq -r --arg n "pending-approval" '.[] | select(.name == $n) | .id // empty' || true) + + # Add labels if they exist + if [ -n "$vault_label_id" ]; then + curl -sf -X POST \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${ops_api}/issues/${pr_num}/labels" \ + -d "[{\"id\":${vault_label_id}}]" >/dev/null 2>&1 || true + fi + + if [ -n "$pending_label_id" ]; then + curl -sf -X POST \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${ops_api}/issues/${pr_num}/labels" \ + -d "[{\"id\":${pending_label_id}}]" >/dev/null 2>&1 || true + fi + + printf '%s' "$pr_num" + return 0 +}