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>
203 lines
6.2 KiB
Go
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)
|
|
}
|