fix(gitea-labels-milestones): read exclusive per label, never infer it

SKILL.md called `exclusive` "an org-labels-only flag" and concluded that
applying a Kind/*, Priority/* or Status/* label "must replace the one
already there, not stack on it". Live `list_repo_labels` on this repo
returns `exclusive` on every REPO label: all seven Kind/* plus
Compat/Breaking are false, while Priority/*, Reviewed/* and Status/* are
true. So the field is not org-only, and the replace rule would strip a
valid Kind/* label — a destructive write from a false premise.

The nuance kept: label_write's `exclusive` parameter genuinely is
annotated org-only, so that row was schema-accurate. The error was
generalising a write-parameter restriction into a claim about where the
field exists. The row is qualified rather than deleted.

The rule is now per-label: where exclusive is true the server drops the
sibling itself, so do not pre-remove; where it is false the label is
legitimately stackable.

Also fixes the org-label fallback, which treated any list_org_labels
failure as proof the owner is a user account with no org pool and said so
was "an answer, not an error to report". Under the token scopes this skill
declares the call fails with required=[read:organization] before any
org-vs-user determination is made, so a capability gap was being reported
as an absent label. Scope errors are now distinguished and reported.

Verified live against gitea-mcp v1.7.0, read-only calls.

Refs #99
This commit is contained in:
2026-08-30 20:51:12 +00:00
parent dd1981db80
commit 8982ac58b7
6 changed files with 94 additions and 54 deletions

View File

@@ -24,7 +24,7 @@ allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__m
- **Applying a label takes a numeric ID, but issue/PR responses slim labels down to name strings.** An issue's existing labels yield no IDs — resolve name → ID with `label_read`. - **Applying a label takes a numeric ID, but issue/PR responses slim labels down to name strings.** An issue's existing labels yield no IDs — resolve name → ID with `label_read`.
- **`pull_request_read` returns `milestone` as a bare title string** where `issue_read` returns `{id, title}` — recover the milestone's ID by listing milestones and matching the title. - **`pull_request_read` returns `milestone` as a bare title string** where `issue_read` returns `{id, title}` — recover the milestone's ID by listing milestones and matching the title.
- **`Kind/*`/`Priority/*`/`Status/*` exclusivity is a client-side convention.** `exclusive` is an org-labels-only flag, so applying a label in such a scope must replace the one already there, not stack on it. - **Never assume a `Kind/*`/`Priority/*`/`Status/*` scope is exclusive — read each label's own `exclusive` field.** `list_repo_labels` returns it per repo label, it is not org-only, and where it is `true` Gitea enforces one-per-scope itself. Replacing rather than stacking on a label whose `exclusive` is `false` destroys a valid label.
## Step 1 — Resolve owner, repo and org ## Step 1 — Resolve owner, repo and org
@@ -36,7 +36,9 @@ git remote get-url origin
If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
The `*_org_label*` methods take `org`, not `owner`/`repo`. Pass that same `owner` as `org` — it is the org name whenever the owner is an organisation, and the remote URL does not say whether it is one. So let the call itself decide: a failure means the owner is a user account with no org label pool, which is an answer, not an error to report. The `*_org_label*` methods take `org`, not `owner`/`repo`. Pass that same `owner` as `org` — it is the org name whenever the owner is an organisation, and the remote URL does not say whether it is one.
Read the failure text before interpreting it. `list_org_labels` needs the `read:organization` token scope, which this skill's declared scopes (`write:issue`, `write:repository`) do not carry, so it fails with `token does not have at least one of required scope(s), required=[read:organization]` *before* it ever determines org-vs-user. Report that: the org pool went unchecked, not empty. Only a not-found response is evidence the owner is a user account with no org pool.
## Step 2 — Dispatch ## Step 2 — Dispatch

View File

@@ -12,19 +12,24 @@ description) to this repo's `Kind/*` / `Priority/*` / `Status/*` label taxonomy.
`gitea-issues` and `gitea-prs` before creating or updating an issue/PR, and directly when the user `gitea-issues` and `gitea-prs` before creating or updating an issue/PR, and directly when the user
asks to label something without naming exact labels. asks to label something without naming exact labels.
## Scoped labels are mutually exclusive — replace, don't stack ## Exclusivity is per label — read it, never infer it
Each of `Kind/*`, `Priority/*`, `Status/*` is treated as a scoped-label group by convention (the `/` Gitea's `exclusive` flag is a real per-label boolean returned by `list_repo_labels`, and where it is
delimiter naming pattern). Gitea's `exclusive` flag — the mechanism that would let the server itself `true` the server enforces one-label-per-scope itself. It is not an org-only setting, and the `/`
enforce one-label-per-scope — is documented as an org-labels-only setting, and the repo-level delimiter in a name says nothing about it. Verified on `Defame1297/holocron`: every `Priority/*`,
`label_write` methods used here don't accept it at all. So exclusivity within these scopes is a `Reviewed/*` and `Status/*` label is `exclusive: true`, while every `Kind/*` label — and
convention this skill enforces client-side, not something the server guarantees: applying a new `Compat/Breaking` — is `exclusive: false` and is used stacked.
label within a scope is expected to replace any existing label in that same scope on the target
issue/PR, not add alongside it. When inference So read each candidate label's own `exclusive` value from the resolution call and branch on it:
selects a `Priority/High` label and the issue already carries `Priority/Medium`, the write should
result in only `Priority/High` remaining — use `replace_labels` scoped to that group's labels, or at - **`exclusive: true`** — the server drops the sibling on write. Add the label and let it; do not
minimum remove the superseded label before adding the new one. Never leave two labels from the same pre-remove the label already there, and do not compute a replacement set client-side. Inferring
scope applied at once. `Priority/High` onto an issue carrying `Priority/Medium` needs no special handling.
- **`exclusive: false`** — **add alongside, never replace.** Stripping a co-existing label in the
same scope destroys a valid one: an issue can legitimately carry `Kind/Bug` and `Kind/Security`
at once.
There is no client-side exclusivity convention for this skill to enforce.
## Signal → label mapping ## Signal → label mapping
@@ -57,16 +62,18 @@ scope applied at once.
1. Read the conversation context (issue/PR title, body, or the triggering discussion) for the 1. Read the conversation context (issue/PR title, body, or the triggering discussion) for the
signals above. signals above.
2. Call `label_read method: "list_repo_labels"` (see `references/labels.md`) to get the current 2. Call `label_read method: "list_repo_labels"` (see `references/labels.md`) to get the current
label set with IDs — inference must never guess an ID, only a name, then resolve it. Because label set with IDs and each label's `exclusive` value — inference must never guess an ID, only a
`exclusive` is an org-labels-only flag, this taxonomy plausibly lives at org scope too: for any name, then resolve it. Both pools can apply to one issue, so for any inferred name absent from
inferred name absent from the repo pool, also call `label_read method: "list_org_labels"` with the repo pool, also call `label_read method: "list_org_labels"` with `org` set to the repo's
`org` set to the repo's `owner` before treating it as unresolved. A failure there means the owner `owner` before treating it as unresolved. Read that call's failure text: a
is a user account, not an organisation, so no org pool exists and the name is genuinely absent. `required=[read:organization]` scope error means the org pool was never queried — report the
3. Match inferred label names against the resolved list (case-insensitive). If a scope group missing token scope rather than reporting the label unresolved. Only a not-found response means
already has a different label applied on the target and a new one is inferred for that same there is no org pool and the name is genuinely absent.
scope, plan to replace rather than add (see above). 3. Match inferred label names against the resolved list (case-insensitive) and carry each match's
`exclusive` value forward: `true` means the server replaces the sibling on write, `false` means
the label is added alongside whatever is already applied (see above).
4. **Low-confidence inference omits the label.** If no signal confidently maps to a `Kind/*` value, 4. **Low-confidence inference omits the label.** If no signal confidently maps to a `Kind/*` value,
do not guess — omit `Kind/*` entirely rather than default to one. `Priority/Medium` is the one do not guess — omit `Kind/*` entirely rather than default to one. `Priority/Medium` is the one
exception: it's the explicit default when no urgency signal is present, not a guess. exception: it's the explicit default when no urgency signal is present, not a guess.
5. Hand the resolved IDs (plus which scopes to replace) to the caller's `issue_write`/ 5. Hand the resolved IDs, each with its `exclusive` value, to the caller's `issue_write`/
`pull_request_write` call — this skill does not apply labels to an issue or PR itself. `pull_request_write` call — this skill does not apply labels to an issue or PR itself.

View File

@@ -37,7 +37,7 @@ or **org-scoped** labels — never both in one call. Pick the method family (`*_
| `name` | string | required for create | | `name` | string | required for create |
| `color` | string | hex `#RRGGBB`, required for create | | `color` | string | hex `#RRGGBB`, required for create |
| `description` | string | optional | | `description` | string | optional |
| `exclusive` | boolean | org labels only | | `exclusive` | boolean | accepted as a *write* param on org creates only — repo labels still carry and enforce `exclusive`, set outside this tool surface |
| `is_archived` | boolean | repo labels only | | `is_archived` | boolean | repo labels only |
Note: unlike `milestone_read`/`milestone_write`, `owner`/`repo`/`org` are **not** schema-required on Note: unlike `milestone_read`/`milestone_write`, `owner`/`repo`/`org` are **not** schema-required on
@@ -53,6 +53,12 @@ label_read method: "list_repo_labels" owner: <owner> repo: <repo> per_page:
Paginate (`page: 1, 2, ...`) until the returned count is less than `per_page`. This is the only way Paginate (`page: 1, 2, ...`) until the returned count is less than `per_page`. This is the only way
to build a complete name → ID map — there is no lookup-by-name endpoint. to build a complete name → ID map — there is no lookup-by-name endpoint.
Every returned repo label carries its own `exclusive` boolean; the field is not org-only. Verified on
`Defame1297/holocron`: all `Priority/*`, `Reviewed/*` and `Status/*` labels are `exclusive: true`,
while all `Kind/*` labels and `Compat/Breaking` are `exclusive: false`. Where it is `true` Gitea
enforces one-label-per-scope server-side; where it is `false` labels in that scope stack legitimately.
Read the field — never infer exclusivity from the `/` in a name.
## Get one label ## Get one label
``` ```
@@ -65,8 +71,10 @@ There is no direct name lookup. List all repo labels (paginating if needed), sca
case-insensitive name match, and extract `id`. Both pools can apply to one issue: if the name is case-insensitive name match, and extract `id`. Both pools can apply to one issue: if the name is
not in `list_repo_labels`, also check `list_org_labels` before reporting it unresolved. That method not in `list_repo_labels`, also check `list_org_labels` before reporting it unresolved. That method
takes `org`, not `owner`/`repo` — pass the repo's `owner` as `org`, which is what it means when the takes `org`, not `owner`/`repo` — pass the repo's `owner` as `org`, which is what it means when the
owner is an organisation. If that call fails, the owner is a user account, there is no org pool, and owner is an organisation. Its failure modes are not interchangeable. `token does not have at least
the miss is a real miss. one of required scope(s), required=[read:organization]` means the org pool was never queried — report
that missing scope rather than reporting the label unresolved. Only a not-found response means the
owner is a user account with no org pool, making the miss a real miss.
Resolution is the required first step before any label application on an issue or PR — the actual Resolution is the required first step before any label application on an issue or PR — the actual
`add_labels`/`replace_labels`/`remove_label` call lives in `gitea-issues`/`gitea-prs` via `add_labels`/`replace_labels`/`remove_label` call lives in `gitea-issues`/`gitea-prs` via
@@ -83,7 +91,10 @@ label_write method: "create_repo_label"
``` ```
For an org label, use `method: "create_org_label"` with `org:` instead of `owner`/`repo`, and For an org label, use `method: "create_org_label"` with `org:` instead of `owner`/`repo`, and
`exclusive: true` if the label belongs to a mutually-exclusive scope group. `exclusive: true` if the label belongs to a mutually-exclusive scope group. `label_write` accepts
`exclusive` on org methods only, so a repo label's exclusivity cannot be set or cleared through this
tool — it is set in the Gitea UI or against the REST API directly, and read back via
`list_repo_labels`.
## Edit a label ## Edit a label

View File

@@ -24,7 +24,7 @@ allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__m
- **Applying a label takes a numeric ID, but issue/PR responses slim labels down to name strings.** An issue's existing labels yield no IDs — resolve name → ID with `label_read`. - **Applying a label takes a numeric ID, but issue/PR responses slim labels down to name strings.** An issue's existing labels yield no IDs — resolve name → ID with `label_read`.
- **`pull_request_read` returns `milestone` as a bare title string** where `issue_read` returns `{id, title}` — recover the milestone's ID by listing milestones and matching the title. - **`pull_request_read` returns `milestone` as a bare title string** where `issue_read` returns `{id, title}` — recover the milestone's ID by listing milestones and matching the title.
- **`Kind/*`/`Priority/*`/`Status/*` exclusivity is a client-side convention.** `exclusive` is an org-labels-only flag, so applying a label in such a scope must replace the one already there, not stack on it. - **Never assume a `Kind/*`/`Priority/*`/`Status/*` scope is exclusive — read each label's own `exclusive` field.** `list_repo_labels` returns it per repo label, it is not org-only, and where it is `true` Gitea enforces one-per-scope itself. Replacing rather than stacking on a label whose `exclusive` is `false` destroys a valid label.
## Step 1 — Resolve owner, repo and org ## Step 1 — Resolve owner, repo and org
@@ -36,7 +36,9 @@ git remote get-url origin
If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
The `*_org_label*` methods take `org`, not `owner`/`repo`. Pass that same `owner` as `org` — it is the org name whenever the owner is an organisation, and the remote URL does not say whether it is one. So let the call itself decide: a failure means the owner is a user account with no org label pool, which is an answer, not an error to report. The `*_org_label*` methods take `org`, not `owner`/`repo`. Pass that same `owner` as `org` — it is the org name whenever the owner is an organisation, and the remote URL does not say whether it is one.
Read the failure text before interpreting it. `list_org_labels` needs the `read:organization` token scope, which this skill's declared scopes (`write:issue`, `write:repository`) do not carry, so it fails with `token does not have at least one of required scope(s), required=[read:organization]` *before* it ever determines org-vs-user. Report that: the org pool went unchecked, not empty. Only a not-found response is evidence the owner is a user account with no org pool.
## Step 2 — Dispatch ## Step 2 — Dispatch

View File

@@ -12,19 +12,24 @@ description) to this repo's `Kind/*` / `Priority/*` / `Status/*` label taxonomy.
`gitea-issues` and `gitea-prs` before creating or updating an issue/PR, and directly when the user `gitea-issues` and `gitea-prs` before creating or updating an issue/PR, and directly when the user
asks to label something without naming exact labels. asks to label something without naming exact labels.
## Scoped labels are mutually exclusive — replace, don't stack ## Exclusivity is per label — read it, never infer it
Each of `Kind/*`, `Priority/*`, `Status/*` is treated as a scoped-label group by convention (the `/` Gitea's `exclusive` flag is a real per-label boolean returned by `list_repo_labels`, and where it is
delimiter naming pattern). Gitea's `exclusive` flag — the mechanism that would let the server itself `true` the server enforces one-label-per-scope itself. It is not an org-only setting, and the `/`
enforce one-label-per-scope — is documented as an org-labels-only setting, and the repo-level delimiter in a name says nothing about it. Verified on `Defame1297/holocron`: every `Priority/*`,
`label_write` methods used here don't accept it at all. So exclusivity within these scopes is a `Reviewed/*` and `Status/*` label is `exclusive: true`, while every `Kind/*` label — and
convention this skill enforces client-side, not something the server guarantees: applying a new `Compat/Breaking` — is `exclusive: false` and is used stacked.
label within a scope is expected to replace any existing label in that same scope on the target
issue/PR, not add alongside it. When inference So read each candidate label's own `exclusive` value from the resolution call and branch on it:
selects a `Priority/High` label and the issue already carries `Priority/Medium`, the write should
result in only `Priority/High` remaining — use `replace_labels` scoped to that group's labels, or at - **`exclusive: true`** — the server drops the sibling on write. Add the label and let it; do not
minimum remove the superseded label before adding the new one. Never leave two labels from the same pre-remove the label already there, and do not compute a replacement set client-side. Inferring
scope applied at once. `Priority/High` onto an issue carrying `Priority/Medium` needs no special handling.
- **`exclusive: false`** — **add alongside, never replace.** Stripping a co-existing label in the
same scope destroys a valid one: an issue can legitimately carry `Kind/Bug` and `Kind/Security`
at once.
There is no client-side exclusivity convention for this skill to enforce.
## Signal → label mapping ## Signal → label mapping
@@ -57,16 +62,18 @@ scope applied at once.
1. Read the conversation context (issue/PR title, body, or the triggering discussion) for the 1. Read the conversation context (issue/PR title, body, or the triggering discussion) for the
signals above. signals above.
2. Call `label_read method: "list_repo_labels"` (see `references/labels.md`) to get the current 2. Call `label_read method: "list_repo_labels"` (see `references/labels.md`) to get the current
label set with IDs — inference must never guess an ID, only a name, then resolve it. Because label set with IDs and each label's `exclusive` value — inference must never guess an ID, only a
`exclusive` is an org-labels-only flag, this taxonomy plausibly lives at org scope too: for any name, then resolve it. Both pools can apply to one issue, so for any inferred name absent from
inferred name absent from the repo pool, also call `label_read method: "list_org_labels"` with the repo pool, also call `label_read method: "list_org_labels"` with `org` set to the repo's
`org` set to the repo's `owner` before treating it as unresolved. A failure there means the owner `owner` before treating it as unresolved. Read that call's failure text: a
is a user account, not an organisation, so no org pool exists and the name is genuinely absent. `required=[read:organization]` scope error means the org pool was never queried — report the
3. Match inferred label names against the resolved list (case-insensitive). If a scope group missing token scope rather than reporting the label unresolved. Only a not-found response means
already has a different label applied on the target and a new one is inferred for that same there is no org pool and the name is genuinely absent.
scope, plan to replace rather than add (see above). 3. Match inferred label names against the resolved list (case-insensitive) and carry each match's
`exclusive` value forward: `true` means the server replaces the sibling on write, `false` means
the label is added alongside whatever is already applied (see above).
4. **Low-confidence inference omits the label.** If no signal confidently maps to a `Kind/*` value, 4. **Low-confidence inference omits the label.** If no signal confidently maps to a `Kind/*` value,
do not guess — omit `Kind/*` entirely rather than default to one. `Priority/Medium` is the one do not guess — omit `Kind/*` entirely rather than default to one. `Priority/Medium` is the one
exception: it's the explicit default when no urgency signal is present, not a guess. exception: it's the explicit default when no urgency signal is present, not a guess.
5. Hand the resolved IDs (plus which scopes to replace) to the caller's `issue_write`/ 5. Hand the resolved IDs, each with its `exclusive` value, to the caller's `issue_write`/
`pull_request_write` call — this skill does not apply labels to an issue or PR itself. `pull_request_write` call — this skill does not apply labels to an issue or PR itself.

View File

@@ -37,7 +37,7 @@ or **org-scoped** labels — never both in one call. Pick the method family (`*_
| `name` | string | required for create | | `name` | string | required for create |
| `color` | string | hex `#RRGGBB`, required for create | | `color` | string | hex `#RRGGBB`, required for create |
| `description` | string | optional | | `description` | string | optional |
| `exclusive` | boolean | org labels only | | `exclusive` | boolean | accepted as a *write* param on org creates only — repo labels still carry and enforce `exclusive`, set outside this tool surface |
| `is_archived` | boolean | repo labels only | | `is_archived` | boolean | repo labels only |
Note: unlike `milestone_read`/`milestone_write`, `owner`/`repo`/`org` are **not** schema-required on Note: unlike `milestone_read`/`milestone_write`, `owner`/`repo`/`org` are **not** schema-required on
@@ -53,6 +53,12 @@ label_read method: "list_repo_labels" owner: <owner> repo: <repo> per_page:
Paginate (`page: 1, 2, ...`) until the returned count is less than `per_page`. This is the only way Paginate (`page: 1, 2, ...`) until the returned count is less than `per_page`. This is the only way
to build a complete name → ID map — there is no lookup-by-name endpoint. to build a complete name → ID map — there is no lookup-by-name endpoint.
Every returned repo label carries its own `exclusive` boolean; the field is not org-only. Verified on
`Defame1297/holocron`: all `Priority/*`, `Reviewed/*` and `Status/*` labels are `exclusive: true`,
while all `Kind/*` labels and `Compat/Breaking` are `exclusive: false`. Where it is `true` Gitea
enforces one-label-per-scope server-side; where it is `false` labels in that scope stack legitimately.
Read the field — never infer exclusivity from the `/` in a name.
## Get one label ## Get one label
``` ```
@@ -65,8 +71,10 @@ There is no direct name lookup. List all repo labels (paginating if needed), sca
case-insensitive name match, and extract `id`. Both pools can apply to one issue: if the name is case-insensitive name match, and extract `id`. Both pools can apply to one issue: if the name is
not in `list_repo_labels`, also check `list_org_labels` before reporting it unresolved. That method not in `list_repo_labels`, also check `list_org_labels` before reporting it unresolved. That method
takes `org`, not `owner`/`repo` — pass the repo's `owner` as `org`, which is what it means when the takes `org`, not `owner`/`repo` — pass the repo's `owner` as `org`, which is what it means when the
owner is an organisation. If that call fails, the owner is a user account, there is no org pool, and owner is an organisation. Its failure modes are not interchangeable. `token does not have at least
the miss is a real miss. one of required scope(s), required=[read:organization]` means the org pool was never queried — report
that missing scope rather than reporting the label unresolved. Only a not-found response means the
owner is a user account with no org pool, making the miss a real miss.
Resolution is the required first step before any label application on an issue or PR — the actual Resolution is the required first step before any label application on an issue or PR — the actual
`add_labels`/`replace_labels`/`remove_label` call lives in `gitea-issues`/`gitea-prs` via `add_labels`/`replace_labels`/`remove_label` call lives in `gitea-issues`/`gitea-prs` via
@@ -83,7 +91,10 @@ label_write method: "create_repo_label"
``` ```
For an org label, use `method: "create_org_label"` with `org:` instead of `owner`/`repo`, and For an org label, use `method: "create_org_label"` with `org:` instead of `owner`/`repo`, and
`exclusive: true` if the label belongs to a mutually-exclusive scope group. `exclusive: true` if the label belongs to a mutually-exclusive scope group. `label_write` accepts
`exclusive` on org methods only, so a repo label's exclusivity cannot be set or cleared through this
tool — it is set in the Gitea UI or against the REST API directly, and read back via
`list_repo_labels`.
## Edit a label ## Edit a label