fix(gitea): reconcile priority labels by numeric Gitea ID (#2467)

Issue #2467 carried both P0 and P2 because name-based label DELETE returned 404 and the workflow silently ignored it. Use the numeric ID API, enforce exactly one priority, clear stale unprioritized flags, and execute the workflow JavaScript against a Gitea-compatible fake in regression tests.
This commit is contained in:
Nick committed 2026-10-05 22:14:47 +01:00
1 parent 24d14ff8e1
commit 3186566514
2 files changed
+67 -5

No files matched your search

+26 -5
View File
@@ -23,12 +23,13 @@ jobs:
const labels = (issue.labels || []).map(l => l.name);
const priorities = ['P0 — critical', 'P1 — high', 'P2 — medium', 'P3 — low'];
const has = labels.filter(l => priorities.includes(l));
if (has.length >= 1) {
if (has.length === 1) {
// Prioritized — clear the flag if it was set.
if (labels.includes('unprioritized')) {
await github.rest.issues.removeLabel({
await github.request('DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{id}', {
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, name: 'unprioritized',
issue_number: issue.number,
id: issue.labels.find(l => l.name === 'unprioritized').id,
}).catch(e => { if (e.status !== 404) throw e; });
}
return;
@@ -170,6 +171,7 @@ jobs:
issue_number: issueNumber,
});
const currentLabels = new Set(current.data.map(label => label.name));
const currentLabelIds = new Map(current.data.map(label => [label.name, label.id]));
const staleManaged = [...currentLabels].filter(
label => managedLabels.has(label) && !desiredLabels.has(label)
);
@@ -179,11 +181,14 @@ jobs:
for (const name of staleManaged) {
try {
await github.rest.issues.removeLabel({
// Gitea's DELETE /labels/{id} requires the numeric label ID.
// Octokit's GitHub-style removeLabel(name) returned 404 and
// left stale priority labels behind (#2467: P0 AND P2).
await github.request('DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{id}', {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name,
id: currentLabelIds.get(name),
});
} catch (error) {
if (error.status !== 404) {
@@ -201,5 +206,21 @@ jobs:
labels: missingDesired,
});
}
// The priority gate can flag a duplicate before this job
// reconciles it. Clear the flag only AFTER verifying exactly one
// surviving P-label against Gitea's current state.
const reconciled = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issueNumber,
});
const actual = reconciled.data;
const activePriority = actual.filter(l => priorityLabelNames.includes(l.name));
const flag = actual.find(l => l.name === 'unprioritized');
if (activePriority.length === 1 && flag) {
await github.request('DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{id}', {
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issueNumber, id: flag.id,
});
}
console.log(`Removed managed labels: ${staleManaged.join(', ') || 'none'}`);
console.log(`Added managed labels: ${missingDesired.join(', ') || 'none'}`);
+41
View File
@@ -0,0 +1,41 @@
"""Exercise the Gitea auto-label script, not just its YAML shape."""
import shutil
import subprocess
from pathlib import Path
import pytest
import yaml
def test_duplicate_priority_reconciled_by_numeric_id(tmp_path):
node = shutil.which("node")
if node is None:
pytest.skip("node not installed")
workflow = yaml.safe_load((Path(__file__).parents[1] / ".gitea/workflows/auto-label.yml").read_text())
script = workflow["jobs"]["auto-label"]["steps"][0]["with"]["script"]
fixture = """
const labels = [{name:'P0 — critical',id:23},{name:'P2 — medium',id:25}];
const context = {payload:{action:'labeled',issue:{state:'open',number:2467,
title:'Scaffolding completion',body:'kind: feature\\nspine protocol'}},
repo:{owner:'conductor',repo:'rune'}};
const github = {rest:{issues:{
listLabelsOnIssue: async () => ({data:labels.map(x => ({...x}))}),
addLabels: async ({labels:names}) => {
for (const name of names) labels.push({name,id:100+labels.length});
}
}},request: async (route,{id}) => {
if (!route.includes('/labels/{id}') || typeof id !== 'number')
throw new Error('Gitea requires numeric label ID');
const index=labels.findIndex(x => x.id === id);
if (index < 0) throw new Error('missing label ID '+id);
labels.splice(index,1);
}};
"""
js = (fixture + "\n(async () => {\n" + script + "\n})().then(() => {\n"
+ "const priorities=labels.filter(x => /^P[0-3] — /.test(x.name));\n"
+ "if (priorities.length !== 1 || priorities[0].id !== 23) process.exitCode=1;\n"
+ "}).catch(e => {console.error(e);process.exitCode=1;});\n")
path = tmp_path / "priority.js"
path.write_text(js)
run = subprocess.run([node, str(path)], capture_output=True, text=True, timeout=10)
assert run.returncode == 0, run.stdout + run.stderr