967 lines
28 KiB
Bash
Executable file
967 lines
28 KiB
Bash
Executable file
#!/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
|