xetup/internal/runreport/runreport.go
X9 Dev 65c98ebd81
All checks were successful
release / build-and-release (push) Successful in 35s
fix(runreport): resolve "failed" label id by name instead of hardcoding 1
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>
2026-07-28 18:11:15 +02:00

203 lines
6.2 KiB
Go

// Package runreport records a failed deployment run as a Forgejo issue in the
// x9/xetup-runs tracker, so recurring failures can be triaged and fixed.
//
// It only fires when a run has at least one ERROR step - successful runs make
// no noise. It is best-effort and non-blocking: any failure to post is logged
// to stderr and never blocks completion (the email report is the fallback).
package runreport
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"git.xetup.x9.cz/x9/xetup/internal/buildinfo"
"git.xetup.x9.cz/x9/xetup/internal/report"
)
const (
// Forgejo API endpoints for the runs tracker.
issuesAPI = "https://git.xetup.x9.cz/api/v1/repos/x9/xetup-runs/issues"
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
// the tail (the end is where the failure and its context live).
maxLogBytes = 60 * 1024
)
// Report creates a Forgejo issue for a failed run and returns the issue's web
// URL (empty string if no issue was created - clean run, no token, or the post
// failed). The returned URL is meant to be linked from the email report.
func Report(results []report.StepResult, logPath string) string {
// No token -> local/dev build, reporting disabled.
if buildinfo.RunsToken == "" {
return ""
}
errs := 0
for _, r := range results {
if r.Status == "ERROR" {
errs++
}
}
if errs == 0 {
return "" // only failures get an issue
}
hostname, _ := os.Hostname()
now := time.Now().Format("2006-01-02 15:04")
title := fmt.Sprintf("FAILED %s - %s - %s", hostname, buildinfo.Version, now)
body := buildBody(results, hostname, now, logPath)
client := &http.Client{Timeout: 15 * time.Second}
// Look up the label id by name; on any failure file the issue unlabeled
// 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 {
fmt.Fprintf(os.Stderr, "[WARN] runreport: marshal failed: %v\n", err)
return ""
}
delays := []time.Duration{0, 2 * time.Second, 5 * time.Second}
for attempt, delay := range delays {
if delay > 0 {
time.Sleep(delay)
}
url, err := postIssue(client, payload)
if err != nil {
fmt.Fprintf(os.Stderr, "[WARN] runreport attempt %d/3 failed: %v\n", attempt+1, err)
continue
}
fmt.Fprintf(os.Stderr, "[OK] runreport: issue created %s\n", url)
return url
}
fmt.Fprintf(os.Stderr, "[ERROR] runreport: all attempts failed; run recorded only in email\n")
return ""
}
// postIssue sends one create-issue request and returns the new issue's html_url.
func postIssue(client *http.Client, payload []byte) (string, error) {
req, err := http.NewRequest(http.MethodPost, issuesAPI, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "token "+buildinfo.RunsToken)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("unexpected status %s", resp.Status)
}
var out struct {
HTMLURL string `json:"html_url"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
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
// the (tail-capped) Deploy.log in a collapsed block.
func buildBody(results []report.StepResult, hostname, now, logPath string) string {
var ok, errs, skipped int
var table bytes.Buffer
table.WriteString("| Status | Krok | Nazev | Cas |\n|---|---|---|---|\n")
for _, r := range results {
switch r.Status {
case "OK":
ok++
case "ERROR":
errs++
default:
skipped++
}
elapsed := ""
if r.Elapsed > 0 {
elapsed = r.Elapsed.Round(time.Second).String()
}
fmt.Fprintf(&table, "| %s | %s | %s | %s |\n", r.Status, r.Num, r.Name, elapsed)
}
var b bytes.Buffer
fmt.Fprintf(&b, "**Host:** %s\n", hostname)
fmt.Fprintf(&b, "**xetup:** %s\n", buildinfo.Version)
fmt.Fprintf(&b, "**Cas:** %s\n", now)
fmt.Fprintf(&b, "**Vysledek:** OK %d / CHYBY %d / PRESKOCENO %d\n\n", ok, errs, skipped)
b.WriteString(table.String())
b.WriteString("\n")
b.WriteString("<details><summary>Deploy.log</summary>\n\n```\n")
b.WriteString(readLogTail(logPath))
b.WriteString("\n```\n\n</details>\n")
return b.String()
}
// readLogTail returns the Deploy.log content, capped to the last maxLogBytes
// with a truncation note. Returns a placeholder if the log cannot be read.
func readLogTail(logPath string) string {
data, err := os.ReadFile(logPath)
if err != nil {
return fmt.Sprintf("(log unavailable: %v)", err)
}
if len(data) > maxLogBytes {
note := fmt.Sprintf("... (truncated to last %d KB) ...\n", maxLogBytes/1024)
return note + string(data[len(data)-maxLogBytes:])
}
return string(data)
}