// 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("
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) }