feat: add tokenless Tobserver change skill

This commit is contained in:
Tobias Ostner 2026-07-24 20:22:01 +07:00
parent 8cc510b2c7
commit 886a885355
6 changed files with 1364 additions and 0 deletions

View file

@ -0,0 +1,967 @@
#!/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
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 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
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
}
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] Evaluating the Tobserver system derivation path"
(cd "$workspace" && nix eval --raw --no-write-lock-file \
"path:.#nixosConfigurations.tobserver.config.system.build.toplevel.drvPath" \
>/dev/null)
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")"
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"
}
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
local pr_url
jq -e --argjson number "$pr_number" '.number == $number' <<<"$pr_json" >/dev/null \
|| die "Codeberg returned the wrong pull request."
[[ "$(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."
pr_url=$(jq -r '.html_url' <<<"$pr_json")
[[ "$pr_url" == "${CODEBERG_REPO_URL}/pulls/${pr_number}" ]] \
|| die "Pull request #$pr_number returned an unexpected URL."
}
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
[[ "$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}"
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"
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 "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"
;;
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