forgejo-mcp/demos/label-management.md
Christoph Görn bc3f3dbdec
docs: 📝 add label-management showboat demo + README index entry
Live demo covering create/edit/delete/get for repo and org labels,
color normalisation, safe-delete in-use guard, and all three
URI-addressable resource templates captured against codeberg.org.
2026-06-10 08:33:35 +02:00

320 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Label CRUD + resource-templates (create / edit / delete / get)
*2026-06-10T06:30:07Z by Showboat dev*
<!-- showboat-id: 88dd08cd-baeb-455b-b2e4-85f1b22e65d6 -->
*Captured: 2026-06-10 via Showboat*
<!-- captured-for: PR #192 / Codeberg #190 -->
<!-- captured-at: 2026-06-10 -->
<!-- captured-against: 00221ef -->
## Background
`forgejo-mcp` previously only offered label *read* and *assignment* tools
(`list_repo_labels`, `list_org_labels`, `add_issue_labels`,
`remove_issue_labels`). Creating, renaming, recoloring, or deleting a label
required falling back to raw `curl`.
This change adds the missing lifecycle half:
**Repo-label CRUD (via `forgejo-sdk/v3`):**
- `create_repo_label` — create a label, get its numeric id back
- `edit_repo_label` — PATCH one or more fields (only supplied fields change)
- `delete_repo_label` — safe-by-default delete: refuses when the label is in
use and reports the reference count; `delete_mode=force` overrides
- `get_repo_label` — read one label by id
**Org-label CRUD (via raw-HTTP `DoJSON` — no SDK method exists):**
- `create_org_label` / `edit_org_label` / `delete_org_label` / `get_org_label`
— same shape as the repo tools; org in-use count is best-effort over repos
the token can see
**Three URI-addressable resource templates:**
- `forgejo://repo/{owner}/{repo}/label/{id}` — single label
- `forgejo://repo/{owner}/{repo}/labels{?page,limit}` — bounded list
- `forgejo://org/{org}/labels{?page,limit}` — bounded org-label list
All eight tools share a `color` normaliser that accepts `rrggbb` or `#rrggbb`
(6-digit hex only) and prepends `#` if absent.
## Replay setup
```bash
export FORGEJO_URL=https://codeberg.org
export FORGEJO_ACCESS_TOKEN=<your-token>
export FORGEJO_MCP_BIN="${FORGEJO_MCP_BIN:-./forgejo-mcp}"
make build # produces ./forgejo-mcp
```
Spec: `openspec/changes/label-crud/specs/label-crud/spec.md`
`openspec/changes/label-crud/specs/mcp-resource-label/spec.md`
Issue: codeberg.org/goern/forgejo-mcp/issues/190
## 1. Tool surface
```bash
${FORGEJO_MCP_BIN:-./forgejo-mcp} --cli list 2>/dev/null | grep -E "(create|edit|delete|get)_(repo|org)_label"
```
```output
create_org_label Create an organization-level label. Returns the created label including its numeric id.
create_repo_label Create a repository label. Returns the created label including its numeric id.
delete_org_label Delete an organization-level label. By default refuses if the label is in use; set delete_mode=force to override. Note: in-use count is best-effort over repos visible to the token and may under-count.
delete_repo_label Delete a repository label. By default refuses if the label is in use; set delete_mode=force to override.
edit_org_label Edit an organization-level label (PATCH — only supplied fields change). Providing no fields is an error.
edit_repo_label Edit a repository label (PATCH — only supplied fields change). Providing no fields is an error.
get_org_label Get a single organization-level label by ID.
get_repo_label Get a single repository label by ID.
```
## 2. create_repo_label — full create/read cycle
Create a label, then read it back by id to verify the returned fields.
```bash
${FORGEJO_MCP_BIN:-./forgejo-mcp} --cli create_repo_label \
--args '{"owner":"goern","repo":"forgejo-mcp","name":"Demo/Label","color":"0e8a16","description":"Showboat demo label"}' 2>/dev/null | python3 -c "
import sys, json
data = json.loads(sys.stdin.read())
r = json.loads(data[0][\"text\"])[\"Result\"]
print(f\"created: id={r[\"id\"]} name={r[\"name\"]} color={r[\"color\"]} description={r[\"description\"]}\")
"
```
```output
created: id=1761185 name=Demo/Label color=0e8a16 description=Showboat demo label
```
Color (no leading ) was normalised to by the server before it reached the API — no . The returned uid=1000(goern) gid=1000(goern) groups=1000(goern),10(wheel) (1761185) is what all subsequent tools use.
Color `0e8a16` (no leading `#`) was normalised to `#0e8a16` before reaching the API — no 422. The returned id (1761185) is what all subsequent tools use.
## 3. get_repo_label — read one label by id
```bash
${FORGEJO_MCP_BIN:-./forgejo-mcp} --cli get_repo_label \
--args '{"owner":"goern","repo":"forgejo-mcp","id":1761185}' 2>/dev/null | python3 -c "
import sys, json
data = json.loads(sys.stdin.read())
r = json.loads(data[0][\"text\"])[\"Result\"]
print(f\"id={r[\"id\"]} name={r[\"name\"]} color={r[\"color\"]} url={r[\"url\"]}\")
"
```
```output
id=1761185 name=Demo/Label color=0e8a16 url=https://codeberg.org/api/v1/repos/goern/forgejo-mcp/labels/1761185
```
## 4. edit_repo_label — PATCH semantics (only supplied fields change)
Change only the color; name and description stay untouched.
```bash
${FORGEJO_MCP_BIN:-./forgejo-mcp} --cli edit_repo_label \
--args '{"owner":"goern","repo":"forgejo-mcp","id":1761185,"color":"0288d1"}' 2>/dev/null | python3 -c "
import sys, json
data = json.loads(sys.stdin.read())
r = json.loads(data[0][\"text\"])[\"Result\"]
print(f\"id={r[\"id\"]} name={r[\"name\"]} color={r[\"color\"]} description={r[\"description\"]}\")
"
```
```output
id=1761185 name=Demo/Label color=0288d1 description=Showboat demo label
```
Color changed to `0288d1`; `name` and `description` are identical to the values set on creation. PATCH semantics confirmed.
## 5. Invalid color is rejected at the boundary
No network call is made; the server returns an error before touching the API.
```bash
${FORGEJO_MCP_BIN:-./forgejo-mcp} --cli create_repo_label \
--args '{"owner":"goern","repo":"forgejo-mcp","name":"Bad","color":"not-a-color"}' 2>&1 | head -3
```
```output
2026-06-10 08:30:59 ERROR to/to.go:45 color must be a 6-digit hex string (e.g. #0088ff or 0088ff), got "not-a-color"
codeberg.org/goern/forgejo-mcp/v2/pkg/to.ErrorResult
/var/home/goern/Source/codeberg.org/goern/forgejo-mcp/pkg/to/to.go:45
```
## 6. delete_repo_label — safe-by-default guard
### 6a. Refuse when in use (safe mode)
First apply the label to an issue, then attempt deletion without force.
```bash
${FORGEJO_MCP_BIN:-./forgejo-mcp} --cli add_issue_labels \
--args '{"owner":"goern","repo":"forgejo-mcp","index":190,"labels":"1761185"}' 2>/dev/null | python3 -c "
import sys, json
data = json.loads(sys.stdin.read())
r = json.loads(data[0][\"text\"])[\"Result\"]
labels = [l[\"name\"] for l in r[\"labels\"]]
print(f\"issue #190 labels: {labels}\")
"
```
```output
issue #190 labels: ['Demo/Label', 'Kind/Feature', 'Priority/High']
```
```bash
${FORGEJO_MCP_BIN:-./forgejo-mcp} --cli delete_repo_label \
--args '{"owner":"goern","repo":"forgejo-mcp","id":1761185}' 2>&1 | grep -v "^codeberg" | head -3
```
```output
2026-06-10 08:31:10 INFO forgejo/forgejo.go:79 Successfully created Forgejo client {"url": "https://codeberg.org/", "token_configured": true, "user_agent": "forgejo-mcp/2.28.0-dev+00221ef"}
2026-06-10 08:31:10 ERROR to/to.go:45 label "Demo/Label" is used by 1 issue(s)/PR(s); set delete_mode=force to delete anyway
/var/home/goern/Source/codeberg.org/goern/forgejo-mcp/pkg/to/to.go:45
```
`Demo/Label` is applied to issue #190, so the safe-mode guard fires and reports the count. No label was deleted.
### 6b. Force delete removes the label even when in use
```bash
${FORGEJO_MCP_BIN:-./forgejo-mcp} --cli delete_repo_label \
--args '{"owner":"goern","repo":"forgejo-mcp","id":1761185,"delete_mode":"force"}' 2>/dev/null | python3 -c "
import sys, json
data = json.loads(sys.stdin.read())
r = json.loads(data[0][\"text\"])[\"Result\"]
print(f\"deleted={r[\"deleted\"]} id={r[\"id\"]}\")
"
```
```output
deleted=True id=1761185
```
## 7. Resource templates — URI-addressable labels
### 7a. List registered templates
## 7. Resource templates — URI-addressable labels
Three new resource templates let MCP-resource-aware clients read labels by URI
without tool calls. These are additive — existing tools are unchanged.
| URI | What it returns |
|-----|----------------|
| | Single label (JSON) |
| | Bounded list, cap 30 |
| | Bounded org-label list, cap 30 |
The demo uses the MCP stdio transport directly.
## 7. Resource templates — URI-addressable labels
Three new resource templates let MCP-resource-aware clients read labels by URI
without tool calls. These are additive — existing tools are unchanged.
The new templates:
- `forgejo://repo/{owner}/{repo}/label/{id}` — single label (JSON)
- `forgejo://repo/{owner}/{repo}/labels{?page,limit}` — bounded list, cap 30
- `forgejo://org/{org}/labels{?page,limit}` — bounded org-label list, cap 30
The demo uses the MCP stdio transport directly.
### 7a. resources/templates/list — confirm the three label templates are registered
```bash
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"showboat","version":"1"}}}
{"jsonrpc":"2.0","id":2,"method":"resources/templates/list","params":{}}
' | ${FORGEJO_MCP_BIN:-./forgejo-mcp} -t stdio -url "$FORGEJO_URL" -token "$FORGEJO_ACCESS_TOKEN" 2>/dev/null \
| jq -r 'select(.id==2) | .result.resourceTemplates[].uriTemplate' | grep label
```
```output
forgejo://org/{org}/labels
forgejo://repo/{owner}/{repo}/label/{id}
forgejo://repo/{owner}/{repo}/labels
```
### 7b. Read single label — forgejo://repo/{owner}/{repo}/label/{id}
```bash
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"showboat","version":"1"}}}
{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"forgejo://repo/goern/forgejo-mcp/label/335058"}}
' | ${FORGEJO_MCP_BIN:-./forgejo-mcp} -t stdio -url "$FORGEJO_URL" -token "$FORGEJO_ACCESS_TOKEN" 2>/dev/null \
| jq 'select(.id==2) | .result.contents[0].text | fromjson'
```
```output
{
"id": 335058,
"name": "Kind/Feature",
"color": "0288d1",
"description": "New functionality",
"url": "https://codeberg.org/api/v1/repos/goern/forgejo-mcp/labels/335058"
}
```
### 7c. Read bounded label list — forgejo://repo/{owner}/{repo}/labels
```bash
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"showboat","version":"1"}}}
{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"forgejo://repo/goern/forgejo-mcp/labels"}}
' | ${FORGEJO_MCP_BIN:-./forgejo-mcp} -t stdio -url "$FORGEJO_URL" -token "$FORGEJO_ACCESS_TOKEN" 2>/dev/null \
| jq 'select(.id==2) | .result.contents[0].text | fromjson | {owner, repo, label_count: (.labels | length), truncated}'
```
```output
{
"owner": "goern",
"repo": "forgejo-mcp",
"label_count": 26,
"truncated": null
}
```
26 labels, under the 30-item cap — no truncation sentinel emitted. For a repo with more than 30 labels, the response would include and a field naming as the enumeration fallback.
26 labels, under the 30-item cap — no truncation sentinel emitted.
For repos with more than 30 labels the response includes `truncated: true`
and a `sentinel` field naming `list_repo_labels` as the unbounded fallback.
### 7d. Read org-label list — forgejo://org/{org}/labels
```bash
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"showboat","version":"1"}}}
{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"forgejo://org/forgejo/labels"}}
' | ${FORGEJO_MCP_BIN:-./forgejo-mcp} -t stdio -url "$FORGEJO_URL" -token "$FORGEJO_ACCESS_TOKEN" 2>/dev/null \
| jq 'select(.id==2) | .result.contents[0].text | fromjson | {org, label_count: (.labels | length), truncated, list_tool}'
```
```output
{
"org": "forgejo",
"label_count": 17,
"truncated": null,
"list_tool": null
}
```
## 8. End-to-end: autonomous label bootstrap workflow
An agent setting up a new repository can bootstrap a full label taxonomy
without leaving the MCP loop:
1. `create_repo_label` for each label — color normalised automatically,
numeric id returned for immediate use in `add_issue_labels`.
2. `get_repo_label` to verify a specific label is what the agent expects.
3. `edit_repo_label` to rename or recolor a label after reviewing issue
distributions — only the changed field is sent upstream.
4. `delete_repo_label` (safe mode) to clean up stale labels — the in-use
guard prevents silent data loss; `delete_mode=force` only when the agent
has confirmed the label should be stripped from all referencing issues.
5. Resource URIs (`forgejo://repo/{owner}/{repo}/labels`) let resource-aware
clients cache the current label set between calls without a separate
`list_repo_labels` invocation.
For org-wide triage (e.g. applying a shared priority taxonomy across multiple
repos), the same workflow applies to `create_org_label` / `edit_org_label` /
`delete_org_label` — the org-level delete guard counts usage across all
org repos the token can read and discloses when the count may be
under-reported due to inaccessible repos.