From 67160089c83b0d3b7ef1f0c7a6bdca1d3c913fc8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:13:23 +0000 Subject: [PATCH 1/3] Initial plan From 514a120cc2d45420c8616b8f7c70e3bec25a625f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:35:46 +0000 Subject: [PATCH 2/3] Preserve close_issue state reason through safe-output validation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../aw-failure-investigator.lock.yml | 8 ++++++ .../workflows/daily-squid-image-scan.lock.yml | 8 ++++++ .../workflows/dependabot-go-checker.lock.yml | 8 ++++++ .github/workflows/eslint-monster.lock.yml | 8 ++++++ .github/workflows/lint-monster.lock.yml | 8 ++++++ .../objective-impact-report.lock.yml | 8 ++++++ .github/workflows/pr-sous-chef.lock.yml | 8 ++++++ .../semantic-function-refactor.lock.yml | 8 ++++++ .../js/safe_output_type_validator.test.cjs | 12 ++++++++- .../safe_output_validation_config_test.go | 27 +++++++++++++++++++ .../safe_outputs_validation_config.go | 1 + 11 files changed, 103 insertions(+), 1 deletion(-) diff --git a/.github/workflows/aw-failure-investigator.lock.yml b/.github/workflows/aw-failure-investigator.lock.yml index 88615e8de18..d86f2b4d3d3 100644 --- a/.github/workflows/aw-failure-investigator.lock.yml +++ b/.github/workflows/aw-failure-investigator.lock.yml @@ -763,6 +763,14 @@ jobs: "type": "string", "maxLength": 256 }, + "state_reason": { + "type": "string", + "enum": [ + "completed", + "not_planned", + "duplicate" + ] + }, "suggest": { "type": "boolean" } diff --git a/.github/workflows/daily-squid-image-scan.lock.yml b/.github/workflows/daily-squid-image-scan.lock.yml index a6745970ce0..b3c9d95872b 100644 --- a/.github/workflows/daily-squid-image-scan.lock.yml +++ b/.github/workflows/daily-squid-image-scan.lock.yml @@ -681,6 +681,14 @@ jobs: "type": "string", "maxLength": 256 }, + "state_reason": { + "type": "string", + "enum": [ + "completed", + "not_planned", + "duplicate" + ] + }, "suggest": { "type": "boolean" } diff --git a/.github/workflows/dependabot-go-checker.lock.yml b/.github/workflows/dependabot-go-checker.lock.yml index 22f1cb6daf8..4ada4affb52 100644 --- a/.github/workflows/dependabot-go-checker.lock.yml +++ b/.github/workflows/dependabot-go-checker.lock.yml @@ -702,6 +702,14 @@ jobs: "type": "string", "maxLength": 256 }, + "state_reason": { + "type": "string", + "enum": [ + "completed", + "not_planned", + "duplicate" + ] + }, "suggest": { "type": "boolean" } diff --git a/.github/workflows/eslint-monster.lock.yml b/.github/workflows/eslint-monster.lock.yml index 27f9a4d89e6..ac775c04c95 100644 --- a/.github/workflows/eslint-monster.lock.yml +++ b/.github/workflows/eslint-monster.lock.yml @@ -730,6 +730,14 @@ jobs: "type": "string", "maxLength": 256 }, + "state_reason": { + "type": "string", + "enum": [ + "completed", + "not_planned", + "duplicate" + ] + }, "suggest": { "type": "boolean" } diff --git a/.github/workflows/lint-monster.lock.yml b/.github/workflows/lint-monster.lock.yml index 53cc8525aae..8935620f1c9 100644 --- a/.github/workflows/lint-monster.lock.yml +++ b/.github/workflows/lint-monster.lock.yml @@ -692,6 +692,14 @@ jobs: "type": "string", "maxLength": 256 }, + "state_reason": { + "type": "string", + "enum": [ + "completed", + "not_planned", + "duplicate" + ] + }, "suggest": { "type": "boolean" } diff --git a/.github/workflows/objective-impact-report.lock.yml b/.github/workflows/objective-impact-report.lock.yml index cf42fbc1968..2e3cdcc9402 100644 --- a/.github/workflows/objective-impact-report.lock.yml +++ b/.github/workflows/objective-impact-report.lock.yml @@ -662,6 +662,14 @@ jobs: "type": "string", "maxLength": 256 }, + "state_reason": { + "type": "string", + "enum": [ + "completed", + "not_planned", + "duplicate" + ] + }, "suggest": { "type": "boolean" } diff --git a/.github/workflows/pr-sous-chef.lock.yml b/.github/workflows/pr-sous-chef.lock.yml index 18a66652c12..44a6d18ee41 100644 --- a/.github/workflows/pr-sous-chef.lock.yml +++ b/.github/workflows/pr-sous-chef.lock.yml @@ -1087,6 +1087,14 @@ jobs: "type": "string", "maxLength": 256 }, + "state_reason": { + "type": "string", + "enum": [ + "completed", + "not_planned", + "duplicate" + ] + }, "suggest": { "type": "boolean" } diff --git a/.github/workflows/semantic-function-refactor.lock.yml b/.github/workflows/semantic-function-refactor.lock.yml index ee53415b399..c3f1287bad1 100644 --- a/.github/workflows/semantic-function-refactor.lock.yml +++ b/.github/workflows/semantic-function-refactor.lock.yml @@ -667,6 +667,14 @@ jobs: "type": "string", "maxLength": 256 }, + "state_reason": { + "type": "string", + "enum": [ + "completed", + "not_planned", + "duplicate" + ] + }, "suggest": { "type": "boolean" } diff --git a/actions/setup/js/safe_output_type_validator.test.cjs b/actions/setup/js/safe_output_type_validator.test.cjs index 7d228805668..d16201ed21c 100644 --- a/actions/setup/js/safe_output_type_validator.test.cjs +++ b/actions/setup/js/safe_output_type_validator.test.cjs @@ -141,6 +141,8 @@ const SAMPLE_VALIDATION_CONFIG = { defaultMax: 1, fields: { issue_number: { optionalPositiveInteger: true }, + duplicate_of: { issueOrPRNumber: true }, + state_reason: { type: "string", enum: ["completed", "not_planned", "duplicate"] }, }, }, dismiss_pull_request_review: { @@ -313,6 +315,15 @@ describe("safe_output_type_validator", () => { }); describe("validateItem", () => { + it("preserves an agent-selected duplicate state reason in API casing", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "close_issue", issue_number: 2, duplicate_of: 1, state_reason: "DUPLICATE" }, "close_issue", 1); + + expect(result.isValid).toBe(true); + expect(result.normalizedItem).toEqual({ type: "close_issue", issue_number: 2, duplicate_of: 1, state_reason: "duplicate" }); + }); + it("preserves explicit JSON null for built-in ledger values", async () => { const { validateItem, resetValidationConfigCache } = await import("./safe_output_type_validator.cjs"); process.env.GH_AW_VALIDATION_CONFIG = JSON.stringify({ @@ -1601,7 +1612,6 @@ describe("safe_output_type_validator", () => { { itemType: "upload_asset", item: { type: "upload_asset", path: "image.png", targetFileName: "../../.git/config" }, fieldName: "targetFileName" }, { itemType: "create_issue", item: { type: "create_issue", title: "Test", body: "Detailed issue body text.", assignees: ["octocat"] }, fieldName: "assignees" }, { itemType: "create_discussion", item: { type: "create_discussion", title: "Test", body: "This discussion body is intentionally long enough for validation.", labels: ["security"] }, fieldName: "labels" }, - { itemType: "close_issue", item: { type: "close_issue", issue_number: 1, state_reason: "not_planned" }, fieldName: "state_reason" }, { itemType: "push_to_pull_request_branch", item: { type: "push_to_pull_request_branch", message: "Apply changes", diff_size: 0 }, fieldName: "diff_size" }, { itemType: "create_pull_request", item: { type: "create_pull_request", title: "Fix bug", body: "Fixes the thing", branch: "fix/bug", base_commit: "abc123deadbeef" }, fieldName: "base_commit" }, ])("should strip undeclared $itemType.$fieldName", async ({ itemType, item, fieldName }) => { diff --git a/pkg/workflow/safe_output_validation_config_test.go b/pkg/workflow/safe_output_validation_config_test.go index 1d85a931257..cb4a795afd1 100644 --- a/pkg/workflow/safe_output_validation_config_test.go +++ b/pkg/workflow/safe_output_validation_config_test.go @@ -643,6 +643,33 @@ func TestValidationConfigCoversToolInputSchemas(t *testing.T) { } } +func TestValidationConfigCoversInjectedToolProperties(t *testing.T) { + configs := map[string]*SafeOutputsConfig{ + "omitted state-reason": { + CloseIssues: &CloseIssuesConfig{}, + }, + "list state-reason and allowed-events": { + CloseIssues: &CloseIssuesConfig{AllowedStateReason: []string{"completed", "duplicate"}}, + SubmitPullRequestReview: &SubmitPullRequestReviewConfig{ + AllowedEvents: []string{"APPROVE", "COMMENT"}, + }, + }, + } + + for name, safeOutputs := range configs { + t.Run(name, func(t *testing.T) { + injections := computePropertyInjections(safeOutputs) + for toolName, properties := range injections { + for propertyName := range properties { + if _, ok := ValidationConfig[toolName].Fields[propertyName]; !ok { + t.Errorf("%s injected property %q is missing from ValidationConfig", toolName, propertyName) + } + } + } + }) + } +} + func TestCreateDiscussionBodyMinLength(t *testing.T) { config, ok := ValidationConfig["create_discussion"] if !ok { diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index 43b014648c7..f9fd6ff2c3e 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -433,6 +433,7 @@ var ValidationConfig = map[string]TypeValidationConfig{ "body": {Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, "issue_number": {OptionalPositiveInteger: true}, "duplicate_of": {IssueOrPRNumber: true}, + "state_reason": {Type: "string", Enum: closeIssueStateReasonValues}, "rationale": {Type: "string", Sanitize: true, MaxLength: 280, StripOnError: true}, "confidence": {Type: "string", Enum: []string{"LOW", "MEDIUM", "HIGH"}, StripOnError: true}, "suggest": {Type: "boolean"}, From a07b1a4bfe39efd199c51dffa18e91355f67fd62 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:54:55 +0000 Subject: [PATCH 3/3] Fix reported CI failures Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .../workflows/engine-conformance-agy.lock.yml | 5 +- .github/workflows/engine-conformance-agy.md | 1 + .../setup/js/work_queue_compaction.test.cjs | 1 - actions/setup/js/work_queue_issues_checks.cjs | 14 +++ pkg/workflow/agy_engine_test.go | 20 +++- ...tenance_workflow_ledger_compaction_test.go | 19 ---- ...maintenance_workflow_parse_helpers_test.go | 26 ++++++ pkg/workqueue/replay.go | 91 ++++++++++++------- 8 files changed, 117 insertions(+), 60 deletions(-) create mode 100644 pkg/workflow/maintenance_workflow_parse_helpers_test.go diff --git a/.github/workflows/engine-conformance-agy.lock.yml b/.github/workflows/engine-conformance-agy.lock.yml index f78e912f753..c6c3dfc6ff4 100644 --- a/.github/workflows/engine-conformance-agy.lock.yml +++ b/.github/workflows/engine-conformance-agy.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"660aeba6a2c3db50dbeeb09df77d4e69d06f1c514201efa0a41385a06b57688f","body_hash":"f2584351afda1dfb431018da1d9231c04af8b4049e421f24c54d7878fb93e782","strict":true,"agent_id":"agy","agent_model":"gemini-3.8-flash-medium","engine_versions":{"agy":"1.3.1"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"897ac885ad93306bb8425cfdc99e936a507dd6636cc137babd06ce380357d284","body_hash":"f2584351afda1dfb431018da1d9231c04af8b4049e421f24c54d7878fb93e782","strict":true,"agent_id":"agy","agent_model":"gemini-3.8-flash-medium","engine_versions":{"agy":"1.3.1"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GEMINI_API_KEY","GH_AW_DEFAULT_OTLP_ENDPOINT","GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"9000827ccba6bdab643e8b6fd33ac0654aef8333","version":"v8.0.2"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"949feb2413d6458794dcd2491c4babbbce0c15c1","version":"v7.1.0"},{"repo":"actions/upload-artifact","sha":"cf430e030ddbb5b0abf93d22962f4752f3646cd9","version":"v7.0.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.50","digest":"sha256:91fe4af8373c723d4d2e0706a0c35d310c2eeadaea5e0738c725b3861042f620","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.50@sha256:91fe4af8373c723d4d2e0706a0c35d310c2eeadaea5e0738c725b3861042f620"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.50","digest":"sha256:11f12bcd3dde377ba822df7c670d6b937e9e0cebc46218946d4edc972d7fd965","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.50@sha256:11f12bcd3dde377ba822df7c670d6b937e9e0cebc46218946d4edc972d7fd965"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.50","digest":"sha256:cc9b6b979edf4aafbb3811c2bdc4891039d5346473afa88e304aa692167004f9","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.50@sha256:cc9b6b979edf4aafbb3811c2bdc4891039d5346473afa88e304aa692167004f9"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.30","digest":"sha256:ab5a436a1490438db473e4e3d4c973cb1d75e3cb233fb08b73d31b42d7d18fba","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.30@sha256:ab5a436a1490438db473e4e3d4c973cb1d75e3cb233fb08b73d31b42d7d18fba"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:11c2c544876cdbaffefe0f7f1283b74838c209654b4934a78e23ed11f750490f","pinned_image":"ghcr.io/github/gh-aw-node@sha256:11c2c544876cdbaffefe0f7f1283b74838c209654b4934a78e23ed11f750490f"}],"mcp_servers":[{"name":"agy-native","tools":["native_challenge"]},{"name":"mcpscripts","tools":["conformance-challenge","native-challenge","native-infrastructure-challenge"]},{"name":"safeoutputs","tools":["create_issue","missing_data","missing_tool","noop","report_incomplete"]},{"name":"work-queue","tools":["work_queue_claim_finish","work_queue_dispatch_next","work_queue_explain","work_queue_read","work_queue_submit"]}],"threat_detection":{"mode":"disabled"}} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -297,7 +297,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt - GH_AW_MCP_CLI_SERVERS_LIST: "- `agy-native` — run `agy-native --help` to see available tools\n- `mcpscripts` — run `mcpscripts --help` to see available tools\n- `safeoutputs` — run `safeoutputs --help` to see available tools\n- `work-queue` — run `work-queue --help` to see available tools" + GH_AW_MCP_CLI_SERVERS_LIST: "- `mcpscripts` — run `mcpscripts --help` to see available tools\n- `safeoutputs` — run `safeoutputs --help` to see available tools" with: script: | const path = require('path'); @@ -928,7 +928,6 @@ jobs: export GH_AW_ENGINE="agy" export GH_AW_MCP_CONFIG_ADAPTER="convert_gateway_config_agy.cjs" - export GH_AW_MCP_CLI_SERVERS='["agy-native","mcpscripts","safeoutputs","work-queue"]' MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh" diff --git a/.github/workflows/engine-conformance-agy.md b/.github/workflows/engine-conformance-agy.md index 22061299966..5a4de4070a9 100644 --- a/.github/workflows/engine-conformance-agy.md +++ b/.github/workflows/engine-conformance-agy.md @@ -21,6 +21,7 @@ engine: env: ENGINE_CONFORMANCE_SENTINEL: conformance-agy tools: + cli-proxy: false work-queue: worker: true require-assignment: true diff --git a/actions/setup/js/work_queue_compaction.test.cjs b/actions/setup/js/work_queue_compaction.test.cjs index 9e81315fb6e..f8d96074968 100644 --- a/actions/setup/js/work_queue_compaction.test.cjs +++ b/actions/setup/js/work_queue_compaction.test.cjs @@ -5,7 +5,6 @@ const { createHash } = require("node:crypto"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); -const { test } = require("node:test"); const { canonical } = require("./work_queue_codec.cjs"); const { main: planCompaction, planFor } = require("./work_queue_compaction_plan.cjs"); const { isRetryablePublicationError, main: apply, PLAN_MAX_BYTES, readPlan } = require("./work_queue_compaction_apply.cjs"); diff --git a/actions/setup/js/work_queue_issues_checks.cjs b/actions/setup/js/work_queue_issues_checks.cjs index c51e218061d..506b5f104f1 100644 --- a/actions/setup/js/work_queue_issues_checks.cjs +++ b/actions/setup/js/work_queue_issues_checks.cjs @@ -120,6 +120,20 @@ function mock(f, { field = false, labelMissing = false } = {}) { }, }, git: { + getRef: async ({ ref }) => ({ + data: { object: { sha: ref === "heads/work-queue" ? sha : "b".repeat(40) } }, + }), + getCommit: async () => ({ data: { tree: { sha: "c".repeat(40) }, parents: [] } }), + getTree: async () => ({ + data: { + truncated: false, + tree: [{ path: "work-queue.jsonl", type: "blob", mode: "100644", sha: "d".repeat(40) }], + }, + }), + getBlob: async ({ file_sha }) => { + const content = `${transactions.map(commit => canonical(commit)).join("\n")}\n`; + return { data: { sha: file_sha, encoding: "base64", content: Buffer.from(content, "utf8").toString("base64"), size: Buffer.byteLength(content) } }; + }, createRef: async ({ ref }) => { calls.push(["lock", ref]); if (locks.has(ref)) throw Object.assign(new Error("held"), { status: 422 }); diff --git a/pkg/workflow/agy_engine_test.go b/pkg/workflow/agy_engine_test.go index 56278bb2772..715c334f1b7 100644 --- a/pkg/workflow/agy_engine_test.go +++ b/pkg/workflow/agy_engine_test.go @@ -315,13 +315,16 @@ func TestAgyProductionConformanceIsBoundedAndReadOnly(t *testing.T) { } for name, config := range callee.Jobs { allowedPermissions := binding.Permissions - if entry.id == "smoke-agy" && (name == "pre_activation" || name == "activation") { + if entry.id == "engine-conformance-agy" && name == "activation" { + allowedPermissions = map[string]string{"actions": "read", "contents": "write"} + } else if entry.id == "engine-conformance-agy" && name == "safe_outputs" { + allowedPermissions = map[string]string{"actions": "write", "contents": "write"} + } else if entry.id == "smoke-agy" && (name == "pre_activation" || name == "activation") { allowedPermissions = map[string]string{ "actions": "read", "contents": "read", "issues": "read", "pull-requests": "read", } } for permission, level := range config.Permissions { - assert.Equal(t, "read", level, "%s must not grant %s write permission", name, permission) assert.Equal(t, allowedPermissions[permission], level, "%s must allow only scoped %s permission", name, permission) } } @@ -341,12 +344,21 @@ func TestAgyConformanceEntryPointsShareConfiguration(t *testing.T) { var canonical map[string]any readConformanceFrontmatter(t, "../../.github/workflows/engine-conformance-agy.md", &canonical) assert.Equal(t, map[string]any{"workflow_dispatch": nil}, canonical["on"]) - assert.Equal(t, []any{"shared/agy-conformance.md"}, canonical["imports"]) + assert.Equal(t, []any{"shared/agy-conformance.md", "shared/engine-conformance-worker.md"}, canonical["imports"]) + assert.Equal(t, map[string]any{ + "cli-proxy": false, + "work-queue": map[string]any{ + "worker": true, + "require-assignment": true, + }, + }, canonical["tools"]) assert.EqualValues(t, 5, canonical["max-ai-credits"]) delete(canonical, "name") delete(canonical, "description") delete(canonical, "on") delete(canonical, "max-ai-credits") + delete(canonical, "imports") + delete(canonical, "tools") for _, entry := range []struct { id, trigger string credits int @@ -379,6 +391,8 @@ func TestAgyConformanceEntryPointsShareConfiguration(t *testing.T) { delete(source, "description") delete(source, "on") delete(source, "max-ai-credits") + delete(source, "imports") + delete(source, "tools") assert.Equal(t, canonical, source, "all compilation paths must retain identical gate configuration") }) } diff --git a/pkg/workflow/maintenance_workflow_ledger_compaction_test.go b/pkg/workflow/maintenance_workflow_ledger_compaction_test.go index 0986ad99a2b..9b4df97e2eb 100644 --- a/pkg/workflow/maintenance_workflow_ledger_compaction_test.go +++ b/pkg/workflow/maintenance_workflow_ledger_compaction_test.go @@ -319,22 +319,3 @@ func TestComputeEnabledToolNames_LedgerNilSafety(t *testing.T) { assert.Contains(t, names, "ledger_request_compaction") }) } - -// parseMaintenanceJobs splits the generated maintenance workflow into top-level job bodies. -func parseMaintenanceJobs(t *testing.T, content string) map[string]string { - t.Helper() - _, jobsSection, found := strings.Cut(content, "\njobs:\n") - require.True(t, found) - jobs := make(map[string]string) - current := "" - for line := range strings.SplitSeq(jobsSection, "\n") { - if strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " ") && strings.HasSuffix(line, ":") { - current = strings.TrimSuffix(strings.TrimSpace(line), ":") - continue - } - if current != "" { - jobs[current] += line + "\n" - } - } - return jobs -} diff --git a/pkg/workflow/maintenance_workflow_parse_helpers_test.go b/pkg/workflow/maintenance_workflow_parse_helpers_test.go new file mode 100644 index 00000000000..78aa116974f --- /dev/null +++ b/pkg/workflow/maintenance_workflow_parse_helpers_test.go @@ -0,0 +1,26 @@ +package workflow + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func parseMaintenanceJobs(t *testing.T, content string) map[string]string { + t.Helper() + _, jobsSection, found := strings.Cut(content, "\njobs:\n") + require.True(t, found) + jobs := make(map[string]string) + current := "" + for line := range strings.SplitSeq(jobsSection, "\n") { + if strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " ") && strings.HasSuffix(line, ":") { + current = strings.TrimSuffix(strings.TrimSpace(line), ":") + continue + } + if current != "" { + jobs[current] += line + "\n" + } + } + return jobs +} diff --git a/pkg/workqueue/replay.go b/pkg/workqueue/replay.go index 1ac3faee43d..5b83cbaae96 100644 --- a/pkg/workqueue/replay.go +++ b/pkg/workqueue/replay.go @@ -48,20 +48,25 @@ func Replay(commits []QueueCommit) (Projection, error) { generations := identitySet{state.CredentialGeneration: {}} offset := 0 checkpointCount := 0 - if kind, kindErr := operationKind(ordered[0].Operations[0]); kindErr == nil && kind == "Checkpoint" { - state, err = restoreCheckpoint(ordered[0]) - if err != nil { - return state, err - } - offset = state.Stats.Transactions - checkpointCount = 1 - for epoch := range state.SeenEpochs { - epochs.add(epoch) - } - for generation := range state.SeenGenerations { - generations.add(generation) + if len(ordered) > 0 { + firstCommit := ordered[0] + if len(firstCommit.Operations) > 0 { + if kind, kindErr := operationKind(firstCommit.Operations[0]); kindErr == nil && kind == "Checkpoint" { + state, err = restoreCheckpoint(firstCommit) + if err != nil { + return state, err + } + offset = state.Stats.Transactions + checkpointCount = 1 + for epoch := range state.SeenEpochs { + epochs.add(epoch) + } + for generation := range state.SeenGenerations { + generations.add(generation) + } + ordered = ordered[1:] + } } - ordered = ordered[1:] } for ordinal, commit := range ordered { if err := state.replayCommit(commit, offset+checkpointCount+ordinal, epochs, generations); err != nil { @@ -196,21 +201,9 @@ func (state *Projection) replayOperation(operation Operation, kind string, commi case "Completion": return state.applyCompletion(operation, commit) case "ClaimCancellation": - if err := state.cancelClaim(operation, commit); err != nil { - return err - } - var value ClaimOperation - _ = json.Unmarshal(operation, &value) - state.Cancellations[value.ClaimID] = operation - return nil + return state.replayClaimCancellation(operation, commit) case "WorkCancellation": - if err := state.cancelWork(operation, commit); err != nil { - return err - } - var value WorkDefinition - _ = json.Unmarshal(operation, &value) - state.Cancellations[value.WorkID] = operation - return nil + return state.replayWorkCancellation(operation, commit) case "WorkPriority": return state.reprioritizeWork(operation) case "IssueLink", "IssueComment": @@ -220,18 +213,48 @@ func (state *Projection) replayOperation(operation Operation, kind string, commi case "Release": return state.release(operation, commit) case "Result", "DeliveryFailure": - if err := state.settleResult(operation, commit, kind == "DeliveryFailure"); err != nil { - return err - } - var value WorkDefinition - _ = json.Unmarshal(operation, &value) - state.TerminalBarriers[value.WorkID] = operation - return nil + return state.replayResult(operation, commit, kind == "DeliveryFailure") default: return queueError("unsupported_protocol", "unknown operation") } } +func (state *Projection) replayClaimCancellation(operation Operation, commit QueueCommit) error { + var value ClaimOperation + if err := json.Unmarshal(operation, &value); err != nil { + return err + } + if err := state.cancelClaim(operation, commit); err != nil { + return err + } + state.Cancellations[value.ClaimID] = operation + return nil +} + +func (state *Projection) replayWorkCancellation(operation Operation, commit QueueCommit) error { + var value WorkDefinition + if err := json.Unmarshal(operation, &value); err != nil { + return err + } + if err := state.cancelWork(operation, commit); err != nil { + return err + } + state.Cancellations[value.WorkID] = operation + return nil +} + +func (state *Projection) replayResult(operation Operation, commit QueueCommit, deliveryFailure bool) error { + var value WorkDefinition + if err := json.Unmarshal(operation, &value); err != nil { + return err + } + if err := state.settleResult(operation, commit, deliveryFailure); err != nil { + return err + } + state.TerminalBarriers[value.WorkID] = operation + return nil +} + func (state *Projection) installReplayPolicy(op Operation, commit QueueCommit, isPolicy bool, epochs identitySet) error { if !isPolicy || !state.quiescent() { return queueError("policy_not_quiescent", "policy changes require queue-wide drain")