-
Notifications
You must be signed in to change notification settings - Fork 322
Add create-labels maintenance operation and compile --json label reporting #24341
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| // @ts-check | ||
| /// <reference types="@actions/github-script" /> | ||
|
|
||
| const { getErrorMessage } = require("./error_helpers.cjs"); | ||
| const { ERR_SYSTEM } = require("./error_codes.cjs"); | ||
|
|
||
| /** | ||
| * Generate a deterministic pastel hex color string from a label name. | ||
| * Produces colors in the pastel range (128–191 per channel) for readability. | ||
| * | ||
| * @param {string} name | ||
| * @returns {string} Six-character hex color (no leading #) | ||
| */ | ||
| function deterministicLabelColor(name) { | ||
| let hash = 0; | ||
| for (let i = 0; i < name.length; i++) { | ||
| hash = (hash * 31 + name.charCodeAt(i)) >>> 0; | ||
| } | ||
| // Map to pastel range: 128–223 per channel | ||
| const r = 128 + (hash & 0x3f); | ||
| const g = 128 + ((hash >> 6) & 0x3f); | ||
| const b = 128 + ((hash >> 12) & 0x3f); | ||
| return ((r << 16) | (g << 8) | b).toString(16).padStart(6, "0"); | ||
| } | ||
|
|
||
| /** | ||
| * Compile all agentic workflows, collect the labels referenced in safe-outputs | ||
| * configurations, and create any labels that are missing from the repository. | ||
| * | ||
| * Required environment variables: | ||
| * GH_AW_CMD_PREFIX - Command prefix: './gh-aw' (dev) or 'gh aw' (release) | ||
| * | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async function main() { | ||
| const cmdPrefixStr = process.env.GH_AW_CMD_PREFIX || "gh aw"; | ||
| const [bin, ...prefixArgs] = cmdPrefixStr.split(" ").filter(Boolean); | ||
|
|
||
| // Run compile --json --no-emit to collect labels without writing lock files. | ||
| // Use ignoreReturnCode because compile exits non-zero when some workflows have errors, | ||
| // but still produces valid JSON output for all (valid and invalid) workflows. | ||
| const compileArgs = [...prefixArgs, "compile", "--json", "--no-emit"]; | ||
| core.info(`Running: ${bin} ${compileArgs.join(" ")}`); | ||
|
|
||
| let compileOutput; | ||
| try { | ||
| const result = await exec.getExecOutput(bin, compileArgs, { ignoreReturnCode: true }); | ||
| compileOutput = result.stdout; | ||
| // Only treat as a fatal error when the exit is non-zero AND there is no stdout at all. | ||
| // A non-zero exit with JSON on stdout means some workflows failed validation but we | ||
| // still have label data from the successfully-parsed ones — continue processing. | ||
| if (result.exitCode !== 0 && !compileOutput.trim()) { | ||
| throw new Error(`${ERR_SYSTEM}: compile exited with code ${result.exitCode}: ${result.stderr}`); | ||
| } | ||
| } catch (err) { | ||
| core.setFailed(`Failed to run compile: ${getErrorMessage(err)}`); | ||
| return; | ||
| } | ||
|
|
||
| // Parse JSON output | ||
| let validationResults; | ||
| try { | ||
| validationResults = JSON.parse(compileOutput); | ||
| } catch (err) { | ||
| core.setFailed(`Failed to parse compile JSON output: ${getErrorMessage(err)}`); | ||
| return; | ||
| } | ||
|
|
||
| // Collect all unique labels across all workflows | ||
| /** @type {Set<string>} */ | ||
| const allLabels = new Set(); | ||
| for (const result of validationResults) { | ||
| if (Array.isArray(result.labels)) { | ||
| for (const label of result.labels) { | ||
| if (typeof label === "string" && label.trim()) { | ||
| allLabels.add(label.trim()); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (allLabels.size === 0) { | ||
| core.info("No labels found in safe-outputs configurations — nothing to create"); | ||
| return; | ||
| } | ||
|
|
||
| core.info(`Found ${allLabels.size} unique label(s) in safe-outputs: ${[...allLabels].join(", ")}`); | ||
|
Comment on lines
+35
to
+87
|
||
|
|
||
| // Fetch all existing labels from the repository | ||
| const { owner, repo } = context.repo; | ||
| let existingLabels; | ||
| try { | ||
| existingLabels = await github.paginate(github.rest.issues.listLabelsForRepo, { | ||
| owner, | ||
| repo, | ||
| per_page: 100, | ||
| }); | ||
| } catch (err) { | ||
| core.setFailed(`Failed to list repository labels: ${getErrorMessage(err)}`); | ||
| return; | ||
| } | ||
|
|
||
| const existingLabelNames = new Set(existingLabels.map(l => l.name.toLowerCase())); | ||
|
|
||
| // Create missing labels | ||
| let created = 0; | ||
| let skipped = 0; | ||
|
|
||
| for (const labelName of allLabels) { | ||
| if (existingLabelNames.has(labelName.toLowerCase())) { | ||
| core.info(`ℹ️ Label already exists: ${labelName}`); | ||
| skipped++; | ||
| } else { | ||
| try { | ||
| await github.rest.issues.createLabel({ | ||
| owner, | ||
| repo, | ||
| name: labelName, | ||
| color: deterministicLabelColor(labelName), | ||
| description: "", | ||
| }); | ||
| core.info(`✅ Created label: ${labelName}`); | ||
| created++; | ||
| } catch (err) { | ||
| // 422 means label already exists (race condition) — treat as success | ||
| if (err && typeof err === "object" && /** @type {any} */ err.status === 422) { | ||
| core.info(`ℹ️ Label already exists (concurrent): ${labelName}`); | ||
| skipped++; | ||
| } else { | ||
| core.warning(`Failed to create label '${labelName}': ${getErrorMessage(err)}`); | ||
| } | ||
|
Comment on lines
+124
to
+131
|
||
| } | ||
| } | ||
| } | ||
|
|
||
| core.info(`Done: ${created} label(s) created, ${skipped} already existed`); | ||
| } | ||
|
|
||
| module.exports = { main }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -142,6 +142,67 @@ func compileWorkflowFile( | |
| if !noEmit { | ||
| result.validationResult.CompiledFile = lockFile | ||
| } | ||
|
|
||
| // Collect labels from safe-outputs for JSON output (used by create-labels maintenance operation) | ||
| result.validationResult.Labels = extractSafeOutputLabels(workflowData) | ||
|
|
||
|
Comment on lines
+146
to
+148
|
||
| compileWorkflowProcessorLog.Printf("Successfully processed workflow file: %s", resolvedFile) | ||
| return result | ||
| } | ||
|
|
||
| // extractSafeOutputLabels collects all unique labels referenced in safe-outputs configurations. | ||
| // These are labels that should exist in the repository for the workflow to function correctly. | ||
| // Scans: create-issue.labels/allowed-labels, create-discussion.labels/allowed-labels, | ||
| // create-pull-request.labels/allowed-labels, and add-labels.allowed. | ||
| func extractSafeOutputLabels(data *workflow.WorkflowData) []string { | ||
|
Comment on lines
+153
to
+157
|
||
| if data == nil || data.SafeOutputs == nil { | ||
| return nil | ||
| } | ||
|
|
||
| seen := make(map[string]bool) | ||
| var labels []string | ||
|
|
||
| addLabel := func(label string) { | ||
| if label != "" && !seen[label] { | ||
| seen[label] = true | ||
| labels = append(labels, label) | ||
| } | ||
| } | ||
|
|
||
| so := data.SafeOutputs | ||
|
|
||
| if so.CreateIssues != nil { | ||
| for _, l := range so.CreateIssues.Labels { | ||
| addLabel(l) | ||
| } | ||
| for _, l := range so.CreateIssues.AllowedLabels { | ||
| addLabel(l) | ||
| } | ||
| } | ||
|
|
||
| if so.CreateDiscussions != nil { | ||
| for _, l := range so.CreateDiscussions.Labels { | ||
| addLabel(l) | ||
| } | ||
| for _, l := range so.CreateDiscussions.AllowedLabels { | ||
| addLabel(l) | ||
| } | ||
| } | ||
|
|
||
| if so.CreatePullRequests != nil { | ||
| for _, l := range so.CreatePullRequests.Labels { | ||
| addLabel(l) | ||
| } | ||
| for _, l := range so.CreatePullRequests.AllowedLabels { | ||
| addLabel(l) | ||
| } | ||
| } | ||
|
|
||
| if so.AddLabels != nil { | ||
| for _, l := range so.AddLabels.Allowed { | ||
| addLabel(l) | ||
| } | ||
| } | ||
|
|
||
| return labels | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The deterministic color docs are internally inconsistent: the function comment says pastel range is 128–191 per channel, but the inline comment says 128–223. The code uses
0x3f(0–63), so the actual range is 128–191. Please align the comment(s) with the actual range to avoid confusion.