diff --git a/charts/gha-runner-scale-set/values.yaml b/charts/gha-runner-scale-set/values.yaml index a235f26a..5a0f4ca3 100644 --- a/charts/gha-runner-scale-set/values.yaml +++ b/charts/gha-runner-scale-set/values.yaml @@ -154,7 +154,7 @@ githubConfigSecret: # counters: # gha_started_jobs_total: # labels: -# ["repository", "organization", "enterprise", "job_name", "event_name", "job_workflow_ref"] +# ["repository", "organization", "enterprise", "job_name", "event_name", "job_workflow_ref", "job_workflow_name", "job_workflow_target"] # gha_completed_jobs_total: # labels: # [ @@ -165,6 +165,8 @@ githubConfigSecret: # "event_name", # "job_result", # "job_workflow_ref", +# "job_workflow_name", +# "job_workflow_target", # ] # gauges: # gha_assigned_jobs: @@ -186,7 +188,7 @@ githubConfigSecret: # histograms: # gha_job_startup_duration_seconds: # labels: -# ["repository", "organization", "enterprise", "job_name", "event_name","job_workflow_ref"] +# ["repository", "organization", "enterprise", "job_name", "event_name","job_workflow_ref", "job_workflow_name", "job_workflow_target"] # buckets: # [ # 0.01, @@ -244,7 +246,9 @@ githubConfigSecret: # "job_name", # "event_name", # "job_result", -# "job_workflow_ref" +# "job_workflow_ref", +# "job_workflow_name", +# "job_workflow_target" # ] # buckets: # [ diff --git a/cmd/ghalistener/metrics/metrics.go b/cmd/ghalistener/metrics/metrics.go index 2aed6fb8..a9353ccc 100644 --- a/cmd/ghalistener/metrics/metrics.go +++ b/cmd/ghalistener/metrics/metrics.go @@ -22,6 +22,8 @@ const ( labelKeyRepository = "repository" labelKeyJobName = "job_name" labelKeyJobWorkflowRef = "job_workflow_ref" + labelKeyJobWorkflowName = "job_workflow_name" + labelKeyJobWorkflowTarget = "job_workflow_target" labelKeyEventName = "event_name" labelKeyJobResult = "job_result" ) @@ -75,13 +77,16 @@ var metricsHelp = metricsHelpRegistry{ } func (e *exporter) jobLabels(jobBase *actions.JobMessageBase) prometheus.Labels { + workflowRefInfo := ParseWorkflowRef(jobBase.JobWorkflowRef) return prometheus.Labels{ - labelKeyEnterprise: e.scaleSetLabels[labelKeyEnterprise], - labelKeyOrganization: jobBase.OwnerName, - labelKeyRepository: jobBase.RepositoryName, - labelKeyJobName: jobBase.JobDisplayName, - labelKeyJobWorkflowRef: jobBase.JobWorkflowRef, - labelKeyEventName: jobBase.EventName, + labelKeyEnterprise: e.scaleSetLabels[labelKeyEnterprise], + labelKeyOrganization: jobBase.OwnerName, + labelKeyRepository: jobBase.RepositoryName, + labelKeyJobName: jobBase.JobDisplayName, + labelKeyJobWorkflowRef: jobBase.JobWorkflowRef, + labelKeyJobWorkflowName: workflowRefInfo.Name, + labelKeyJobWorkflowTarget: workflowRefInfo.Target, + labelKeyEventName: jobBase.EventName, } } diff --git a/cmd/ghalistener/metrics/metrics_integration_test.go b/cmd/ghalistener/metrics/metrics_integration_test.go new file mode 100644 index 00000000..a0e41ae0 --- /dev/null +++ b/cmd/ghalistener/metrics/metrics_integration_test.go @@ -0,0 +1,99 @@ +package metrics + +import ( + "testing" + + "github.com/actions/actions-runner-controller/github/actions" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" +) + +func TestMetricsWithWorkflowRefParsing(t *testing.T) { + // Create a test exporter + exporter := &exporter{ + scaleSetLabels: prometheus.Labels{ + labelKeyEnterprise: "test-enterprise", + labelKeyOrganization: "test-org", + labelKeyRepository: "test-repo", + labelKeyRunnerScaleSetName: "test-scale-set", + labelKeyRunnerScaleSetNamespace: "test-namespace", + }, + } + + tests := []struct { + name string + jobBase actions.JobMessageBase + wantName string + wantTarget string + }{ + { + name: "main branch workflow", + jobBase: actions.JobMessageBase{ + OwnerName: "actions", + RepositoryName: "runner", + JobDisplayName: "Build and Test", + JobWorkflowRef: "actions/runner/.github/workflows/build.yml@refs/heads/main", + EventName: "push", + }, + wantName: "build", + wantTarget: "heads/main", + }, + { + name: "feature branch workflow", + jobBase: actions.JobMessageBase{ + OwnerName: "myorg", + RepositoryName: "myrepo", + JobDisplayName: "CI/CD Pipeline", + JobWorkflowRef: "myorg/myrepo/.github/workflows/ci-cd-pipeline.yml@refs/heads/feature/new-metrics", + EventName: "push", + }, + wantName: "ci-cd-pipeline", + wantTarget: "heads/feature/new-metrics", + }, + { + name: "pull request workflow", + jobBase: actions.JobMessageBase{ + OwnerName: "actions", + RepositoryName: "runner", + JobDisplayName: "PR Checks", + JobWorkflowRef: "actions/runner/.github/workflows/pr-checks.yml@refs/pull/123/merge", + EventName: "pull_request", + }, + wantName: "pr-checks", + wantTarget: "pull/123", + }, + { + name: "tag workflow", + jobBase: actions.JobMessageBase{ + OwnerName: "actions", + RepositoryName: "runner", + JobDisplayName: "Release", + JobWorkflowRef: "actions/runner/.github/workflows/release.yml@refs/tags/v1.2.3", + EventName: "release", + }, + wantName: "release", + wantTarget: "tags/v1.2.3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + labels := exporter.jobLabels(&tt.jobBase) + + // Build expected labels + expectedLabels := prometheus.Labels{ + labelKeyEnterprise: "test-enterprise", + labelKeyOrganization: tt.jobBase.OwnerName, + labelKeyRepository: tt.jobBase.RepositoryName, + labelKeyJobName: tt.jobBase.JobDisplayName, + labelKeyJobWorkflowRef: tt.jobBase.JobWorkflowRef, + labelKeyJobWorkflowName: tt.wantName, + labelKeyJobWorkflowTarget: tt.wantTarget, + labelKeyEventName: tt.jobBase.EventName, + } + + // Assert all expected labels match + assert.Equal(t, expectedLabels, labels, "jobLabels() returned unexpected labels for %s", tt.name) + }) + } +} diff --git a/cmd/ghalistener/metrics/workflow_ref_parser.go b/cmd/ghalistener/metrics/workflow_ref_parser.go new file mode 100644 index 00000000..065ac9f7 --- /dev/null +++ b/cmd/ghalistener/metrics/workflow_ref_parser.go @@ -0,0 +1,78 @@ +package metrics + +import ( + "path" + "strings" +) + +// WorkflowRefInfo contains parsed information from a job_workflow_ref +type WorkflowRefInfo struct { + // Name is the workflow file name without extension + Name string + // Target is the target ref with type prefix retained for clarity + // Examples: + // - heads/main (branch) + // - heads/feature/new-feature (branch) + // - tags/v1.2.3 (tag) + // - pull/123 (pull request) + Target string +} + +// ParseWorkflowRef parses a job_workflow_ref string to extract workflow name and target +// Format: {owner}/{repo}/.github/workflows/{workflow_file}@{ref} +// Example: mygithuborg/myrepo/.github/workflows/blank.yml@refs/heads/main +// +// The target field preserves type prefixes to differentiate between: +// - Branch references: "heads/{branch}" (from refs/heads/{branch}) +// - Tag references: "tags/{tag}" (from refs/tags/{tag}) +// - Pull requests: "pull/{number}" (from refs/pull/{number}/merge) +func ParseWorkflowRef(workflowRef string) WorkflowRefInfo { + info := WorkflowRefInfo{} + + if workflowRef == "" { + return info + } + + // Split by @ to separate path and ref + parts := strings.Split(workflowRef, "@") + if len(parts) != 2 { + return info + } + + workflowPath := parts[0] + ref := parts[1] + + // Extract workflow name from path + // The path format is: {owner}/{repo}/.github/workflows/{workflow_file} + workflowFile := path.Base(workflowPath) + // Remove .yml or .yaml extension + info.Name = strings.TrimSuffix(strings.TrimSuffix(workflowFile, ".yml"), ".yaml") + + // Extract target from ref based on type + // Branch refs: refs/heads/{branch} + // Tag refs: refs/tags/{tag} + // PR refs: refs/pull/{number}/merge + const ( + branchPrefix = "refs/heads/" + tagPrefix = "refs/tags/" + prPrefix = "refs/pull/" + ) + + switch { + case strings.HasPrefix(ref, branchPrefix): + // Keep "heads/" prefix to indicate branch + info.Target = "heads/" + strings.TrimPrefix(ref, branchPrefix) + case strings.HasPrefix(ref, tagPrefix): + // Keep "tags/" prefix to indicate tag + info.Target = "tags/" + strings.TrimPrefix(ref, tagPrefix) + case strings.HasPrefix(ref, prPrefix): + // Extract PR number from refs/pull/{number}/merge + // Keep "pull/" prefix to indicate pull request + prPart := strings.TrimPrefix(ref, prPrefix) + if idx := strings.Index(prPart, "/"); idx > 0 { + info.Target = "pull/" + prPart[:idx] + } + } + + return info +} diff --git a/cmd/ghalistener/metrics/workflow_ref_parser_test.go b/cmd/ghalistener/metrics/workflow_ref_parser_test.go new file mode 100644 index 00000000..fcb29efa --- /dev/null +++ b/cmd/ghalistener/metrics/workflow_ref_parser_test.go @@ -0,0 +1,82 @@ +package metrics + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseWorkflowRef(t *testing.T) { + tests := []struct { + name string + workflowRef string + wantName string + wantTarget string + }{ + { + name: "standard branch reference with yml", + workflowRef: "actions-runner-controller-sandbox/mumoshu-orgrunner-test-01/.github/workflows/blank.yml@refs/heads/main", + wantName: "blank", + wantTarget: "heads/main", + }, + { + name: "branch with special characters", + workflowRef: "owner/repo/.github/workflows/ci-cd.yml@refs/heads/feature/new-feature", + wantName: "ci-cd", + wantTarget: "heads/feature/new-feature", + }, + { + name: "yaml extension", + workflowRef: "owner/repo/.github/workflows/deploy.yaml@refs/heads/develop", + wantName: "deploy", + wantTarget: "heads/develop", + }, + { + name: "tag reference", + workflowRef: "owner/repo/.github/workflows/release.yml@refs/tags/v1.0.0", + wantName: "release", + wantTarget: "tags/v1.0.0", + }, + { + name: "pull request reference", + workflowRef: "owner/repo/.github/workflows/test.yml@refs/pull/123/merge", + wantName: "test", + wantTarget: "pull/123", + }, + { + name: "empty workflow ref", + workflowRef: "", + wantName: "", + wantTarget: "", + }, + { + name: "invalid format - no @ separator", + workflowRef: "owner/repo/.github/workflows/test.yml", + wantName: "", + wantTarget: "", + }, + { + name: "workflow with dots in name", + workflowRef: "owner/repo/.github/workflows/build.test.yml@refs/heads/main", + wantName: "build.test", + wantTarget: "heads/main", + }, + { + name: "workflow with hyphen and underscore", + workflowRef: "owner/repo/.github/workflows/build-test_deploy.yml@refs/heads/main", + wantName: "build-test_deploy", + wantTarget: "heads/main", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseWorkflowRef(tt.workflowRef) + expected := WorkflowRefInfo{ + Name: tt.wantName, + Target: tt.wantTarget, + } + assert.Equal(t, expected, got, "ParseWorkflowRef(%q) returned unexpected result", tt.workflowRef) + }) + } +}