From 7504eb764b5dbe179b03b0dbd2fbd47fdc37d726 Mon Sep 17 00:00:00 2001 From: 7h3-3mp7y-m4n Date: Fri, 22 May 2026 01:49:31 +0530 Subject: [PATCH 1/2] feat: added notify logic Signed-off-by: 7h3-3mp7y-m4n --- .github/workflows/updates.yaml | 26 +-- config.yaml | 6 + main.go | 325 ++++++++++++++++++++++++++------ notify.go | 335 +++++++++++++++++++++++++++++++++ 4 files changed, 623 insertions(+), 69 deletions(-) create mode 100644 notify.go diff --git a/.github/workflows/updates.yaml b/.github/workflows/updates.yaml index 79195a3..8eea686 100644 --- a/.github/workflows/updates.yaml +++ b/.github/workflows/updates.yaml @@ -21,7 +21,7 @@ jobs: - name: Fetch workflow data env: - GH_TOKEN: ${{ secrets.GH_PAT }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh api repos/urunc-dev/urunc/actions/workflows \ --paginate > workflows_raw.json @@ -29,18 +29,18 @@ jobs: echo "{}" > runs_raw.json jq -r '.workflows[] | "\(.id) \(.name)"' workflows_raw.json | while read wf_id wf_name; do - echo "Processing workflow: $wf_name (ID: $wf_id)" - - gh api "repos/urunc-dev/urunc/actions/workflows/${wf_id}/runs?per_page=20" \ - > runs_tmp.json - - jq --arg id "$wf_id" \ - --arg name "$wf_name" \ - --slurpfile runs runs_tmp.json \ - '. + {($id): ($runs[0] + {name: $name})}' \ - runs_raw.json > tmp.json - - mv tmp.json runs_raw.json + echo "Processing workflow: $wf_name (ID: $wf_id)" + + gh api "repos/urunc-dev/urunc/actions/workflows/${wf_id}/runs?per_page=20" \ + > runs_tmp.json + + jq --arg id "$wf_id" \ + --arg name "$wf_name" \ + --slurpfile runs runs_tmp.json \ + '. + {($id): ($runs[0] + {name: $name})}' \ + runs_raw.json > tmp.json + + mv tmp.json runs_raw.json done echo "===== FINAL workflows_raw.json =====" cat workflows_raw.json diff --git a/config.yaml b/config.yaml index 0b835cf..3fb7061 100644 --- a/config.yaml +++ b/config.yaml @@ -3,6 +3,12 @@ settings: refresh_interval: 60 max_runs_per_workflow: 20 +# notify logic +notify: + enabled: false + target_repo: "urunc-dev/ci-dashboard" + label: "ci-failure" + log_analysis: max_signals_per_job: 40 diff --git a/main.go b/main.go index 13dbcd7..5e21924 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "os" + "regexp" "sort" "strings" "time" @@ -14,6 +15,9 @@ import ( "go.yaml.in/yaml/v3" ) +// ansiEscape matches ANSI escape sequences for Github logs +var ansiEscape = regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])`) + type Config struct { Settings struct { SourceRepo string `yaml:"source_repo"` @@ -21,6 +25,7 @@ type Config struct { RecentRunsInOutput int `yaml:"recent_runs_in_output"` } `yaml:"settings"` + Notify NotifyConfig `yaml:"notify"` LogAnalysis LogAnalysisConfig `yaml:"log_analysis"` Workflows []struct { @@ -70,12 +75,21 @@ type WorkflowsResponse struct { WorkflowRuns []Run `json:"workflow_runs"` } +type Step struct { + Name string `json:"name"` + Conclusion string `json:"conclusion"` + Number int `json:"number"` + StartedAt time.Time `json:"started_at"` + CompletedAt time.Time `json:"completed_at"` +} + type FailedJob struct { ID int `json:"id"` Name string `json:"name"` Conclusion string `json:"conclusion"` HTMLURL string `json:"html_url"` LogSnippet string `json:"log_snippet"` + RawLog string `json:"raw_log"` } type Run struct { @@ -101,6 +115,7 @@ type Job struct { HTMLURL string `json:"html_url"` StartedAt time.Time `json:"started_at"` CompletedAt time.Time `json:"completed_at"` + Steps []Step `json:"steps"` } type JobSummary struct { @@ -153,15 +168,20 @@ func NewClient(token, repo string, la LogAnalysisConfig) *Client { } } +func (c *Client) setAuthHeaders(req *http.Request) { + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + req.Header.Set("Accept", "application/vnd.github+json") +} + +// get performs an authenticated GET request against the GitHub API func (c *Client) get(url string, v interface{}) error { req, err := http.NewRequest("GET", url, nil) if err != nil { return err } - if c.token != "" { - req.Header.Set("Authorization", "Bearer "+c.token) - } - req.Header.Set("Accept", "application/vnd.github+json") + c.setAuthHeaders(req) resp, err := c.httpClient.Do(req) if err != nil { @@ -175,6 +195,22 @@ func (c *Client) get(url string, v interface{}) error { return json.NewDecoder(resp.Body).Decode(v) } +func parseGHTimestamp(line string) (time.Time, error) { + if len(line) < 28 { + return time.Time{}, fmt.Errorf("line too short") + } + return time.Parse("2006-01-02T15:04:05.9999999Z", line[:28]) +} + +func stripGHTimestamp(line string) string { + if len(line) > 29 && line[10] == 'T' { + line = line[29:] + } + line = ansiEscape.ReplaceAllString(line, "") + return strings.TrimSpace(line) +} + +// analyseLog scans log lines and extracts meaningful failure signals based on the category patterns defined in config.yaml func analyseLog(scanner *bufio.Scanner, cfg LogAnalysisConfig) LogSummary { maxSignals := cfg.MaxSignalsPerJob if maxSignals <= 0 { @@ -228,6 +264,7 @@ func analyseLog(scanner *bufio.Scanner, cfg LogAnalysisConfig) LogSummary { } } +// renderSummary formats a LogSummary into a human-readable plain-text block grouped by category func renderSummary(s LogSummary) string { if s.Empty { return "(no actionable failure signal found in log)" @@ -267,18 +304,12 @@ func matchesAny(lower string, patterns []string) bool { return false } -// fetchAndAnalyseLog streams the log response directly into the analyser -func (c *Client) fetchAndAnalyseLog(logURL string) (string, error) { - var ( - resp *http.Response - err error - ) +// fetchAndAnalyseLog downloads the raw log for a single job +func (c *Client) fetchAndAnalyseLog(logURL string, failedSteps []Step) (snippet, raw string, err error) { + var resp *http.Response for attempt := 0; attempt < 2; attempt++ { req, _ := http.NewRequest("GET", logURL, nil) - if c.token != "" { - req.Header.Set("Authorization", "Bearer "+c.token) - } - req.Header.Set("Accept", "application/vnd.github+json") + c.setAuthHeaders(req) resp, err = c.logClient.Do(req) if err == nil { break @@ -289,30 +320,192 @@ func (c *Client) fetchAndAnalyseLog(logURL string) (string, error) { } } if err != nil { - return "", err + return "", "", err } defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return "", "", fmt.Errorf("log expired (404)") + } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("log fetch returned HTTP %d", resp.StatusCode) + return "", "", fmt.Errorf("log fetch returned HTTP %d", resp.StatusCode) + } + + // Build a time window that covers all failed steps, with a small buffer + // before the first step and after the last, so we capture context lines + var windowStart, windowEnd time.Time + hasWindow := false + for _, step := range failedSteps { + if step.StartedAt.IsZero() { + continue + } + s := step.StartedAt.Add(-time.Second) + e := step.CompletedAt.Add(5 * time.Second) + if !hasWindow { + windowStart, windowEnd = s, e + hasWindow = true + } else { + if s.Before(windowStart) { + windowStart = s + } + if e.After(windowEnd) { + windowEnd = e + } + } + } + + if hasWindow { + log.Printf("window: %s -> %s (%d failed step(s))", + windowStart.Format(time.RFC3339), + windowEnd.Format(time.RFC3339), + len(failedSteps)) + } else { + log.Printf("no step timestamps - capturing full job log") } scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 256*1024), 256*1024) - summary := analyseLog(scanner, c.logAnalysis) - if err := scanner.Err(); err != nil { - return "", err + + // Slice the log to the computed window (or keep all lines if no window) + var sliced []string + for scanner.Scan() { + line := scanner.Text() + if hasWindow { + ts, parseErr := parseGHTimestamp(line) + if parseErr != nil || ts.Before(windowStart) { + continue + } + if ts.After(windowEnd) { + break + } + } + sliced = append(sliced, line) + } + if scanErr := scanner.Err(); scanErr != nil { + return "", "", scanErr + } + + log.Printf("sliced: %d lines", len(sliced)) + + // Tighten the window further by finding the last line that contains a + // known precise error signal and discarding everything after it. This + // removes trailing teardown noise that follows the actual failure + if hasWindow && len(sliced) > 0 { + preciseSignals := []string{ + "##[error]", + "--- fail:", + "fail!", + "[fail]", + "panic:", + "✖", + "timed out after", + } + var preciseEnd time.Time + for _, line := range sliced { + lower := strings.ToLower(stripGHTimestamp(line)) + for _, sig := range preciseSignals { + if strings.Contains(lower, sig) { + if ts, parseErr := parseGHTimestamp(line); parseErr == nil { + preciseEnd = ts + } + break + } + } + } + if !preciseEnd.IsZero() { + log.Printf("precise end: %s", preciseEnd.Format("2006-01-02T15:04:05.9999999Z")) + var precise []string + for _, line := range sliced { + ts, parseErr := parseGHTimestamp(line) + if parseErr != nil { + precise = append(precise, line) + continue + } + if ts.After(preciseEnd) { + break + } + precise = append(precise, line) + } + sliced = precise + } + } + var captured []string + for _, line := range sliced { + clean := stripGHTimestamp(line) + if clean == "" { + continue + } + if matchesAny(strings.ToLower(clean), c.logAnalysis.NoisePatterns) { + continue + } + captured = append(captured, clean) + } + log.Printf("captured: %d lines after noise filter", len(captured)) + + lineReader := strings.NewReader(strings.Join(captured, "\n")) + summary := analyseLog(bufio.NewScanner(lineReader), c.logAnalysis) + snippet = renderSummary(summary) + raw = strings.Join(captured, "\n") + if len(raw) > 50000 { + raw = raw[:50000] + "\n\n[truncated at 50KB — see full log at: " + logURL + "]" } - return renderSummary(summary), nil + return snippet, raw, nil } -func (c *Client) fetchJobSummaries(run *Run) { - url := fmt.Sprintf("https://api.github.com/repos/%s/actions/runs/%d/jobs", c.repo, run.ID) +// fetchAnnotationFallback is called when the job log has expired or is otherwise unavailable +func (c *Client) fetchAnnotationFallback(jobID int, htmlURL string) (snippet, raw string) { + url := fmt.Sprintf( + "https://api.github.com/repos/%s/check-runs/%d/annotations", + c.repo, jobID) + + var annotations []struct { + Level string `json:"annotation_level"` + Message string `json:"message"` + Title string `json:"title"` + } + + if err := c.get(url, &annotations); err != nil { + log.Printf("annotation fallback failed for job %d: %v", jobID, err) + snippet = "(log expired — no annotations available)" + raw = fmt.Sprintf("(log expired — see: %s)", htmlURL) + return + } + + var failures []string + for _, a := range annotations { + if strings.EqualFold(a.Level, "failure") { + msg := a.Message + if a.Title != "" { + msg = a.Title + ": " + msg + } + failures = append(failures, msg) + } + } + + if len(failures) == 0 { + snippet = "(log expired — no failure annotations found)" + raw = fmt.Sprintf("(log expired — see: %s)", htmlURL) + return + } + + snippet = strings.Join(failures, "\n") + raw = fmt.Sprintf("(log expired — annotations only)\n\n%s\n\nSee: %s", + snippet, htmlURL) + return +} + +// fetchJobsAndEnrich fetches all jobs for a run, appends their summaries to run.Jobs, +// and for each failed job downloads and analyses the log (falling back to annotations if the log is unavailable) +func (c *Client) fetchJobsAndEnrich(run *Run) { + url := fmt.Sprintf( + "https://api.github.com/repos/%s/actions/runs/%d/jobs", + c.repo, run.ID) var resp JobsResponse if err := c.get(url, &resp); err != nil { log.Printf("warn: could not fetch jobs for run %d: %v", run.ID, err) return } + for _, job := range resp.Jobs { dur := job.CompletedAt.Sub(job.StartedAt).Seconds() if dur < 0 { @@ -324,44 +517,49 @@ func (c *Client) fetchJobSummaries(run *Run) { DurationSec: dur, HTMLURL: job.HTMLURL, }) - } -} -func (c *Client) enrichWithLogs(run *Run) { - url := fmt.Sprintf("https://api.github.com/repos/%s/actions/runs/%d/jobs", c.repo, run.ID) - var resp JobsResponse - if err := c.get(url, &resp); err != nil { - log.Printf("warn: could not fetch jobs for run %d: %v", run.ID, err) - return - } - for _, job := range resp.Jobs { if job.Conclusion != "failure" { continue } - logURL := fmt.Sprintf("https://api.github.com/repos/%s/actions/jobs/%d/logs", c.repo, job.ID) + + var failedSteps []Step + for _, step := range job.Steps { + if step.Conclusion == "failure" { + failedSteps = append(failedSteps, step) + log.Printf(" failed step #%d %q | %s → %s", + step.Number, step.Name, + step.StartedAt.Format(time.RFC3339), + step.CompletedAt.Format(time.RFC3339)) + } + } + + if len(failedSteps) == 0 { + log.Printf("job %d (%s): no step-level data — will capture full job log", + job.ID, job.Name) + } + + logURL := fmt.Sprintf( + "https://api.github.com/repos/%s/actions/jobs/%d/logs", + c.repo, job.ID) log.Printf("fetching logs for failed job %d (%s)...", job.ID, job.Name) - snippet, err := c.fetchAndAnalyseLog(logURL) - if err != nil { - log.Printf("warn: could not fetch logs for job %d: %v", job.ID, err) - snippet = "(log fetch failed)" + + snippet, raw, fetchErr := c.fetchAndAnalyseLog(logURL, failedSteps) + if fetchErr != nil { + log.Printf("warn: log fetch failed for job %d: %v", job.ID, fetchErr) + snippet, raw = c.fetchAnnotationFallback(job.ID, job.HTMLURL) } + run.FailedJobs = append(run.FailedJobs, FailedJob{ ID: job.ID, Name: job.Name, Conclusion: job.Conclusion, HTMLURL: job.HTMLURL, LogSnippet: snippet, + RawLog: raw, }) } } -func stripGHTimestamp(line string) string { - if len(line) > 29 && line[10] == 'T' { - line = line[29:] - } - return strings.TrimSpace(line) -} - func normalize(s string) string { s = strings.ToLower(s) s = strings.ReplaceAll(s, "-", "") @@ -378,6 +576,8 @@ func stemPath(path string) string { return normalize(file) } +// findWorkflow locates a GitHub Workflow in the list by matching keyword +// against file stems and display names using three passes in increasing fuzziness func findWorkflow(workflows []Workflow, keyword string) *Workflow { key := normalize(keyword) for i, wf := range workflows { @@ -400,6 +600,7 @@ func findWorkflow(workflows []Workflow, keyword string) *Workflow { return nil } +// buildWeatherHistory returns a fixed-width slice of the 7 most recent run conclusions func buildWeatherHistory(runs []Run) []string { const slots = 7 history := make([]string, slots) @@ -410,7 +611,6 @@ func buildWeatherHistory(runs []Run) []string { if len(take) > slots { take = runs[:slots] } - offset := slots - len(take) for i, r := range take { idx := len(take) - 1 - i @@ -428,6 +628,7 @@ func buildWeatherHistory(runs []Run) []string { return history } +// buildSummary computes aggregate statistics (failure rate, average duration, weather history) for the given runs and returns a WorkflowSummary func buildSummary(runs []Run, name, desc string, critical, required bool) WorkflowSummary { var failed int var totalDuration float64 @@ -467,6 +668,9 @@ func buildSummary(runs []Run, name, desc string, critical, required bool) Workfl } } +// main loads config.yaml, workflows_raw.json, and runs_raw.json, then for each +// configured workflow: matches it to a GitHub workflow ID, fetches job/log data +// for recent runs, builds a WorkflowSummary func main() { cfgBytes, err := os.ReadFile("config.yaml") if err != nil { @@ -490,7 +694,6 @@ func main() { if err := json.Unmarshal(workflowsBytes, &workflowsResp); err != nil { log.Fatalf("cannot parse workflows_raw.json: %v", err) } - runsBytes, err := os.ReadFile("runs_raw.json") if err != nil { log.Fatalf("cannot read runs_raw.json: %v", err) @@ -499,12 +702,20 @@ func main() { if err := json.Unmarshal(runsBytes, &allRuns); err != nil { log.Fatalf("cannot parse runs_raw.json: %v", err) } - client := NewClient(os.Getenv("GITHUB_TOKEN"), cfg.Settings.SourceRepo, cfg.LogAnalysis) + var notifier *Notifier + if cfg.Notify.Enabled { + if os.Getenv("GITHUB_TOKEN") == "" { + log.Println("warn: notify.enabled = true but GITHUB_TOKEN not set — skipping notifications") + } else { + notifier = NewNotifier(os.Getenv("GITHUB_TOKEN"), cfg.Settings.SourceRepo, cfg.Notify) + log.Printf("Notifier enabled -> target: %s, label: %s", + notifier.targetRepo, notifier.label) + } + } var summaries []WorkflowSummary var totalHealth float64 - for _, w := range cfg.Workflows { wf := findWorkflow(workflowsResp.Workflows, w.Name) if wf == nil { @@ -519,7 +730,6 @@ func main() { continue } runs := runsData.WorkflowRuns - sort.Slice(runs, func(i, j int) bool { return runs[i].CreatedAt.After(runs[j].CreatedAt) }) @@ -528,21 +738,24 @@ func main() { if len(recent) > recentLimit { recent = runs[:recentLimit] } - - log.Printf("fetching logs for failed runs in %s…", w.Name) + log.Printf("fetching jobs and logs for %s...", w.Name) for i := range recent { - client.fetchJobSummaries(&recent[i]) - if recent[i].Conclusion == "failure" { - client.enrichWithLogs(&recent[i]) - } + client.fetchJobsAndEnrich(&recent[i]) } - summary := buildSummary(runs, w.Name, w.Description, w.Critical, w.Required) summary.RecentRuns = recent + if len(recent) > 0 && summary.LastRun != nil && recent[0].ID == summary.LastRun.ID { + r := recent[0] + summary.LastRun = &r + } summaries = append(summaries, summary) totalHealth += (100 - summary.FailureRate) + if notifier != nil { + notifier.Process(summary) + } } + // Overall health is the mean of (100 - failureRate) across all workflows health := 0.0 if len(summaries) > 0 { health = totalHealth / float64(len(summaries)) diff --git a/notify.go b/notify.go new file mode 100644 index 0000000..e928610 --- /dev/null +++ b/notify.go @@ -0,0 +1,335 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "strings" + "time" +) + +type NotifyConfig struct { + Enabled bool `yaml:"enabled"` + TargetRepo string `yaml:"target_repo"` + Label string `yaml:"label"` +} + +type Issue struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + HTMLURL string `json:"html_url"` + UpdatedAt time.Time `json:"updated_at"` +} + +type IssuesResponse []Issue + +type Notifier struct { + token string + sourceRepo string + targetRepo string + label string + http *http.Client +} + +// NewNotifier creates a new Notifier instance +func NewNotifier(token, sourceRepo string, cfg NotifyConfig) *Notifier { + targetRepo := cfg.TargetRepo + if targetRepo == "" { + targetRepo = sourceRepo + } + label := cfg.Label + if label == "" { + label = "ci-failure" + } + return &Notifier{ + token: token, + sourceRepo: sourceRepo, + targetRepo: targetRepo, + label: label, + http: &http.Client{Timeout: 15 * time.Second}, + } +} + +func anyFailed(runs []Run) *Run { + for i := range runs { + if runs[i].Conclusion == "failure" { + return &runs[i] + } + } + return nil +} + +// Process evaluates a WorkflowSummary and creates or updates a GitHub issue +// when a critical workflow has a failed run. It skips non-critical workflows +// and avoids duplicate comments if the issue was already updated within 24 hours. +func (n *Notifier) Process(summary WorkflowSummary) { + if !summary.Critical { + return + } + repr := anyFailed(summary.RecentRuns) + if repr == nil { + log.Printf("notify: %q — no failures in recent runs, skipping", summary.Name) + return + } + log.Printf("notify: %q — failure found (run #%d)", summary.Name, repr.RunNumber) + existingIssue := n.findOpenIssue(summary.Name) + if existingIssue == nil { + log.Printf("notify: opening issue for %q", summary.Name) + n.createIssue(summary, repr) + return + } + if time.Since(existingIssue.UpdatedAt) >= 24*time.Hour { + log.Printf("notify: adding update to issue #%d for %q", existingIssue.Number, summary.Name) + n.addComment(existingIssue.Number, summary, repr) + } else { + log.Printf("notify: issue #%d for %q updated within 24h — skipping comment", + existingIssue.Number, summary.Name) + } +} + +func (n *Notifier) apiURL(path string) string { + return "https://api.github.com/repos/" + n.targetRepo + path +} + +// do executes an authenticated HTTP request against the GitHub API +func (n *Notifier) do(method, rawURL string, body interface{}) (*http.Response, error) { + var buf *bytes.Buffer + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + buf = bytes.NewBuffer(b) + } else { + buf = bytes.NewBuffer(nil) + } + + req, err := http.NewRequest(method, rawURL, buf) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+n.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + return n.http.Do(req) +} + +// findOpenIssue searches paginated open issues on the target repo +func (n *Notifier) findOpenIssue(workflowName string) *Issue { + needle := issueTitle(workflowName) + page := 1 + for { + rawURL := n.apiURL(fmt.Sprintf( + "/issues?state=open&labels=%s&per_page=100&page=%d", + url.QueryEscape(n.label), page, + )) + resp, err := n.do("GET", rawURL, nil) + if err != nil { + log.Printf("notify: warn: could not list issues: %v", err) + return nil + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + log.Printf("notify: warn: issues list returned HTTP %d", resp.StatusCode) + return nil + } + + var issues IssuesResponse + if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { + resp.Body.Close() + log.Printf("notify: warn: could not decode issues: %v", err) + return nil + } + resp.Body.Close() + + for i := range issues { + title := issues[i].Title + if strings.EqualFold(title, needle) || strings.Contains(title, workflowName) { + log.Printf("notify: found existing issue #%d for %q", + issues[i].Number, workflowName) + return &issues[i] + } + } + if len(issues) < 100 { + break + } + page++ + } + return nil +} + +func (n *Notifier) createIssue(summary WorkflowSummary, repr *Run) { + resp, err := n.do("POST", n.apiURL("/issues"), map[string]interface{}{ + "title": issueTitle(summary.Name), + "body": buildIssueBody(summary, repr, n.sourceRepo), + "labels": []string{n.label}, + }) + if err != nil { + log.Printf("notify: warn: could not create issue: %v", err) + return + } + defer resp.Body.Close() + + var created Issue + json.NewDecoder(resp.Body).Decode(&created) + if created.Number > 0 { + log.Printf("notify: issue #%d created → %s", created.Number, created.HTMLURL) + } +} + +// addComment posts a follow-up comment to an existing issue. +func (n *Notifier) addComment(issueNumber int, summary WorkflowSummary, repr *Run) { + rawURL := n.apiURL(fmt.Sprintf("/issues/%d/comments", issueNumber)) + resp, err := n.do("POST", rawURL, map[string]string{ + "body": buildCommentBody(summary, repr, n.sourceRepo), + }) + if err != nil { + log.Printf("notify: warn: could not add comment: %v", err) + return + } + resp.Body.Close() +} + +func issueTitle(workflowName string) string { + return fmt.Sprintf("CI Failure: %s", workflowName) +} + +func weatherEmoji(c string) string { + switch c { + case "success": + return "✅" + case "failure": + return "❌" + case "skipped": + return "⏭️" + case "action_required": + return "⚠️" + default: + return "⬜" + } +} + +func buildSparkline(history []string) string { + if len(history) == 0 { + return "" + } + var sb strings.Builder + for _, c := range history { + sb.WriteString(weatherEmoji(c)) + sb.WriteString(" ") + } + return strings.TrimSpace(sb.String()) +} + +// buildRawLogBlock renders a collapsible
block containing the raw step +// log for a failed job. Returns an empty string if no usable log is available. +// Logs exceeding 30KB are truncated with a link +func buildRawLogBlock(job FailedJob) string { + raw := job.RawLog + if raw == "" || + strings.HasPrefix(raw, "(log expired") || + strings.HasPrefix(raw, "(log fetch") { + return "" + } + const maxBytes = 30000 + truncated := false + if len(raw) > maxBytes { + raw = raw[:maxBytes] + truncated = true + } + + var sb strings.Builder + sb.WriteString("
\n") + sb.WriteString(fmt.Sprintf("Full step log — %s\n\n", job.Name)) + sb.WriteString("```\n") + sb.WriteString(raw) + if truncated { + sb.WriteString("\n\n[truncated at 30KB — see full log at: ") + sb.WriteString(job.HTMLURL) + sb.WriteString("]") + } + sb.WriteString("\n```\n") + sb.WriteString("
\n\n") + return sb.String() +} + +// buildSnippetSection renders the log snippet and optional raw log block for a failed job +func buildSnippetSection(job FailedJob) string { + var sb strings.Builder + switch job.LogSnippet { + case "", "(no actionable failure signal found in log)", "(log fetch failed)": + if job.LogSnippet != "" { + sb.WriteString(fmt.Sprintf("> _%s_\n\n", job.LogSnippet)) + } + default: + sb.WriteString("**Signal summary:**\n\n") + sb.WriteString("```\n") + sb.WriteString(strings.TrimSpace(job.LogSnippet)) + sb.WriteString("\n```\n\n") + } + sb.WriteString(buildRawLogBlock(job)) + return sb.String() +} + +// buildFailedJobsSection renders a Markdown section listing each failed job with its log snippet +func buildFailedJobsSection(jobs []FailedJob) string { + if len(jobs) == 0 { + return "> No individual job failure data captured — check the run link above.\n\n" + } + var sb strings.Builder + for _, job := range jobs { + sb.WriteString(fmt.Sprintf("#### ❌ [%s](%s)\n\n", job.Name, job.HTMLURL)) + sb.WriteString(buildSnippetSection(job)) + } + return sb.String() +} + +// buildIssueBody composes the full Markdown body for a newly opened CI failure issue +func buildIssueBody(summary WorkflowSummary, repr *Run, sourceRepo string) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("## ❌ Critical workflow failing: `%s`\n\n", summary.Name)) + if summary.Description != "" { + sb.WriteString(fmt.Sprintf("> %s\n\n", summary.Description)) + } + + sb.WriteString("### Failed Run\n\n") + sb.WriteString("| Field | Value |\n|---|---|\n") + sb.WriteString(fmt.Sprintf("| Run | [#%d](%s) |\n", repr.RunNumber, repr.HTMLURL)) + sb.WriteString(fmt.Sprintf("| Started | `%s` |\n", repr.CreatedAt.Format(time.RFC1123))) + sb.WriteString(fmt.Sprintf("| Conclusion | `%s` |\n", repr.Conclusion)) + sb.WriteString(fmt.Sprintf("| Attempt | `%d` |\n", repr.RunAttempt)) + sb.WriteString(fmt.Sprintf("| Repo | [%s](https://github.com/%s) |\n\n", + sourceRepo, sourceRepo)) + + if spark := buildSparkline(summary.WeatherHistory); spark != "" { + sb.WriteString("### Recent History (oldest → newest)\n\n") + sb.WriteString(spark + "\n\n") + } + + sb.WriteString("### Failed Jobs\n\n") + sb.WriteString(buildFailedJobsSection(repr.FailedJobs)) + sb.WriteString("---\n") + sb.WriteString("This issue was opened automatically by the CI dashboard.") + sb.WriteString("Please close it manually once the issue is resolved._\n") + return sb.String() +} + +// buildCommentBody composes a shorter Markdown body for a follow-up comment on +// an existing issue, summarising the latest failing run and updated job details. +func buildCommentBody(summary WorkflowSummary, repr *Run, sourceRepo string) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("### ❌ Still failing — run [#%d](%s)\n\n", + repr.RunNumber, repr.HTMLURL)) + sb.WriteString(fmt.Sprintf("Failed at `%s`.\n\n", repr.CreatedAt.Format(time.RFC1123))) + + if spark := buildSparkline(summary.WeatherHistory); spark != "" { + sb.WriteString("**Recent history (oldest → newest):** " + spark + "\n\n") + } + sb.WriteString(buildFailedJobsSection(repr.FailedJobs)) + return sb.String() +} From c41964de46946a9f85a8978cc8d310a1b55f7e3e Mon Sep 17 00:00:00 2001 From: 7h3-3mp7y-m4n Date: Sat, 23 May 2026 00:59:56 +0530 Subject: [PATCH 2/2] fix spacing Signed-off-by: 7h3-3mp7y-m4n --- .github/workflows/updates.yaml | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/.github/workflows/updates.yaml b/.github/workflows/updates.yaml index 8eea686..da0efaf 100644 --- a/.github/workflows/updates.yaml +++ b/.github/workflows/updates.yaml @@ -21,26 +21,26 @@ jobs: - name: Fetch workflow data env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh api repos/urunc-dev/urunc/actions/workflows \ --paginate > workflows_raw.json echo "{}" > runs_raw.json - jq -r '.workflows[] | "\(.id) \(.name)"' workflows_raw.json | while read wf_id wf_name; do - echo "Processing workflow: $wf_name (ID: $wf_id)" + jq -r '.workflows[] | "\(.id)\t\(.name)"' workflows_raw.json | while IFS=$'\t' read -r wf_id wf_name; do + echo "Processing workflow: $wf_name (ID: $wf_id)" - gh api "repos/urunc-dev/urunc/actions/workflows/${wf_id}/runs?per_page=20" \ - > runs_tmp.json + gh api "repos/urunc-dev/urunc/actions/workflows/${wf_id}/runs?per_page=20" \ + > runs_tmp.json - jq --arg id "$wf_id" \ - --arg name "$wf_name" \ - --slurpfile runs runs_tmp.json \ - '. + {($id): ($runs[0] + {name: $name})}' \ - runs_raw.json > tmp.json + jq --arg id "$wf_id" \ + --arg name "$wf_name" \ + --slurpfile runs runs_tmp.json \ + '. + {($id): ($runs[0] + {name: $name})}' \ + runs_raw.json > tmp.json - mv tmp.json runs_raw.json + mv tmp.json runs_raw.json done echo "===== FINAL workflows_raw.json =====" cat workflows_raw.json @@ -51,8 +51,6 @@ jobs: rm -f runs_tmp.json - name: Generate stats - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: go run . - name: Cleanup temp files