feat: add tokenless Tobserver change skill
This commit is contained in:
parent
8cc510b2c7
commit
886a885355
6 changed files with 1364 additions and 0 deletions
967
tobserver-change/scripts/tobserver-agent
Executable file
967
tobserver-change/scripts/tobserver-agent
Executable 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue