Skip to content

Instantly share code, notes, and snippets.

@0xack13
Last active March 4, 2025 18:48
Show Gist options
  • Save 0xack13/f7585ff9ef9eca68f55b3e06ec1189d4 to your computer and use it in GitHub Desktop.
Save 0xack13/f7585ff9ef9eca68f55b3e06ec1189d4 to your computer and use it in GitHub Desktop.
gha_wfdiff.go
#!/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
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