Closes #106. Adds 12 new MCP tools (list/get/download/create/edit/delete × issue + comment) backed by a new pkg/forgejo/rawhttp.go helper. Download tools return inline BlobResourceContents under 1 MiB and metadata + browser_download_url at or above the cap, per the design agreed with @heathen711 on issue #106. Includes unit tests, demos, and an e2e script under test/e2e/.
156 lines
7 KiB
Bash
Executable file
156 lines
7 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# E2E test for issue-attachment MCP tools (v2.19.0-alpha.1+).
|
|
#
|
|
# Validates that ./forgejo-mcp --cli can:
|
|
# 1. create an issue
|
|
# 2. upload an attachment
|
|
# 3. list attachments and see the one we just uploaded
|
|
# 4. fetch single-attachment metadata, with browser_download_url present
|
|
# 5. fetch the URL with the configured token and recover the original bytes
|
|
#
|
|
# Cleans up (deletes attachment, closes issue) on success or failure.
|
|
#
|
|
# Usage:
|
|
# FORGEJO_URL=https://codeberg.org \
|
|
# FORGEJO_ACCESS_TOKEN=... \
|
|
# OWNER=goern REPO=forgejo-mcp \
|
|
# ./test/e2e/attachments.sh
|
|
#
|
|
# Requires: jq, curl, base64, ./forgejo-mcp built (run `make build` first).
|
|
|
|
set -euo pipefail
|
|
|
|
: "${FORGEJO_URL:?FORGEJO_URL required (e.g. https://codeberg.org)}"
|
|
: "${FORGEJO_ACCESS_TOKEN:?FORGEJO_ACCESS_TOKEN required}"
|
|
OWNER="${OWNER:-goern}"
|
|
REPO="${REPO:-forgejo-mcp}"
|
|
BIN="${BIN:-./forgejo-mcp}"
|
|
|
|
[[ -x "$BIN" ]] || { echo "binary not found at $BIN — run 'make build' first" >&2; exit 1; }
|
|
command -v jq >/dev/null || { echo "jq required" >&2; exit 1; }
|
|
command -v curl >/dev/null || { echo "curl required" >&2; exit 1; }
|
|
|
|
# --- helpers ----------------------------------------------------------------
|
|
|
|
# Run a forgejo-mcp tool and unwrap the {"Result":...} envelope.
|
|
# Tools wrap output as [{"type":"text","text":"{\"Result\":...}"}].
|
|
mcp() {
|
|
local tool="$1" args="$2"
|
|
"$BIN" --cli "$tool" --args "$args" 2>/dev/null \
|
|
| jq -r '.[] | select(.type=="text") | .text' \
|
|
| jq '.Result // .'
|
|
}
|
|
|
|
# Status line on a green background; failure on red.
|
|
ok() { printf '\033[32m✓ %s\033[0m\n' "$*"; }
|
|
fail() { printf '\033[31m✗ %s\033[0m\n' "$*" >&2; exit 1; }
|
|
step() { printf '\n\033[1m▶ %s\033[0m\n' "$*"; }
|
|
|
|
# --- state for cleanup ------------------------------------------------------
|
|
|
|
ISSUE_INDEX=""
|
|
ATTACH_ID=""
|
|
|
|
cleanup() {
|
|
local rc=$?
|
|
set +e
|
|
if [[ -n "$ATTACH_ID" && -n "$ISSUE_INDEX" ]]; then
|
|
echo "[cleanup] delete attachment $ATTACH_ID"
|
|
mcp delete_issue_attachment \
|
|
"$(jq -nc --arg o "$OWNER" --arg r "$REPO" --argjson i "$ISSUE_INDEX" --argjson a "$ATTACH_ID" \
|
|
'{owner:$o, repo:$r, index:$i, attachment_id:$a}')" >/dev/null
|
|
fi
|
|
if [[ -n "$ISSUE_INDEX" ]]; then
|
|
echo "[cleanup] close issue #$ISSUE_INDEX"
|
|
mcp issue_state_change \
|
|
"$(jq -nc --arg o "$OWNER" --arg r "$REPO" --argjson i "$ISSUE_INDEX" \
|
|
'{owner:$o, repo:$r, index:$i, state:"closed"}')" >/dev/null
|
|
fi
|
|
exit "$rc"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
# --- test payload -----------------------------------------------------------
|
|
|
|
PAYLOAD="forgejo-mcp e2e attachment test $(date -u +%s) $RANDOM"
|
|
PAYLOAD_FILE="$(mktemp)"
|
|
printf '%s' "$PAYLOAD" > "$PAYLOAD_FILE"
|
|
PAYLOAD_B64="$(base64 -w0 "$PAYLOAD_FILE")"
|
|
PAYLOAD_SHA="$(printf '%s' "$PAYLOAD" | sha256sum | cut -d' ' -f1)"
|
|
|
|
# --- 1. create issue --------------------------------------------------------
|
|
|
|
step "create issue"
|
|
ISSUE_JSON="$(mcp create_issue \
|
|
"$(jq -nc --arg o "$OWNER" --arg r "$REPO" \
|
|
'{owner:$o, repo:$r, title:"[e2e] attachment test", body:"transient — will be auto-closed"}')")"
|
|
ISSUE_INDEX="$(echo "$ISSUE_JSON" | jq '.number')"
|
|
[[ "$ISSUE_INDEX" =~ ^[0-9]+$ ]] || fail "create_issue did not return a numeric .number; got: $ISSUE_JSON"
|
|
ok "issue #$ISSUE_INDEX created"
|
|
|
|
# --- 2. upload attachment ---------------------------------------------------
|
|
|
|
step "upload attachment"
|
|
CREATE_JSON="$(mcp create_issue_attachment \
|
|
"$(jq -nc --arg o "$OWNER" --arg r "$REPO" --argjson i "$ISSUE_INDEX" \
|
|
--arg c "$PAYLOAD_B64" --arg fn "e2e-test.txt" --arg mt "text/plain" \
|
|
'{owner:$o, repo:$r, index:$i, content:$c, filename:$fn, mime_type:$mt}')")"
|
|
ATTACH_ID="$(echo "$CREATE_JSON" | jq '.id')"
|
|
ATTACH_URL="$(echo "$CREATE_JSON" | jq -r '.browser_download_url')"
|
|
ATTACH_SIZE="$(echo "$CREATE_JSON" | jq '.size')"
|
|
[[ "$ATTACH_ID" =~ ^[0-9]+$ ]] || fail "create_issue_attachment did not return numeric .id; got: $CREATE_JSON"
|
|
[[ "$ATTACH_URL" == http*://* ]] || fail "create did not return browser_download_url; got: $ATTACH_URL"
|
|
[[ "$ATTACH_SIZE" -eq "${#PAYLOAD}" ]] || fail "uploaded size $ATTACH_SIZE != local size ${#PAYLOAD}"
|
|
ok "attachment $ATTACH_ID uploaded ($ATTACH_SIZE bytes)"
|
|
ok "browser_download_url = $ATTACH_URL"
|
|
|
|
# --- 3. list attachments ----------------------------------------------------
|
|
|
|
step "list attachments"
|
|
LIST_JSON="$(mcp list_issue_attachments \
|
|
"$(jq -nc --arg o "$OWNER" --arg r "$REPO" --argjson i "$ISSUE_INDEX" \
|
|
'{owner:$o, repo:$r, index:$i}')")"
|
|
LIST_LEN="$(echo "$LIST_JSON" | jq 'length')"
|
|
[[ "$LIST_LEN" -ge 1 ]] || fail "list returned $LIST_LEN entries; expected ≥ 1"
|
|
LIST_HAS_ID="$(echo "$LIST_JSON" | jq --argjson a "$ATTACH_ID" 'map(.id) | index($a) != null')"
|
|
[[ "$LIST_HAS_ID" == "true" ]] || fail "uploaded attachment $ATTACH_ID not in list: $LIST_JSON"
|
|
ok "list contains attachment $ATTACH_ID"
|
|
|
|
# --- 4. get single attachment ----------------------------------------------
|
|
|
|
step "get attachment metadata"
|
|
GET_JSON="$(mcp get_issue_attachment \
|
|
"$(jq -nc --arg o "$OWNER" --arg r "$REPO" --argjson i "$ISSUE_INDEX" --argjson a "$ATTACH_ID" \
|
|
'{owner:$o, repo:$r, index:$i, attachment_id:$a}')")"
|
|
GET_URL="$(echo "$GET_JSON" | jq -r '.browser_download_url')"
|
|
GET_NAME="$(echo "$GET_JSON" | jq -r '.name')"
|
|
[[ "$GET_URL" == "$ATTACH_URL" ]] || fail "get returned different URL: $GET_URL vs $ATTACH_URL"
|
|
[[ "$GET_NAME" == "e2e-test.txt" ]] || fail "get returned wrong name: $GET_NAME"
|
|
ok "get returns matching url + name"
|
|
|
|
# --- 5. download via browser_download_url + auth header --------------------
|
|
|
|
step "fetch browser_download_url with token (over-cap fall-through path)"
|
|
FETCHED="$(curl -sS -f -H "Authorization: token $FORGEJO_ACCESS_TOKEN" "$ATTACH_URL")"
|
|
FETCHED_SHA="$(printf '%s' "$FETCHED" | sha256sum | cut -d' ' -f1)"
|
|
[[ "$FETCHED_SHA" == "$PAYLOAD_SHA" ]] || fail "downloaded sha $FETCHED_SHA != uploaded sha $PAYLOAD_SHA"
|
|
ok "download bytes match (sha256=$PAYLOAD_SHA)"
|
|
|
|
# --- 6. download via MCP tool (under-cap inline path) ----------------------
|
|
|
|
step "download via MCP tool (under-cap → inline base64 blob)"
|
|
DL_RAW="$("$BIN" --cli download_issue_attachment \
|
|
--args "$(jq -nc --arg o "$OWNER" --arg r "$REPO" --argjson i "$ISSUE_INDEX" --argjson a "$ATTACH_ID" \
|
|
'{owner:$o, repo:$r, index:$i, attachment_id:$a}')" 2>/dev/null)"
|
|
DL_TEXT="$(echo "$DL_RAW" | jq -r '.[] | select(.type=="text") | .text')"
|
|
DL_INLINE="$(echo "$DL_TEXT" | jq '.inline')"
|
|
[[ "$DL_INLINE" == "true" ]] || fail "expected inline=true for ${#PAYLOAD}-byte payload; got: $DL_TEXT"
|
|
DL_BLOB="$(echo "$DL_RAW" | jq -r '.[] | select(.type=="resource") | .resource.blob')"
|
|
[[ -n "$DL_BLOB" && "$DL_BLOB" != "null" ]] || fail "no resource.blob in download response: $DL_RAW"
|
|
DL_BYTES="$(printf '%s' "$DL_BLOB" | base64 -d)"
|
|
DL_SHA="$(printf '%s' "$DL_BYTES" | sha256sum | cut -d' ' -f1)"
|
|
[[ "$DL_SHA" == "$PAYLOAD_SHA" ]] || fail "MCP-downloaded sha $DL_SHA != uploaded sha $PAYLOAD_SHA"
|
|
ok "MCP download bytes match (sha256=$PAYLOAD_SHA)"
|
|
|
|
echo
|
|
ok "E2E PASSED — issue #$ISSUE_INDEX, attachment $ATTACH_ID"
|