// 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("
Deploy.log\n\n```\n") b.WriteString(readLogTail(logPath)) b.WriteString("\n```\n\n
\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) }