feat: strengthen Tobserver checks and PR inspection

This commit is contained in:
Tobias Ostner 2026-07-28 17:29:19 +07:00
parent ce0f775ccb
commit d35f1642a5
4 changed files with 326 additions and 14 deletions

View file

@ -16,6 +16,9 @@ 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"
@ -46,6 +49,7 @@ 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]
@ -53,6 +57,10 @@ Usage:
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.
@ -424,6 +432,41 @@ run_gitleaks_check() {
| 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
@ -441,10 +484,8 @@ run_checks() {
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 "[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"
@ -470,6 +511,9 @@ show_status() {
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"
@ -521,6 +565,157 @@ 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
@ -538,10 +733,8 @@ assert_revisable_pull_request() {
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."
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" ]] \
@ -561,10 +754,6 @@ assert_revisable_pull_request() {
|| 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() {
@ -578,6 +767,9 @@ revise_pull_request() {
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."
@ -593,6 +785,9 @@ revise_pull_request() {
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%/}
@ -615,6 +810,9 @@ revise_pull_request() {
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
@ -624,6 +822,7 @@ revise_pull_request() {
log "Branch: $branch"
log "AGit topic: $topic"
log "Pull request: $pr_url"
log "Review bundle: $review_bundle_path"
log "Resume: $PROGRAM_NAME resume $workspace"
}
@ -902,6 +1101,10 @@ main() {
(($# == 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"