diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e660fd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..1c64463 --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,9 @@ +when: + - event: pull_request + +steps: + - name: docker-build + image: woodpeckerci/plugin-docker-buildx + settings: + repo: git.unkin.net/unkin/agent-base + dry_run: true diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml new file mode 100644 index 0000000..95ed5e1 --- /dev/null +++ b/.woodpecker/docker.yaml @@ -0,0 +1,16 @@ +when: + - event: tag + ref: refs/tags/v* + +steps: + - name: docker + image: woodpeckerci/plugin-docker-buildx + settings: + registry: git.unkin.net + repo: git.unkin.net/unkin/agent-base + username: droneci + password: + from_secret: DRONECI_PASSWORD + tags: + - ${CI_COMMIT_TAG} + - latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d5aa066 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM git.unkin.net/unkin/almalinux9-base:latest + +RUN dnf install -y \ + git \ + git-lfs \ + jq \ + curl \ + claude-code \ + && dnf clean all + +RUN useradd -m -s /bin/bash agent + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +USER agent +WORKDIR /home/agent + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3f1ef3a --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +.PHONY: build clean patch minor major + +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev") + +build: + docker build -t git.unkin.net/unkin/agent-base:$(VERSION) . + +clean: + docker rmi git.unkin.net/unkin/agent-base:$(VERSION) 2>/dev/null || true + +_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) +_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) +_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) +_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2) +_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3) + +patch: + @NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +minor: + @NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +major: + @NEW=v$(shell expr $(_MAJ) + 1).0.0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..b19d613 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -euo pipefail + +############################################################################### +# entrypoint.sh - Base entrypoint for forgebot AI agent containers +############################################################################### + +# --------------------------------------------------------------------------- +# Required environment variables +# --------------------------------------------------------------------------- +REQUIRED_VARS=( + FORGEBOT_REPO + FORGEBOT_REF + FORGEBOT_COMMAND + FORGEBOT_API_URL + FORGEBOT_TASK_ID + ANTHROPIC_API_KEY +) + +for var in "${REQUIRED_VARS[@]}"; do + if [[ -z "${!var:-}" ]]; then + echo "FATAL: required env var $var is not set" >&2 + exit 1 + fi +done + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- +export ANTHROPIC_BASE_URL="${ANTHROPIC_BASE_URL:-https://litellm.k8s.syd1.au.unkin.net}" +export GITEA_URL="${GITEA_URL:-https://git.unkin.net}" +FORGEBOT_MODEL="${FORGEBOT_MODEL:-claude-sonnet-4-20250514}" + +# --------------------------------------------------------------------------- +# Helper functions +# --------------------------------------------------------------------------- + +# Report task status back to the forgebot API. +# report_status +report_status() { + local status="$1" + local message="$2" + curl -sf -X PATCH \ + -H "Content-Type: application/json" \ + "${FORGEBOT_API_URL}/tasks/${FORGEBOT_TASK_ID}" \ + -d "$(jq -n --arg s "$status" --arg m "$message" '{status: $s, message: $m}')" \ + || echo "WARNING: failed to report status '$status'" >&2 +} + +# Create a subtask. Reads JSON body from stdin or from the first argument. +# echo '{"kind":"review",...}' | create_subtask +# create_subtask '{"kind":"review",...}' +create_subtask() { + local body + if [[ $# -gt 0 ]]; then + body="$1" + else + body="$(cat)" + fi + curl -sf -X POST \ + -H "Content-Type: application/json" \ + "${FORGEBOT_API_URL}/tasks" \ + -d "$body" +} + +# Check the status of a task by ID. +# check_task +check_task() { + local task_id="$1" + curl -sf -X GET \ + -H "Content-Type: application/json" \ + "${FORGEBOT_API_URL}/tasks/${task_id}" +} + +# Post a comment on the current task. +# post_comment +post_comment() { + local message="$1" + curl -sf -X POST \ + -H "Content-Type: application/json" \ + "${FORGEBOT_API_URL}/tasks/${FORGEBOT_TASK_ID}/comment" \ + -d "$(jq -n --arg m "$message" '{message: $m}')" +} + +# --------------------------------------------------------------------------- +# Clone & checkout +# --------------------------------------------------------------------------- +REPO_DIR="/home/agent/workspace" + +echo "Cloning ${FORGEBOT_REPO} ..." +git clone "${GITEA_URL}/${FORGEBOT_REPO}.git" "$REPO_DIR" +cd "$REPO_DIR" + +echo "Checking out ref ${FORGEBOT_REF} ..." +git checkout "${FORGEBOT_REF}" + +# --------------------------------------------------------------------------- +# Extra tools (optional) +# --------------------------------------------------------------------------- +if [[ -n "${FORGEBOT_EXTRA_TOOLS:-}" ]]; then + echo "Downloading extra tools: ${FORGEBOT_EXTRA_TOOLS}" + mkdir -p /home/agent/bin + export PATH="/home/agent/bin:${PATH}" + + IFS=',' read -ra TOOLS <<< "$FORGEBOT_EXTRA_TOOLS" + for tool in "${TOOLS[@]}"; do + tool="$(echo "$tool" | xargs)" # trim whitespace + echo " -> fetching $tool" + curl -sf -o "/home/agent/bin/${tool}" \ + "${FORGEBOT_API_URL}/tools/${tool}" \ + && chmod +x "/home/agent/bin/${tool}" \ + || echo "WARNING: failed to download tool '${tool}'" >&2 + done +fi + +# --------------------------------------------------------------------------- +# Load skill (optional configmap mount) +# --------------------------------------------------------------------------- +SKILL_CONTEXT="" +if [[ -n "${FORGEBOT_SKILL:-}" && -f "/skills/${FORGEBOT_SKILL}/SKILL.md" ]]; then + echo "Loading skill: ${FORGEBOT_SKILL}" + SKILL_CONTEXT="$(cat "/skills/${FORGEBOT_SKILL}/SKILL.md")" +fi + +# --------------------------------------------------------------------------- +# Build the prompt +# --------------------------------------------------------------------------- +PROMPT="${FORGEBOT_COMMAND}" + +if [[ -n "$SKILL_CONTEXT" ]]; then + PROMPT="$(printf '%s\n\n---\n\n%s' "$SKILL_CONTEXT" "$PROMPT")" +fi + +# --------------------------------------------------------------------------- +# Run claude +# --------------------------------------------------------------------------- +report_status "running" "Agent started, executing command" + +set +e +RESULT="$(claude \ + --model "$FORGEBOT_MODEL" \ + --print \ + --dangerously-skip-permissions \ + <<< "$PROMPT" 2>&1)" +EXIT_CODE=$? +set -e + +# --------------------------------------------------------------------------- +# Report result +# --------------------------------------------------------------------------- +if [[ $EXIT_CODE -eq 0 ]]; then + echo "Agent completed successfully." + report_status "completed" "$RESULT" +else + echo "Agent failed with exit code ${EXIT_CODE}." >&2 + report_status "failed" "Exit code ${EXIT_CODE}: ${RESULT}" +fi + +exit $EXIT_CODE