diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index bbe583b..43b3154 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -44,11 +44,23 @@ jobs: echo "rsrc.syso: $(ls -lh cmd/xetup/rsrc.syso | awk '{print $5}')" - name: Build xetup.exe + env: + # write:issue token for x9/xetup-runs; baked in so runs self-report + # failures. Empty on forks/without-secret -> runreport is a no-op. + FORGEJO_RUNS_TOKEN: ${{ secrets.FORGEJO_RUNS_TOKEN }} run: | + # Version = tag (v0.10) on a tag build, else short commit SHA. + case "${{ github.ref }}" in + refs/tags/v*) VERSION=$(echo "${{ github.ref }}" | sed 's#refs/tags/##') ;; + *) VERSION=$(echo "${{ github.sha }}" | cut -c1-7) ;; + esac + PKG=git.xetup.x9.cz/x9/xetup/internal/buildinfo CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc \ GOOS=windows GOARCH=amd64 \ - go build -ldflags="-s -w -H windowsgui" -o xetup.exe ./cmd/xetup/ - echo "Built: $(ls -lh xetup.exe | awk '{print $5}')" + go build \ + -ldflags="-s -w -H windowsgui -X ${PKG}.Version=${VERSION} -X ${PKG}.RunsToken=${FORGEJO_RUNS_TOKEN}" \ + -o xetup.exe ./cmd/xetup/ + echo "Built: $(ls -lh xetup.exe | awk '{print $5}') - version ${VERSION}, runreport $([ -n "$FORGEJO_RUNS_TOKEN" ] && echo enabled || echo DISABLED)" - name: Sign xetup.exe (Azure Trusted Signing) env: diff --git a/CHANGELOG.md b/CHANGELOG.md index d3932bd..2781300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ Builds are continuous: every push to `main` produces a signed `xetup.exe` publis ## [Unreleased] +### Added +- **Automatic failure reporting to Forgejo** (runreport): a run that finishes with any ERROR step + files an issue in the private `x9/xetup-runs` tracker - title `FAILED - - `, + `failed` label, body = per-step table + the tail-capped `Deploy.log` (last 60 KB). Filed by the + `xetup-bot` account via a build-injected `write:issue` token. Best-effort and non-blocking; + successful runs stay silent, and the email report is the fallback if the post fails. +- **Deploy.log attached to the email**: the deployment email now carries the zipped log as + `-Deploy.log.zip` (best-effort; omitted if the log cannot be read). +- **Tracker links in the email**: the email links straight to this run's failure issue (when one was + created) and to the run history in the tracker filtered to this machine. +- **Build version stamp** (buildinfo): builds embed their git tag / short SHA, shown in report titles. + ### Changed - **Photos now kept** (01): `Microsoft.Windows.Photos` is added to the always-keep list (`KeepPackages`), so the default image viewer is no longer removed - like Calculator. It stays diff --git a/CLAUDE.md b/CLAUDE.md index 3911dd3..376f426 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,13 +33,15 @@ xetup/ │ ├── main.go <- entry point: extract, load config, launch GUI │ └── app.manifest <- Windows manifest (requireAdministrator) ├── internal/ +│ ├── buildinfo/buildinfo.go <- ldflags-injected Version + RunsToken (empty on local builds) │ ├── config/config.go <- Config struct, Load/Save, DefaultConfig │ ├── gui/gui.go <- Walk GUI: form → run → summary (3 phases) │ ├── runner/runner.go <- sequential PS script executor with log streaming │ ├── state/state.go <- JSON state file for reboot-resume persistence │ ├── prereboot/ <- autologon + X9-Resume scheduled task for reboot cycle │ ├── preflight/ <- pre-run checks (admin, winget, network, disk) -│ └── report/report.go <- HTML email report via SMTP2Go +│ ├── report/report.go <- HTML email report via SMTP2Go (+ zipped log, tracker links) +│ └── runreport/runreport.go <- files a Forgejo issue in x9/xetup-runs on failed runs ├── scripts/ │ ├── common.ps1 <- shared functions (Write-Log, Get-Feature, Load-Config) │ ├── 00-admin-account.ps1 <- create hidden admin account (adminx9, no password) @@ -156,6 +158,17 @@ xetup.exe start - From: xetup@x9.cz, To: net@x9.cz - Subject: "xetup report HOSTNAME" - HTML body with per-step status table +- Zipped Deploy.log attached as `-Deploy.log.zip` (multipart/mixed; omitted if log unreadable) +- Footer links to the x9/xetup-runs tracker: this run's failure issue (if any) + runs filtered to this machine +- `report.go` splits `buildMessage` (testable MIME assembly) from `sendMail`; see `report_test.go` + +### Run failure reporting (runreport) +- On any ERROR step, files an issue in the private `x9/xetup-runs` repo; successful runs stay silent +- Title `FAILED - - `, label `failed`, body = step table + tail-capped Deploy.log (60 KB) +- Filed by the `xetup-bot` Forgejo account (collaborator on xetup-runs only) via a `write:issue` token +- Token + version injected at build via `-ldflags -X internal/buildinfo.{RunsToken,Version}`; empty token -> no-op +- Best-effort/non-blocking (15s HTTP timeout, 3 retries); the email report is the fallback +- Returns the issue URL so the email can link to it ### Parallel winget - 02-software.ps1 launches all winget installs as background jobs (Start-Job) @@ -179,6 +192,11 @@ xetup.exe start - Only AZURE_CLIENT_SECRET is a Forgejo Actions secret; the SP is shared across X9 projects - do NOT rotate - jsign auth needs the Trusted Signing token; runner-config mounts the docker socket for the deploy.json step +### Forgejo run reporting secret +- `FORGEJO_RUNS_TOKEN` (Actions secret) is the `xetup-bot` `write:issue` token, baked into xetup.exe via ldflags +- Unlike the signing SP, this token IS safe to rotate/revoke: regenerate for `xetup-bot`, update the secret, rebuild +- Blast radius is limited to opening issues in `x9/xetup-runs` (bot is collaborator there only) + --- ## Workflow @@ -209,6 +227,7 @@ git push "http://x9:${TOKEN}@localhost:3100/x9/xetup.git" main - Do not remove OneDrive policy-block-free (M365 must be able to reinstall it) - Do not remove RDP/RDS or Microsoft-RemoteDesktopConnection - Do not create Deploy-Windows.ps1 or other CLI entry points (xetup.exe is sole entry point) +- Do not make x9/xetup-runs public (run logs contain client hostnames and machine names) --- diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..6ce5155 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,13 @@ +// Package buildinfo holds values injected at build time via -ldflags -X. +// Defaults keep local (non-CI) builds working: no token means run reporting +// is silently skipped, and version shows "dev". +package buildinfo + +var ( + // Version is the git tag (e.g. "v0.10") or short commit SHA of this build. + Version = "dev" + + // RunsToken is the Forgejo write:issue token for the x9/xetup-runs repo. + // Empty on local builds -> runreport is a no-op (see internal/runreport). + RunsToken = "" +) diff --git a/internal/gui/gui.go b/internal/gui/gui.go index 07aaaa9..2f275f7 100644 --- a/internal/gui/gui.go +++ b/internal/gui/gui.go @@ -31,11 +31,18 @@ import ( "git.xetup.x9.cz/x9/xetup/internal/prereboot" "git.xetup.x9.cz/x9/xetup/internal/report" "git.xetup.x9.cz/x9/xetup/internal/runner" + "git.xetup.x9.cz/x9/xetup/internal/runreport" "git.xetup.x9.cz/x9/xetup/internal/state" ) +// deployLogPath is the Deploy.log location, captured at Run() so the summary +// phase can attach it to the email and to the Forgejo run report. +var deployLogPath string + // Run opens the xetup window and blocks until the user closes it. func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string) { + deployLogPath = runCfg.LogFile + // Resume mode: state file present from a previous interrupted run if st, err := state.Load(); err == nil { resumePhase(st, runCfg) @@ -672,9 +679,12 @@ func donePhase(currentResults []runner.Result, prevResults []state.StepResult) { summaryText := fmt.Sprintf("OK: %d CHYBY: %d PRESKOCENO: %d", ok, errs, skipped) - // Send email report (non-blocking; report.Send retries and saves local copy) + // Report the run (non-blocking). On failure, runreport files a Forgejo + // issue and returns its URL; the email then links to it and attaches the + // zipped log. Both steps retry internally and never block completion. go func() { - _ = report.Send(emailRows) + issueURL := runreport.Report(emailRows, deployLogPath) + _ = report.Send(emailRows, issueURL, deployLogPath) }() cancelReboot := make(chan struct{}) diff --git a/internal/report/report.go b/internal/report/report.go index 9225c9c..3c11a14 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -2,8 +2,12 @@ package report import ( + "archive/zip" + "bytes" + "encoding/base64" "fmt" "net/smtp" + "net/url" "os" "path/filepath" "strings" @@ -20,9 +24,19 @@ const ( mailTo = "net@x9.cz" ) +// runsTracker is the Forgejo repo where failed runs are filed as issues. +// The email links here (filtered to this machine) so the technician can jump +// straight to the run history / bug tracker. +const runsTracker = "https://git.xetup.x9.cz/x9/xetup-runs/issues" + // localReportPath is where a local HTML copy of the report is always saved. const localReportPath = `C:\X9\report.html` +// mimeBoundary separates the HTML body from the zipped-log attachment. Fixed +// (not random) because Math/rand is avoided project-wide and a single message +// never needs a unique one - the string just must not appear in the content. +const mimeBoundary = "xetup-mixed-8f3a2c1d0e7b46f9" + // StepResult holds one row of the deployment report. type StepResult struct { Num string @@ -31,15 +45,18 @@ type StepResult struct { Elapsed time.Duration } -// Send builds the deployment report, saves a local HTML copy to C:\X9\, -// and emails it via SMTP with retries. Returns the last SMTP error if all -// attempts fail (the local copy is always written regardless). -func Send(results []StepResult) error { +// Send builds the deployment report, saves a local HTML copy to C:\X9\, and +// emails it via SMTP with retries. The full Deploy.log is attached as a zip +// (best-effort - omitted if unreadable). issueURL, when non-empty, is a link +// to this run's failure issue in the Forgejo tracker and is shown in the body. +// Returns the last SMTP error if all attempts fail (the local copy is always +// written regardless). +func Send(results []StepResult, issueURL, logPath string) error { hostname, _ := os.Hostname() now := time.Now().Format("2006-01-02 15:04") subject := fmt.Sprintf("xetup report %s", hostname) - body := buildHTML(results, hostname, now) + body := buildHTML(results, hostname, now, issueURL) // Always save local copy so technician has a record even if SMTP fails _ = os.MkdirAll(filepath.Dir(localReportPath), 0755) @@ -47,6 +64,9 @@ func Send(results []StepResult) error { fmt.Fprintf(os.Stderr, "[WARN] Failed to save local report: %v\n", err) } + // Zip the log for attachment (best-effort; nil attachment => plain email) + attachName, attachData := zipLog(logPath, hostname) + // Retry SMTP up to 3 times with exponential backoff (1s, 5s, 15s) delays := []time.Duration{0, 1 * time.Second, 5 * time.Second} var lastErr error @@ -54,7 +74,7 @@ func Send(results []StepResult) error { if delay > 0 { time.Sleep(delay) } - if err := sendMail(subject, body); err != nil { + if err := sendMail(subject, body, attachName, attachData); err != nil { lastErr = err fmt.Fprintf(os.Stderr, "[WARN] Email attempt %d/3 failed: %v\n", attempt+1, err) continue @@ -65,28 +85,90 @@ func Send(results []StepResult) error { return lastErr } -func sendMail(subject, body string) error { - msg := strings.Join([]string{ - "From: " + mailFrom, - "To: " + mailTo, - "Subject: " + subject, - "MIME-Version: 1.0", - "Content-Type: text/html; charset=UTF-8", - "", - body, - }, "\r\n") +// zipLog reads logPath and returns a zip archive of it (name, bytes). On any +// error it returns ("", nil) so the caller sends a plain email without failing. +func zipLog(logPath, hostname string) (string, []byte) { + data, err := os.ReadFile(logPath) + if err != nil { + fmt.Fprintf(os.Stderr, "[WARN] Cannot read log for attachment: %v\n", err) + return "", nil + } + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("Deploy.log") + if err == nil { + _, err = w.Write(data) + } + if err == nil { + err = zw.Close() + } + if err != nil { + fmt.Fprintf(os.Stderr, "[WARN] Cannot zip log for attachment: %v\n", err) + return "", nil + } + name := hostname + "-Deploy.log.zip" + return name, buf.Bytes() +} +func sendMail(subject, body, attachName string, attach []byte) error { + msg := buildMessage(subject, body, attachName, attach) auth := smtp.PlainAuth("", smtpUser, smtpPass, smtpHost) return smtp.SendMail( smtpHost+":"+smtpPort, auth, mailFrom, []string{mailTo}, - []byte(msg), + msg, ) } -func buildHTML(results []StepResult, hostname, dateTime string) string { +// buildMessage assembles the raw RFC 822 message: a plain text/html message +// when attach is nil, otherwise multipart/mixed with the zipped log attached. +func buildMessage(subject, body, attachName string, attach []byte) []byte { + var msg bytes.Buffer + fmt.Fprintf(&msg, "From: %s\r\n", mailFrom) + fmt.Fprintf(&msg, "To: %s\r\n", mailTo) + fmt.Fprintf(&msg, "Subject: %s\r\n", subject) + msg.WriteString("MIME-Version: 1.0\r\n") + + if attach == nil { + // Simple single-part HTML message. + msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n") + msg.WriteString(body) + } else { + // multipart/mixed: HTML body + zipped log attachment. + fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=%s\r\n\r\n", mimeBoundary) + + fmt.Fprintf(&msg, "--%s\r\n", mimeBoundary) + msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n") + msg.WriteString(body) + msg.WriteString("\r\n") + + fmt.Fprintf(&msg, "--%s\r\n", mimeBoundary) + msg.WriteString("Content-Type: application/zip\r\n") + msg.WriteString("Content-Transfer-Encoding: base64\r\n") + fmt.Fprintf(&msg, "Content-Disposition: attachment; filename=\"%s\"\r\n\r\n", attachName) + writeBase64(&msg, attach) + msg.WriteString("\r\n") + + fmt.Fprintf(&msg, "--%s--\r\n", mimeBoundary) + } + return msg.Bytes() +} + +// writeBase64 writes data as base64 wrapped at 76 columns (RFC 2045). +func writeBase64(w *bytes.Buffer, data []byte) { + enc := base64.StdEncoding.EncodeToString(data) + for len(enc) > 76 { + w.WriteString(enc[:76]) + w.WriteString("\r\n") + enc = enc[76:] + } + w.WriteString(enc) + w.WriteString("\r\n") +} + +func buildHTML(results []StepResult, hostname, dateTime, issueURL string) string { var ok, errs, skipped int var rows strings.Builder @@ -150,12 +232,35 @@ func buildHTML(results []StepResult, hostname, dateTime string) string {
%s — OK: %d   CHYBY: %d   PRESKOCENO: %d
+ %s

- Odeslano z xetup.exe — log: C:\Windows\Setup\Scripts\Deploy.log + Odeslano z xetup.exe — log v priloze (%s-Deploy.log.zip) i lokalne: C:\Windows\Setup\Scripts\Deploy.log

`, hostname, dateTime, rows.String(), - summaryColor, summaryText, ok, errs, skipped) + summaryColor, summaryText, ok, errs, skipped, + trackerLinks(hostname, issueURL), + hostname) +} + +// trackerLinks renders the tracker links block: a direct link to this run's +// failure issue (when present) plus a link filtered to this machine's runs. +func trackerLinks(hostname, issueURL string) string { + filter := fmt.Sprintf("%s?q=%s&type=all&state=all", runsTracker, url.QueryEscape(hostname)) + var b strings.Builder + b.WriteString(`
`) + if issueURL != "" { + fmt.Fprintf(&b, + `

Chyby tohoto behu: `+ + `%s

`, + issueURL, issueURL) + } + fmt.Fprintf(&b, + `

Vsechny behy tohoto stroje v trackeru: `+ + `%s

`, + filter, filter) + b.WriteString(`
`) + return b.String() } diff --git a/internal/report/report_test.go b/internal/report/report_test.go new file mode 100644 index 0000000..1079ba9 --- /dev/null +++ b/internal/report/report_test.go @@ -0,0 +1,155 @@ +package report + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "io" + "mime" + "mime/multipart" + "net/mail" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +var sampleResults = []StepResult{ + {Num: "01", Name: "Bloatware", Status: "OK", Elapsed: 3 * time.Second}, + {Num: "08", Name: "Activation", Status: "ERROR", Elapsed: 2 * time.Second}, +} + +// writeLog writes a temp Deploy.log and returns its path. +func writeLog(t *testing.T, content []byte) string { + t.Helper() + p := filepath.Join(t.TempDir(), "Deploy.log") + if err := os.WriteFile(p, content, 0644); err != nil { + t.Fatal(err) + } + return p +} + +// buildHTML must embed the direct issue link and the per-machine tracker filter. +func TestBuildHTMLLinks(t *testing.T) { + issueURL := "https://git.xetup.x9.cz/x9/xetup-runs/issues/42" + html := buildHTML(sampleResults, "PC-TEST", "2026-07-28 10:00", issueURL) + + if !strings.Contains(html, issueURL) { + t.Errorf("html does not link the run issue %q", issueURL) + } + if !strings.Contains(html, "xetup-runs/issues?q=PC-TEST") { + t.Errorf("html missing per-machine tracker filter link; got:\n%s", html) + } +} + +// With no issue (clean run / post failed), only the tracker link is shown. +func TestBuildHTMLNoIssue(t *testing.T) { + html := buildHTML(sampleResults, "PC-TEST", "2026-07-28 10:00", "") + if strings.Contains(html, "Chyby tohoto behu") { + t.Error("issue-specific line shown when issueURL is empty") + } + if !strings.Contains(html, "xetup-runs/issues?q=PC-TEST") { + t.Error("tracker filter link missing") + } +} + +// zipLog must produce a valid zip whose Deploy.log entry round-trips. +func TestZipLogRoundTrip(t *testing.T) { + content := []byte("line one\nERROR something failed\nline three\n") + name, data := zipLog(writeLog(t, content), "PC-TEST") + + if name != "PC-TEST-Deploy.log.zip" { + t.Errorf("unexpected attachment name %q", name) + } + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.Fatalf("attachment is not a valid zip: %v", err) + } + if len(zr.File) != 1 || zr.File[0].Name != "Deploy.log" { + t.Fatalf("zip should contain exactly Deploy.log, got %v", zr.File) + } + rc, _ := zr.File[0].Open() + got, _ := io.ReadAll(rc) + rc.Close() + if !bytes.Equal(got, content) { + t.Errorf("zip content mismatch: got %q want %q", got, content) + } +} + +// zipLog returns no attachment when the log is unreadable (email still sends). +func TestZipLogMissing(t *testing.T) { + name, data := zipLog(filepath.Join(t.TempDir(), "nope.log"), "PC-TEST") + if name != "" || data != nil { + t.Errorf("expected no attachment for missing log, got name=%q len=%d", name, len(data)) + } +} + +// buildMessage must yield a parseable multipart/mixed message: an HTML part and +// a base64 application/zip attachment that unzips back to the log. +func TestBuildMessageMultipart(t *testing.T) { + logContent := []byte("deploy log body\nERROR boom\n") + _, zipData := zipLog(writeLog(t, logContent), "PC-TEST") + html := buildHTML(sampleResults, "PC-TEST", "2026-07-28 10:00", "https://x/issues/1") + + raw := buildMessage("xetup report PC-TEST", html, "PC-TEST-Deploy.log.zip", zipData) + + m, err := mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("message does not parse: %v", err) + } + mediaType, params, err := mime.ParseMediaType(m.Header.Get("Content-Type")) + if err != nil || mediaType != "multipart/mixed" { + t.Fatalf("expected multipart/mixed, got %q (%v)", mediaType, err) + } + + mr := multipart.NewReader(m.Body, params["boundary"]) + var sawHTML, sawZip bool + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("part read error: %v", err) + } + ct := p.Header.Get("Content-Type") + body, _ := io.ReadAll(p) + switch { + case strings.HasPrefix(ct, "text/html"): + sawHTML = true + if !strings.Contains(string(body), "xetup report") { + t.Error("html part missing report content") + } + case strings.HasPrefix(ct, "application/zip"): + sawZip = true + if enc := p.Header.Get("Content-Transfer-Encoding"); enc != "base64" { + t.Errorf("zip part not base64, got %q", enc) + } + // multipart does not auto-decode base64; strip wraps and decode. + clean := strings.NewReplacer("\r", "", "\n", "").Replace(string(body)) + dec, err := base64.StdEncoding.DecodeString(clean) + if err != nil { + t.Fatalf("attachment base64 decode failed: %v", err) + } + if _, err := zip.NewReader(bytes.NewReader(dec), int64(len(dec))); err != nil { + t.Errorf("decoded attachment is not a zip: %v", err) + } + } + } + if !sawHTML || !sawZip { + t.Errorf("missing parts: html=%v zip=%v", sawHTML, sawZip) + } +} + +// Plain message (no attachment) must be single-part text/html. +func TestBuildMessagePlain(t *testing.T) { + raw := buildMessage("subj", "x", "", nil) + m, err := mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("plain message does not parse: %v", err) + } + if ct := m.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("expected text/html, got %q", ct) + } +} diff --git a/internal/runreport/runreport.go b/internal/runreport/runreport.go new file mode 100644 index 0000000..5672405 --- /dev/null +++ b/internal/runreport/runreport.go @@ -0,0 +1,158 @@ +// 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) +}