Skip to content

Gitea Integration

Taskbase can optionally mirror tasks as Gitea issues — one issue per task in the linked repository. The sync is bidirectional for issue lifecycle events: outbound (Taskbase → Gitea) for task create/update/close/reopen and agent progress comments, inbound (Gitea → Taskbase) via webhooks for issue edits, state changes, comments, and PR references. See the Gitea → Taskbase (Webhooks) section for the inbound flow.

Activation

Sync is opt-in at the project level. Both the organization's gitea_org field and the project's gitea_repo field must be set for sync to be active on that project. Once both are set, the API auto-registers a webhook on the target repo — no manual per-repo setup.

The sections below walk through each step. If you are enabling sync for an existing Taskbase project, jump straight to Enabling a project.

Prerequisites (platform-level, one-time)

These are done once per Taskbase deployment and already in place for the prod environment. You only need to revisit them when standing up a new deployment or rotating credentials.

Item Where Notes
taskbase-bot user in Gitea gitea.prod.skatzi.com site admin Service account that owns the PAT and is named in GITEA_BOT_LOGIN (webhook events from this user are dropped as loop echoes).
Bot PAT User Settings → Applications → Tokens (logged in as the bot) Scopes: read:user, write:issue, write:repository. write:repository is required to manage webhooks; write:issue alone will 403 on hook creation.
hetzner-mgmt/taskbase/gitea / token OpenBao The bot PAT. Consumed as GITEA_TOKEN.
hetzner-mgmt/taskbase/gitea-webhook / secret OpenBao Random HMAC (openssl rand -hex 32). Consumed as GITEA_WEBHOOK_SECRET and passed to Gitea when registering each webhook.
hetzner-mgmt/taskbase/gitea-webhook / bot-login OpenBao Bot username (e.g. taskbase-bot). Consumed as GITEA_BOT_LOGIN.
PUBLIC_BASE_URL env deploy/prod/api-deployment.yaml External URL of the Taskbase API (e.g. https://taskbase.skatzi.com). Gitea uses this to reach our webhook endpoint. Unset in dev — registration becomes a no-op.

After changing any OpenBao value, trigger an ExternalSecret refresh and restart the deployment so the new secret reaches the pod:

kubectl -n tasks-prod annotate externalsecret tasks-gitea force-sync="$(date +%s)" --overwrite
kubectl -n tasks-prod rollout restart deploy platform-tasks-api

Enabling a Gitea org on a Taskbase organization

Per org, done once. The bot must be a member of the Gitea org with Admin on the repos that will be linked — webhook management requires Admin, not Write. Easiest path: add taskbase-bot to the Owners team of the Gitea org. If you prefer a narrower team, grant it Code: Admin on the repos you plan to link.

Then set gitea_org on the Taskbase organization:

curl -sH "X-API-Key: $TASKBASE_PROD_API_KEY" -XPUT \
  https://taskbase.skatzi.com/api/organizations/{orgId} \
  -H 'Content-Type: application/json' \
  -d '{"name":"Skatzi","description":"...","gitea_org":"Skatzi"}'

gitea_org is the owner name as it appears in Gitea URLs (gitea.prod.skatzi.com/<gitea_org>/<repo>). It's case-sensitive.

Enabling a project

Per project, done once. Set gitea_repo to the repository name (not a URL):

curl -sH "X-API-Key: $TASKBASE_PROD_API_KEY" -XPUT \
  https://taskbase.skatzi.com/api/projects/{id} \
  -H 'Content-Type: application/json' \
  -d '{"name":"Platform","description":"...","gitea_repo":"platform"}'

The API validates that the repo exists before persisting the link. If Gitea responds with 404, the PUT is rejected with HTTP 400 and {"error":"gitea repo not found"} — catches typos and wrong capitalisation before they produce a broken project. Other errors (network, 5xx) are treated as soft-fails: the link is saved and the webhook registration step is attempted anyway, so a Gitea outage can't block project edits.

As soon as this PUT succeeds, the API:

  1. Calls POST /api/v1/repos/{gitea_org}/{gitea_repo}/hooks to register the webhook.
  2. Stores the returned hook ID in projects.gitea_webhook_id.

Verify:

curl -sH "X-API-Key: $TASKBASE_PROD_API_KEY" \
  https://taskbase.skatzi.com/api/projects/{id} | jq '{gitea_repo, gitea_webhook_id}'

gitea_webhook_id should be non-null. You should also see a new webhook at gitea.prod.skatzi.com/{gitea_org}/{gitea_repo}/settings/hooks. Smoke-test by editing an issue title in Gitea and confirming the linked task updates in Taskbase.

Pre-existing gitea_repo values

Auto-registration fires only on a change to gitea_repo. If a project already had gitea_repo set before auto-registration shipped, a plain save won't re-trigger it. Toggle the field: PUT with gitea_repo: null, then PUT again with the original value.

Disabling sync

Clearing gitea_repo (PUT with null) deletes the webhook and stops sync for that project. Deleting the project itself also deletes the webhook. Existing issues in Gitea stay untouched.

Troubleshooting

Symptom Likely cause
PUT /api/projects/{id} returns 400 gitea repo not found The gitea_repo value doesn't match any repo under the org's gitea_org. Check capitalisation (Gitea is case-sensitive) and confirm the repo exists at gitea.prod.skatzi.com/{gitea_org}/{gitea_repo}.
gitea_webhook_id stays null after setting gitea_repo Registration failed and was logged non-fatally. Check the pod logs: kubectl -n tasks-prod logs -l app=platform-tasks-api --tail=200 \| grep -i gitea.
Logs show HTTP 403 ... required=[write:repository] The PAT in hetzner-mgmt/taskbase/gitea is missing write:repository scope. Regenerate as the bot, update OpenBao, force-sync, restart the pod.
Logs show HTTP 403 without a scope complaint Bot lacks Admin on the target repo. Add it to the Owners team of the Gitea org (or give its team Code: Admin).
Webhook created but events never arrive PUBLIC_BASE_URL points at an address Gitea can't reach (dev/staging). Registration succeeded; delivery fails. Check the Recent Deliveries tab on the Gitea webhook.
Inbound webhook returns 401 HMAC mismatch — usually means the OpenBao secret was rotated but the pod wasn't restarted, or existing webhooks still hold the old config.secret. Toggle the project's gitea_repo to re-register with the new value.
Inbound webhook returns 503 GITEA_WEBHOOK_SECRET is empty in the pod env. Secret missing or not mounted.

What gets synced

Taskbase event Gitea action
Task created Issue created with title and description (Markdown)
Task title or description updated Issue updated
Task marked done = true Issue closed
Task reopened (done = false) Issue reopened
Task deleted Issue hard-deleted (destructive — see note below)
Agent posts a progress note Comment added to issue

Task delete is destructive

Deleting a task in Taskbase permanently removes the linked Gitea issue and all of its comments. This is intentional: a task delete signals "this should never have existed" (e.g. duplicate, spam, mis-clicked create), so the mirror issue is retracted with it. Use task close (set done=true or move to the Done column) when you simply want to mark work as finished — that maps to PATCH state=closed on the Gitea side and preserves history.

Tasks created before sync was enabled are not back-filled. Only changes after activation trigger Gitea calls.

Gitea API — Investigation Notes

Gitea exposes a REST API at https://gitea.prod.skatzi.com/api/v1/.

Auth

Personal Access Token (PAT) via Authorization: token <pat> header. A service account PAT with read:user, write:issue, and write:repository scopes is required. No OAuth app is needed for server-side sync.

The PAT is stored in OpenBao at hetzner-mgmt/taskbase/gitea under key token and injected as GITEA_TOKEN env var in the Taskbase API deployment via External Secrets Operator. See Prerequisites above for the full secret layout.

Endpoints used

Operation Endpoint
Create issue POST /repos/{owner}/{repo}/issues
Update issue title/body PATCH /repos/{owner}/{repo}/issues/{index}
Close issue PATCH /repos/{owner}/{repo}/issues/{index} with {"state": "closed"}
Reopen issue PATCH /repos/{owner}/{repo}/issues/{index} with {"state": "open"}
Delete issue DELETE /repos/{owner}/{repo}/issues/{index}
Add comment POST /repos/{owner}/{repo}/issues/{index}/comments

The POST /issues response includes a stable number field (the issue number within the repo) that Taskbase stores in tasks.gitea_issue_number for subsequent updates.

Rate limits

Our self-hosted Gitea has no enforced rate limits. If limits are added in future, the client should handle 429 Too Many Requests with exponential backoff.

Error handling

Gitea sync failures are non-fatal — a task update succeeds even if the Gitea API call fails. Errors are logged by the Taskbase API server. In v1 there is no retry queue; the operator can manually sync if needed.

Configuration

Env var Description Example
GITEA_BASE_URL Base URL of the Gitea instance https://gitea.prod.skatzi.com
GITEA_TOKEN Personal Access Token for the service account (from OpenBao)
GITEA_WEBHOOK_SECRET HMAC secret shared with Gitea for signing inbound webhooks (from OpenBao)
GITEA_BOT_LOGIN Username that owns GITEA_TOKEN; webhook events from this user are ignored to prevent loops taskbase-bot
PUBLIC_BASE_URL External URL of the Taskbase API; used when auto-registering webhooks. If unset, auto-registration is skipped (dev). https://taskbase.skatzi.com

GITEA_TOKEN, GITEA_BASE_URL are all optional. If GITEA_TOKEN is empty, Gitea sync (both directions) is globally disabled regardless of per-project configuration.

Gitea → Taskbase (Webhooks)

Inbound webhooks keep Taskbase state in sync with changes made on the Gitea side: issue edits, state changes, comments, and pull requests that reference an issue.

Endpoint

POST /api/webhooks/gitea — single endpoint, dispatches on the X-Gitea-Event header.

Event scope (v1)

Gitea event Header value Taskbase effect
Issue opened issues / action=opened Ignored — issues created outside Taskbase are not imported. Responds 200.
Issue edited (title or body) issues / action=edited Update tasks.title and tasks.description.
Issue closed issues / action=closed Move task to the project's Done column and set done=true.
Issue reopened issues / action=reopened Clear done=false; keep group unless it is Done, in which case move back to Backlog.
Issue comment created/edited/deleted issue_comment Upsert / soft-delete a row in task_comments keyed on gitea_comment_id. Orphan comments (issue not linked to a task) are logged and dropped.
Pull request opened/closed/reopened referencing an issue pull_request For each #<N> ref in the PR title or body that matches a linked task, insert a synthetic row in task_comments (with gitea_comment_id=null) summarising the PR action. Merge does not auto-close the task — humans move the card.

Label, assignee, and milestone events are out of scope for v1; they will 200 and log.

Signature verification

Gitea signs webhook bodies with HMAC-SHA256 using the configured secret. The hex digest is sent in X-Gitea-Signature. The handler:

  1. Reads the raw request body.
  2. Computes hmac.SHA256(GITEA_WEBHOOK_SECRET, body) and compares (constant-time) to the header value.
  3. On mismatch, returns 401 and does not process.

Task lookup

  1. repository.full_name (e.g. Skatzi/platform) is split and matched to a project via organizations.gitea_org + projects.gitea_repo.
  2. issue.number is matched to a task via tasks.gitea_issue_number + that project id.
  3. If either lookup fails, respond 200 with a log line. This prevents Gitea from retrying forever on unlinked repos or orphan issues.

Idempotency and redelivery

Handlers are idempotent by construction — every operation is "set state to X" or "upsert by Gitea ID". Redelivery is safe. The X-Gitea-Delivery UUID is recorded in the structured log for debugging but is not stored.

Loop prevention

When Taskbase writes to Gitea, Gitea immediately fires the webhook back. The handler drops events whose sender.login == GITEA_BOT_LOGIN. This string-match is sufficient as long as the bot PAT is not used by any other automation.

Auto-registration

Webhooks are registered automatically by the Taskbase API when a project's gitea_repo is set (or changes) — no manual setup per repo. All three of the following must be true:

  • the project has a non-empty gitea_repo
  • its organization has a non-empty gitea_org
  • the deployment has PUBLIC_BASE_URL and GITEA_WEBHOOK_SECRET set

When these conditions are met, the API calls:

POST /api/v1/repos/{owner}/{repo}/hooks
{
  "type": "gitea",
  "active": true,
  "events": ["issues", "issue_comment", "pull_request"],
  "config": {
    "url": "{PUBLIC_BASE_URL}/api/webhooks/gitea",
    "content_type": "json",
    "secret": "{GITEA_WEBHOOK_SECRET}"
  }
}

The returned hook ID is stored on the project in the gitea_webhook_id column so it can be removed later. When gitea_repo changes, the old hook is deleted (DELETE /repos/{owner}/{repo}/hooks/{id}) and a new one is registered against the new repo. Clearing gitea_repo or deleting the project also deletes the hook. Registration failures are non-fatal — they log and leave gitea_webhook_id null; the project CRUD call itself still succeeds. An operator can rerun registration by toggling the gitea_repo field.

In local development PUBLIC_BASE_URL is typically unset, in which case registration is a no-op (dev is not reachable from the prod Gitea anyway).

Schema additions

ALTER TABLE projects
    ADD COLUMN gitea_webhook_id BIGINT DEFAULT NULL;

CREATE TABLE task_comments (
    id               BIGSERIAL PRIMARY KEY,
    task_id          BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
    gitea_comment_id BIGINT UNIQUE,
    author_login     TEXT NOT NULL,
    body             TEXT NOT NULL,
    created_at       TIMESTAMPTZ NOT NULL,
    updated_at       TIMESTAMPTZ NOT NULL,
    deleted_at       TIMESTAMPTZ DEFAULT NULL
);

CREATE INDEX ON task_comments (task_id, created_at);

gitea_comment_id is unique to deduplicate redelivery; nullable so the table can later hold Taskbase-originated comments. Deletion is soft so a user who accidentally deletes in Gitea can see the ghost row in history.

Surfacing activity in the UI

Mirrored comments and PR activity are exposed at GET /api/tasks/{id}/comments and rendered as an Activity section in the task detail modal. Comments with a non-null gitea_comment_id deep-link back to the issue page on Gitea; synthetic PR-activity rows render with a dashed border to distinguish them from real comments.

Out of scope (deferred to v2)

  • Bi-directional comment authoring (v1 is read-only; users comment in Gitea).
  • Assignee / label / milestone sync.
  • Bulk import of pre-existing Gitea issues as tasks.
  • Automatic issue close on PR merge (kept as human action so the kanban stays the source of truth for workflow state).