Skip to content

feat(forgejo): automate project board management via web routes #133

@kdrgo

Description

@kdrgo

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 workservices/auth/basic.go line 47 explicitly skips non-/api/ paths
  • OAuth2 tokens do NOT workservices/auth/oauth2.go line 221 explicitly skips non-API paths
  • Session cookies are the only auth method for web routes
  • Issue assignment (issue_ids param) uses internal DB IDs, not issue numbers — must resolve via GET /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 JSON

Implementation 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-cmd instead of storing passwords — delegates to rbw, pass, op, etc.
  • Auto re-login_curl wrapper 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-project script location and usage

Full Research

See: .agents/.deepwork/jobs/repo/forgejo-project-api-research.md

Metadata

Metadata

Assignees

Labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions