fix(runreport): resolve "failed" label id by name instead of hardcoding 1
All checks were successful
release / build-and-release (push) Successful in 35s

The label id was hardcoded (failedLabel = 1), so deleting/recreating the
label in x9/xetup-runs (new id) would make issue creation drop the label or
fail. Look the id up by name at runtime; on any lookup failure (network,
auth, decode, or label absent) file the issue unlabeled rather than not at
all. Verified end-to-end: label resolved and attached via lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
X9 Dev 2026-07-28 18:11:15 +02:00
parent ef56410139
commit 65c98ebd81
2 changed files with 55 additions and 10 deletions

View file

@ -164,7 +164,7 @@ xetup.exe start
### Run failure reporting (runreport) ### Run failure reporting (runreport)
- On any ERROR step, files an issue in the private `x9/xetup-runs` repo; successful runs stay silent - On any ERROR step, files an issue in the private `x9/xetup-runs` repo; successful runs stay silent
- Title `FAILED <host> - <version> - <date>`, label `failed`, body = step table + tail-capped Deploy.log (60 KB) - Title `FAILED <host> - <version> - <date>`, label `failed` (id resolved by name at runtime), body = step table + tail-capped Deploy.log (60 KB)
- Filed by the `xetup-bot` Forgejo account (collaborator on xetup-runs only) via a `write:issue` token - Filed by the `xetup-bot` Forgejo account (collaborator on xetup-runs only) via a `write:issue` token
- Token + version injected at build via `-ldflags -X internal/buildinfo.{RunsToken,Version}`; empty token -> no-op - Token + version injected at build via `-ldflags -X internal/buildinfo.{RunsToken,Version}`; empty token -> no-op
- Best-effort/non-blocking (15s HTTP timeout, 3 retries); the email report is the fallback - Best-effort/non-blocking (15s HTTP timeout, 3 retries); the email report is the fallback

View file

@ -19,9 +19,13 @@ import (
) )
const ( const (
// Forgejo API + web base for the runs tracker. // Forgejo API endpoints for the runs tracker.
issuesAPI = "https://git.xetup.x9.cz/api/v1/repos/x9/xetup-runs/issues" issuesAPI = "https://git.xetup.x9.cz/api/v1/repos/x9/xetup-runs/issues"
failedLabel = 1 // id of the "failed" label in x9/xetup-runs labelsAPI = "https://git.xetup.x9.cz/api/v1/repos/x9/xetup-runs/labels?limit=50"
// labelName is looked up by name at runtime (not a hardcoded id) so the
// label surviving a delete/recreate with a new id does not break filing.
labelName = "failed"
// Cap the inline log so a huge Deploy.log does not bloat the issue; keep // Cap the inline log so a huge Deploy.log does not bloat the issue; keep
// the tail (the end is where the failure and its context live). // the tail (the end is where the failure and its context live).
@ -52,17 +56,20 @@ func Report(results []report.StepResult, logPath string) string {
title := fmt.Sprintf("FAILED %s - %s - %s", hostname, buildinfo.Version, now) title := fmt.Sprintf("FAILED %s - %s - %s", hostname, buildinfo.Version, now)
body := buildBody(results, hostname, now, logPath) body := buildBody(results, hostname, now, logPath)
payload, err := json.Marshal(map[string]any{ client := &http.Client{Timeout: 15 * time.Second}
"title": title,
"body": body, // Look up the label id by name; on any failure file the issue unlabeled
"labels": []int{failedLabel}, // rather than not at all.
}) fields := map[string]any{"title": title, "body": body}
if ids := lookupLabelIDs(client); len(ids) > 0 {
fields["labels"] = ids
}
payload, err := json.Marshal(fields)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "[WARN] runreport: marshal failed: %v\n", err) fmt.Fprintf(os.Stderr, "[WARN] runreport: marshal failed: %v\n", err)
return "" return ""
} }
client := &http.Client{Timeout: 15 * time.Second}
delays := []time.Duration{0, 2 * time.Second, 5 * time.Second} delays := []time.Duration{0, 2 * time.Second, 5 * time.Second}
for attempt, delay := range delays { for attempt, delay := range delays {
if delay > 0 { if delay > 0 {
@ -107,6 +114,44 @@ func postIssue(client *http.Client, payload []byte) (string, error) {
return out.HTMLURL, nil return out.HTMLURL, nil
} }
// lookupLabelIDs resolves labelName to its id in the runs repo. Returns nil on
// any error (network, auth, decode, or label absent) so the caller files the
// issue without a label instead of failing.
func lookupLabelIDs(client *http.Client) []int {
req, err := http.NewRequest(http.MethodGet, labelsAPI, nil)
if err != nil {
return nil
}
req.Header.Set("Authorization", "token "+buildinfo.RunsToken)
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "[WARN] runreport: label lookup failed: %v\n", err)
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(os.Stderr, "[WARN] runreport: label lookup status %s\n", resp.Status)
return nil
}
var labels []struct {
ID int `json:"id"`
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&labels); err != nil {
fmt.Fprintf(os.Stderr, "[WARN] runreport: label decode failed: %v\n", err)
return nil
}
for _, l := range labels {
if l.Name == labelName {
return []int{l.ID}
}
}
fmt.Fprintf(os.Stderr, "[WARN] runreport: label %q not found; filing unlabeled\n", labelName)
return nil
}
// buildBody renders the issue markdown: a summary line, a per-step table, and // buildBody renders the issue markdown: a summary line, a per-step table, and
// the (tail-capped) Deploy.log in a collapsed block. // the (tail-capped) Deploy.log in a collapsed block.
func buildBody(results []report.StepResult, hostname, now, logPath string) string { func buildBody(results []report.StepResult, hostname, now, logPath string) string {