feat(report): file failed runs as Forgejo issues + attach zipped log to email
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>
This commit is contained in:
X9 Dev 2026-07-28 16:30:08 +02:00
parent 76b72e569e
commit eabf207e3f
8 changed files with 509 additions and 25 deletions

View file

@ -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:

View file

@ -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 <host> - <version> - <date>`,
`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
`<host>-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

View file

@ -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 `<host>-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 <host> - <version> - <date>`, 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)
---

View file

@ -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 = ""
)

View file

@ -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{})

View file

@ -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 {
<div style="padding:16px 24px;background:%s;color:#fff;text-align:center;font-weight:bold">
%s &mdash; OK: %d &nbsp; CHYBY: %d &nbsp; PRESKOCENO: %d
</div>
%s
</div>
<p style="text-align:center;color:#999;font-size:12px;margin-top:16px">
Odeslano z xetup.exe &mdash; log: C:\Windows\Setup\Scripts\Deploy.log
Odeslano z xetup.exe &mdash; log v priloze (%s-Deploy.log.zip) i lokalne: C:\Windows\Setup\Scripts\Deploy.log
</p>
</body></html>`,
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(`<div style="padding:12px 24px;font-size:13px;color:#555;border-top:1px solid #eee">`)
if issueURL != "" {
fmt.Fprintf(&b,
`<p style="margin:0 0 6px"><b>Chyby tohoto behu:</b> `+
`<a href="%s" style="color:#c62828">%s</a></p>`,
issueURL, issueURL)
}
fmt.Fprintf(&b,
`<p style="margin:0">Vsechny behy tohoto stroje v trackeru: `+
`<a href="%s" style="color:#223B47">%s</a></p>`,
filter, filter)
b.WriteString(`</div>`)
return b.String()
}

View file

@ -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", "<html>x</html>", "", 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)
}
}

View file

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