All checks were successful
release / build-and-release (push) Successful in 43s
On any ERROR step, xetup now files an issue in the private x9/xetup-runs tracker (title "FAILED <host> - <version> - <date>", label "failed", body = step table + tail-capped Deploy.log) via the xetup-bot write:issue token, baked in through -ldflags. Best-effort and non-blocking; successful runs stay silent and the email is the fallback. The deployment email now attaches the zipped Deploy.log and links to this run's issue plus the run history filtered to this machine. - internal/buildinfo: ldflags-injected Version + RunsToken - internal/runreport: issue filing, retries, tail-capped log - internal/report: multipart/mixed with zip attachment, tracker links; buildMessage split out and covered by report_test.go - release.yml: inject Version (tag/SHA) + FORGEJO_RUNS_TOKEN via ldflags Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
158 lines
4.7 KiB
Go
158 lines
4.7 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 + web base for the runs tracker.
|
|
issuesAPI = "https://git.xetup.x9.cz/api/v1/repos/x9/xetup-runs/issues"
|
|
failedLabel = 1 // id of the "failed" label in x9/xetup-runs
|
|
|
|
// 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)
|
|
|
|
payload, err := json.Marshal(map[string]any{
|
|
"title": title,
|
|
"body": body,
|
|
"labels": []int{failedLabel},
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[WARN] runreport: marshal failed: %v\n", err)
|
|
return ""
|
|
}
|
|
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|