Last active
March 4, 2025 18:48
-
-
Save 0xack13/f7585ff9ef9eca68f55b3e06ec1189d4 to your computer and use it in GitHub Desktop.
gha_wfdiff.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash | |
# Disable pager | |
export GH_PAGER=cat | |
# Usage: ./failed_steps.sh <workflow_name_or_id> <repo_owner/repo_name> | |
WORKFLOW_NAME="$1" | |
REPO="$2" | |
# GitHub base URL | |
GITHUB_URL="https://github.com/$REPO/actions/runs" | |
# List failed workflow runs | |
FAILED_RUNS=$(gh run list --workflow "$WORKFLOW_NAME" --repo "$REPO" --status failure --limit 1000 --json id) | |
if [[ -z "$FAILED_RUNS" ]]; then | |
echo "No failed workflows found." | |
exit 0 | |
fi | |
# Loop through each failed run and get failed steps | |
echo "Failed workflow runs for '$WORKFLOW_NAME' in repository '$REPO':" | |
echo "" | |
# Extract run ID from the output | |
echo "$FAILED_RUNS" | jq -r '.[] | .id' | while read RUN_ID; do | |
echo "Checking run ID: $RUN_ID" | |
# Generate link for the failed run (job-level URL) | |
RUN_URL="$GITHUB_URL/$RUN_ID" | |
echo " View run details: $RUN_URL" | |
# Get failed steps from the run | |
FAILED_STEPS=$(gh run view "$RUN_ID" --repo "$REPO" --log --json conclusion,steps | jq -r '.steps[] | select(.conclusion == "failure") | .name') | |
if [[ -z "$FAILED_STEPS" ]]; then | |
echo " No failed steps in this run." | |
else | |
echo " Failed steps:" | |
echo "$FAILED_STEPS" | while read STEP; do | |
# Generate link for the failed step (this is a simple link to the job URL) | |
STEP_URL="$RUN_URL" | |
echo " - $STEP (View step in GitHub: $STEP_URL)" | |
done | |
fi | |
echo "" | |
done |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package main | |
import ( | |
"context" | |
"fmt" | |
"log" | |
"os" | |
"time" | |
"github.com/charmbracelet/bubbles/table" | |
tea "github.com/charmbracelet/bubbletea" | |
"github.com/charmbracelet/lipgloss" | |
"github.com/google/go-github/v62/github" | |
"golang.org/x/oauth2" | |
) | |
// Styling | |
var baseStyle = lipgloss.NewStyle(). | |
BorderStyle(lipgloss.NormalBorder()). | |
BorderForeground(lipgloss.Color("240")) | |
var dialogStyle = lipgloss.NewStyle(). | |
Padding(1, 2). | |
BorderStyle(lipgloss.DoubleBorder()). | |
BorderForeground(lipgloss.Color("33")). | |
Foreground(lipgloss.Color("229")). | |
Align(lipgloss.Center) | |
// model now stores runID1 and runID2 for the table header. | |
type model struct { | |
table table.Model | |
jobs []*github.WorkflowJob | |
jobs2 []*github.WorkflowJob | |
showDialog bool | |
selectedJob1 *github.WorkflowJob | |
selectedJob2 *github.WorkflowJob | |
width, height int | |
showAll bool // Tracks the current view mode | |
runID1 int64 // Primary run ID for header | |
runID2 int64 // Secondary run ID for header | |
showStats bool // Tracks if stats view is enabled | |
stats string // Stores the stats to display | |
} | |
func (m model) Init() tea.Cmd { | |
m.updateTableRows() // Initialize table rows | |
return nil | |
} | |
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
var cmd tea.Cmd | |
switch msg := msg.(type) { | |
case tea.WindowSizeMsg: | |
m.width, m.height = msg.Width, msg.Height | |
m.table.SetWidth(msg.Width - 20) | |
m.table.SetHeight(msg.Height - 6) // Adjusted to keep header visible | |
case tea.KeyMsg: | |
switch msg.String() { | |
case "esc": | |
if m.showDialog || m.showStats { | |
m.showDialog = false | |
m.showStats = false | |
m.selectedJob1 = nil | |
m.selectedJob2 = nil | |
} else { | |
if m.table.Focused() { | |
m.table.Blur() | |
} else { | |
m.table.Focus() | |
} | |
} | |
case "q", "ctrl+c": | |
return m, tea.Quit | |
case "enter": | |
if !m.showDialog { | |
selectedIndex := m.table.Cursor() | |
if selectedIndex >= 0 && selectedIndex < len(m.jobs) { | |
m.selectedJob1 = m.jobs[selectedIndex] | |
// Find the corresponding job in the second set. | |
for _, job := range m.jobs2 { | |
if job.GetName() == m.selectedJob1.GetName() { | |
m.selectedJob2 = job | |
break | |
} | |
} | |
m.showDialog = true | |
} | |
} | |
case "s": | |
m.showAll = !m.showAll | |
m.updateTableRows() | |
case "w": | |
// Swap the jobs slices. | |
m.jobs, m.jobs2 = m.jobs2, m.jobs | |
// Also swap the run IDs. | |
m.runID1, m.runID2 = m.runID2, m.runID1 | |
m.updateTableRows() | |
// Update selected job references, if any. | |
if m.selectedJob1 != nil { | |
for _, job := range m.jobs { | |
if job.GetName() == m.selectedJob1.GetName() { | |
m.selectedJob1 = job | |
break | |
} | |
} | |
} | |
if m.selectedJob2 != nil { | |
for _, job := range m.jobs2 { | |
if job.GetName() == m.selectedJob2.GetName() { | |
m.selectedJob2 = job | |
break | |
} | |
} | |
} | |
// Update table header columns with new run IDs and counts. | |
cols := []table.Column{ | |
{Title: "Job Name", Width: 40}, | |
{Title: "Runners Label", Width: 40}, | |
{Title: "Diff", Width: 10}, | |
{Title: fmt.Sprintf("Job (%v) [%d]", m.runID1, len(m.jobs)), Width: 30}, | |
{Title: fmt.Sprintf("Job (%v) [%d]", m.runID2, len(m.jobs2)), Width: 30}, | |
} | |
m.table.SetColumns(cols) | |
m.updateTableRows() | |
case "t": | |
m.showStats = !m.showStats | |
if m.showStats { | |
m.stats = m.calculateStats() | |
} | |
} | |
} | |
if !m.showDialog && !m.showStats { | |
m.table, cmd = m.table.Update(msg) | |
} | |
return m, cmd | |
} | |
func (m *model) updateTableRows() { | |
var rows []table.Row | |
// Add jobs from m.jobs first. | |
for _, job1 := range m.jobs { | |
var duration1, duration2, diff, runnerLabel string | |
var job2 *github.WorkflowJob | |
// Find matching job in m.jobs2. | |
for _, j2 := range m.jobs2 { | |
if j2.GetName() == job1.GetName() { | |
job2 = j2 | |
break | |
} | |
} | |
// Calculate duration for job1. | |
if job1.StartedAt == nil || job1.CompletedAt == nil { | |
duration1 = "N/A (not completed)" | |
} else { | |
duration1 = job1.GetCompletedAt().Time.Sub(job1.GetStartedAt().Time).String() | |
} | |
if job2 != nil { | |
// Calculate duration for job2. | |
if job2.StartedAt == nil || job2.CompletedAt == nil { | |
duration2 = "N/A (not completed)" | |
} else { | |
duration2 = job2.GetCompletedAt().Time.Sub(job2.GetStartedAt().Time).String() | |
} | |
// Calculate percentage difference. | |
if duration1 == "N/A (not completed)" || duration2 == "N/A (not completed)" { | |
diff = "N/A" | |
} else { | |
duration1Time, _ := time.ParseDuration(duration1) | |
duration2Time, _ := time.ParseDuration(duration2) | |
percentageChange := ((duration2Time.Seconds() - duration1Time.Seconds()) / duration1Time.Seconds()) * 100 | |
diff = fmt.Sprintf("%.2f%%", percentageChange) | |
} | |
runnerLabel = fmt.Sprintf("%v->%v", job1.Labels, job2.Labels) | |
rows = append(rows, table.Row{job1.GetName(), runnerLabel, diff, duration1, duration2}) | |
} else if m.showAll { | |
// Show job1 even if no matching job exists in m.jobs2. | |
runnerLabel = fmt.Sprintf("%v", job1.Labels) | |
rows = append(rows, table.Row{job1.GetName(), runnerLabel, "N/A", duration1, "N/A"}) | |
} | |
} | |
// Update the table rows. | |
m.table.SetRows(rows) | |
} | |
func (m model) calculateStats() string { | |
var totalDuration1, totalDuration2 time.Duration | |
var completedJobs1, completedJobs2 int | |
for _, job := range m.jobs { | |
if job.StartedAt != nil && job.CompletedAt != nil { | |
totalDuration1 += job.GetCompletedAt().Time.Sub(job.GetStartedAt().Time) | |
completedJobs1++ | |
} | |
} | |
for _, job := range m.jobs2 { | |
if job.StartedAt != nil && job.CompletedAt != nil { | |
totalDuration2 += job.GetCompletedAt().Time.Sub(job.GetStartedAt().Time) | |
completedJobs2++ | |
} | |
} | |
var avgDuration1, avgDuration2 time.Duration | |
if completedJobs1 > 0 { | |
avgDuration1 = totalDuration1 / time.Duration(completedJobs1) | |
} | |
if completedJobs2 > 0 { | |
avgDuration2 = totalDuration2 / time.Duration(completedJobs2) | |
} | |
var improvement string | |
if avgDuration1 > 0 { | |
improvementValue := ((avgDuration2.Seconds() - avgDuration1.Seconds()) / avgDuration1.Seconds()) * 100 | |
if improvementValue > 0 { | |
improvement = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render(fmt.Sprintf("+%.2f%%", improvementValue)) | |
} else { | |
improvement = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render(fmt.Sprintf("%.2f%%", improvementValue)) | |
} | |
} else { | |
improvement = "N/A" | |
} | |
// Calculate the length of the improvement string without ANSI escape codes. | |
improvementLength := lipgloss.Width(improvement) | |
return fmt.Sprintf( | |
"%-25s | %-25s | %-25s\n"+ | |
"%-25s | %-25s | %-25s\n"+ | |
"%-25s | %-25s | %-25s\n"+ | |
"%-21s | %-37s %-*s\n", | |
"Attribute", "Job #1", "Job #2", | |
"Total Duration:", totalDuration1.String(), totalDuration2.String(), | |
"Average Duration:", avgDuration1.String(), avgDuration2.String(), | |
"Improvement:", improvement, 25-improvementLength, "", | |
) | |
} | |
func (m model) View() string { | |
if m.showDialog && m.selectedJob1 != nil { | |
// Helper to safely get job attribute values. | |
getValue := func(job *github.WorkflowJob, getter func() string) string { | |
if job != nil { | |
return getter() | |
} | |
return "N/A" | |
} | |
dialogBox := dialogStyle.Width(m.width).Render( | |
fmt.Sprintf( | |
"Job: %s\n\n"+ | |
"%-12s | %-20s | %-20s\n"+ | |
"%-12s | %-20s | %-20s\n"+ | |
"%-12s | %-20s | %-20s\n"+ | |
"%-12s | %-20s | %-20s\n"+ | |
"%-12s | %-20s | %-20s\n"+ | |
"(Press ESC to go back)", | |
m.selectedJob1.GetName(), | |
"Attribute", "Job #1", "Job #2", | |
"Status:", m.selectedJob1.GetStatus(), getValue(m.selectedJob2, m.selectedJob2.GetStatus), | |
"Labels:", fmt.Sprintf("%v", m.selectedJob1.Labels), getValue(m.selectedJob2, func() string { | |
return fmt.Sprintf("%v", m.selectedJob2.Labels) | |
}), | |
"Started:", getValue(m.selectedJob1, func() string { | |
return m.selectedJob1.GetStartedAt().Format("2006-01-02 15:04:05") | |
}), getValue(m.selectedJob2, func() string { | |
return m.selectedJob2.GetStartedAt().Format("2006-01-02 15:04:05") | |
}), | |
"Completed:", getValue(m.selectedJob1, func() string { | |
return m.selectedJob1.GetCompletedAt().Format("2006-01-02 15:04:05") | |
}), getValue(m.selectedJob2, func() string { | |
return m.selectedJob2.GetCompletedAt().Format("2006-01-02 15:04:05") | |
}), | |
), | |
) | |
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialogBox) | |
} | |
if m.showStats { | |
statsBox := dialogStyle.Width(m.width).Render(m.stats) | |
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, statsBox) | |
} | |
// Render the table view. | |
tableView := baseStyle.Width(m.width).Height(m.height - 20).Render(m.table.View()) | |
return fmt.Sprintf("%s\n(Press ENTER to see job details, ESC to toggle focus, S to toggle view, W to swap runs, T to show stats, Q to quit)", tableView) | |
} | |
func main() { | |
// Set your GitHub token. | |
token := os.Getenv("GITHUB_TOKEN") | |
if token == "" { | |
log.Fatal("GITHUB_TOKEN environment variable is required") | |
} | |
// Create GitHub client. | |
ctx := context.Background() | |
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) | |
tc := oauth2.NewClient(ctx, ts) | |
client := github.NewClient(tc) | |
// Set your repo details. | |
owner := "0xack13" | |
repo := "gbook" | |
runID := int64(13479017854) // Replace with your first workflow run ID. | |
runID2 := int64(13475259748) // Replace with your second workflow run ID. | |
// List jobs for the first workflow run. | |
jobs, _, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, nil) | |
if err != nil { | |
log.Fatalf("Error fetching workflow jobs: %v", err) | |
} | |
// List jobs for the second workflow run. | |
jobs2, _, err2 := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID2, nil) | |
if err2 != nil { | |
log.Fatalf("Error fetching workflow jobs: %v", err2) | |
} | |
// Prepare initial table rows. | |
var rows []table.Row | |
jobMap := make(map[string]*github.WorkflowJob) | |
for _, job := range jobs.Jobs { | |
jobMap[job.GetName()] = job | |
} | |
for _, job2 := range jobs2.Jobs { | |
job1, exists := jobMap[job2.GetName()] | |
var duration1, duration2, diff, runnerLabel string | |
if exists { | |
if job1.StartedAt == nil || job1.CompletedAt == nil { | |
duration1 = "N/A (not completed)" | |
} else { | |
duration1 = job1.GetCompletedAt().Time.Sub(job1.GetStartedAt().Time).String() | |
} | |
if job2.StartedAt == nil || job2.CompletedAt == nil { | |
duration2 = "N/A (not completed)" | |
} else { | |
duration2 = job2.GetCompletedAt().Time.Sub(job2.GetStartedAt().Time).String() | |
} | |
if duration1 == "N/A (not completed)" || duration2 == "N/A (not completed)" { | |
diff = "N/A" | |
} else { | |
duration1Time, _ := time.ParseDuration(duration1) | |
duration2Time, _ := time.ParseDuration(duration2) | |
percentageChange := ((duration2Time.Seconds() - duration1Time.Seconds()) / duration1Time.Seconds()) * 100 | |
diff = fmt.Sprintf("%.2f%%", percentageChange) | |
} | |
runnerLabel = fmt.Sprintf("%v->%v", job1.Labels, job2.Labels) | |
rows = append(rows, table.Row{job1.GetName(), runnerLabel, diff, duration1, duration2}) | |
} else { | |
var duration2 string | |
if job2.StartedAt == nil || job2.CompletedAt == nil { | |
duration2 = "N/A (not completed)" | |
} else { | |
duration2 = job2.GetCompletedAt().Time.Sub(job2.GetStartedAt().Time).String() | |
} | |
rows = append(rows, table.Row{job2.GetName(), fmt.Sprintf("%v", job2.Labels), "N/A", "N/A", duration2}) | |
} | |
} | |
// Create table with initial headers. | |
cols := []table.Column{ | |
{Title: "Job Name", Width: 40}, | |
{Title: "Runners Label", Width: 40}, | |
{Title: "Diff", Width: 10}, | |
{Title: fmt.Sprintf("Job (%v) [%d]", runID, len(jobs.Jobs)), Width: 30}, | |
{Title: fmt.Sprintf("Job (%v) [%d]", runID2, len(jobs2.Jobs)), Width: 30}, | |
} | |
t := table.New( | |
table.WithColumns(cols), | |
table.WithRows(rows), | |
table.WithFocused(true), | |
) | |
// Style the table. | |
s := table.DefaultStyles() | |
s.Header = s.Header. | |
BorderStyle(lipgloss.ThickBorder()). | |
BorderForeground(lipgloss.Color("240")). | |
Background(lipgloss.Color("236")). | |
BorderBottom(true). | |
Bold(true) | |
s.Selected = s.Selected. | |
Foreground(lipgloss.Color("229")). | |
Background(lipgloss.Color("57")). | |
Bold(false) | |
t.SetStyles(s) | |
// Initialize the model. | |
m := model{ | |
table: t, | |
jobs: jobs.Jobs, | |
jobs2: jobs2.Jobs, | |
runID1: runID, | |
runID2: runID2, | |
} | |
// Start the program. | |
if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { | |
fmt.Println("Error running program:", err) | |
os.Exit(1) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment