#!/usr/bin/env bash set -Eeuo pipefail readonly PROGRAM_NAME="tobserver-agent" readonly STATE_VERSION=2 readonly REPOSITORY="oibot/Tobserver" readonly FETCH_URL="https://codeberg.org/oibot/Tobserver.git" readonly PUSH_URL="ssh://git@codeberg.org/oibot/Tobserver.git" readonly CODEBERG_API_URL="https://codeberg.org/api/v1/repos/oibot/Tobserver" readonly CODEBERG_REPO_URL="https://codeberg.org/oibot/Tobserver" readonly BASE_BRANCH="main" readonly BRANCH_PREFIX="agent/" readonly TOPIC_PREFIX="tobserver-agent-" readonly GIT_AUTHOR_NAME="Tobserver Agent" readonly GIT_AUTHOR_EMAIL="oibot@noreply.codeberg.org" readonly MAX_TITLE_BYTES=200 readonly MAX_BODY_BYTES=32768 readonly MAX_REVIEW_BUNDLE_BYTES=262144 readonly API_PAGE_SIZE=50 readonly MAX_API_PAGES=20 STATE_HOME="${XDG_STATE_HOME:-${HOME}/.local/state}/${PROGRAM_NAME}" ACTIVE_FILE="${STATE_HOME}/active" START_WORKSPACE_PATH="" log() { printf '%s\n' "$*" } warn() { printf '%s: %s\n' "$PROGRAM_NAME" "$*" >&2 } die() { warn "$*" exit 1 } preserve_start_failure() { local exit_code=$? trap - ERR warn "Start failed; preserving workspace: ${START_WORKSPACE_PATH}" exit "$exit_code" } usage() { cat <<'EOF' Usage: tobserver-agent start "task description" tobserver-agent inspect PR_NUMBER tobserver-agent revise PR_NUMBER tobserver-agent resume /tmp/tobserver-agent.XXXXXX tobserver-agent status [workspace] tobserver-agent check [workspace] tobserver-agent submit --title "title" --body-file PATH [workspace] tobserver-agent cleanup WORKSPACE inspect reads the public Codeberg API and writes a bounded review bundle to stdout without creating a workspace. revise creates an isolated workspace and stores the same bundle under .git for inspection before editing. The helper is intentionally limited to oibot/Tobserver on Codeberg. It submits only AGit pull requests from local agent/* branches, never pushes main, never uses a Codeberg API token, and never accesses or deploys to the live server. EOF } require_command() { command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1" } ensure_state_home() { mkdir -p "$STATE_HOME" chmod 700 "$STATE_HOME" } canonical_directory() { local directory=$1 [[ -d "$directory" ]] || die "Workspace does not exist: $directory" (cd "$directory" && pwd -P) } state_file_for() { printf '%s/.git/tobserver-agent-state.json\n' "$1" } write_active_workspace() { local workspace=$1 local temporary ensure_state_home temporary="${ACTIVE_FILE}.tmp.$$" printf '%s\n' "$workspace" >"$temporary" chmod 600 "$temporary" mv -f "$temporary" "$ACTIVE_FILE" } clear_active_workspace() { local workspace=$1 local active="" if [[ -f "$ACTIVE_FILE" ]]; then IFS= read -r active <"$ACTIVE_FILE" || true if [[ "$active" == "$workspace" ]]; then rm -f "$ACTIVE_FILE" fi fi } active_workspace() { local workspace="" [[ -f "$ACTIVE_FILE" ]] || return 1 IFS= read -r workspace <"$ACTIVE_FILE" || return 1 [[ -n "$workspace" ]] || return 1 printf '%s\n' "$workspace" } workspace_from_current_directory() { local root="" root=$(git rev-parse --show-toplevel 2>/dev/null) || return 1 [[ -f "$(state_file_for "$root")" ]] || return 1 canonical_directory "$root" } resolve_workspace() { local requested=${1:-} local workspace="" if [[ -n "$requested" ]]; then workspace=$(canonical_directory "$requested") elif workspace=$(workspace_from_current_directory); then : elif workspace=$(active_workspace); then workspace=$(canonical_directory "$workspace") else die "No active workspace. Run '${PROGRAM_NAME} start \"task\"' or provide a workspace path." fi printf '%s\n' "$workspace" } assert_single_config_value() { local key=$1 local expected=$2 local actual local count actual=$(git config --local --get "$key" || true) count=$(git config --local --get-all "$key" 2>/dev/null | wc -l | tr -d '[:space:]') [[ "$count" == "1" && "$actual" == "$expected" ]] \ || die "Unsafe Git configuration for $key. Expected exactly: $expected" } is_valid_topic() { [[ "$1" =~ ^${TOPIC_PREFIX}[a-z0-9][a-z0-9-]*$ ]] } topic_for_branch() { local branch=$1 local suffix [[ "$branch" == "${BRANCH_PREFIX}"* && "$branch" != "$BRANCH_PREFIX" ]] \ || return 1 suffix=${branch#"$BRANCH_PREFIX"} [[ "$suffix" =~ ^[a-z0-9][a-z0-9-]*$ ]] || return 1 printf '%s%s\n' "$TOPIC_PREFIX" "$suffix" } branch_for_topic() { local topic=$1 is_valid_topic "$topic" || return 1 printf '%s%s\n' "$BRANCH_PREFIX" "${topic#"$TOPIC_PREFIX"}" } validate_workspace() { local workspace=$1 local state_file local state_workspace local state_repository local state_status local state_branch local state_topic local state_mode local expected_topic local root local branch [[ -d "$workspace/.git" ]] || die "Not a helper-created Git workspace: $workspace" state_file=$(state_file_for "$workspace") [[ -f "$state_file" ]] || die "Missing helper state in workspace: $workspace" root=$(cd "$workspace" && git rev-parse --show-toplevel) root=$(canonical_directory "$root") [[ "$root" == "$workspace" ]] || die "Workspace root mismatch: $workspace" jq -e --argjson version "$STATE_VERSION" '.version == $version' "$state_file" >/dev/null \ || die "Unsupported workspace state version." state_workspace=$(jq -er '.workspace' "$state_file") \ || die "Invalid workspace state: $state_file" state_repository=$(jq -er '.repository' "$state_file") \ || die "Invalid workspace repository state: $state_file" state_status=$(jq -er '.status' "$state_file") \ || die "Invalid workspace status: $state_file" state_branch=$(jq -er '.branch' "$state_file") \ || die "Invalid workspace branch state: $state_file" state_topic=$(jq -er '.topic' "$state_file") \ || die "Invalid workspace topic state: $state_file" state_mode=$(jq -er '.mode' "$state_file") \ || die "Invalid workspace mode: $state_file" [[ "$state_workspace" == "$workspace" ]] || die "Workspace state path mismatch." [[ "$state_repository" == "$REPOSITORY" ]] || die "Workspace is not for $REPOSITORY." [[ "$state_status" == "active" || "$state_status" == "submitted" ]] \ || die "Unknown workspace status: $state_status" [[ "$state_mode" == "new" || "$state_mode" == "revision" ]] \ || die "Unknown workspace mode: $state_mode" is_valid_topic "$state_topic" || die "Unsafe AGit topic in workspace state." expected_topic=$(topic_for_branch "$state_branch") \ || die "Unsafe branch in workspace state: $state_branch" [[ "$state_topic" == "$expected_topic" ]] \ || die "Workspace branch and AGit topic do not match." ( cd "$workspace" assert_single_config_value remote.origin.url "$FETCH_URL" assert_single_config_value remote.origin.pushurl "$PUSH_URL" assert_single_config_value user.name "$GIT_AUTHOR_NAME" assert_single_config_value user.email "$GIT_AUTHOR_EMAIL" branch=$(git symbolic-ref --quiet --short HEAD) \ || die "Detached HEAD is not allowed." [[ "$branch" == "$state_branch" ]] || die "Workspace branch does not match helper state." [[ "$branch" != "$BASE_BRANCH" ]] || die "Working directly on $BASE_BRANCH is forbidden." ) } create_branch_name() { local task=$1 local slug local timestamp local random_suffix slug=$(printf '%s' "$task" \ | tr '[:upper:]' '[:lower:]' \ | tr -cs 'a-z0-9' '-' \ | sed -e 's/^-//' -e 's/-$//' \ | cut -c1-40) [[ -n "$slug" ]] || slug="change" timestamp=$(date -u '+%Y%m%d-%H%M%S') random_suffix=$(od -An -N3 -tx1 /dev/urandom | tr -d '[:space:]') printf '%s%s-%s-%s\n' "$BRANCH_PREFIX" "$slug" "$timestamp" "$random_suffix" } write_initial_state() { local workspace=$1 local task=$2 local branch=$3 local topic=$4 local mode=$5 local initial_head=$6 local pr_number=${7:-} local pr_url=${8:-} local state_file local temporary state_file=$(state_file_for "$workspace") temporary="${state_file}.tmp.$$" jq -n \ --argjson version "$STATE_VERSION" \ --arg repository "$REPOSITORY" \ --arg workspace "$workspace" \ --arg task "$task" \ --arg branch "$branch" \ --arg topic "$topic" \ --arg mode "$mode" \ --arg initialHead "$initial_head" \ --arg pullRequestNumber "$pr_number" \ --arg pullRequestUrl "$pr_url" \ --arg status "active" \ --arg createdAt "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{version: $version, repository: $repository, workspace: $workspace, task: $task, branch: $branch, topic: $topic, mode: $mode, initialHead: $initialHead, pullRequestNumber: $pullRequestNumber, pullRequestUrl: $pullRequestUrl, status: $status, createdAt: $createdAt}' \ >"$temporary" chmod 600 "$temporary" mv -f "$temporary" "$state_file" } configure_workspace_git() { local workspace=$1 ( cd "$workspace" git remote set-url --push origin "$PUSH_URL" git config --local user.name "$GIT_AUTHOR_NAME" git config --local user.email "$GIT_AUTHOR_EMAIL" ) } start_workspace() { local task=$1 local temp_root local workspace local branch local topic local initial_head [[ -n "${task//[[:space:]]/}" ]] || die "Task description must not be empty." require_command git require_command jq temp_root=${TMPDIR:-/tmp} temp_root=${temp_root%/} workspace=$(mktemp -d "${temp_root}/tobserver-agent.XXXXXX") chmod 700 "$workspace" workspace=$(canonical_directory "$workspace") START_WORKSPACE_PATH=$workspace trap preserve_start_failure ERR git clone --quiet --origin origin --branch "$BASE_BRANCH" --single-branch \ "$FETCH_URL" "$workspace" branch=$(create_branch_name "$task") topic=$(topic_for_branch "$branch") || die "Could not derive a safe AGit topic." configure_workspace_git "$workspace" (cd "$workspace" && git switch --quiet --create "$branch") initial_head=$(cd "$workspace" && git rev-parse HEAD) write_initial_state "$workspace" "$task" "$branch" "$topic" "new" "$initial_head" write_active_workspace "$workspace" validate_workspace "$workspace" trap - ERR unset START_WORKSPACE_PATH log "Workspace: $workspace" log "Branch: $branch" log "AGit topic: $topic" log "Resume: $PROGRAM_NAME resume $workspace" } assert_active_workspace() { local workspace=$1 local state_file state_file=$(state_file_for "$workspace") jq -e '.status == "active"' "$state_file" >/dev/null \ || die "Workspace is not active: $workspace" } refresh_base_branch() { local workspace=$1 local mode mode=$(jq -r '.mode' "$(state_file_for "$workspace")") ( cd "$workspace" git fetch --quiet origin \ "refs/heads/${BASE_BRANCH}:refs/remotes/origin/${BASE_BRANCH}" git merge-base "origin/${BASE_BRANCH}" HEAD >/dev/null \ || die "The agent branch has no common history with origin/${BASE_BRANCH}." if [[ "$mode" == "new" ]]; then git merge-base --is-ancestor "origin/${BASE_BRANCH}" HEAD \ || die "The new agent branch is not based on the latest origin/${BASE_BRANCH}." elif ! git merge-base --is-ancestor "origin/${BASE_BRANCH}" HEAD; then warn "origin/${BASE_BRANCH} advanced after this pull request was opened; Codeberg must verify mergeability." fi ) } changed_paths() { git diff --name-only --diff-filter=ACDMRTUXB -z "origin/${BASE_BRANCH}" -- git ls-files --others --exclude-standard -z } assert_no_secret_payload_changes() { local workspace=$1 local path while IFS= read -r -d '' path; do case "$path" in secrets/* | .sops.yaml) die "Agent changes to SOPS payload/configuration are forbidden: $path" ;; esac done < <(cd "$workspace" && changed_paths) } run_nix_formatter_check() { local workspace=$1 local file_list local path file_list="$workspace/.git/tobserver-agent-nix-files.$$" ( cd "$workspace" while IFS= read -r -d '' path; do if [[ "$path" == *.nix && -f "$path" ]]; then printf '%s\0' "$path" fi done < <(changed_paths) >"$file_list" if [[ -s "$file_list" ]]; then xargs -0 nixpkgs-fmt --check <"$file_list" fi ) rm -f "$file_list" } emit_proposed_patch() { local workspace=$1 local path ( cd "$workspace" git diff --binary --no-ext-diff "origin/${BASE_BRANCH}" -- while IFS= read -r -d '' path; do printf '\ndiff --git a/%s b/%s\nnew file mode 100644\n--- /dev/null\n+++ b/%s\n' \ "$path" "$path" "$path" cat -- "$path" printf '\n' done < <(git ls-files --others --exclude-standard -z) ) } run_gitleaks_check() { local workspace=$1 emit_proposed_patch "$workspace" \ | gitleaks stdin --no-banner --no-color --redact } strict_nixos_apply_expression() { cat <<'EOF' config: let failedAssertions = builtins.filter (item: !item.assertion) config.assertions; checkedAssertions = if failedAssertions == [ ] then true else throw ( "Failed NixOS assertions:\n" + builtins.concatStringsSep "\n" (map (item: "- " + item.message) failedAssertions) ); unitTexts = builtins.mapAttrs (_: unit: unit.text) config.systemd.units; in builtins.deepSeq checkedAssertions ( builtins.deepSeq unitTexts config.system.build.toplevel.drvPath ) EOF } run_strict_nixos_evaluation() { local workspace=$1 local apply_expression apply_expression=$(strict_nixos_apply_expression) ( cd "$workspace" nix eval --raw --no-write-lock-file \ "path:.#nixosConfigurations.tobserver.config" \ --apply "$apply_expression" \ >/dev/null ) } run_checks() { local workspace=$1 validate_workspace "$workspace" assert_active_workspace "$workspace" refresh_base_branch "$workspace" assert_no_secret_payload_changes "$workspace" log "[1/5] Checking Nix formatting" run_nix_formatter_check "$workspace" log "[2/5] Checking Git whitespace" (cd "$workspace" && git diff --check "origin/${BASE_BRANCH}" --) log "[3/5] Evaluating the flake without building" (cd "$workspace" && nix flake check --no-build --no-write-lock-file "path:.") log "[4/5] Strictly evaluating NixOS assertions and systemd units" run_strict_nixos_evaluation "$workspace" log "[5/5] Scanning the proposed patch with Gitleaks" run_gitleaks_check "$workspace" log "All checks passed." } show_status() { local workspace=$1 local state_file local branch validate_workspace "$workspace" state_file=$(state_file_for "$workspace") branch=$(cd "$workspace" && git symbolic-ref --quiet --short HEAD) log "Workspace: $workspace" log "Repository: $(jq -r '.repository' "$state_file")" log "Branch: $branch" log "AGit topic: $(jq -r '.topic' "$state_file")" log "State: $(jq -r '.status' "$state_file")" log "Mode: $(jq -r '.mode' "$state_file")" log "Task: $(jq -r '.task' "$state_file")" if [[ "$(jq -r '.mode' "$state_file")" == "revision" ]]; then log "Pull request: $(jq -r '.pullRequestUrl' "$state_file")" if [[ -f "$workspace/.git/tobserver-agent-review.md" ]]; then log "Review bundle: $workspace/.git/tobserver-agent-review.md" fi fi log "Fetch URL: $FETCH_URL" log "Push URL: $PUSH_URL" log "Changes:" (cd "$workspace" && git status --short) } resume_workspace() { local workspace=$1 workspace=$(canonical_directory "$workspace") validate_workspace "$workspace" assert_active_workspace "$workspace" write_active_workspace "$workspace" log "Resumed workspace: $workspace" show_status "$workspace" } fetch_json_url() { local url=$1 local response_file local api_error ensure_state_home response_file="$STATE_HOME/api-response.$$" umask 077 if ! curl --silent --show-error --fail-with-body \ --proto '=https' --tlsv1.2 \ --request GET \ --url "$url" \ --output "$response_file"; then api_error=$(jq -r '.message // "Codeberg rejected the public API request."' \ "$response_file" 2>/dev/null || true) rm -f "$response_file" die "$api_error" fi cat "$response_file" rm -f "$response_file" } fetch_pull_request_json() { local pr_number=$1 fetch_json_url "${CODEBERG_API_URL}/pulls/${pr_number}" } fetch_open_pull_requests_json() { fetch_json_url "${CODEBERG_API_URL}/pulls?state=open&limit=50" } fetch_paginated_json_array() { local base_url=$1 local separator='?' local page=1 local response local count local combined='[]' [[ "$base_url" == *\?* ]] && separator='&' while :; do response=$(fetch_json_url \ "${base_url}${separator}limit=${API_PAGE_SIZE}&page=${page}") jq -e 'type == "array"' <<<"$response" >/dev/null \ || die "Codeberg returned an unexpected paginated API response." count=$(jq 'length' <<<"$response") if ((page > MAX_API_PAGES)); then ((count == 0)) \ || die "Codeberg review data exceeds the ${MAX_API_PAGES}-page inspection limit." break fi combined=$(jq -cn \ --argjson accumulated "$combined" \ --argjson pageItems "$response" \ '$accumulated + $pageItems') ((count < API_PAGE_SIZE)) && break page=$((page + 1)) done printf '%s\n' "$combined" } assert_pull_request_identity() { local pr_json=$1 local pr_number=$2 local pr_url jq -e --argjson number "$pr_number" '.number == $number' <<<"$pr_json" >/dev/null \ || die "Codeberg returned the wrong pull request." pr_url=$(jq -er '.html_url' <<<"$pr_json") \ || die "Codeberg returned an invalid pull request." [[ "$pr_url" == "${CODEBERG_REPO_URL}/pulls/${pr_number}" ]] \ || die "Pull request #$pr_number returned an unexpected URL." } pull_request_review_bundle_json() { local pr_number=$1 local pr_json=$2 local issue_comments local reviews local reviews_with_comments='[]' local review_id local review local inline_comments issue_comments=$(fetch_paginated_json_array \ "${CODEBERG_API_URL}/issues/${pr_number}/comments") reviews=$(fetch_paginated_json_array \ "${CODEBERG_API_URL}/pulls/${pr_number}/reviews") while IFS= read -r review_id; do [[ -n "$review_id" ]] || continue inline_comments=$(fetch_paginated_json_array \ "${CODEBERG_API_URL}/pulls/${pr_number}/reviews/${review_id}/comments") review=$(jq -c --argjson id "$review_id" \ '.[] | select(.id == $id)' <<<"$reviews") reviews_with_comments=$(jq -cn \ --argjson accumulated "$reviews_with_comments" \ --argjson review "$review" \ --argjson comments "$inline_comments" \ '$accumulated + [($review + {inline_comments: $comments})]') done < <(jq -r '.[].id' <<<"$reviews") jq -cn \ --arg fetchedAt "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ --argjson pullRequest "$pr_json" \ --argjson issueComments "$issue_comments" \ --argjson reviews "$reviews_with_comments" \ '{fetched_at: $fetchedAt, pull_request: $pullRequest, issue_comments: $issueComments, reviews: $reviews}' } render_pull_request_review_bundle() { jq -r ' def quoted: if . == null or . == "" then "_None._" else tostring | split("\n") | map("> " + .) | join("\n") end; .pull_request as $pr | "# Tobserver pull request #\($pr.number) review bundle\n\n" + "Fetched: \(.fetched_at)\n\n" + "URL: \($pr.html_url)\n\n" + "State: \($pr.state)\n\n" + "Head: \($pr.head.label // $pr.head.ref) @ \($pr.head.sha)\n\n" + "## Title\n\n\($pr.title | quoted)\n\n" + "## Description\n\n\($pr.body | quoted)\n\n" + "## General discussion\n\n" + (([.issue_comments[] | "### \(.user.login // "unknown") at \(.created_at // "unknown time")\n\n" + "URL: \(.html_url // "unavailable")\n\n" + (.body | quoted) ] | if length == 0 then ["_No general comments._"] else . end) | join("\n\n")) + "\n\n## Reviews\n\n" + (([.reviews[] | "### Review \(.id) — \(.state // "unknown")\n\n" + "Reviewer: \(.user.login // "unknown")\n\n" + "Submitted: \(.submitted_at // "unknown time")\n\n" + "Commit: \(.commit_id // "unknown")\n\n" + "Official: \(.official // false); stale: \(.stale // false); dismissed: \(.dismissed // false)\n\n" + "Review body:\n\n" + (.body | quoted) + "\n\n" + (([.inline_comments[] | "#### Inline comment on `\(.path // "unknown path")`\n\n" + "Author: \(.user.login // "unknown")\n\n" + "URL: \(.html_url // "unavailable")\n\n" + "Commit: \(.commit_id // "unknown"); position: \(.position // "unknown")\n\n" + "Resolution: \(if .resolver then "resolved by " + (.resolver.login // "unknown") else "unresolved" end)\n\n" + "Diff context:\n\n" + (.diff_hunk | quoted) + "\n\n" + "Comment:\n\n" + (.body | quoted) ] | if length == 0 then ["_No inline comments._"] else . end) | join("\n\n")) ] | if length == 0 then ["_No reviews._"] else . end) | join("\n\n")) ' } validate_review_bundle_size() { local review_bundle=$1 local bundle_bytes bundle_bytes=$(printf '%s' "$review_bundle" | wc -c | tr -d '[:space:]') ((bundle_bytes <= MAX_REVIEW_BUNDLE_BYTES)) \ || die "Pull-request review bundle exceeds ${MAX_REVIEW_BUNDLE_BYTES} bytes." } inspect_pull_request() { local pr_number=$1 local pr_json local bundle_json local review_bundle [[ "$pr_number" =~ ^[1-9][0-9]*$ ]] \ || die "inspect requires a positive numeric Codeberg pull-request number." require_command curl require_command jq pr_json=$(fetch_pull_request_json "$pr_number") assert_pull_request_identity "$pr_json" "$pr_number" bundle_json=$(pull_request_review_bundle_json "$pr_number" "$pr_json") review_bundle=$(render_pull_request_review_bundle <<<"$bundle_json") validate_review_bundle_size "$review_bundle" printf '%s\n' "$review_bundle" } pr_topic_from_json() { local pr_json=$1 local label local topic label=$(jq -er '.head.label' <<<"$pr_json") || return 1 [[ "$label" == "oibot/"* ]] || return 1 topic=${label#oibot/} is_valid_topic "$topic" || return 1 printf '%s\n' "$topic" } assert_revisable_pull_request() { local pr_json=$1 local pr_number=$2 local expected_topic=${3:-} local topic assert_pull_request_identity "$pr_json" "$pr_number" [[ "$(jq -r '.state' <<<"$pr_json")" == "open" ]] \ || die "Pull request #$pr_number is not open." [[ "$(jq -r '.merged // false' <<<"$pr_json")" == "false" ]] \ || die "Pull request #$pr_number is already merged." [[ "$(jq -r '.base.ref' <<<"$pr_json")" == "$BASE_BRANCH" ]] \ || die "Pull request #$pr_number does not target $BASE_BRANCH." [[ "$(jq -r '.head.repo.full_name' <<<"$pr_json")" == "$REPOSITORY" ]] \ || die "Pull request #$pr_number comes from another repository or fork." [[ "$(jq -r '.user.login' <<<"$pr_json")" == "oibot" ]] \ || die "Pull request #$pr_number was not opened by oibot." [[ "$(jq -r '.flow // 0' <<<"$pr_json")" == "1" ]] \ || die "Pull request #$pr_number was not created through AGit." [[ "$(jq -r '.head.ref' <<<"$pr_json")" == "refs/pull/${pr_number}/head" ]] \ || die "Pull request #$pr_number has an unexpected head ref." topic=$(pr_topic_from_json "$pr_json") \ || die "Pull request #$pr_number does not use a helper-managed AGit topic." [[ -z "$expected_topic" || "$topic" == "$expected_topic" ]] \ || die "Pull request #$pr_number no longer uses the expected AGit topic." } revise_pull_request() { local pr_number=$1 local pr_json local pr_title local pr_url local topic local branch local task local temp_root local workspace local initial_head local bundle_json local review_bundle local review_bundle_path [[ "$pr_number" =~ ^[1-9][0-9]*$ ]] \ || die "revise requires a positive numeric Codeberg pull-request number." require_command curl require_command git require_command jq pr_json=$(fetch_pull_request_json "$pr_number") assert_revisable_pull_request "$pr_json" "$pr_number" pr_title=$(jq -r '.title' <<<"$pr_json") pr_url=$(jq -r '.html_url' <<<"$pr_json") topic=$(pr_topic_from_json "$pr_json") branch=$(branch_for_topic "$topic") \ || die "Could not derive a safe local branch for pull request #$pr_number." task="Revise Codeberg PR #${pr_number}: ${pr_title}" bundle_json=$(pull_request_review_bundle_json "$pr_number" "$pr_json") review_bundle=$(render_pull_request_review_bundle <<<"$bundle_json") validate_review_bundle_size "$review_bundle" temp_root=${TMPDIR:-/tmp} temp_root=${temp_root%/} workspace=$(mktemp -d "${temp_root}/tobserver-agent.XXXXXX") chmod 700 "$workspace" workspace=$(canonical_directory "$workspace") START_WORKSPACE_PATH=$workspace trap preserve_start_failure ERR git clone --quiet --origin origin --branch "$BASE_BRANCH" --single-branch \ "$FETCH_URL" "$workspace" configure_workspace_git "$workspace" ( cd "$workspace" git fetch --quiet origin "refs/pull/${pr_number}/head" git switch --quiet --create "$branch" FETCH_HEAD ) initial_head=$(cd "$workspace" && git rev-parse HEAD) write_initial_state "$workspace" "$task" "$branch" "$topic" "revision" \ "$initial_head" "$pr_number" "$pr_url" review_bundle_path="$workspace/.git/tobserver-agent-review.md" printf '%s\n' "$review_bundle" >"$review_bundle_path" chmod 600 "$review_bundle_path" write_active_workspace "$workspace" validate_workspace "$workspace" trap - ERR unset START_WORKSPACE_PATH log "Workspace: $workspace" log "Branch: $branch" log "AGit topic: $topic" log "Pull request: $pr_url" log "Review bundle: $review_bundle_path" log "Resume: $PROGRAM_NAME resume $workspace" } encode_push_option_string() { local value=$1 printf '{base64}' printf '%s' "$value" | base64 | tr -d '\r\n' } encode_push_option_file() { local path=$1 printf '{base64}' base64 <"$path" | tr -d '\r\n' } absolute_file() { local path=$1 local directory local filename [[ -f "$path" ]] || die "File does not exist: $path" directory=$(dirname "$path") filename=$(basename "$path") directory=$(canonical_directory "$directory") printf '%s/%s\n' "$directory" "$filename" } validate_submission_text() { local title=$1 local body_file=$2 local title_bytes local body_bytes [[ -n "$title" ]] || die "Commit title must not be empty." [[ "$title" != *$'\n'* && "$title" != *$'\r'* ]] \ || die "Commit title must be one line." title_bytes=$(printf '%s' "$title" | wc -c | tr -d '[:space:]') ((title_bytes <= MAX_TITLE_BYTES)) \ || die "Commit title must be at most ${MAX_TITLE_BYTES} bytes." body_bytes=$(wc -c <"$body_file" | tr -d '[:space:]') ((body_bytes > 0)) || die "Pull-request body must not be empty." ((body_bytes <= MAX_BODY_BYTES)) \ || die "Pull-request body must be at most ${MAX_BODY_BYTES} bytes." } pr_number_from_url() { local url=$1 local number [[ "$url" =~ ^${CODEBERG_REPO_URL}/pulls/([1-9][0-9]*)$ ]] || return 1 number=${BASH_REMATCH[1]} printf '%s\n' "$number" } pr_url_from_push_output() { local output_file=$1 grep -Eo "${CODEBERG_REPO_URL}/pulls/[1-9][0-9]*" "$output_file" \ | tail -n 1 } find_open_pr_number_by_topic_and_sha() { local topic=$1 local head_sha=$2 local pulls_json local matches pulls_json=$(fetch_open_pull_requests_json) matches=$(jq -r \ --arg repository "$REPOSITORY" \ --arg topic "oibot/${topic}" \ --arg sha "$head_sha" \ --arg base "$BASE_BRANCH" \ '[.[] | select(.user.login == "oibot" and .head.repo.full_name == $repository and .head.label == $topic and .head.sha == $sha and .base.ref == $base and (.flow // 0) == 1)] | if length == 1 then .[0].number else empty end' \ <<<"$pulls_json") [[ "$matches" =~ ^[1-9][0-9]*$ ]] || return 1 printf '%s\n' "$matches" } wait_for_pull_request_head() { local pr_number=$1 local topic=$2 local head_sha=$3 local pr_json for _ in 1 2 3 4 5; do pr_json=$(fetch_pull_request_json "$pr_number") if [[ "$(jq -r '.head.sha // ""' <<<"$pr_json")" == "$head_sha" ]]; then assert_revisable_pull_request "$pr_json" "$pr_number" "$topic" printf '%s\n' "$(jq -r '.html_url' <<<"$pr_json")" return 0 fi sleep 1 done return 1 } push_agit_change() { local workspace=$1 local mode=$2 local topic=$3 local title=$4 local body_file=$5 local existing_pr_number=${6:-} local output_file local refspec local encoded_title local encoded_body local head_sha local pr_url="" local pr_number="" require_command base64 refspec="HEAD:refs/for/${BASE_BRANCH}/${topic}" output_file="$workspace/.git/tobserver-agent-push.$$" head_sha=$(cd "$workspace" && git rev-parse HEAD) if [[ "$mode" == "new" ]]; then encoded_title=$(encode_push_option_string "$title") encoded_body=$(encode_push_option_file "$body_file") if ! ( cd "$workspace" git push --porcelain origin "$refspec" \ -o "title=${encoded_title}" \ -o "description=${encoded_body}" ) >"$output_file" 2>&1; then cat "$output_file" >&2 rm -f "$output_file" die "AGit submission failed. The workspace was preserved." fi else if ! (cd "$workspace" && git push --porcelain origin "$refspec") \ >"$output_file" 2>&1; then cat "$output_file" >&2 rm -f "$output_file" die "AGit revision failed. The workspace was preserved." fi fi cat "$output_file" >&2 pr_url=$(pr_url_from_push_output "$output_file" || true) rm -f "$output_file" if [[ "$mode" == "revision" ]]; then pr_number=$existing_pr_number elif [[ -n "$pr_url" ]]; then pr_number=$(pr_number_from_url "$pr_url") \ || die "Codeberg returned an unexpected pull-request URL." else for _ in 1 2 3 4 5; do if pr_number=$(find_open_pr_number_by_topic_and_sha "$topic" "$head_sha"); then break fi sleep 1 done fi [[ "$pr_number" =~ ^[1-9][0-9]*$ ]] \ || die "Could not identify the AGit pull request. The workspace was preserved." pr_url=$(wait_for_pull_request_head "$pr_number" "$topic" "$head_sha") \ || die "Codeberg did not expose the expected pull-request head. The workspace was preserved." printf '%s\n' "$pr_url" } mark_submitted() { local workspace=$1 local pr_url=$2 local state_file local temporary state_file=$(state_file_for "$workspace") temporary="${state_file}.tmp.$$" jq --arg prUrl "$pr_url" \ --arg submittedAt "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '.status = "submitted" | .prUrl = $prUrl | .submittedAt = $submittedAt' \ "$state_file" >"$temporary" chmod 600 "$temporary" mv -f "$temporary" "$state_file" } submit_workspace() { local workspace=$1 local title=$2 local body_file=$3 local state_file local mode local topic local initial_head local pr_number="" local pr_json local branch local new_commits local changes local pr_url body_file=$(absolute_file "$body_file") validate_submission_text "$title" "$body_file" state_file=$(state_file_for "$workspace") mode=$(jq -r '.mode' "$state_file") topic=$(jq -er '.topic' "$state_file") \ || die "Workspace is missing its AGit topic." initial_head=$(jq -er '.initialHead' "$state_file") \ || die "Workspace is missing its initial Git revision." branch=$(cd "$workspace" && git symbolic-ref --quiet --short HEAD) if [[ "$mode" == "revision" ]]; then pr_number=$(jq -er '.pullRequestNumber' "$state_file") \ || die "Revision workspace is missing its pull-request number." pr_json=$(fetch_pull_request_json "$pr_number") assert_revisable_pull_request "$pr_json" "$pr_number" "$topic" [[ "$(jq -r '.head.sha' <<<"$pr_json")" == "$initial_head" ]] \ || die "Pull request #$pr_number advanced after this workspace was created." fi run_checks "$workspace" changes=$(cd "$workspace" && git status --porcelain) if [[ -n "$changes" ]]; then ( cd "$workspace" git add -A git diff --cached --check git commit --quiet -m "$title" ) fi new_commits=$(cd "$workspace" && git rev-list --count "${initial_head}..HEAD") ((new_commits > 0)) || die "There are no new changes to submit." [[ -z "$(cd "$workspace" && git status --porcelain)" ]] \ || die "The workspace changed during submission; it was preserved." [[ "$branch" == "$(branch_for_topic "$topic")" ]] \ || die "The active branch no longer matches the AGit topic." pr_url=$(push_agit_change "$workspace" "$mode" "$topic" "$title" "$body_file" "$pr_number") mark_submitted "$workspace" "$pr_url" clear_active_workspace "$workspace" rm -rf -- "$workspace" log "Pull request: $pr_url" if [[ "$mode" == "revision" ]]; then log "Existing pull request updated." fi log "Successful workspace cleaned: $workspace" } cleanup_workspace() { local workspace=$1 local basename workspace=$(canonical_directory "$workspace") validate_workspace "$workspace" basename=$(basename "$workspace") [[ "$basename" == tobserver-agent.* ]] \ || die "Refusing to clean a path without the tobserver-agent.* workspace name." clear_active_workspace "$workspace" rm -rf -- "$workspace" log "Removed workspace: $workspace" } main() { local command=${1:-} local workspace="" local title="" local body_file="" [[ -n "$command" ]] || { usage exit 1 } shift case "$command" in start) (($# == 1)) || die "start requires exactly one task description." start_workspace "$1" ;; inspect) (($# == 1)) || die "inspect requires exactly one pull-request number." inspect_pull_request "$1" ;; revise) (($# == 1)) || die "revise requires exactly one pull-request number." revise_pull_request "$1" ;; resume) (($# == 1)) || die "resume requires exactly one workspace path." resume_workspace "$1" ;; status) (($# <= 1)) || die "status accepts at most one workspace path." workspace=$(resolve_workspace "${1:-}") show_status "$workspace" ;; check) (($# <= 1)) || die "check accepts at most one workspace path." workspace=$(resolve_workspace "${1:-}") run_checks "$workspace" ;; submit) while (($# > 0)); do case "$1" in --title) (($# >= 2)) || die "--title requires a value." title=$2 shift 2 ;; --body-file) (($# >= 2)) || die "--body-file requires a value." body_file=$2 shift 2 ;; --*) die "Unknown submit option: $1" ;; *) [[ -z "$workspace" ]] || die "submit accepts at most one workspace path." workspace=$1 shift ;; esac done [[ -n "$title" ]] || die "submit requires --title." [[ -n "$body_file" ]] || die "submit requires --body-file." workspace=$(resolve_workspace "$workspace") submit_workspace "$workspace" "$title" "$body_file" ;; cleanup) (($# == 1)) || die "cleanup requires exactly one workspace path." cleanup_workspace "$1" ;; help | --help | -h) usage ;; *) usage >&2 die "Unknown command: $command" ;; esac } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@" fi