-
Notifications
You must be signed in to change notification settings - Fork 1
Description
Context
Forgejo has no REST API for project boards. The upstream Gitea PR (go-gitea/gitea#28111) to add project API endpoints has been open since Nov 2023 with no merge in sight.
However, investigation of the Forgejo source code (routers/web/repo/projects.go) reveals that all board operations are automatable via curl using the web UI's internal HTTP endpoints with session cookie authentication.
Findings
What works (all tested against git.ncrmro.com)
| Operation | Method | Endpoint | Auth | Response |
|---|---|---|---|---|
| Login | POST | /user/login |
form: user_name, password |
303 + session cookie |
| Create project | POST | /{owner}/{repo}/projects/new |
session cookie | 303 redirect |
| Add column | POST | /{owner}/{repo}/projects/{id} |
session cookie | {"ok":true} |
| Assign issue to project | POST | /{owner}/{repo}/issues/projects |
session cookie | {"ok":true} |
| Move issue between columns | POST | /{owner}/{repo}/projects/{id}/{colID}/move |
session cookie + JSON body | {"ok":true} |
| Edit column | PUT | /{owner}/{repo}/projects/{id}/{colID} |
session cookie | {"ok":true} |
| Set default column | POST | /{owner}/{repo}/projects/{id}/{colID}/default |
session cookie | {"ok":true} |
| Delete column | DELETE | /{owner}/{repo}/projects/{id}/{colID} |
session cookie | JSON |
| Close/open project | POST | /{owner}/{repo}/projects/{id}/{open|close} |
session cookie | JSON redirect |
| Delete project | POST | /{owner}/{repo}/projects/{id}/delete |
session cookie | {"redirect":"..."} |
Why it works without CSRF tokens
Forgejo uses Go 1.25's net/http.CrossOriginProtection which only blocks browser cross-origin requests. Plain curl (no Sec-Fetch-Site or Origin headers) passes the check by design.
Auth limitations
- API tokens do NOT work —
services/auth/basic.goline 47 explicitly skips non-/api/paths - OAuth2 tokens do NOT work —
services/auth/oauth2.goline 221 explicitly skips non-API paths - Session cookies are the only auth method for web routes
- Issue assignment (
issue_idsparam) uses internal DB IDs, not issue numbers — must resolve viaGET /api/v1/repos/{owner}/{repo}/issues/{number}first
Form parameters
CreateProjectForm:
Title string (required, max 100)
Content string (description)
TemplateType int (0=none, 1=basic kanban, 2=bug triage)
CardType int (card display type)
EditProjectColumnForm:
Title string (required, max 100)
Sorting int8 (column sort order)
Color string (hex color, max 7 chars)
MoveIssues (JSON body):
{"issues": [{"issueID": <internal_id>, "sorting": <int>}]}
New Requirements
1. forgejo-project CLI script
Location: bin/forgejo-project (in keystone or agents repo — TBD)
The script wraps Forgejo's web routes into a gh project-like CLI. It should be a single Bash script with no dependencies beyond curl and jq.
Interface
# Auth — login once, cache session cookie at ~/.local/state/forgejo-project/cookies.txt
forgejo-project login --host git.ncrmro.com --user drago --password-cmd "rbw get mail.ncrmro.com --field password"
# Project CRUD
forgejo-project create --repo ncrmro/agents --title "v1.0" --template basic-kanban
forgejo-project list --repo ncrmro/agents # parse HTML, output JSON
forgejo-project close --repo ncrmro/agents --project 5
forgejo-project open --repo ncrmro/agents --project 5
forgejo-project delete --repo ncrmro/agents --project 5
# Column CRUD
forgejo-project column add --repo ncrmro/agents --project 5 --title "In Review" --color "#0075ca"
forgejo-project column list --repo ncrmro/agents --project 5 # parse HTML, output JSON
forgejo-project column edit --repo ncrmro/agents --project 5 --column 3 --title "Reviewing" --color "#e4e669"
forgejo-project column default --repo ncrmro/agents --project 5 --column 1
forgejo-project column delete --repo ncrmro/agents --project 5 --column 3
# Issue management
forgejo-project item add --repo ncrmro/agents --project 5 --issue 42 # resolves #42 → internal ID via API
forgejo-project item move --repo ncrmro/agents --project 5 --issue 42 --column 3
forgejo-project item list --repo ncrmro/agents --project 5 # parse HTML, output JSONImplementation details
#!/usr/bin/env bash
set -euo pipefail
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/forgejo-project"
COOKIE_JAR="$STATE_DIR/cookies.txt"
# ── Helpers ──────────────────────────────────────────────────────────
_host() { # read from --host flag or FORGEJO_HOST env
echo "${FORGEJO_HOST:?Set FORGEJO_HOST or pass --host}"
}
_base_url() {
echo "https://$(_host)"
}
_curl() {
# All requests go through here.
# Auto-retries login once if session expired (302 to /user/login).
local response http_code
response=$(curl -s -b "$COOKIE_JAR" -c "$COOKIE_JAR" -w "\n%{http_code}" "$@")
http_code=$(echo "$response" | tail -1)
response=$(echo "$response" | sed '$d')
# Session expired? Re-login and retry once.
if [[ "$http_code" == "302" ]] && echo "$response" | grep -q '/user/login'; then
_do_login
response=$(curl -s -b "$COOKIE_JAR" -c "$COOKIE_JAR" -w "\n%{http_code}" "$@")
http_code=$(echo "$response" | tail -1)
response=$(echo "$response" | sed '$d')
fi
echo "$response"
return 0
}
_resolve_issue_id() {
# Issue numbers → internal DB IDs via the REST API (which DOES accept tokens)
local repo="$1" issue_number="$2"
curl -s "$(_base_url)/api/v1/repos/$repo/issues/$issue_number" \
-b "$COOKIE_JAR" | jq -r '.id'
}
# ── Login ────────────────────────────────────────────────────────────
_do_login() {
local password
password=$($PASSWORD_CMD)
mkdir -p "$STATE_DIR"
curl -s -c "$COOKIE_JAR" -X POST "$(_base_url)/user/login" \
-d "user_name=$FORGEJO_USER&password=$password" \
-o /dev/null -w "%{http_code}" | grep -q "303" || {
echo "error: login failed" >&2; exit 1
}
}
cmd_login() {
_do_login
echo "Logged in as $FORGEJO_USER at $(_host)"
}
# ── Projects ─────────────────────────────────────────────────────────
cmd_create() {
local repo="$1" title="$2" template="${3:-1}" # 1=basic-kanban
_curl -X POST "$(_base_url)/$repo/projects/new" \
-d "Title=$(printf '%s' "$title" | jq -sRr @uri)&Content=&TemplateType=$template&CardType=0" \
-o /dev/null
echo "Created project '$title' on $repo"
}
cmd_list() {
local repo="$1"
# GET the projects HTML page, parse project links and titles
_curl "$(_base_url)/$repo/projects" | \
grep -oP 'href="/'"$repo"'/projects/\K[0-9]+(?=")' | \
sort -u
# TODO: enhance to output JSON with titles by parsing more HTML
}
cmd_close() {
local repo="$1" project_id="$2"
_curl -X POST "$(_base_url)/$repo/projects/$project_id/close"
}
cmd_delete() {
local repo="$1" project_id="$2"
_curl -X POST "$(_base_url)/$repo/projects/$project_id/delete"
}
# ── Columns ──────────────────────────────────────────────────────────
cmd_column_add() {
local repo="$1" project_id="$2" title="$3" color="${4:-}"
_curl -X POST "$(_base_url)/$repo/projects/$project_id" \
-d "Title=$(printf '%s' "$title" | jq -sRr @uri)&Color=$color"
}
cmd_column_edit() {
local repo="$1" project_id="$2" column_id="$3" title="$4" color="${5:-}"
_curl -X PUT "$(_base_url)/$repo/projects/$project_id/$column_id" \
-d "Title=$(printf '%s' "$title" | jq -sRr @uri)&Color=$color"
}
cmd_column_default() {
local repo="$1" project_id="$2" column_id="$3"
_curl -X POST "$(_base_url)/$repo/projects/$project_id/$column_id/default"
}
cmd_column_delete() {
local repo="$1" project_id="$2" column_id="$3"
_curl -X DELETE "$(_base_url)/$repo/projects/$project_id/$column_id"
}
# ── Items ────────────────────────────────────────────────────────────
cmd_item_add() {
local repo="$1" project_id="$2" issue_number="$3"
local issue_id
issue_id=$(_resolve_issue_id "$repo" "$issue_number")
_curl -X POST "$(_base_url)/$repo/issues/projects" \
-d "issue_ids=$issue_id&id=$project_id"
}
cmd_item_move() {
local repo="$1" project_id="$2" issue_number="$3" column_id="$4"
local issue_id
issue_id=$(_resolve_issue_id "$repo" "$issue_number")
_curl -X POST "$(_base_url)/$repo/projects/$project_id/$column_id/move" \
-H "Content-Type: application/json" \
-d "{\"issues\": [{\"issueID\": $issue_id, \"sorting\": 0}]}"
}Key design decisions
--password-cmdinstead of storing passwords — delegates torbw,pass,op, etc.- Auto re-login —
_curlwrapper detects session expiry (302 →/user/login) and retries - Issue number → ID resolution — uses the REST API (
/api/v1/) which accepts session cookies, so no separate token needed - HTML parsing for list commands — since there's no JSON list endpoint, parse the HTML page. Keep it minimal (
grep -oP) rather than pulling in a full HTML parser - Single file, no deps beyond curl+jq — must work in any Nix devshell without extra packages
2. Update repo job board steps
The repo DeepWork job currently outputs "manual web UI instructions" for Forgejo boards. With forgejo-project, the check_boards and audit_boards steps should automate Forgejo boards the same way they automate GitHub boards.
3. Convention update
Update tool.forgejo convention to document:
- Project board web route automation capability
- Session cookie auth requirement
- The
forgejo-projectscript location and usage
Full Research
See: .agents/.deepwork/jobs/repo/forgejo-project-api-research.md