diff --git a/.github/workflows/ai-moderator.lock.yml b/.github/workflows/ai-moderator.lock.yml index 96ecca66c9..caaec3a23c 100644 --- a/.github/workflows/ai-moderator.lock.yml +++ b/.github/workflows/ai-moderator.lock.yml @@ -554,6 +554,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -569,6 +570,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", @@ -4027,6 +4029,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4042,6 +4045,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/archie.lock.yml b/.github/workflows/archie.lock.yml index c774698267..5fc10d21d9 100644 --- a/.github/workflows/archie.lock.yml +++ b/.github/workflows/archie.lock.yml @@ -599,6 +599,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -614,6 +615,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", @@ -4758,6 +4760,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4773,6 +4776,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/artifacts-summary.lock.yml b/.github/workflows/artifacts-summary.lock.yml index 4a4dcca715..507ed5bf04 100644 --- a/.github/workflows/artifacts-summary.lock.yml +++ b/.github/workflows/artifacts-summary.lock.yml @@ -3041,6 +3041,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3056,6 +3057,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/audit-workflows.lock.yml b/.github/workflows/audit-workflows.lock.yml index 8643e648cc..1099b6eff9 100644 --- a/.github/workflows/audit-workflows.lock.yml +++ b/.github/workflows/audit-workflows.lock.yml @@ -4605,6 +4605,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4620,6 +4621,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/blog-auditor.lock.yml b/.github/workflows/blog-auditor.lock.yml index a2bf5d7466..a6c0a0ed8f 100644 --- a/.github/workflows/blog-auditor.lock.yml +++ b/.github/workflows/blog-auditor.lock.yml @@ -3665,6 +3665,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3680,6 +3681,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/brave.lock.yml b/.github/workflows/brave.lock.yml index db8f03ddaf..9fea6b03f2 100644 --- a/.github/workflows/brave.lock.yml +++ b/.github/workflows/brave.lock.yml @@ -496,6 +496,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -511,6 +512,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", @@ -4548,6 +4550,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4563,6 +4566,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/breaking-change-checker.lock.yml b/.github/workflows/breaking-change-checker.lock.yml index 03adfb05ee..08e46659e8 100644 --- a/.github/workflows/breaking-change-checker.lock.yml +++ b/.github/workflows/breaking-change-checker.lock.yml @@ -3125,6 +3125,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3140,6 +3141,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/changeset.lock.yml b/.github/workflows/changeset.lock.yml index c8d96f8176..dd4ae6fabe 100644 --- a/.github/workflows/changeset.lock.yml +++ b/.github/workflows/changeset.lock.yml @@ -642,6 +642,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -657,6 +658,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", @@ -4060,6 +4062,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4075,6 +4078,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/ci-coach.lock.yml b/.github/workflows/ci-coach.lock.yml index 22e65bea11..44f0b1b443 100644 --- a/.github/workflows/ci-coach.lock.yml +++ b/.github/workflows/ci-coach.lock.yml @@ -4327,6 +4327,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4342,6 +4343,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index dce0c70f75..a9ec7e9617 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -3989,6 +3989,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4004,6 +4005,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/cli-consistency-checker.lock.yml b/.github/workflows/cli-consistency-checker.lock.yml index 42ba1e28aa..7865115f06 100644 --- a/.github/workflows/cli-consistency-checker.lock.yml +++ b/.github/workflows/cli-consistency-checker.lock.yml @@ -3122,6 +3122,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3137,6 +3138,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/cli-version-checker.lock.yml b/.github/workflows/cli-version-checker.lock.yml index 757b492665..2d6c1eb7b6 100644 --- a/.github/workflows/cli-version-checker.lock.yml +++ b/.github/workflows/cli-version-checker.lock.yml @@ -3614,6 +3614,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3629,6 +3630,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/cloclo.lock.yml b/.github/workflows/cloclo.lock.yml index 069de3bba0..9b411b3796 100644 --- a/.github/workflows/cloclo.lock.yml +++ b/.github/workflows/cloclo.lock.yml @@ -704,6 +704,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -719,6 +720,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", @@ -5296,6 +5298,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -5311,6 +5314,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/close-old-discussions.lock.yml b/.github/workflows/close-old-discussions.lock.yml index 85703ba2cf..63c8071a72 100644 --- a/.github/workflows/close-old-discussions.lock.yml +++ b/.github/workflows/close-old-discussions.lock.yml @@ -3220,6 +3220,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3235,6 +3236,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/commit-changes-analyzer.lock.yml b/.github/workflows/commit-changes-analyzer.lock.yml index cf2f5f44ec..a25594cb90 100644 --- a/.github/workflows/commit-changes-analyzer.lock.yml +++ b/.github/workflows/commit-changes-analyzer.lock.yml @@ -3545,6 +3545,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3560,6 +3561,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/copilot-agent-analysis.lock.yml b/.github/workflows/copilot-agent-analysis.lock.yml index 328460c8ab..d7e1497ad8 100644 --- a/.github/workflows/copilot-agent-analysis.lock.yml +++ b/.github/workflows/copilot-agent-analysis.lock.yml @@ -4290,6 +4290,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4305,6 +4306,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/copilot-pr-merged-report.lock.yml b/.github/workflows/copilot-pr-merged-report.lock.yml index 89ac711d1b..6ca2ba4c2a 100644 --- a/.github/workflows/copilot-pr-merged-report.lock.yml +++ b/.github/workflows/copilot-pr-merged-report.lock.yml @@ -4563,6 +4563,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4578,6 +4579,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/copilot-pr-nlp-analysis.lock.yml b/.github/workflows/copilot-pr-nlp-analysis.lock.yml index ae25e72b7d..40bbf9a5bf 100644 --- a/.github/workflows/copilot-pr-nlp-analysis.lock.yml +++ b/.github/workflows/copilot-pr-nlp-analysis.lock.yml @@ -4662,6 +4662,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4677,6 +4678,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/copilot-pr-prompt-analysis.lock.yml b/.github/workflows/copilot-pr-prompt-analysis.lock.yml index 789e8c6875..8024b3ddfa 100644 --- a/.github/workflows/copilot-pr-prompt-analysis.lock.yml +++ b/.github/workflows/copilot-pr-prompt-analysis.lock.yml @@ -3685,6 +3685,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3700,6 +3701,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/copilot-session-insights.lock.yml b/.github/workflows/copilot-session-insights.lock.yml index 2ab360900c..95464bde30 100644 --- a/.github/workflows/copilot-session-insights.lock.yml +++ b/.github/workflows/copilot-session-insights.lock.yml @@ -5700,6 +5700,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -5715,6 +5716,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/craft.lock.yml b/.github/workflows/craft.lock.yml index dd3eae391a..95a31d1283 100644 --- a/.github/workflows/craft.lock.yml +++ b/.github/workflows/craft.lock.yml @@ -654,6 +654,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -669,6 +670,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", @@ -4892,6 +4894,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4907,6 +4910,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/daily-assign-issue-to-user.lock.yml b/.github/workflows/daily-assign-issue-to-user.lock.yml index bb099cd69e..cd443e3b6f 100644 --- a/.github/workflows/daily-assign-issue-to-user.lock.yml +++ b/.github/workflows/daily-assign-issue-to-user.lock.yml @@ -3493,6 +3493,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3508,6 +3509,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/daily-code-metrics.lock.yml b/.github/workflows/daily-code-metrics.lock.yml index bb8bf92273..3213c82a99 100644 --- a/.github/workflows/daily-code-metrics.lock.yml +++ b/.github/workflows/daily-code-metrics.lock.yml @@ -4745,6 +4745,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4760,6 +4761,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/daily-copilot-token-report.lock.yml b/.github/workflows/daily-copilot-token-report.lock.yml index 76595608cd..7b986928be 100644 --- a/.github/workflows/daily-copilot-token-report.lock.yml +++ b/.github/workflows/daily-copilot-token-report.lock.yml @@ -4831,6 +4831,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4846,6 +4847,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/daily-doc-updater.lock.yml b/.github/workflows/daily-doc-updater.lock.yml index 32bf70e450..d642c12fd1 100644 --- a/.github/workflows/daily-doc-updater.lock.yml +++ b/.github/workflows/daily-doc-updater.lock.yml @@ -3341,6 +3341,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3356,6 +3357,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/daily-fact.lock.yml b/.github/workflows/daily-fact.lock.yml index 74235c0e79..afc1079f94 100644 --- a/.github/workflows/daily-fact.lock.yml +++ b/.github/workflows/daily-fact.lock.yml @@ -3588,6 +3588,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3603,6 +3604,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/daily-file-diet.lock.yml b/.github/workflows/daily-file-diet.lock.yml index 4dd257461b..10d61ce799 100644 --- a/.github/workflows/daily-file-diet.lock.yml +++ b/.github/workflows/daily-file-diet.lock.yml @@ -3373,6 +3373,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3388,6 +3389,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/daily-firewall-report.lock.yml b/.github/workflows/daily-firewall-report.lock.yml index 33dcedbbd8..26ae260e34 100644 --- a/.github/workflows/daily-firewall-report.lock.yml +++ b/.github/workflows/daily-firewall-report.lock.yml @@ -4116,6 +4116,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4131,6 +4132,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/daily-issues-report.lock.yml b/.github/workflows/daily-issues-report.lock.yml index a07cac6430..5e1e9a0b3e 100644 --- a/.github/workflows/daily-issues-report.lock.yml +++ b/.github/workflows/daily-issues-report.lock.yml @@ -4957,6 +4957,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4972,6 +4973,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/daily-malicious-code-scan.lock.yml b/.github/workflows/daily-malicious-code-scan.lock.yml index 06e7e6861c..67534d3350 100644 --- a/.github/workflows/daily-malicious-code-scan.lock.yml +++ b/.github/workflows/daily-malicious-code-scan.lock.yml @@ -3360,6 +3360,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3375,6 +3376,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/daily-multi-device-docs-tester.lock.yml b/.github/workflows/daily-multi-device-docs-tester.lock.yml index 45ef015a9d..4d9791147b 100644 --- a/.github/workflows/daily-multi-device-docs-tester.lock.yml +++ b/.github/workflows/daily-multi-device-docs-tester.lock.yml @@ -3252,6 +3252,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3267,6 +3268,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/daily-news.lock.yml b/.github/workflows/daily-news.lock.yml index 593e7987ed..8c9a34782e 100644 --- a/.github/workflows/daily-news.lock.yml +++ b/.github/workflows/daily-news.lock.yml @@ -4590,6 +4590,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4605,6 +4606,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/daily-performance-summary.lock.yml b/.github/workflows/daily-performance-summary.lock.yml index 5297351709..30ffc5ec4e 100644 --- a/.github/workflows/daily-performance-summary.lock.yml +++ b/.github/workflows/daily-performance-summary.lock.yml @@ -6190,6 +6190,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -6205,6 +6206,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/daily-repo-chronicle.lock.yml b/.github/workflows/daily-repo-chronicle.lock.yml index 3cfd8c2314..8fc59f84c3 100644 --- a/.github/workflows/daily-repo-chronicle.lock.yml +++ b/.github/workflows/daily-repo-chronicle.lock.yml @@ -4264,6 +4264,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4279,6 +4280,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/daily-team-status.lock.yml b/.github/workflows/daily-team-status.lock.yml index bbfbc81b40..46a80efe69 100644 --- a/.github/workflows/daily-team-status.lock.yml +++ b/.github/workflows/daily-team-status.lock.yml @@ -2888,6 +2888,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -2903,6 +2904,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/daily-workflow-updater.lock.yml b/.github/workflows/daily-workflow-updater.lock.yml index 1117dd0142..a1bfd9d4e8 100644 --- a/.github/workflows/daily-workflow-updater.lock.yml +++ b/.github/workflows/daily-workflow-updater.lock.yml @@ -3052,6 +3052,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3067,6 +3068,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/deep-report.lock.yml b/.github/workflows/deep-report.lock.yml index 5e549303fe..6a141a7380 100644 --- a/.github/workflows/deep-report.lock.yml +++ b/.github/workflows/deep-report.lock.yml @@ -3833,6 +3833,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3848,6 +3849,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/dependabot-go-checker.lock.yml b/.github/workflows/dependabot-go-checker.lock.yml index 35642b86d0..671f91bd1d 100644 --- a/.github/workflows/dependabot-go-checker.lock.yml +++ b/.github/workflows/dependabot-go-checker.lock.yml @@ -3655,6 +3655,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3670,6 +3671,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/dev-hawk.lock.yml b/.github/workflows/dev-hawk.lock.yml index ff9d917158..8c9ac4c736 100644 --- a/.github/workflows/dev-hawk.lock.yml +++ b/.github/workflows/dev-hawk.lock.yml @@ -3773,6 +3773,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3788,6 +3789,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 08ec10068f..533e86ebcf 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -3742,6 +3742,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3757,6 +3758,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/developer-docs-consolidator.lock.yml b/.github/workflows/developer-docs-consolidator.lock.yml index 657dbf8a27..f8b877263c 100644 --- a/.github/workflows/developer-docs-consolidator.lock.yml +++ b/.github/workflows/developer-docs-consolidator.lock.yml @@ -4492,6 +4492,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4507,6 +4508,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/dictation-prompt.lock.yml b/.github/workflows/dictation-prompt.lock.yml index 39593a0252..6031b0779a 100644 --- a/.github/workflows/dictation-prompt.lock.yml +++ b/.github/workflows/dictation-prompt.lock.yml @@ -2995,6 +2995,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3010,6 +3011,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/docs-noob-tester.lock.yml b/.github/workflows/docs-noob-tester.lock.yml index 85dea9d173..1804dee6b3 100644 --- a/.github/workflows/docs-noob-tester.lock.yml +++ b/.github/workflows/docs-noob-tester.lock.yml @@ -3134,6 +3134,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3149,6 +3150,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/duplicate-code-detector.lock.yml b/.github/workflows/duplicate-code-detector.lock.yml index df6a38a703..ea798eb8d7 100644 --- a/.github/workflows/duplicate-code-detector.lock.yml +++ b/.github/workflows/duplicate-code-detector.lock.yml @@ -3205,6 +3205,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3220,6 +3221,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/example-workflow-analyzer.lock.yml b/.github/workflows/example-workflow-analyzer.lock.yml index c410247ddb..25ddda5285 100644 --- a/.github/workflows/example-workflow-analyzer.lock.yml +++ b/.github/workflows/example-workflow-analyzer.lock.yml @@ -3059,6 +3059,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3074,6 +3075,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/github-mcp-structural-analysis.lock.yml b/.github/workflows/github-mcp-structural-analysis.lock.yml index b4438238b3..b4c01de7f3 100644 --- a/.github/workflows/github-mcp-structural-analysis.lock.yml +++ b/.github/workflows/github-mcp-structural-analysis.lock.yml @@ -4418,6 +4418,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4433,6 +4434,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/github-mcp-tools-report.lock.yml b/.github/workflows/github-mcp-tools-report.lock.yml index 58435df126..5417027869 100644 --- a/.github/workflows/github-mcp-tools-report.lock.yml +++ b/.github/workflows/github-mcp-tools-report.lock.yml @@ -4195,6 +4195,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4210,6 +4211,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/glossary-maintainer.lock.yml b/.github/workflows/glossary-maintainer.lock.yml index 3dc8eb5f3c..77a642bb46 100644 --- a/.github/workflows/glossary-maintainer.lock.yml +++ b/.github/workflows/glossary-maintainer.lock.yml @@ -4153,6 +4153,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4168,6 +4169,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/go-fan.lock.yml b/.github/workflows/go-fan.lock.yml index ee97a21346..d21bab687e 100644 --- a/.github/workflows/go-fan.lock.yml +++ b/.github/workflows/go-fan.lock.yml @@ -3767,6 +3767,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3782,6 +3783,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/go-logger.lock.yml b/.github/workflows/go-logger.lock.yml index 91f21cc6f3..a26b2855e1 100644 --- a/.github/workflows/go-logger.lock.yml +++ b/.github/workflows/go-logger.lock.yml @@ -3500,6 +3500,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3515,6 +3516,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/go-pattern-detector.lock.yml b/.github/workflows/go-pattern-detector.lock.yml index d78e53aeff..525de2bc7b 100644 --- a/.github/workflows/go-pattern-detector.lock.yml +++ b/.github/workflows/go-pattern-detector.lock.yml @@ -3251,6 +3251,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3266,6 +3267,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/grumpy-reviewer.lock.yml b/.github/workflows/grumpy-reviewer.lock.yml index f7a970e03c..95ce05e48e 100644 --- a/.github/workflows/grumpy-reviewer.lock.yml +++ b/.github/workflows/grumpy-reviewer.lock.yml @@ -535,6 +535,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -550,6 +551,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", @@ -4697,6 +4699,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4712,6 +4715,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/hourly-ci-cleaner.lock.yml b/.github/workflows/hourly-ci-cleaner.lock.yml index b69da6a99b..2f15ff1762 100644 --- a/.github/workflows/hourly-ci-cleaner.lock.yml +++ b/.github/workflows/hourly-ci-cleaner.lock.yml @@ -3471,6 +3471,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3486,6 +3487,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/human-ai-collaboration.lock.yml b/.github/workflows/human-ai-collaboration.lock.yml index 45b6c2176e..cf00b2923f 100644 --- a/.github/workflows/human-ai-collaboration.lock.yml +++ b/.github/workflows/human-ai-collaboration.lock.yml @@ -3716,6 +3716,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3731,6 +3732,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/incident-response.lock.yml b/.github/workflows/incident-response.lock.yml index 2b27b1d1a7..b383dd1236 100644 --- a/.github/workflows/incident-response.lock.yml +++ b/.github/workflows/incident-response.lock.yml @@ -5178,6 +5178,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -5193,6 +5194,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/instructions-janitor.lock.yml b/.github/workflows/instructions-janitor.lock.yml index 223282f3d7..335fa411bf 100644 --- a/.github/workflows/instructions-janitor.lock.yml +++ b/.github/workflows/instructions-janitor.lock.yml @@ -3265,6 +3265,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3280,6 +3281,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/intelligence.lock.yml b/.github/workflows/intelligence.lock.yml index 4cb37c93b6..38e7b7960b 100644 --- a/.github/workflows/intelligence.lock.yml +++ b/.github/workflows/intelligence.lock.yml @@ -4980,6 +4980,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4995,6 +4996,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/issue-arborist.lock.yml b/.github/workflows/issue-arborist.lock.yml index 877b1a1457..4d0fdf6722 100644 --- a/.github/workflows/issue-arborist.lock.yml +++ b/.github/workflows/issue-arborist.lock.yml @@ -3214,6 +3214,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3229,6 +3230,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/issue-classifier.lock.yml b/.github/workflows/issue-classifier.lock.yml index 09c4642101..4ac9e94062 100644 --- a/.github/workflows/issue-classifier.lock.yml +++ b/.github/workflows/issue-classifier.lock.yml @@ -424,6 +424,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -439,6 +440,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", @@ -4108,6 +4110,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4123,6 +4126,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/issue-monster.lock.yml b/.github/workflows/issue-monster.lock.yml index 3e32be4913..54279fddfd 100644 --- a/.github/workflows/issue-monster.lock.yml +++ b/.github/workflows/issue-monster.lock.yml @@ -3932,6 +3932,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3947,6 +3948,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/issue-triage-agent.lock.yml b/.github/workflows/issue-triage-agent.lock.yml index 95e1bdaadf..24a0ab1d6d 100644 --- a/.github/workflows/issue-triage-agent.lock.yml +++ b/.github/workflows/issue-triage-agent.lock.yml @@ -3227,6 +3227,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3242,6 +3243,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/layout-spec-maintainer.lock.yml b/.github/workflows/layout-spec-maintainer.lock.yml index 0eff7157d6..b207880e12 100644 --- a/.github/workflows/layout-spec-maintainer.lock.yml +++ b/.github/workflows/layout-spec-maintainer.lock.yml @@ -3286,6 +3286,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3301,6 +3302,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/lockfile-stats.lock.yml b/.github/workflows/lockfile-stats.lock.yml index c662ab17d8..d5efa38667 100644 --- a/.github/workflows/lockfile-stats.lock.yml +++ b/.github/workflows/lockfile-stats.lock.yml @@ -3778,6 +3778,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3793,6 +3794,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/mcp-inspector.lock.yml b/.github/workflows/mcp-inspector.lock.yml index d696d917e2..f35dbf4303 100644 --- a/.github/workflows/mcp-inspector.lock.yml +++ b/.github/workflows/mcp-inspector.lock.yml @@ -3666,6 +3666,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3681,6 +3682,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/mergefest.lock.yml b/.github/workflows/mergefest.lock.yml index 73bc7746b4..9b7a008ec2 100644 --- a/.github/workflows/mergefest.lock.yml +++ b/.github/workflows/mergefest.lock.yml @@ -3838,6 +3838,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3853,6 +3854,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/notion-issue-summary.lock.yml b/.github/workflows/notion-issue-summary.lock.yml index b2ea956f24..d2cafd4f6b 100644 --- a/.github/workflows/notion-issue-summary.lock.yml +++ b/.github/workflows/notion-issue-summary.lock.yml @@ -2730,6 +2730,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -2745,6 +2746,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/org-health-report.lock.yml b/.github/workflows/org-health-report.lock.yml index 015d0f0591..555e315b4b 100644 --- a/.github/workflows/org-health-report.lock.yml +++ b/.github/workflows/org-health-report.lock.yml @@ -4525,6 +4525,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4540,6 +4541,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/org-wide-rollout.lock.yml b/.github/workflows/org-wide-rollout.lock.yml index 9f00b6207a..16d737b90c 100644 --- a/.github/workflows/org-wide-rollout.lock.yml +++ b/.github/workflows/org-wide-rollout.lock.yml @@ -5230,6 +5230,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -5245,6 +5246,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/pdf-summary.lock.yml b/.github/workflows/pdf-summary.lock.yml index a77fef4d40..6cf5aeb8f7 100644 --- a/.github/workflows/pdf-summary.lock.yml +++ b/.github/workflows/pdf-summary.lock.yml @@ -587,6 +587,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -602,6 +603,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", @@ -4722,6 +4724,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4737,6 +4740,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/plan.lock.yml b/.github/workflows/plan.lock.yml index 3dbd02d8e6..57339b8aaf 100644 --- a/.github/workflows/plan.lock.yml +++ b/.github/workflows/plan.lock.yml @@ -575,6 +575,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -590,6 +591,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", @@ -4009,6 +4011,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4024,6 +4027,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index 9da21f206f..251c7172f8 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -615,6 +615,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -630,6 +631,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", @@ -5774,6 +5776,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -5789,6 +5792,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/portfolio-analyst.lock.yml b/.github/workflows/portfolio-analyst.lock.yml index 8f5f4cae4f..d7c9a8cc36 100644 --- a/.github/workflows/portfolio-analyst.lock.yml +++ b/.github/workflows/portfolio-analyst.lock.yml @@ -3890,6 +3890,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3905,6 +3906,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/pr-nitpick-reviewer.lock.yml b/.github/workflows/pr-nitpick-reviewer.lock.yml index 4dca834d45..7161184d6d 100644 --- a/.github/workflows/pr-nitpick-reviewer.lock.yml +++ b/.github/workflows/pr-nitpick-reviewer.lock.yml @@ -5007,6 +5007,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -5022,6 +5023,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/prompt-clustering-analysis.lock.yml b/.github/workflows/prompt-clustering-analysis.lock.yml index c5b3cba3be..8a357b5db2 100644 --- a/.github/workflows/prompt-clustering-analysis.lock.yml +++ b/.github/workflows/prompt-clustering-analysis.lock.yml @@ -5055,6 +5055,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -5070,6 +5071,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/python-data-charts.lock.yml b/.github/workflows/python-data-charts.lock.yml index 0cf91cd10a..0a99636944 100644 --- a/.github/workflows/python-data-charts.lock.yml +++ b/.github/workflows/python-data-charts.lock.yml @@ -4898,6 +4898,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4913,6 +4914,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/q.lock.yml b/.github/workflows/q.lock.yml index 638a2c7d9e..179abc6262 100644 --- a/.github/workflows/q.lock.yml +++ b/.github/workflows/q.lock.yml @@ -825,6 +825,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -840,6 +841,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", @@ -5306,6 +5308,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -5321,6 +5324,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/release.lock.yml b/.github/workflows/release.lock.yml index 5c37a518f4..6808ad94d4 100644 --- a/.github/workflows/release.lock.yml +++ b/.github/workflows/release.lock.yml @@ -3188,6 +3188,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3203,6 +3204,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/repo-tree-map.lock.yml b/.github/workflows/repo-tree-map.lock.yml index a68b3f98ea..fcad1d1b4e 100644 --- a/.github/workflows/repo-tree-map.lock.yml +++ b/.github/workflows/repo-tree-map.lock.yml @@ -3069,6 +3069,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3084,6 +3085,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/repository-quality-improver.lock.yml b/.github/workflows/repository-quality-improver.lock.yml index ecac606bd3..e4f48fbb34 100644 --- a/.github/workflows/repository-quality-improver.lock.yml +++ b/.github/workflows/repository-quality-improver.lock.yml @@ -4106,6 +4106,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4121,6 +4122,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/research.lock.yml b/.github/workflows/research.lock.yml index 2176a4a13e..da64fa9e3b 100644 --- a/.github/workflows/research.lock.yml +++ b/.github/workflows/research.lock.yml @@ -2983,6 +2983,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -2998,6 +2999,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/safe-output-health.lock.yml b/.github/workflows/safe-output-health.lock.yml index 9588282e16..1f90b8e188 100644 --- a/.github/workflows/safe-output-health.lock.yml +++ b/.github/workflows/safe-output-health.lock.yml @@ -4077,6 +4077,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4092,6 +4093,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/schema-consistency-checker.lock.yml b/.github/workflows/schema-consistency-checker.lock.yml index bb13f68f45..4b87fc19b7 100644 --- a/.github/workflows/schema-consistency-checker.lock.yml +++ b/.github/workflows/schema-consistency-checker.lock.yml @@ -3723,6 +3723,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3738,6 +3739,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/scout.lock.yml b/.github/workflows/scout.lock.yml index cab038c5da..425bbd3c1a 100644 --- a/.github/workflows/scout.lock.yml +++ b/.github/workflows/scout.lock.yml @@ -786,6 +786,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -801,6 +802,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", @@ -5342,6 +5344,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -5357,6 +5360,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/security-compliance.lock.yml b/.github/workflows/security-compliance.lock.yml index c4663c49da..2386ea05b4 100644 --- a/.github/workflows/security-compliance.lock.yml +++ b/.github/workflows/security-compliance.lock.yml @@ -3353,6 +3353,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3368,6 +3369,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/security-fix-pr.lock.yml b/.github/workflows/security-fix-pr.lock.yml index 64759a50d2..547c5178aa 100644 --- a/.github/workflows/security-fix-pr.lock.yml +++ b/.github/workflows/security-fix-pr.lock.yml @@ -3273,6 +3273,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3288,6 +3289,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/semantic-function-refactor.lock.yml b/.github/workflows/semantic-function-refactor.lock.yml index b9fdff5fc5..1199315802 100644 --- a/.github/workflows/semantic-function-refactor.lock.yml +++ b/.github/workflows/semantic-function-refactor.lock.yml @@ -4111,6 +4111,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4126,6 +4127,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index c28ae441f6..fc91bb4615 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -5190,6 +5190,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -5205,6 +5206,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index e1677159c3..172ccd0f4a 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -4771,6 +4771,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4786,6 +4787,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/smoke-copilot-no-firewall.lock.yml b/.github/workflows/smoke-copilot-no-firewall.lock.yml index ac23ceac35..6a6ffe1198 100644 --- a/.github/workflows/smoke-copilot-no-firewall.lock.yml +++ b/.github/workflows/smoke-copilot-no-firewall.lock.yml @@ -6177,6 +6177,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -6192,6 +6193,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/smoke-copilot-playwright.lock.yml b/.github/workflows/smoke-copilot-playwright.lock.yml index 4c19e4a167..a221e7fe3e 100644 --- a/.github/workflows/smoke-copilot-playwright.lock.yml +++ b/.github/workflows/smoke-copilot-playwright.lock.yml @@ -6157,6 +6157,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -6172,6 +6173,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/smoke-copilot-safe-inputs.lock.yml b/.github/workflows/smoke-copilot-safe-inputs.lock.yml index 6c622b75a4..4e530026a0 100644 --- a/.github/workflows/smoke-copilot-safe-inputs.lock.yml +++ b/.github/workflows/smoke-copilot-safe-inputs.lock.yml @@ -5882,6 +5882,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -5897,6 +5898,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index b61beec3d8..2fb8c52879 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -4707,6 +4707,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4722,6 +4723,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/smoke-detector.lock.yml b/.github/workflows/smoke-detector.lock.yml index a173a928c7..9f88526bdf 100644 --- a/.github/workflows/smoke-detector.lock.yml +++ b/.github/workflows/smoke-detector.lock.yml @@ -4935,6 +4935,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4950,6 +4951,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/smoke-srt.lock.yml b/.github/workflows/smoke-srt.lock.yml index e8bf6a685e..570e79bba0 100644 --- a/.github/workflows/smoke-srt.lock.yml +++ b/.github/workflows/smoke-srt.lock.yml @@ -2879,6 +2879,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -2894,6 +2895,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/spec-kit-execute.lock.yml b/.github/workflows/spec-kit-execute.lock.yml index 82b3595c88..8bd8f02c26 100644 --- a/.github/workflows/spec-kit-execute.lock.yml +++ b/.github/workflows/spec-kit-execute.lock.yml @@ -3597,6 +3597,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3612,6 +3613,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/spec-kit-executor.lock.yml b/.github/workflows/spec-kit-executor.lock.yml index 3d184c4fb2..ddfc85e267 100644 --- a/.github/workflows/spec-kit-executor.lock.yml +++ b/.github/workflows/spec-kit-executor.lock.yml @@ -3287,6 +3287,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3302,6 +3303,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/speckit-dispatcher.lock.yml b/.github/workflows/speckit-dispatcher.lock.yml index 58b4b2c07d..731e360f4a 100644 --- a/.github/workflows/speckit-dispatcher.lock.yml +++ b/.github/workflows/speckit-dispatcher.lock.yml @@ -802,6 +802,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -817,6 +818,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", @@ -5217,6 +5219,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -5232,6 +5235,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/stale-repo-identifier.lock.yml b/.github/workflows/stale-repo-identifier.lock.yml index 276aec8fd3..aa6b4909cb 100644 --- a/.github/workflows/stale-repo-identifier.lock.yml +++ b/.github/workflows/stale-repo-identifier.lock.yml @@ -1210,7 +1210,7 @@ jobs: ORGANIZATION: ${{ env.ORGANIZATION }} id: stale-repos name: Run stale_repos tool - uses: github/stale-repos@3477b6488008d9411aaf22a0924ec7c1f6a69980 # v3 + uses: github/stale-repos@v3 - env: INACTIVE_REPOS: ${{ steps.stale-repos.outputs.inactiveRepos }} name: Save stale repos output @@ -4761,6 +4761,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4776,6 +4777,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/static-analysis-report.lock.yml b/.github/workflows/static-analysis-report.lock.yml index 29e57ba6b1..70683853b9 100644 --- a/.github/workflows/static-analysis-report.lock.yml +++ b/.github/workflows/static-analysis-report.lock.yml @@ -3816,6 +3816,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3831,6 +3832,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/super-linter.lock.yml b/.github/workflows/super-linter.lock.yml index 32b7495f97..5afd9afa56 100644 --- a/.github/workflows/super-linter.lock.yml +++ b/.github/workflows/super-linter.lock.yml @@ -3284,6 +3284,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3299,6 +3300,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/technical-doc-writer.lock.yml b/.github/workflows/technical-doc-writer.lock.yml index baa36adefd..6737b5c877 100644 --- a/.github/workflows/technical-doc-writer.lock.yml +++ b/.github/workflows/technical-doc-writer.lock.yml @@ -4510,6 +4510,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4525,6 +4526,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/test-discussion-expires.lock.yml b/.github/workflows/test-discussion-expires.lock.yml index 6d27cb2f18..bbafbee492 100644 --- a/.github/workflows/test-discussion-expires.lock.yml +++ b/.github/workflows/test-discussion-expires.lock.yml @@ -2668,6 +2668,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -2683,6 +2684,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/test-hide-older-comments.lock.yml b/.github/workflows/test-hide-older-comments.lock.yml index 2dc3e89f52..673e2befbf 100644 --- a/.github/workflows/test-hide-older-comments.lock.yml +++ b/.github/workflows/test-hide-older-comments.lock.yml @@ -3442,6 +3442,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3457,6 +3458,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/test-python-safe-input.lock.yml b/.github/workflows/test-python-safe-input.lock.yml index 1d06c15096..042d0e6542 100644 --- a/.github/workflows/test-python-safe-input.lock.yml +++ b/.github/workflows/test-python-safe-input.lock.yml @@ -4281,6 +4281,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4296,6 +4297,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index 122999f962..5b8b18669a 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -3411,6 +3411,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3426,6 +3427,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/typist.lock.yml b/.github/workflows/typist.lock.yml index 205b34e175..f055e1dc9f 100644 --- a/.github/workflows/typist.lock.yml +++ b/.github/workflows/typist.lock.yml @@ -4141,6 +4141,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4156,6 +4157,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/unbloat-docs.lock.yml b/.github/workflows/unbloat-docs.lock.yml index ce69c4b595..a39d5a5f49 100644 --- a/.github/workflows/unbloat-docs.lock.yml +++ b/.github/workflows/unbloat-docs.lock.yml @@ -5053,6 +5053,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -5068,6 +5069,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/video-analyzer.lock.yml b/.github/workflows/video-analyzer.lock.yml index 1fe72460c1..81fef4d9ed 100644 --- a/.github/workflows/video-analyzer.lock.yml +++ b/.github/workflows/video-analyzer.lock.yml @@ -3325,6 +3325,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -3340,6 +3341,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/.github/workflows/weekly-issue-summary.lock.yml b/.github/workflows/weekly-issue-summary.lock.yml index b146b95b5a..1f76027dd4 100644 --- a/.github/workflows/weekly-issue-summary.lock.yml +++ b/.github/workflows/weekly-issue-summary.lock.yml @@ -4117,6 +4117,7 @@ jobs: "blockquote", "br", "code", + "details", "em", "h1", "h2", @@ -4132,6 +4133,7 @@ jobs: "pre", "strong", "sub", + "summary", "sup", "table", "tbody", diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index 1e90fd5e51..7091a953fb 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -5,6 +5,7 @@ import ( "os" "strings" + "github.com/githubnext/gh-aw/pkg/campaign" "github.com/githubnext/gh-aw/pkg/cli" "github.com/githubnext/gh-aw/pkg/console" "github.com/githubnext/gh-aw/pkg/constants" @@ -492,7 +493,7 @@ Use "` + constants.CLIExtensionPrefix + ` help all" to show help for all command mcpServerCmd := cli.NewMCPServerCommand() mcpGatewayCmd := cli.NewMCPGatewayCommand() prCmd := cli.NewPRCommand() - campaignCmd := cli.NewCampaignCommand() + campaignCmd := campaign.NewCommand() // Assign commands to groups // Setup Commands diff --git a/go.mod b/go.mod index 9a5a7da369..4f60c28853 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 golang.org/x/term v0.38.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -54,13 +55,15 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/thlib/go-timezone-local v0.0.7 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) // Update semver to v3.4.0 for bug fixes and new features diff --git a/go.sum b/go.sum index 94eab8600c..a8487ebc6a 100644 --- a/go.sum +++ b/go.sum @@ -54,6 +54,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= @@ -119,10 +120,18 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/thlib/go-timezone-local v0.0.7 h1:fX8zd3aJydqLlTs/TrROrIIdztzsdFV23OzOQx31jII= github.com/thlib/go-timezone-local v0.0.7/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= diff --git a/pkg/cli/campaigns_test.go b/pkg/campaign/campaign_test.go similarity index 91% rename from pkg/cli/campaigns_test.go rename to pkg/campaign/campaign_test.go index 512797c897..5f21411d45 100644 --- a/pkg/cli/campaigns_test.go +++ b/pkg/campaign/campaign_test.go @@ -1,4 +1,4 @@ -package cli +package campaign import ( "encoding/json" @@ -24,9 +24,9 @@ func TestLoadCampaignSpecs_Basic(t *testing.T) { t.Fatalf("Failed to change to repository root: %v", err) } - specs, err := loadCampaignSpecs(repoRoot) + specs, err := LoadSpecs(repoRoot) if err != nil { - t.Fatalf("loadCampaignSpecs failed: %v", err) + t.Fatalf("LoadSpecs failed: %v", err) } if len(specs) == 0 { @@ -67,9 +67,9 @@ func TestComputeCompiledStateForCampaign_UsesLockFiles(t *testing.T) { t.Fatalf("Failed to change to repository root: %v", err) } - specs, err := loadCampaignSpecs(repoRoot) + specs, err := LoadSpecs(repoRoot) if err != nil { - t.Fatalf("loadCampaignSpecs failed: %v", err) + t.Fatalf("LoadSpecs failed: %v", err) } var incident CampaignSpec @@ -85,7 +85,7 @@ func TestComputeCompiledStateForCampaign_UsesLockFiles(t *testing.T) { t.Skip("incident-response campaign spec not found; skipping compiled-state test") } - state := computeCompiledStateForCampaign(incident) + state := ComputeCompiledState(incident, ".github/workflows") if state == "Missing workflow" { t.Fatalf("Expected incident-response workflows to exist, got compiled state: %s", state) } @@ -108,9 +108,9 @@ func TestRunCampaignStatus_JSON(t *testing.T) { // Capture stdout via a pipe; simpler is to call runCampaignStatus and // re-marshal the result, so instead we directly call the loader and // verify JSON marshaling there. - specs, err := loadCampaignSpecs(repoRoot) + specs, err := LoadSpecs(repoRoot) if err != nil { - t.Fatalf("loadCampaignSpecs failed: %v", err) + t.Fatalf("LoadSpecs failed: %v", err) } data, err := json.Marshal(specs) @@ -134,7 +134,7 @@ func TestValidateCampaignSpec_Basic(t *testing.T) { TrackerLabel: "campaign:security-compliance", } - problems := validateCampaignSpec(spec) + problems := ValidateSpec(spec) if len(problems) != 0 { t.Fatalf("Expected no validation problems for basic spec, got: %v", problems) } @@ -155,7 +155,7 @@ func TestValidateCampaignSpec_InvalidState(t *testing.T) { State: "launching", // invalid } - problems := validateCampaignSpec(spec) + problems := ValidateSpec(spec) if len(problems) == 0 { t.Fatalf("Expected validation problems for invalid state, got none") } diff --git a/pkg/campaign/command.go b/pkg/campaign/command.go new file mode 100644 index 0000000000..363a48f241 --- /dev/null +++ b/pkg/campaign/command.go @@ -0,0 +1,287 @@ +package campaign + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/githubnext/gh-aw/pkg/console" + "github.com/githubnext/gh-aw/pkg/constants" + "github.com/spf13/cobra" +) + +// getWorkflowsDir returns the .github/workflows directory path. +// This is a helper to avoid circular dependencies with cli package. +func getWorkflowsDir() string { + return ".github/workflows" +} + +// NewCommand creates the `gh aw campaign` command that surfaces +// first-class campaign definitions from YAML files. +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "campaign [filter]", + Short: "Inspect first-class campaign definitions from campaigns/*.campaign.md", + Long: `List and inspect first-class campaign definitions declared in YAML files. + +Campaigns are defined using Markdown files with YAML frontmatter under the local repository: + + campaigns/*.campaign.md + +Each file describes a campaign pattern (ID, name, owners, associated +workflows, repo-memory paths, and risk level). This command provides a +single place to see all campaigns configured for the repo. + +Examples: + ` + constants.CLIExtensionPrefix + ` campaign # List all campaigns + ` + constants.CLIExtensionPrefix + ` campaign security # Filter campaigns by ID or name + ` + constants.CLIExtensionPrefix + ` campaign --json # Output campaign definitions as JSON +`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var pattern string + if len(args) > 0 { + pattern = args[0] + } + + jsonOutput, _ := cmd.Flags().GetBool("json") + return runStatus(pattern, jsonOutput) + }, + } + + cmd.Flags().Bool("json", false, "Output campaign definitions in JSON format") + + // Subcommand: campaign status + statusCmd := &cobra.Command{ + Use: "status [filter]", + Short: "Show live status for campaigns (compiled workflows, issues, PRs)", + Long: `Show live status for campaigns, including whether referenced workflows +are compiled and basic issue/PR counts derived from the campaign's +tracker label. + +Examples: + ` + constants.CLIExtensionPrefix + ` campaign status # Status for all campaigns + ` + constants.CLIExtensionPrefix + ` campaign status security # Filter by ID or name + ` + constants.CLIExtensionPrefix + ` campaign status --json # JSON status output +`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var pattern string + if len(args) > 0 { + pattern = args[0] + } + + jsonOutput, _ := cmd.Flags().GetBool("json") + return runRuntimeStatus(pattern, jsonOutput) + }, + } + + statusCmd.Flags().Bool("json", false, "Output campaign status in JSON format") + cmd.AddCommand(statusCmd) + + // Subcommand: campaign new + newCmd := &cobra.Command{ + Use: "new ", + Short: "Create a new markdown campaign spec under campaigns/", + Long: `Create a new campaign spec markdown file under campaigns/. + +The file will be created as campaigns/.campaign.md with YAML +frontmatter (id, name, version, state, tracker_label) followed by a +markdown body. You can then +update owners, workflows, memory paths, metrics_glob, and governance +fields to match your initiative. + +Examples: + ` + constants.CLIExtensionPrefix + ` campaign new security-q1-2025 + ` + constants.CLIExtensionPrefix + ` campaign new modernization-winter2025 --force`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id := args[0] + force, _ := cmd.Flags().GetBool("force") + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + + path, err := CreateSpecSkeleton(cwd, id, force) + if err != nil { + return err + } + + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Created campaign spec at "+path)) + return nil + }, + } + + newCmd.Flags().Bool("force", false, "Overwrite existing spec file if it already exists") + cmd.AddCommand(newCmd) + + // Subcommand: campaign validate + validateCmd := &cobra.Command{ + Use: "validate [filter]", + Short: "Validate campaign spec files for common issues", + Long: `Validate campaign spec files under campaigns/*.campaign.md. + +This command performs lightweight semantic validation of campaign +definitions (IDs, tracker labels, workflows, lifecycle state, and +other key fields). By default it exits with a non-zero status when +problems are found. + +Examples: + ` + constants.CLIExtensionPrefix + ` campaign validate # Validate all campaigns + ` + constants.CLIExtensionPrefix + ` campaign validate security # Filter by ID or name + ` + constants.CLIExtensionPrefix + ` campaign validate --json # JSON validation report + ` + constants.CLIExtensionPrefix + ` campaign validate --no-strict # Report problems without failing`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var pattern string + if len(args) > 0 { + pattern = args[0] + } + + jsonOutput, _ := cmd.Flags().GetBool("json") + strict, _ := cmd.Flags().GetBool("strict") + return runValidate(pattern, jsonOutput, strict) + }, + } + + validateCmd.Flags().Bool("json", false, "Output campaign validation results in JSON format") + validateCmd.Flags().Bool("strict", true, "Exit with non-zero status if any problems are found") + cmd.AddCommand(validateCmd) + + return cmd +} + +// runStatus is the implementation for the `gh aw campaign` command. +// It loads campaign specs from the local repository and renders them either +// as a console table or JSON. +func runStatus(pattern string, jsonOutput bool) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + + specs, err := LoadSpecs(cwd) + if err != nil { + return err + } + + specs = FilterSpecs(specs, pattern) + + if jsonOutput { + jsonBytes, err := json.MarshalIndent(specs, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal campaigns as JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + if len(specs) == 0 { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No campaign specs found. Add files under 'campaigns/*.campaign.md' to define campaigns.")) + return nil + } + + // Render table to stdout for human-friendly output + output := console.RenderStruct(specs) + fmt.Print(output) + return nil +} + +// runRuntimeStatus builds a higher-level view of campaign specs with +// live information derived from GitHub (issue/PR counts) and compiled +// workflow state. +func runRuntimeStatus(pattern string, jsonOutput bool) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + + specs, err := LoadSpecs(cwd) + if err != nil { + return err + } + + specs = FilterSpecs(specs, pattern) + if len(specs) == 0 { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No campaign specs found. Add files under 'campaigns/*.campaign.md' to define campaigns.")) + return nil + } + + workflowsDir := getWorkflowsDir() + var statuses []CampaignRuntimeStatus + for _, spec := range specs { + status := BuildRuntimeStatus(spec, workflowsDir) + statuses = append(statuses, status) + } + + if jsonOutput { + jsonBytes, err := json.MarshalIndent(statuses, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal campaign status as JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + output := console.RenderStruct(statuses) + fmt.Print(output) + return nil +} + +// runValidate loads campaign specs and validates them, returning +// a structured report. When strict is true, the command will exit with +// a non-zero status if any problems are found. +func runValidate(pattern string, jsonOutput bool, strict bool) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + + specs, err := LoadSpecs(cwd) + if err != nil { + return err + } + + specs = FilterSpecs(specs, pattern) + if len(specs) == 0 { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No campaign specs found. Add files under 'campaigns/*.campaign.md' to define campaigns.")) + return nil + } + + var results []CampaignValidationResult + var totalProblems int + + for i := range specs { + problems := ValidateSpec(&specs[i]) + if len(problems) > 0 { + log.Printf("Validation problems for campaign '%s' (%s): %v", specs[i].ID, specs[i].ConfigPath, problems) + } + + results = append(results, CampaignValidationResult{ + ID: specs[i].ID, + Name: specs[i].Name, + ConfigPath: specs[i].ConfigPath, + Problems: problems, + }) + totalProblems += len(problems) + } + + if jsonOutput { + jsonBytes, err := json.MarshalIndent(results, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal campaign validation results as JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + } else { + output := console.RenderStruct(results) + fmt.Print(output) + } + + if strict && totalProblems > 0 { + return fmt.Errorf("campaign validation failed: %d problem(s) found across %d campaign(s)", totalProblems, len(results)) + } + + return nil +} diff --git a/pkg/campaign/create_test.go b/pkg/campaign/create_test.go new file mode 100644 index 0000000000..3ad04b8104 --- /dev/null +++ b/pkg/campaign/create_test.go @@ -0,0 +1,211 @@ +package campaign + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCreateSpecSkeleton_Basic(t *testing.T) { + tmpDir := t.TempDir() + + path, err := CreateSpecSkeleton(tmpDir, "test-campaign", false) + if err != nil { + t.Fatalf("CreateSpecSkeleton failed: %v", err) + } + + expectedPath := "campaigns/test-campaign.campaign.md" + if path != expectedPath { + t.Errorf("Expected path '%s', got '%s'", expectedPath, path) + } + + // Verify file was created + fullPath := filepath.Join(tmpDir, "campaigns", "test-campaign.campaign.md") + if _, err := os.Stat(fullPath); os.IsNotExist(err) { + t.Errorf("Expected file to be created at %s", fullPath) + } + + // Read and verify content + content, err := os.ReadFile(fullPath) + if err != nil { + t.Fatalf("Failed to read created file: %v", err) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "id: test-campaign") { + t.Error("Expected file to contain 'id: test-campaign'") + } + if !strings.Contains(contentStr, "name: Test campaign") { + t.Error("Expected file to contain 'name: Test campaign'") + } + if !strings.Contains(contentStr, "version: v1") { + t.Error("Expected file to contain 'version: v1'") + } + if !strings.Contains(contentStr, "state: planned") { + t.Error("Expected file to contain 'state: planned'") + } + if !strings.Contains(contentStr, "tracker_label: campaign:test-campaign") { + t.Error("Expected file to contain 'tracker_label: campaign:test-campaign'") + } +} + +func TestCreateSpecSkeleton_InvalidID_Empty(t *testing.T) { + tmpDir := t.TempDir() + + _, err := CreateSpecSkeleton(tmpDir, "", false) + if err == nil { + t.Fatal("Expected error for empty ID") + } + + if !strings.Contains(err.Error(), "id is required") { + t.Errorf("Expected 'id is required' error, got: %v", err) + } +} + +func TestCreateSpecSkeleton_InvalidID_Uppercase(t *testing.T) { + tmpDir := t.TempDir() + + _, err := CreateSpecSkeleton(tmpDir, "Test-Campaign", false) + if err == nil { + t.Fatal("Expected error for uppercase in ID") + } + + if !strings.Contains(err.Error(), "lowercase letters, digits, and hyphens") { + t.Errorf("Expected character restriction error, got: %v", err) + } +} + +func TestCreateSpecSkeleton_InvalidID_Underscore(t *testing.T) { + tmpDir := t.TempDir() + + _, err := CreateSpecSkeleton(tmpDir, "test_campaign", false) + if err == nil { + t.Fatal("Expected error for underscore in ID") + } + + if !strings.Contains(err.Error(), "lowercase letters, digits, and hyphens") { + t.Errorf("Expected character restriction error, got: %v", err) + } +} + +func TestCreateSpecSkeleton_InvalidID_Space(t *testing.T) { + tmpDir := t.TempDir() + + _, err := CreateSpecSkeleton(tmpDir, "test campaign", false) + if err == nil { + t.Fatal("Expected error for space in ID") + } + + if !strings.Contains(err.Error(), "lowercase letters, digits, and hyphens") { + t.Errorf("Expected character restriction error, got: %v", err) + } +} + +func TestCreateSpecSkeleton_FileExists_NoForce(t *testing.T) { + tmpDir := t.TempDir() + + // Create first time + _, err := CreateSpecSkeleton(tmpDir, "test-campaign", false) + if err != nil { + t.Fatalf("First CreateSpecSkeleton failed: %v", err) + } + + // Try to create again without force + _, err = CreateSpecSkeleton(tmpDir, "test-campaign", false) + if err == nil { + t.Fatal("Expected error when file exists without force flag") + } + + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("Expected 'already exists' error, got: %v", err) + } +} + +func TestCreateSpecSkeleton_FileExists_WithForce(t *testing.T) { + tmpDir := t.TempDir() + + // Create first time + _, err := CreateSpecSkeleton(tmpDir, "test-campaign", false) + if err != nil { + t.Fatalf("First CreateSpecSkeleton failed: %v", err) + } + + // Try to create again with force + _, err = CreateSpecSkeleton(tmpDir, "test-campaign", true) + if err != nil { + t.Errorf("CreateSpecSkeleton with force should succeed: %v", err) + } +} + +func TestCreateSpecSkeleton_NameFormatting(t *testing.T) { + tests := []struct { + id string + expectedName string + }{ + {"test", "Test"}, + {"test-campaign", "Test campaign"}, + {"security-q1-2025", "Security q1 2025"}, + {"org-modernization", "Org modernization"}, + } + + for _, tt := range tests { + tmpDir := t.TempDir() + + _, err := CreateSpecSkeleton(tmpDir, tt.id, false) + if err != nil { + t.Fatalf("CreateSpecSkeleton failed for ID '%s': %v", tt.id, err) + } + + // Load the created spec + specs, err := LoadSpecs(tmpDir) + if err != nil { + t.Fatalf("LoadSpecs failed: %v", err) + } + + if len(specs) != 1 { + t.Fatalf("Expected 1 spec, got %d", len(specs)) + } + + if specs[0].Name != tt.expectedName { + t.Errorf("For ID '%s', expected name '%s', got '%s'", tt.id, tt.expectedName, specs[0].Name) + } + } +} + +func TestCreateSpecSkeleton_CreatesDirectory(t *testing.T) { + tmpDir := t.TempDir() + // Don't create campaigns directory beforehand + + _, err := CreateSpecSkeleton(tmpDir, "test-campaign", false) + if err != nil { + t.Fatalf("CreateSpecSkeleton failed: %v", err) + } + + // Verify campaigns directory was created + campaignsDir := filepath.Join(tmpDir, "campaigns") + if _, err := os.Stat(campaignsDir); os.IsNotExist(err) { + t.Error("Expected campaigns directory to be created") + } +} + +func TestCreateSpecSkeleton_ValidIDs(t *testing.T) { + validIDs := []string{ + "test", + "test-campaign", + "test123", + "test-123-campaign", + "123-test", + "a", + "1", + } + + for _, id := range validIDs { + tmpDir := t.TempDir() + + _, err := CreateSpecSkeleton(tmpDir, id, false) + if err != nil { + t.Errorf("Expected ID '%s' to be valid, got error: %v", id, err) + } + } +} diff --git a/pkg/campaign/filter_test.go b/pkg/campaign/filter_test.go new file mode 100644 index 0000000000..bf11d277ed --- /dev/null +++ b/pkg/campaign/filter_test.go @@ -0,0 +1,139 @@ +package campaign + +import ( + "testing" +) + +func TestFilterSpecs_EmptyPattern(t *testing.T) { + specs := []CampaignSpec{ + {ID: "campaign1", Name: "Campaign One"}, + {ID: "campaign2", Name: "Campaign Two"}, + {ID: "campaign3", Name: "Campaign Three"}, + } + + filtered := FilterSpecs(specs, "") + + if len(filtered) != len(specs) { + t.Errorf("Expected %d specs with empty pattern, got %d", len(specs), len(filtered)) + } +} + +func TestFilterSpecs_MatchByID(t *testing.T) { + specs := []CampaignSpec{ + {ID: "security-compliance", Name: "Security Compliance"}, + {ID: "incident-response", Name: "Incident Response"}, + {ID: "org-modernization", Name: "Org Modernization"}, + } + + filtered := FilterSpecs(specs, "security") + + if len(filtered) != 1 { + t.Fatalf("Expected 1 spec matching 'security', got %d", len(filtered)) + } + + if filtered[0].ID != "security-compliance" { + t.Errorf("Expected to find 'security-compliance', got '%s'", filtered[0].ID) + } +} + +func TestFilterSpecs_MatchByName(t *testing.T) { + specs := []CampaignSpec{ + {ID: "sec-comp", Name: "Security Compliance"}, + {ID: "incident", Name: "Incident Response"}, + {ID: "modernization", Name: "Org Modernization"}, + } + + filtered := FilterSpecs(specs, "Response") + + if len(filtered) != 1 { + t.Fatalf("Expected 1 spec matching 'Response', got %d", len(filtered)) + } + + if filtered[0].ID != "incident" { + t.Errorf("Expected to find 'incident', got '%s'", filtered[0].ID) + } +} + +func TestFilterSpecs_CaseInsensitive(t *testing.T) { + specs := []CampaignSpec{ + {ID: "security-compliance", Name: "Security Compliance"}, + {ID: "incident-response", Name: "Incident Response"}, + } + + tests := []struct { + pattern string + wantLen int + }{ + {"SECURITY", 1}, + {"Security", 1}, + {"security", 1}, + {"INCIDENT", 1}, + {"incident", 1}, + } + + for _, tt := range tests { + filtered := FilterSpecs(specs, tt.pattern) + if len(filtered) != tt.wantLen { + t.Errorf("Pattern '%s': expected %d matches, got %d", tt.pattern, tt.wantLen, len(filtered)) + } + } +} + +func TestFilterSpecs_MultipleMatches(t *testing.T) { + specs := []CampaignSpec{ + {ID: "security-q1", Name: "Security Q1"}, + {ID: "security-q2", Name: "Security Q2"}, + {ID: "incident-response", Name: "Incident Response"}, + } + + filtered := FilterSpecs(specs, "security") + + if len(filtered) != 2 { + t.Fatalf("Expected 2 specs matching 'security', got %d", len(filtered)) + } + + foundQ1, foundQ2 := false, false + for _, spec := range filtered { + if spec.ID == "security-q1" { + foundQ1 = true + } + if spec.ID == "security-q2" { + foundQ2 = true + } + } + + if !foundQ1 || !foundQ2 { + t.Error("Expected to find both security-q1 and security-q2") + } +} + +func TestFilterSpecs_NoMatches(t *testing.T) { + specs := []CampaignSpec{ + {ID: "security-compliance", Name: "Security Compliance"}, + {ID: "incident-response", Name: "Incident Response"}, + } + + filtered := FilterSpecs(specs, "nonexistent") + + if len(filtered) != 0 { + t.Errorf("Expected 0 specs matching 'nonexistent', got %d", len(filtered)) + } +} + +func TestFilterSpecs_PartialMatch(t *testing.T) { + specs := []CampaignSpec{ + {ID: "security-compliance", Name: "Security Compliance"}, + {ID: "incident-response", Name: "Incident Response"}, + {ID: "org-modernization", Name: "Org Modernization"}, + } + + filtered := FilterSpecs(specs, "comp") + + if len(filtered) != 1 { + t.Fatalf("Expected 1 spec matching 'comp', got %d", len(filtered)) + } + + if filtered[0].ID != "security-compliance" { + t.Errorf("Expected to find 'security-compliance', got '%s'", filtered[0].ID) + } +} diff --git a/pkg/campaign/loader.go b/pkg/campaign/loader.go new file mode 100644 index 0000000000..63b175dfca --- /dev/null +++ b/pkg/campaign/loader.go @@ -0,0 +1,179 @@ +package campaign + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/githubnext/gh-aw/pkg/logger" + "github.com/githubnext/gh-aw/pkg/parser" + "gopkg.in/yaml.v3" +) + +var log = logger.New("campaign:loader") + +// LoadSpecs scans the repository for campaign spec files and returns +// a slice of CampaignSpec. If the campaigns directory does not exist, it +// returns an empty slice and no error. +func LoadSpecs(rootDir string) ([]CampaignSpec, error) { + log.Printf("Loading campaign specs from rootDir=%s", rootDir) + + campaignsDir := filepath.Join(rootDir, "campaigns") + entries, err := os.ReadDir(campaignsDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + log.Print("No campaigns directory found; returning empty list") + return []CampaignSpec{}, nil + } + return nil, fmt.Errorf("failed to read campaigns directory '%s': %w", campaignsDir, err) + } + + var specs []CampaignSpec + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + if !strings.HasSuffix(name, ".campaign.md") { + continue + } + + fullPath := filepath.Join(campaignsDir, name) + log.Printf("Found campaign spec file: %s", fullPath) + + data, err := os.ReadFile(fullPath) + if err != nil { + return nil, fmt.Errorf("failed to read campaign spec '%s': %w", fullPath, err) + } + + // Use parser package's frontmatter extraction helper + result, err := parser.ExtractFrontmatterFromContent(string(data)) + if err != nil { + return nil, fmt.Errorf("failed to parse campaign spec frontmatter '%s': %w", fullPath, err) + } + + if len(result.Frontmatter) == 0 { + return nil, fmt.Errorf("campaign spec '%s' must start with YAML frontmatter delimited by '---'", filepath.ToSlash(filepath.Join("campaigns", name))) + } + + // Marshal frontmatter map to YAML and unmarshal to CampaignSpec + frontmatterYAML, err := yaml.Marshal(result.Frontmatter) + if err != nil { + return nil, fmt.Errorf("failed to marshal frontmatter for '%s': %w", fullPath, err) + } + + var spec CampaignSpec + if err := yaml.Unmarshal(frontmatterYAML, &spec); err != nil { + return nil, fmt.Errorf("failed to parse campaign spec frontmatter '%s': %w", fullPath, err) + } + + if strings.TrimSpace(spec.ID) == "" { + base := strings.TrimSuffix(name, ".campaign.md") + spec.ID = base + } + + if strings.TrimSpace(spec.Name) == "" { + spec.Name = spec.ID + } + + spec.ConfigPath = filepath.ToSlash(filepath.Join("campaigns", name)) + specs = append(specs, spec) + } + + log.Printf("Loaded %d campaign specs", len(specs)) + return specs, nil +} + +// FilterSpecs filters campaigns by a simple substring match on ID or +// Name (case-insensitive). When pattern is empty, all campaigns are returned. +func FilterSpecs(specs []CampaignSpec, pattern string) []CampaignSpec { + if pattern == "" { + return specs + } + + var filtered []CampaignSpec + lowerPattern := strings.ToLower(pattern) + + for _, spec := range specs { + if strings.Contains(strings.ToLower(spec.ID), lowerPattern) || strings.Contains(strings.ToLower(spec.Name), lowerPattern) { + filtered = append(filtered, spec) + } + } + + return filtered +} + +// CreateSpecSkeleton creates a new campaign spec YAML file under +// campaigns/ with a minimal skeleton definition. It returns the +// relative file path created. +func CreateSpecSkeleton(rootDir, id string, force bool) (string, error) { + id = strings.TrimSpace(id) + if id == "" { + return "", fmt.Errorf("campaign id is required") + } + + // Reuse the same simple rules as ValidateSpec for IDs + for _, ch := range id { + if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' { + continue + } + return "", fmt.Errorf("campaign id must use only lowercase letters, digits, and hyphens (%s)", id) + } + + campaignsDir := filepath.Join(rootDir, "campaigns") + if err := os.MkdirAll(campaignsDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create campaigns directory: %w", err) + } + + fileName := id + ".campaign.md" + fullPath := filepath.Join(campaignsDir, fileName) + relPath := filepath.ToSlash(filepath.Join("campaigns", fileName)) + + if _, err := os.Stat(fullPath); err == nil && !force { + return "", fmt.Errorf("campaign spec already exists at %s (use --force to overwrite)", relPath) + } + + name := strings.ReplaceAll(id, "-", " ") + if name != "" { + first := strings.ToUpper(name[:1]) + if len(name) > 1 { + name = first + name[1:] + } else { + name = first + } + } + + spec := CampaignSpec{ + ID: id, + Name: name, + Version: "v1", + State: "planned", + TrackerLabel: "campaign:" + id, + } + + data, err := yaml.Marshal(&spec) + if err != nil { + return "", fmt.Errorf("failed to marshal campaign spec: %w", err) + } + + var buf strings.Builder + buf.WriteString("---\n") + buf.Write(data) + buf.WriteString("---\n\n") + if name != "" { + buf.WriteString("# " + name + "\n\n") + } else { + buf.WriteString("# " + id + "\n\n") + } + buf.WriteString("Describe this campaign's goals, guardrails, stakeholders, and playbook.\n") + + if err := os.WriteFile(fullPath, []byte(buf.String()), 0o644); err != nil { + return "", fmt.Errorf("failed to write campaign spec file '%s': %w", relPath, err) + } + + return relPath, nil +} diff --git a/pkg/campaign/loader_test.go b/pkg/campaign/loader_test.go new file mode 100644 index 0000000000..24b8296227 --- /dev/null +++ b/pkg/campaign/loader_test.go @@ -0,0 +1,240 @@ +package campaign + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadSpecs_EmptyDirectory(t *testing.T) { + // Create temporary directory + tmpDir := t.TempDir() + campaignsDir := filepath.Join(tmpDir, "campaigns") + if err := os.MkdirAll(campaignsDir, 0755); err != nil { + t.Fatalf("Failed to create campaigns directory: %v", err) + } + + specs, err := LoadSpecs(tmpDir) + if err != nil { + t.Fatalf("LoadSpecs failed: %v", err) + } + + if len(specs) != 0 { + t.Errorf("Expected 0 specs in empty directory, got %d", len(specs)) + } +} + +func TestLoadSpecs_NonExistentDirectory(t *testing.T) { + tmpDir := t.TempDir() + + specs, err := LoadSpecs(tmpDir) + if err != nil { + t.Fatalf("LoadSpecs should not fail for non-existent campaigns directory: %v", err) + } + + if len(specs) != 0 { + t.Errorf("Expected 0 specs when campaigns directory doesn't exist, got %d", len(specs)) + } +} + +func TestLoadSpecs_InvalidFrontmatter(t *testing.T) { + tmpDir := t.TempDir() + campaignsDir := filepath.Join(tmpDir, "campaigns") + if err := os.MkdirAll(campaignsDir, 0755); err != nil { + t.Fatalf("Failed to create campaigns directory: %v", err) + } + + // Create file with invalid frontmatter + invalidFile := filepath.Join(campaignsDir, "invalid.campaign.md") + content := `--- +id: test +name: [invalid yaml here +--- +Test content` + if err := os.WriteFile(invalidFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err := LoadSpecs(tmpDir) + if err == nil { + t.Fatalf("Expected error for invalid frontmatter, got nil") + } + + if !strings.Contains(err.Error(), "failed to parse") { + t.Errorf("Expected parse error, got: %v", err) + } +} + +func TestLoadSpecs_MissingFrontmatter(t *testing.T) { + tmpDir := t.TempDir() + campaignsDir := filepath.Join(tmpDir, "campaigns") + if err := os.MkdirAll(campaignsDir, 0755); err != nil { + t.Fatalf("Failed to create campaigns directory: %v", err) + } + + // Create file without frontmatter + noFrontmatterFile := filepath.Join(campaignsDir, "no-frontmatter.campaign.md") + content := `# Test Campaign + +This file has no frontmatter.` + if err := os.WriteFile(noFrontmatterFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err := LoadSpecs(tmpDir) + if err == nil { + t.Fatalf("Expected error for missing frontmatter, got nil") + } + + if !strings.Contains(err.Error(), "must start with YAML frontmatter") { + t.Errorf("Expected frontmatter error, got: %v", err) + } +} + +func TestLoadSpecs_IDDefaults(t *testing.T) { + tmpDir := t.TempDir() + campaignsDir := filepath.Join(tmpDir, "campaigns") + if err := os.MkdirAll(campaignsDir, 0755); err != nil { + t.Fatalf("Failed to create campaigns directory: %v", err) + } + + // Create file without ID in frontmatter + testFile := filepath.Join(campaignsDir, "test-campaign.campaign.md") + content := `--- +name: Test Campaign +version: v1 +--- +Test content` + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + specs, err := LoadSpecs(tmpDir) + if err != nil { + t.Fatalf("LoadSpecs failed: %v", err) + } + + if len(specs) != 1 { + t.Fatalf("Expected 1 spec, got %d", len(specs)) + } + + if specs[0].ID != "test-campaign" { + t.Errorf("Expected ID 'test-campaign' (derived from filename), got '%s'", specs[0].ID) + } +} + +func TestLoadSpecs_NameDefaults(t *testing.T) { + tmpDir := t.TempDir() + campaignsDir := filepath.Join(tmpDir, "campaigns") + if err := os.MkdirAll(campaignsDir, 0755); err != nil { + t.Fatalf("Failed to create campaigns directory: %v", err) + } + + // Create file without name in frontmatter + testFile := filepath.Join(campaignsDir, "test-id.campaign.md") + content := `--- +id: test-id +version: v1 +--- +Test content` + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + specs, err := LoadSpecs(tmpDir) + if err != nil { + t.Fatalf("LoadSpecs failed: %v", err) + } + + if len(specs) != 1 { + t.Fatalf("Expected 1 spec, got %d", len(specs)) + } + + if specs[0].Name != "test-id" { + t.Errorf("Expected Name 'test-id' (derived from ID), got '%s'", specs[0].Name) + } +} + +func TestLoadSpecs_ConfigPath(t *testing.T) { + tmpDir := t.TempDir() + campaignsDir := filepath.Join(tmpDir, "campaigns") + if err := os.MkdirAll(campaignsDir, 0755); err != nil { + t.Fatalf("Failed to create campaigns directory: %v", err) + } + + testFile := filepath.Join(campaignsDir, "test.campaign.md") + content := `--- +id: test +name: Test +--- +Test content` + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + specs, err := LoadSpecs(tmpDir) + if err != nil { + t.Fatalf("LoadSpecs failed: %v", err) + } + + if len(specs) != 1 { + t.Fatalf("Expected 1 spec, got %d", len(specs)) + } + + expectedPath := "campaigns/test.campaign.md" + if specs[0].ConfigPath != expectedPath { + t.Errorf("Expected ConfigPath '%s', got '%s'", expectedPath, specs[0].ConfigPath) + } +} + +func TestLoadSpecs_MultipleSpecs(t *testing.T) { + tmpDir := t.TempDir() + campaignsDir := filepath.Join(tmpDir, "campaigns") + if err := os.MkdirAll(campaignsDir, 0755); err != nil { + t.Fatalf("Failed to create campaigns directory: %v", err) + } + + // Create multiple campaign files + campaigns := []struct { + filename string + id string + }{ + {"campaign1.campaign.md", "campaign1"}, + {"campaign2.campaign.md", "campaign2"}, + {"campaign3.campaign.md", "campaign3"}, + } + + for _, c := range campaigns { + content := `--- +id: ` + c.id + ` +name: ` + strings.Title(c.id) + ` +--- +Content` + testFile := filepath.Join(campaignsDir, c.filename) + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test file %s: %v", c.filename, err) + } + } + + specs, err := LoadSpecs(tmpDir) + if err != nil { + t.Fatalf("LoadSpecs failed: %v", err) + } + + if len(specs) != 3 { + t.Fatalf("Expected 3 specs, got %d", len(specs)) + } + + // Verify all IDs are present + foundIDs := make(map[string]bool) + for _, spec := range specs { + foundIDs[spec.ID] = true + } + + for _, c := range campaigns { + if !foundIDs[c.id] { + t.Errorf("Expected to find campaign with ID '%s'", c.id) + } + } +} diff --git a/pkg/campaign/schemas/campaign_spec_schema.json b/pkg/campaign/schemas/campaign_spec_schema.json new file mode 100644 index 0000000000..5df1bad272 --- /dev/null +++ b/pkg/campaign/schemas/campaign_spec_schema.json @@ -0,0 +1,125 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/githubnext/gh-aw/schemas/campaign_spec_schema.json", + "title": "Campaign Spec Schema", + "description": "Schema for GitHub Agentic Workflows campaign specifications", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the campaign (lowercase letters, digits, and hyphens only)", + "pattern": "^[a-z0-9-]+$", + "minLength": 1 + }, + "name": { + "type": "string", + "description": "Human-readable name for the campaign", + "minLength": 1 + }, + "description": { + "type": "string", + "description": "Brief description of the campaign" + }, + "version": { + "type": "string", + "description": "Spec version (e.g., v1)", + "pattern": "^v[0-9]+$", + "default": "v1" + }, + "workflows": { + "type": "array", + "description": "List of workflow IDs (basenames without .md) implementing this campaign", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + "memory_paths": { + "type": "array", + "description": "Paths where this campaign writes its repo-memory", + "items": { + "type": "string", + "minLength": 1 + } + }, + "metrics_glob": { + "type": "string", + "description": "Glob pattern to locate JSON metrics snapshots in memory/campaigns branch" + }, + "owners": { + "type": "array", + "description": "Primary human owners for this campaign", + "items": { + "type": "string", + "minLength": 1 + } + }, + "executive_sponsors": { + "type": "array", + "description": "Executive stakeholders or sponsors", + "items": { + "type": "string", + "minLength": 1 + } + }, + "risk_level": { + "type": "string", + "description": "Risk level (e.g., low, medium, high)", + "enum": ["low", "medium", "high"] + }, + "tracker_label": { + "type": "string", + "description": "Label used to associate issues/PRs with this campaign (e.g., campaign:incident-response)", + "pattern": "^[^:]+:.+$", + "minLength": 1 + }, + "state": { + "type": "string", + "description": "Lifecycle stage of the campaign", + "enum": ["planned", "active", "paused", "completed", "archived"] + }, + "tags": { + "type": "array", + "description": "Free-form categorization tags", + "items": { + "type": "string", + "minLength": 1 + } + }, + "allowed_safe_outputs": { + "type": "array", + "description": "Safe-outputs operations this campaign is expected to use", + "items": { + "type": "string", + "minLength": 1 + } + }, + "approval_policy": { + "type": "object", + "description": "Approval expectations for this campaign", + "properties": { + "required_approvals": { + "type": "integer", + "description": "Number of required approvals", + "minimum": 0 + }, + "required_roles": { + "type": "array", + "description": "Required roles for approval", + "items": { + "type": "string", + "minLength": 1 + } + }, + "change_control": { + "type": "boolean", + "description": "Whether change control is required" + } + }, + "additionalProperties": false + } + }, + "required": ["id", "name"], + "additionalProperties": false +} diff --git a/pkg/campaign/spec.go b/pkg/campaign/spec.go new file mode 100644 index 0000000000..e5c73fdc7e --- /dev/null +++ b/pkg/campaign/spec.go @@ -0,0 +1,137 @@ +package campaign + +// CampaignSpec defines a first-class campaign configuration loaded from +// YAML frontmatter in Markdown files. +// +// Files are discovered from the local repository under: +// +// campaigns/*.campaign.md +// +// This provides a thin, declarative layer on top of existing agentic +// workflows and repo-memory conventions. +type CampaignSpec struct { + ID string `yaml:"id" json:"id" console:"header:ID"` + Name string `yaml:"name" json:"name" console:"header:Name"` + Description string `yaml:"description,omitempty" json:"description,omitempty" console:"header:Description,omitempty"` + + // Version is an optional spec version string (for example: v1). + // When omitted, it defaults to v1 during validation. + Version string `yaml:"version,omitempty" json:"version,omitempty" console:"header:Version,omitempty"` + + // Workflows associates this campaign with one or more workflow IDs + // (basename of the Markdown file without .md). + Workflows []string `yaml:"workflows,omitempty" json:"workflows,omitempty" console:"header:Workflows,omitempty"` + + // MemoryPaths documents where this campaign writes its repo-memory + // (for example: memory/campaigns/incident-*/**). + MemoryPaths []string `yaml:"memory_paths,omitempty" json:"memory_paths,omitempty" console:"header:Memory Paths,omitempty"` + + // MetricsGlob is an optional glob (relative to the repository root) + // used to locate JSON metrics snapshots stored in the + // memory/campaigns branch. When set, `gh aw campaign status` will + // attempt to read the latest matching metrics file and surface a few + // key fields. + MetricsGlob string `yaml:"metrics_glob,omitempty" json:"metrics_glob,omitempty" console:"header:Metrics Glob,omitempty"` + + // Owners lists the primary human owners for this campaign. + Owners []string `yaml:"owners,omitempty" json:"owners,omitempty" console:"header:Owners,omitempty"` + + // ExecutiveSponsors lists executive stakeholders or sponsors who are + // accountable for the outcome of this campaign. + ExecutiveSponsors []string `yaml:"executive_sponsors,omitempty" json:"executive_sponsors,omitempty" console:"header:Executive Sponsors,omitempty"` + + // RiskLevel is an optional free-form field (e.g. low/medium/high). + RiskLevel string `yaml:"risk_level,omitempty" json:"risk_level,omitempty" console:"header:Risk Level,omitempty"` + + // TrackerLabel describes the label used to associate issues/PRs with + // this campaign (for example: campaign:incident-response). + TrackerLabel string `yaml:"tracker_label,omitempty" json:"tracker_label,omitempty" console:"header:Tracker Label,omitempty"` + + // State describes the lifecycle stage of the campaign definition. + // Valid values are: planned, active, paused, completed, archived. + State string `yaml:"state,omitempty" json:"state,omitempty" console:"header:State,omitempty"` + + // Tags provide free-form categorization for reporting (for example: + // security, modernization, rollout). + Tags []string `yaml:"tags,omitempty" json:"tags,omitempty" console:"header:Tags,omitempty"` + + // AllowedSafeOutputs documents which safe-outputs operations this + // campaign is expected to use (for example: create-issue, + // create-pull-request). This is currently informational but can be + // enforced by validation in the future. + AllowedSafeOutputs []string `yaml:"allowed_safe_outputs,omitempty" json:"allowed_safe_outputs,omitempty" console:"header:Allowed Safe Outputs,omitempty"` + + // ApprovalPolicy describes high-level approval expectations for this + // campaign (for example: number of approvals and required roles). + ApprovalPolicy *CampaignApprovalPolicy `yaml:"approval_policy,omitempty" json:"approval_policy,omitempty"` + + // ConfigPath is populated at load time with the relative path of + // the YAML file on disk, to help users locate definitions. + ConfigPath string `yaml:"-" json:"config_path" console:"header:Config Path"` +} + +// CampaignApprovalPolicy captures basic approval expectations for a +// campaign. It is intentionally lightweight and advisory; enforcement +// is left to workflows and organizational process. +type CampaignApprovalPolicy struct { + RequiredApprovals int `yaml:"required_approvals,omitempty" json:"required_approvals,omitempty"` + RequiredRoles []string `yaml:"required_roles,omitempty" json:"required_roles,omitempty"` + ChangeControl bool `yaml:"change_control,omitempty" json:"change_control,omitempty"` +} + +// CampaignRuntimeStatus represents the live status of a campaign, including +// compiled workflow state and basic issue/PR counts derived from the tracker +// label. +type CampaignRuntimeStatus struct { + ID string `json:"id" console:"header:ID"` + Name string `json:"name" console:"header:Name"` + TrackerLabel string `json:"tracker_label,omitempty" console:"header:Tracker Label,omitempty"` + Workflows []string `json:"workflows,omitempty" console:"header:Workflows,omitempty"` + Compiled string `json:"compiled" console:"header:Compiled"` + + IssuesOpen int `json:"issues_open,omitempty" console:"header:Issues Open,omitempty"` + IssuesClosed int `json:"issues_closed,omitempty" console:"header:Issues Closed,omitempty"` + PRsOpen int `json:"prs_open,omitempty" console:"header:PRs Open,omitempty"` + PRsMerged int `json:"prs_merged,omitempty" console:"header:PRs Merged,omitempty"` + + // Optional metrics from repo-memory (when MetricsGlob is set and a + // matching JSON snapshot is found on the memory/campaigns branch). + MetricsTasksTotal int `json:"metrics_tasks_total,omitempty" console:"header:Tasks Total,omitempty"` + MetricsTasksCompleted int `json:"metrics_tasks_completed,omitempty" console:"header:Tasks Completed,omitempty"` + MetricsVelocityPerDay float64 `json:"metrics_velocity_per_day,omitempty" console:"header:Velocity/Day,omitempty"` + MetricsEstimatedCompletion string `json:"metrics_estimated_completion,omitempty" console:"header:ETA,omitempty"` +} + +// CampaignMetricsSnapshot describes the JSON structure used by campaign +// metrics snapshots written into the memory/campaigns branch. +// +// This mirrors the example in the campaigns guide: +// +// { +// "date": "2025-01-16", +// "campaign_id": "security-q1-2025", +// "tasks_total": 200, +// "tasks_completed": 15, +// "tasks_in_progress": 30, +// "tasks_blocked": 5, +// "velocity_per_day": 7.5, +// "estimated_completion": "2025-02-12" +// } +type CampaignMetricsSnapshot struct { + Date string `json:"date,omitempty"` + CampaignID string `json:"campaign_id,omitempty"` + TasksTotal int `json:"tasks_total,omitempty"` + TasksCompleted int `json:"tasks_completed,omitempty"` + TasksInProgress int `json:"tasks_in_progress,omitempty"` + TasksBlocked int `json:"tasks_blocked,omitempty"` + VelocityPerDay float64 `json:"velocity_per_day,omitempty"` + EstimatedCompletion string `json:"estimated_completion,omitempty"` +} + +// CampaignValidationResult represents the result of validating a campaign spec. +type CampaignValidationResult struct { + ID string `json:"id" console:"header:ID"` + Name string `json:"name" console:"header:Name"` + ConfigPath string `json:"config_path" console:"header:Config Path"` + Problems []string `json:"problems,omitempty" console:"header:Problems,omitempty"` +} diff --git a/pkg/campaign/status.go b/pkg/campaign/status.go new file mode 100644 index 0000000000..3c5c3ca1f7 --- /dev/null +++ b/pkg/campaign/status.go @@ -0,0 +1,225 @@ +package campaign + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + + "github.com/githubnext/gh-aw/pkg/workflow" +) + +// ComputeCompiledState inspects the compiled state of all +// workflows referenced by a campaign. It returns: +// +// "Yes" - all referenced workflows exist and are compiled & up-to-date +// "No" - at least one workflow exists but is missing a lock file or is stale +// "Missing workflow" - at least one referenced workflow markdown file does not exist +// "N/A" - campaign does not reference any workflows +func ComputeCompiledState(spec CampaignSpec, workflowsDir string) string { + if len(spec.Workflows) == 0 { + return "N/A" + } + + compiledAll := true + missingAny := false + + for _, wf := range spec.Workflows { + mdPath := filepath.Join(workflowsDir, wf+".md") + lockPath := mdPath + ".lock.yml" + + mdInfo, err := os.Stat(mdPath) + if err != nil { + log.Printf("Workflow markdown not found for campaign '%s': %s", spec.ID, mdPath) + missingAny = true + compiledAll = false + continue + } + + lockInfo, err := os.Stat(lockPath) + if err != nil { + log.Printf("Lock file not found for workflow '%s' in campaign '%s': %s", wf, spec.ID, lockPath) + compiledAll = false + continue + } + + if mdInfo.ModTime().After(lockInfo.ModTime()) { + log.Printf("Lock file out of date for workflow '%s' in campaign '%s'", wf, spec.ID) + compiledAll = false + } + } + + if missingAny { + return "Missing workflow" + } + if compiledAll { + return "Yes" + } + return "No" +} + +// ghIssueOrPRState is a tiny helper struct for decoding gh issue/pr list +// output when using --json state. +type ghIssueOrPRState struct { + State string `json:"state"` +} + +// FetchItemCounts uses gh CLI (via workflow.ExecGH) to fetch basic +// counts of issues and pull requests tagged with the given tracker label. +// +// If trackerLabel is empty or any errors occur, it falls back to zeros and +// logs at debug level instead of failing the command. +func FetchItemCounts(trackerLabel string) (issuesOpen, issuesClosed, prsOpen, prsMerged int) { + if strings.TrimSpace(trackerLabel) == "" { + return 0, 0, 0, 0 + } + + // Issues + issueCmd := workflow.ExecGH("issue", "list", "--label", trackerLabel, "--state", "all", "--json", "state") + issueOutput, err := issueCmd.Output() + if err == nil && len(issueOutput) > 0 && json.Valid(issueOutput) { + var issues []ghIssueOrPRState + if err := json.Unmarshal(issueOutput, &issues); err == nil { + for _, it := range issues { + state := strings.ToLower(strings.TrimSpace(it.State)) + if state == "open" { + issuesOpen++ + } else { + issuesClosed++ + } + } + } else if err != nil { + log.Printf("Failed to decode issue list for tracker label '%s': %v", trackerLabel, err) + } + } else if err != nil { + log.Printf("Failed to fetch issues for tracker label '%s': %v", trackerLabel, err) + } + + // Pull requests + prCmd := workflow.ExecGH("pr", "list", "--label", trackerLabel, "--state", "all", "--json", "state") + prOutput, err := prCmd.Output() + if err == nil && len(prOutput) > 0 && json.Valid(prOutput) { + var prs []ghIssueOrPRState + if err := json.Unmarshal(prOutput, &prs); err == nil { + for _, it := range prs { + state := strings.ToLower(strings.TrimSpace(it.State)) + switch state { + case "open": + prsOpen++ + case "merged": + prsMerged++ + } + } + } else if err != nil { + log.Printf("Failed to decode PR list for tracker label '%s': %v", trackerLabel, err) + } + } else if err != nil { + log.Printf("Failed to fetch PRs for tracker label '%s': %v", trackerLabel, err) + } + + return issuesOpen, issuesClosed, prsOpen, prsMerged +} + +// FetchMetricsFromRepoMemory attempts to load the latest JSON +// metrics snapshot matching the provided glob from the +// memory/campaigns branch. It is best-effort: errors are logged and +// treated as "no metrics" rather than failing the command. +func FetchMetricsFromRepoMemory(metricsGlob string) (*CampaignMetricsSnapshot, error) { + if strings.TrimSpace(metricsGlob) == "" { + return nil, nil + } + + // List all files in the memory/campaigns branch + cmd := exec.Command("git", "ls-tree", "-r", "--name-only", "memory/campaigns") + output, err := cmd.Output() + if err != nil { + log.Printf("Unable to list repo-memory branch for metrics (memory/campaigns): %v", err) + return nil, nil + } + + scanner := bufio.NewScanner(bytes.NewReader(output)) + var matches []string + for scanner.Scan() { + pathStr := strings.TrimSpace(scanner.Text()) + if pathStr == "" { + continue + } + matched, err := path.Match(metricsGlob, pathStr) + if err != nil { + log.Printf("Invalid metrics_glob '%s': %v", metricsGlob, err) + return nil, nil + } + if matched { + matches = append(matches, pathStr) + } + } + + if len(matches) == 0 { + return nil, nil + } + + // Pick the lexicographically last match as the "latest" snapshot. + latest := matches[0] + for _, m := range matches[1:] { + if m > latest { + latest = m + } + } + + showArg := fmt.Sprintf("memory/campaigns:%s", latest) + showCmd := exec.Command("git", "show", showArg) + fileData, err := showCmd.Output() + if err != nil { + log.Printf("Failed to read metrics file '%s' from memory/campaigns: %v", latest, err) + return nil, nil + } + + var snapshot CampaignMetricsSnapshot + if err := json.Unmarshal(fileData, &snapshot); err != nil { + log.Printf("Failed to decode metrics JSON from '%s': %v", latest, err) + return nil, nil + } + + return &snapshot, nil +} + +// BuildRuntimeStatus builds a CampaignRuntimeStatus for a single campaign spec. +func BuildRuntimeStatus(spec CampaignSpec, workflowsDir string) CampaignRuntimeStatus { + compiled := ComputeCompiledState(spec, workflowsDir) + issuesOpen, issuesClosed, prsOpen, prsMerged := FetchItemCounts(spec.TrackerLabel) + + var metricsTasksTotal, metricsTasksCompleted int + var metricsVelocity float64 + var metricsETA string + if strings.TrimSpace(spec.MetricsGlob) != "" { + if snapshot, err := FetchMetricsFromRepoMemory(spec.MetricsGlob); err != nil { + log.Printf("Failed to fetch metrics for campaign '%s': %v", spec.ID, err) + } else if snapshot != nil { + metricsTasksTotal = snapshot.TasksTotal + metricsTasksCompleted = snapshot.TasksCompleted + metricsVelocity = snapshot.VelocityPerDay + metricsETA = snapshot.EstimatedCompletion + } + } + + return CampaignRuntimeStatus{ + ID: spec.ID, + Name: spec.Name, + TrackerLabel: spec.TrackerLabel, + Workflows: spec.Workflows, + Compiled: compiled, + IssuesOpen: issuesOpen, + IssuesClosed: issuesClosed, + PRsOpen: prsOpen, + PRsMerged: prsMerged, + MetricsTasksTotal: metricsTasksTotal, + MetricsTasksCompleted: metricsTasksCompleted, + MetricsVelocityPerDay: metricsVelocity, + MetricsEstimatedCompletion: metricsETA, + } +} diff --git a/pkg/campaign/validation.go b/pkg/campaign/validation.go new file mode 100644 index 0000000000..70b23a509d --- /dev/null +++ b/pkg/campaign/validation.go @@ -0,0 +1,179 @@ +package campaign + +import ( + "embed" + "encoding/json" + "fmt" + "strings" + + "github.com/githubnext/gh-aw/pkg/parser" + "github.com/xeipuuv/gojsonschema" +) + +//go:embed schemas/campaign_spec_schema.json +var campaignSpecSchemaFS embed.FS + +// ValidateSpec performs lightweight semantic validation of a +// single CampaignSpec and returns a slice of human-readable problems. +// +// It uses JSON schema validation first, then adds additional semantic checks. +func ValidateSpec(spec *CampaignSpec) []string { + var problems []string + + // First, validate against JSON schema + schemaProblems := ValidateSpecWithSchema(spec) + problems = append(problems, schemaProblems...) + + // Additional semantic validation beyond schema + trimmedID := strings.TrimSpace(spec.ID) + if trimmedID == "" { + problems = append(problems, "id is required and must be non-empty") + } else { + // Enforce a simple, URL-safe pattern for IDs + for _, ch := range trimmedID { + if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' { + continue + } + problems = append(problems, "id must use only lowercase letters, digits, and hyphens ("+trimmedID+")") + break + } + } + + if strings.TrimSpace(spec.Name) == "" { + problems = append(problems, "name should be provided (falls back to id, but explicit names are recommended)") + } + + if len(spec.Workflows) == 0 { + problems = append(problems, "workflows should list at least one workflow implementing this campaign") + } + + if strings.TrimSpace(spec.TrackerLabel) == "" { + problems = append(problems, "tracker_label should be set to link issues and PRs to this campaign") + } else if !strings.Contains(spec.TrackerLabel, ":") { + problems = append(problems, "tracker_label should follow a namespaced pattern (for example: campaign:security-q1-2025)") + } + + // Normalize and validate version/state when present. + if strings.TrimSpace(spec.Version) == "" { + // Default version for v1 specs when omitted. + spec.Version = "v1" + } + + if spec.State != "" { + switch spec.State { + case "planned", "active", "paused", "completed", "archived": + // valid + default: + problems = append(problems, "state must be one of: planned, active, paused, completed, archived") + } + } + + return problems +} + +// ValidateSpecWithSchema validates a CampaignSpec against the JSON schema. +// Returns a list of validation error messages, or an empty list if valid. +func ValidateSpecWithSchema(spec *CampaignSpec) []string { + // Read embedded schema + schemaData, err := campaignSpecSchemaFS.ReadFile("schemas/campaign_spec_schema.json") + if err != nil { + return []string{fmt.Sprintf("failed to load campaign spec schema: %v", err)} + } + + // Convert spec to JSON for validation, excluding runtime fields + // Create a copy without ConfigPath (which is set at runtime, not in YAML) + type CampaignSpecForValidation struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version string `json:"version,omitempty"` + Workflows []string `json:"workflows,omitempty"` + MemoryPaths []string `json:"memory_paths,omitempty"` + MetricsGlob string `json:"metrics_glob,omitempty"` + Owners []string `json:"owners,omitempty"` + ExecutiveSponsors []string `json:"executive_sponsors,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + TrackerLabel string `json:"tracker_label,omitempty"` + State string `json:"state,omitempty"` + Tags []string `json:"tags,omitempty"` + AllowedSafeOutputs []string `json:"allowed_safe_outputs,omitempty"` + ApprovalPolicy *CampaignApprovalPolicy `json:"approval_policy,omitempty"` + } + + validationSpec := CampaignSpecForValidation{ + ID: spec.ID, + Name: spec.Name, + Description: spec.Description, + Version: spec.Version, + Workflows: spec.Workflows, + MemoryPaths: spec.MemoryPaths, + MetricsGlob: spec.MetricsGlob, + Owners: spec.Owners, + ExecutiveSponsors: spec.ExecutiveSponsors, + RiskLevel: spec.RiskLevel, + TrackerLabel: spec.TrackerLabel, + State: spec.State, + Tags: spec.Tags, + AllowedSafeOutputs: spec.AllowedSafeOutputs, + ApprovalPolicy: spec.ApprovalPolicy, + } + + specJSON, err := json.Marshal(validationSpec) + if err != nil { + return []string{fmt.Sprintf("failed to marshal spec to JSON: %v", err)} + } + + // Create schema and document loaders + schemaLoader := gojsonschema.NewBytesLoader(schemaData) + documentLoader := gojsonschema.NewBytesLoader(specJSON) + + // Validate + result, err := gojsonschema.Validate(schemaLoader, documentLoader) + if err != nil { + return []string{fmt.Sprintf("schema validation error: %v", err)} + } + + if result.Valid() { + return nil + } + + // Collect validation errors + var problems []string + for _, err := range result.Errors() { + // Format error message similar to how workflow validation does it + field := err.Field() + if field == "(root)" { + field = "root" + } + problems = append(problems, fmt.Sprintf("%s: %s", field, err.Description())) + } + + return problems +} + +// ValidateSpecFromFile validates a campaign spec file by loading and validating it. +// This is useful for validation commands that operate on files directly. +func ValidateSpecFromFile(filePath string) (*CampaignSpec, []string, error) { + data, err := parser.ExtractFrontmatterFromContent(filePath) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse frontmatter: %w", err) + } + + if len(data.Frontmatter) == 0 { + return nil, nil, fmt.Errorf("no frontmatter found in campaign spec file") + } + + // Convert frontmatter to CampaignSpec + specJSON, err := json.Marshal(data.Frontmatter) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal frontmatter: %w", err) + } + + var spec CampaignSpec + if err := json.Unmarshal(specJSON, &spec); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal spec: %w", err) + } + + problems := ValidateSpec(&spec) + return &spec, problems, nil +} diff --git a/pkg/campaign/validation_test.go b/pkg/campaign/validation_test.go new file mode 100644 index 0000000000..22b20d6347 --- /dev/null +++ b/pkg/campaign/validation_test.go @@ -0,0 +1,297 @@ +package campaign + +import ( + "strings" + "testing" +) + +func TestValidateSpec_ValidSpec(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + Version: "v1", + State: "active", + Workflows: []string{"workflow1", "workflow2"}, + TrackerLabel: "campaign:test", + } + + problems := ValidateSpec(spec) + if len(problems) != 0 { + t.Errorf("Expected no validation problems, got: %v", problems) + } +} + +func TestValidateSpec_MissingID(t *testing.T) { + spec := &CampaignSpec{ + Name: "Test Campaign", + Workflows: []string{"workflow1"}, + TrackerLabel: "campaign:test", + } + + problems := ValidateSpec(spec) + if len(problems) == 0 { + t.Fatal("Expected validation problems for missing ID") + } + + found := false + for _, p := range problems { + if strings.Contains(p, "id is required") { + found = true + break + } + } + if !found { + t.Errorf("Expected ID validation problem, got: %v", problems) + } +} + +func TestValidateSpec_InvalidIDCharacters(t *testing.T) { + spec := &CampaignSpec{ + ID: "Test_Campaign", + Name: "Test Campaign", + Workflows: []string{"workflow1"}, + TrackerLabel: "campaign:test", + } + + problems := ValidateSpec(spec) + if len(problems) == 0 { + t.Fatal("Expected validation problems for invalid ID characters") + } + + found := false + for _, p := range problems { + if strings.Contains(p, "lowercase letters, digits, and hyphens") { + found = true + break + } + } + if !found { + t.Errorf("Expected ID character validation problem, got: %v", problems) + } +} + +func TestValidateSpec_MissingName(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Workflows: []string{"workflow1"}, + TrackerLabel: "campaign:test", + } + + problems := ValidateSpec(spec) + if len(problems) == 0 { + t.Fatal("Expected validation problems for missing name") + } + + found := false + for _, p := range problems { + if strings.Contains(p, "name should be provided") { + found = true + break + } + } + if !found { + t.Errorf("Expected name validation problem, got: %v", problems) + } +} + +func TestValidateSpec_MissingWorkflows(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + TrackerLabel: "campaign:test", + } + + problems := ValidateSpec(spec) + if len(problems) == 0 { + t.Fatal("Expected validation problems for missing workflows") + } + + found := false + for _, p := range problems { + if strings.Contains(p, "workflows should list at least one workflow") { + found = true + break + } + } + if !found { + t.Errorf("Expected workflows validation problem, got: %v", problems) + } +} + +func TestValidateSpec_MissingTrackerLabel(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + Workflows: []string{"workflow1"}, + } + + problems := ValidateSpec(spec) + if len(problems) == 0 { + t.Fatal("Expected validation problems for missing tracker label") + } + + found := false + for _, p := range problems { + if strings.Contains(p, "tracker_label should be set") { + found = true + break + } + } + if !found { + t.Errorf("Expected tracker label validation problem, got: %v", problems) + } +} + +func TestValidateSpec_InvalidTrackerLabelFormat(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + Workflows: []string{"workflow1"}, + TrackerLabel: "no-colon-here", + } + + problems := ValidateSpec(spec) + if len(problems) == 0 { + t.Fatal("Expected validation problems for invalid tracker label format") + } + + found := false + for _, p := range problems { + if strings.Contains(p, "tracker_label should follow a namespaced pattern") { + found = true + break + } + } + if !found { + t.Errorf("Expected tracker label format validation problem, got: %v", problems) + } +} + +func TestValidateSpec_InvalidState(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + Workflows: []string{"workflow1"}, + TrackerLabel: "campaign:test", + State: "invalid-state", + } + + problems := ValidateSpec(spec) + if len(problems) == 0 { + t.Fatal("Expected validation problems for invalid state") + } + + found := false + for _, p := range problems { + if strings.Contains(p, "state must be one of") { + found = true + break + } + } + if !found { + t.Errorf("Expected state validation problem, got: %v", problems) + } +} + +func TestValidateSpec_ValidStates(t *testing.T) { + validStates := []string{"planned", "active", "paused", "completed", "archived"} + + for _, state := range validStates { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + Workflows: []string{"workflow1"}, + TrackerLabel: "campaign:test", + State: state, + } + + problems := ValidateSpec(spec) + if len(problems) != 0 { + t.Errorf("Expected no validation problems for state '%s', got: %v", state, problems) + } + } +} + +func TestValidateSpec_VersionDefault(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + Workflows: []string{"workflow1"}, + TrackerLabel: "campaign:test", + } + + _ = ValidateSpec(spec) + + if spec.Version != "v1" { + t.Errorf("Expected version to default to 'v1', got '%s'", spec.Version) + } +} + +func TestValidateSpec_RiskLevel(t *testing.T) { + validRiskLevels := []string{"low", "medium", "high"} + + for _, riskLevel := range validRiskLevels { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + Workflows: []string{"workflow1"}, + TrackerLabel: "campaign:test", + RiskLevel: riskLevel, + } + + problems := ValidateSpec(spec) + // Risk level validation is currently not enforced beyond schema + // This test ensures the field is accepted + if len(problems) != 0 { + t.Errorf("Expected no validation problems for risk level '%s', got: %v", riskLevel, problems) + } + } +} + +func TestValidateSpec_WithApprovalPolicy(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + Workflows: []string{"workflow1"}, + TrackerLabel: "campaign:test", + ApprovalPolicy: &CampaignApprovalPolicy{ + RequiredApprovals: 2, + RequiredRoles: []string{"admin", "security"}, + ChangeControl: true, + }, + } + + problems := ValidateSpec(spec) + if len(problems) != 0 { + t.Errorf("Expected no validation problems with approval policy, got: %v", problems) + } +} + +func TestValidateSpec_CompleteSpec(t *testing.T) { + spec := &CampaignSpec{ + ID: "complete-campaign", + Name: "Complete Campaign", + Description: "A complete campaign spec for testing", + Version: "v1", + Workflows: []string{"workflow1", "workflow2"}, + MemoryPaths: []string{"memory/campaigns/complete/**"}, + MetricsGlob: "memory/campaigns/complete-*.json", + Owners: []string{"owner1", "owner2"}, + ExecutiveSponsors: []string{"sponsor1"}, + RiskLevel: "medium", + TrackerLabel: "campaign:complete", + State: "active", + Tags: []string{"security", "compliance"}, + AllowedSafeOutputs: []string{"create-issue", "create-pull-request"}, + ApprovalPolicy: &CampaignApprovalPolicy{ + RequiredApprovals: 3, + RequiredRoles: []string{"admin", "security", "compliance"}, + ChangeControl: true, + }, + } + + problems := ValidateSpec(spec) + if len(problems) != 0 { + t.Errorf("Expected no validation problems for complete spec, got: %v", problems) + } +} diff --git a/pkg/cli/campaigns.go b/pkg/cli/campaigns.go deleted file mode 100644 index a64de0f1d7..0000000000 --- a/pkg/cli/campaigns.go +++ /dev/null @@ -1,856 +0,0 @@ -package cli - -import ( - "bufio" - "bytes" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path" - "path/filepath" - "strings" - - "github.com/githubnext/gh-aw/pkg/console" - "github.com/githubnext/gh-aw/pkg/constants" - "github.com/githubnext/gh-aw/pkg/logger" - "github.com/githubnext/gh-aw/pkg/workflow" - "github.com/spf13/cobra" - "gopkg.in/yaml.v3" -) - -var campaignLog = logger.New("cli:campaigns") - -// CampaignSpec defines a first-class campaign configuration loaded from -// YAML frontmatter in Markdown files. -// -// Files are discovered from the local repository under: -// -// campaigns/*.campaign.md -// -// This provides a thin, declarative layer on top of existing agentic -// workflows and repo-memory conventions. -type CampaignSpec struct { - ID string `yaml:"id" json:"id" console:"header:ID"` - Name string `yaml:"name" json:"name" console:"header:Name"` - Description string `yaml:"description,omitempty" json:"description,omitempty" console:"header:Description,omitempty"` - - // Version is an optional spec version string (for example: v1). - // When omitted, it defaults to v1 during validation. - Version string `yaml:"version,omitempty" json:"version,omitempty" console:"header:Version,omitempty"` - - // Workflows associates this campaign with one or more workflow IDs - // (basename of the Markdown file without .md). - Workflows []string `yaml:"workflows,omitempty" json:"workflows,omitempty" console:"header:Workflows,omitempty"` - - // MemoryPaths documents where this campaign writes its repo-memory - // (for example: memory/campaigns/incident-*/**). - MemoryPaths []string `yaml:"memory_paths,omitempty" json:"memory_paths,omitempty" console:"header:Memory Paths,omitempty"` - - // MetricsGlob is an optional glob (relative to the repository root) - // used to locate JSON metrics snapshots stored in the - // memory/campaigns branch. When set, `gh aw campaign status` will - // attempt to read the latest matching metrics file and surface a few - // key fields. - MetricsGlob string `yaml:"metrics_glob,omitempty" json:"metrics_glob,omitempty" console:"header:Metrics Glob,omitempty"` - - // Owners lists the primary human owners for this campaign. - Owners []string `yaml:"owners,omitempty" json:"owners,omitempty" console:"header:Owners,omitempty"` - - // ExecutiveSponsors lists executive stakeholders or sponsors who are - // accountable for the outcome of this campaign. - ExecutiveSponsors []string `yaml:"executive_sponsors,omitempty" json:"executive_sponsors,omitempty" console:"header:Executive Sponsors,omitempty"` - - // RiskLevel is an optional free-form field (e.g. low/medium/high). - RiskLevel string `yaml:"risk_level,omitempty" json:"risk_level,omitempty" console:"header:Risk Level,omitempty"` - - // TrackerLabel describes the label used to associate issues/PRs with - // this campaign (for example: campaign:incident-response). - TrackerLabel string `yaml:"tracker_label,omitempty" json:"tracker_label,omitempty" console:"header:Tracker Label,omitempty"` - - // State describes the lifecycle stage of the campaign definition. - // Valid values are: planned, active, paused, completed, archived. - State string `yaml:"state,omitempty" json:"state,omitempty" console:"header:State,omitempty"` - - // Tags provide free-form categorization for reporting (for example: - // security, modernization, rollout). - Tags []string `yaml:"tags,omitempty" json:"tags,omitempty" console:"header:Tags,omitempty"` - - // AllowedSafeOutputs documents which safe-outputs operations this - // campaign is expected to use (for example: create-issue, - // create-pull-request). This is currently informational but can be - // enforced by validation in the future. - AllowedSafeOutputs []string `yaml:"allowed_safe_outputs,omitempty" json:"allowed_safe_outputs,omitempty" console:"header:Allowed Safe Outputs,omitempty"` - - // ApprovalPolicy describes high-level approval expectations for this - // campaign (for example: number of approvals and required roles). - ApprovalPolicy *CampaignApprovalPolicy `yaml:"approval_policy,omitempty" json:"approval_policy,omitempty"` - - // ConfigPath is populated at load time with the relative path of - // the YAML file on disk, to help users locate definitions. - ConfigPath string `yaml:"-" json:"config_path" console:"header:Config Path"` -} - -// CampaignApprovalPolicy captures basic approval expectations for a -// campaign. It is intentionally lightweight and advisory; enforcement -// is left to workflows and organizational process. -type CampaignApprovalPolicy struct { - RequiredApprovals int `yaml:"required_approvals,omitempty" json:"required_approvals,omitempty"` - RequiredRoles []string `yaml:"required_roles,omitempty" json:"required_roles,omitempty"` - ChangeControl bool `yaml:"change_control,omitempty" json:"change_control,omitempty"` -} - -// loadCampaignSpecs scans the repository for campaign spec files and returns -// a slice of CampaignSpec. If the campaigns directory does not exist, it -// returns an empty slice and no error. -func loadCampaignSpecs(rootDir string) ([]CampaignSpec, error) { - campaignLog.Printf("Loading campaign specs from rootDir=%s", rootDir) - - campaignsDir := filepath.Join(rootDir, "campaigns") - entries, err := os.ReadDir(campaignsDir) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - campaignLog.Print("No campaigns directory found; returning empty list") - return []CampaignSpec{}, nil - } - return nil, fmt.Errorf("failed to read campaigns directory '%s': %w", campaignsDir, err) - } - - var specs []CampaignSpec - - for _, entry := range entries { - if entry.IsDir() { - continue - } - - name := entry.Name() - if !strings.HasSuffix(name, ".campaign.md") { - continue - } - - fullPath := filepath.Join(campaignsDir, name) - campaignLog.Printf("Found campaign spec file: %s", fullPath) - - data, err := os.ReadFile(fullPath) - if err != nil { - return nil, fmt.Errorf("failed to read campaign spec '%s': %w", fullPath, err) - } - - content := string(data) - lines := strings.Split(content, "\n") - if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { - return nil, fmt.Errorf("campaign spec '%s' must start with YAML frontmatter delimited by '---'", filepath.ToSlash(filepath.Join("campaigns", name))) - } - - endIndex := -1 - for i := 1; i < len(lines); i++ { - if strings.TrimSpace(lines[i]) == "---" { - endIndex = i - break - } - } - - if endIndex == -1 { - return nil, fmt.Errorf("campaign spec '%s' is missing closing '---' for YAML frontmatter", filepath.ToSlash(filepath.Join("campaigns", name))) - } - - frontmatterLines := lines[1:endIndex] - frontmatter := strings.Join(frontmatterLines, "\n") - - var spec CampaignSpec - if err := yaml.Unmarshal([]byte(frontmatter), &spec); err != nil { - return nil, fmt.Errorf("failed to parse campaign spec frontmatter '%s': %w", fullPath, err) - } - - if strings.TrimSpace(spec.ID) == "" { - base := strings.TrimSuffix(name, ".campaign.md") - spec.ID = base - } - - if strings.TrimSpace(spec.Name) == "" { - spec.Name = spec.ID - } - - spec.ConfigPath = filepath.ToSlash(filepath.Join("campaigns", name)) - specs = append(specs, spec) - } - - campaignLog.Printf("Loaded %d campaign specs", len(specs)) - return specs, nil -} - -// filterCampaignSpecs filters campaigns by a simple substring match on ID or -// Name (case-insensitive). When pattern is empty, all campaigns are returned. -func filterCampaignSpecs(specs []CampaignSpec, pattern string) []CampaignSpec { - if pattern == "" { - return specs - } - - var filtered []CampaignSpec - lowerPattern := strings.ToLower(pattern) - - for _, spec := range specs { - if strings.Contains(strings.ToLower(spec.ID), lowerPattern) || strings.Contains(strings.ToLower(spec.Name), lowerPattern) { - filtered = append(filtered, spec) - } - } - - return filtered -} - -// validateCampaignSpec performs lightweight semantic validation of a -// single CampaignSpec and returns a slice of human-readable problems. -// -// It is intentionally conservative – it does not fail on every -// possible issue, but focuses on the most important invariants for -// enterprise usage. -func validateCampaignSpec(spec *CampaignSpec) []string { - var problems []string - - trimmedID := strings.TrimSpace(spec.ID) - if trimmedID == "" { - problems = append(problems, "id is required and must be non-empty") - } else { - // Enforce a simple, URL-safe pattern for IDs - for _, ch := range trimmedID { - if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' { - continue - } - problems = append(problems, "id must use only lowercase letters, digits, and hyphens ("+trimmedID+")") - break - } - } - - if strings.TrimSpace(spec.Name) == "" { - problems = append(problems, "name should be provided (falls back to id, but explicit names are recommended)") - } - - if len(spec.Workflows) == 0 { - problems = append(problems, "workflows should list at least one workflow implementing this campaign") - } - - if strings.TrimSpace(spec.TrackerLabel) == "" { - problems = append(problems, "tracker_label should be set to link issues and PRs to this campaign") - } else if !strings.Contains(spec.TrackerLabel, ":") { - problems = append(problems, "tracker_label should follow a namespaced pattern (for example: campaign:security-q1-2025)") - } - - // Normalize and validate version/state when present. - if strings.TrimSpace(spec.Version) == "" { - // Default version for v1 specs when omitted. - spec.Version = "v1" - } - - if spec.State != "" { - switch spec.State { - case "planned", "active", "paused", "completed", "archived": - // valid - default: - problems = append(problems, "state must be one of: planned, active, paused, completed, archived") - } - } - - return problems -} - -// CampaignRuntimeStatus represents the live status of a campaign, including -// compiled workflow state and basic issue/PR counts derived from the tracker -// label. -type CampaignRuntimeStatus struct { - ID string `json:"id" console:"header:ID"` - Name string `json:"name" console:"header:Name"` - TrackerLabel string `json:"tracker_label,omitempty" console:"header:Tracker Label,omitempty"` - Workflows []string `json:"workflows,omitempty" console:"header:Workflows,omitempty"` - Compiled string `json:"compiled" console:"header:Compiled"` - - IssuesOpen int `json:"issues_open,omitempty" console:"header:Issues Open,omitempty"` - IssuesClosed int `json:"issues_closed,omitempty" console:"header:Issues Closed,omitempty"` - PRsOpen int `json:"prs_open,omitempty" console:"header:PRs Open,omitempty"` - PRsMerged int `json:"prs_merged,omitempty" console:"header:PRs Merged,omitempty"` - - // Optional metrics from repo-memory (when MetricsGlob is set and a - // matching JSON snapshot is found on the memory/campaigns branch). - MetricsTasksTotal int `json:"metrics_tasks_total,omitempty" console:"header:Tasks Total,omitempty"` - MetricsTasksCompleted int `json:"metrics_tasks_completed,omitempty" console:"header:Tasks Completed,omitempty"` - MetricsVelocityPerDay float64 `json:"metrics_velocity_per_day,omitempty" console:"header:Velocity/Day,omitempty"` - MetricsEstimatedCompletion string `json:"metrics_estimated_completion,omitempty" console:"header:ETA,omitempty"` -} - -// CampaignMetricsSnapshot describes the JSON structure used by campaign -// metrics snapshots written into the memory/campaigns branch. -// -// This mirrors the example in the campaigns guide: -// -// { -// "date": "2025-01-16", -// "campaign_id": "security-q1-2025", -// "tasks_total": 200, -// "tasks_completed": 15, -// "tasks_in_progress": 30, -// "tasks_blocked": 5, -// "velocity_per_day": 7.5, -// "estimated_completion": "2025-02-12" -// } -type CampaignMetricsSnapshot struct { - Date string `json:"date,omitempty"` - CampaignID string `json:"campaign_id,omitempty"` - TasksTotal int `json:"tasks_total,omitempty"` - TasksCompleted int `json:"tasks_completed,omitempty"` - TasksInProgress int `json:"tasks_in_progress,omitempty"` - TasksBlocked int `json:"tasks_blocked,omitempty"` - VelocityPerDay float64 `json:"velocity_per_day,omitempty"` - EstimatedCompletion string `json:"estimated_completion,omitempty"` -} - -// computeCompiledStateForCampaign inspects the compiled state of all -// workflows referenced by a campaign. It returns: -// -// "Yes" - all referenced workflows exist and are compiled & up-to-date -// "No" - at least one workflow exists but is missing a lock file or is stale -// "Missing workflow" - at least one referenced workflow markdown file does not exist -// "N/A" - campaign does not reference any workflows -func computeCompiledStateForCampaign(spec CampaignSpec) string { - if len(spec.Workflows) == 0 { - return "N/A" - } - - workflowsDir := getWorkflowsDir() - compiledAll := true - missingAny := false - - for _, wf := range spec.Workflows { - mdPath := filepath.Join(workflowsDir, wf+".md") - lockPath := mdPath + ".lock.yml" - - mdInfo, err := os.Stat(mdPath) - if err != nil { - campaignLog.Printf("Workflow markdown not found for campaign '%s': %s", spec.ID, mdPath) - missingAny = true - compiledAll = false - continue - } - - lockInfo, err := os.Stat(lockPath) - if err != nil { - campaignLog.Printf("Lock file not found for workflow '%s' in campaign '%s': %s", wf, spec.ID, lockPath) - compiledAll = false - continue - } - - if mdInfo.ModTime().After(lockInfo.ModTime()) { - campaignLog.Printf("Lock file out of date for workflow '%s' in campaign '%s'", wf, spec.ID) - compiledAll = false - } - } - - if missingAny { - return "Missing workflow" - } - if compiledAll { - return "Yes" - } - return "No" -} - -// ghIssueOrPRState is a tiny helper struct for decoding gh issue/pr list -// output when using --json state. -type ghIssueOrPRState struct { - State string `json:"state"` -} - -// fetchCampaignItemCounts uses gh CLI (via workflow.ExecGH) to fetch basic -// counts of issues and pull requests tagged with the given tracker label. -// -// If trackerLabel is empty or any errors occur, it falls back to zeros and -// logs at debug level instead of failing the command. -func fetchCampaignItemCounts(trackerLabel string) (issuesOpen, issuesClosed, prsOpen, prsMerged int) { - if strings.TrimSpace(trackerLabel) == "" { - return 0, 0, 0, 0 - } - - // Issues - issueCmd := workflow.ExecGH("issue", "list", "--label", trackerLabel, "--state", "all", "--json", "state") - issueOutput, err := issueCmd.Output() - if err == nil && len(issueOutput) > 0 && json.Valid(issueOutput) { - var issues []ghIssueOrPRState - if err := json.Unmarshal(issueOutput, &issues); err == nil { - for _, it := range issues { - state := strings.ToLower(strings.TrimSpace(it.State)) - if state == "open" { - issuesOpen++ - } else { - issuesClosed++ - } - } - } else if err != nil { - campaignLog.Printf("Failed to decode issue list for tracker label '%s': %v", trackerLabel, err) - } - } else if err != nil { - campaignLog.Printf("Failed to fetch issues for tracker label '%s': %v", trackerLabel, err) - } - - // Pull requests - prCmd := workflow.ExecGH("pr", "list", "--label", trackerLabel, "--state", "all", "--json", "state") - prOutput, err := prCmd.Output() - if err == nil && len(prOutput) > 0 && json.Valid(prOutput) { - var prs []ghIssueOrPRState - if err := json.Unmarshal(prOutput, &prs); err == nil { - for _, it := range prs { - state := strings.ToLower(strings.TrimSpace(it.State)) - switch state { - case "open": - prsOpen++ - case "merged": - prsMerged++ - } - } - } else if err != nil { - campaignLog.Printf("Failed to decode PR list for tracker label '%s': %v", trackerLabel, err) - } - } else if err != nil { - campaignLog.Printf("Failed to fetch PRs for tracker label '%s': %v", trackerLabel, err) - } - - return issuesOpen, issuesClosed, prsOpen, prsMerged -} - -// fetchCampaignMetricsFromRepoMemory attempts to load the latest JSON -// metrics snapshot matching the provided glob from the -// memory/campaigns branch. It is best-effort: errors are logged and -// treated as "no metrics" rather than failing the command. -func fetchCampaignMetricsFromRepoMemory(metricsGlob string) (*CampaignMetricsSnapshot, error) { - if strings.TrimSpace(metricsGlob) == "" { - return nil, nil - } - - // List all files in the memory/campaigns branch - cmd := exec.Command("git", "ls-tree", "-r", "--name-only", "memory/campaigns") - output, err := cmd.Output() - if err != nil { - campaignLog.Printf("Unable to list repo-memory branch for metrics (memory/campaigns): %v", err) - return nil, nil - } - - scanner := bufio.NewScanner(bytes.NewReader(output)) - var matches []string - for scanner.Scan() { - pathStr := strings.TrimSpace(scanner.Text()) - if pathStr == "" { - continue - } - matched, err := path.Match(metricsGlob, pathStr) - if err != nil { - campaignLog.Printf("Invalid metrics_glob '%s': %v", metricsGlob, err) - return nil, nil - } - if matched { - matches = append(matches, pathStr) - } - } - - if len(matches) == 0 { - return nil, nil - } - - // Pick the lexicographically last match as the "latest" snapshot. - latest := matches[0] - for _, m := range matches[1:] { - if m > latest { - latest = m - } - } - - showArg := fmt.Sprintf("memory/campaigns:%s", latest) - showCmd := exec.Command("git", "show", showArg) - fileData, err := showCmd.Output() - if err != nil { - campaignLog.Printf("Failed to read metrics file '%s' from memory/campaigns: %v", latest, err) - return nil, nil - } - - var snapshot CampaignMetricsSnapshot - if err := json.Unmarshal(fileData, &snapshot); err != nil { - campaignLog.Printf("Failed to decode metrics JSON from '%s': %v", latest, err) - return nil, nil - } - - return &snapshot, nil -} - -// runCampaignStatus is the implementation for the `gh aw campaign` command. -// It loads campaign specs from the local repository and renders them either -// as a console table or JSON. -func runCampaignStatus(pattern string, jsonOutput bool) error { - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current working directory: %w", err) - } - - specs, err := loadCampaignSpecs(cwd) - if err != nil { - return err - } - - specs = filterCampaignSpecs(specs, pattern) - - if jsonOutput { - jsonBytes, err := json.MarshalIndent(specs, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal campaigns as JSON: %w", err) - } - fmt.Println(string(jsonBytes)) - return nil - } - - if len(specs) == 0 { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No campaign specs found. Add files under 'campaigns/*.campaign.md' to define campaigns.")) - return nil - } - - // Render table to stdout for human-friendly output - output := console.RenderStruct(specs) - fmt.Print(output) - return nil -} - -// runCampaignRuntimeStatus builds a higher-level view of campaign specs with -// live information derived from GitHub (issue/PR counts) and compiled -// workflow state. -func runCampaignRuntimeStatus(pattern string, jsonOutput bool) error { - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current working directory: %w", err) - } - - specs, err := loadCampaignSpecs(cwd) - if err != nil { - return err - } - - specs = filterCampaignSpecs(specs, pattern) - if len(specs) == 0 { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No campaign specs found. Add files under 'campaigns/*.campaign.md' to define campaigns.")) - return nil - } - - var statuses []CampaignRuntimeStatus - for _, spec := range specs { - compiled := computeCompiledStateForCampaign(spec) - issuesOpen, issuesClosed, prsOpen, prsMerged := fetchCampaignItemCounts(spec.TrackerLabel) - - var metricsTasksTotal, metricsTasksCompleted int - var metricsVelocity float64 - var metricsETA string - if strings.TrimSpace(spec.MetricsGlob) != "" { - if snapshot, err := fetchCampaignMetricsFromRepoMemory(spec.MetricsGlob); err != nil { - campaignLog.Printf("Failed to fetch metrics for campaign '%s': %v", spec.ID, err) - } else if snapshot != nil { - metricsTasksTotal = snapshot.TasksTotal - metricsTasksCompleted = snapshot.TasksCompleted - metricsVelocity = snapshot.VelocityPerDay - metricsETA = snapshot.EstimatedCompletion - } - } - - statuses = append(statuses, CampaignRuntimeStatus{ - ID: spec.ID, - Name: spec.Name, - TrackerLabel: spec.TrackerLabel, - Workflows: spec.Workflows, - Compiled: compiled, - IssuesOpen: issuesOpen, - IssuesClosed: issuesClosed, - PRsOpen: prsOpen, - PRsMerged: prsMerged, - MetricsTasksTotal: metricsTasksTotal, - MetricsTasksCompleted: metricsTasksCompleted, - MetricsVelocityPerDay: metricsVelocity, - MetricsEstimatedCompletion: metricsETA, - }) - } - - if jsonOutput { - jsonBytes, err := json.MarshalIndent(statuses, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal campaign status as JSON: %w", err) - } - fmt.Println(string(jsonBytes)) - return nil - } - - output := console.RenderStruct(statuses) - fmt.Print(output) - return nil -} - -// runCampaignValidate loads campaign specs and validates them, returning -// a structured report. When strict is true, the command will exit with -// a non-zero status if any problems are found. -type CampaignValidationResult struct { - ID string `json:"id" console:"header:ID"` - Name string `json:"name" console:"header:Name"` - ConfigPath string `json:"config_path" console:"header:Config Path"` - Problems []string `json:"problems,omitempty" console:"header:Problems,omitempty"` -} - -func runCampaignValidate(pattern string, jsonOutput bool, strict bool) error { - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current working directory: %w", err) - } - - specs, err := loadCampaignSpecs(cwd) - if err != nil { - return err - } - - specs = filterCampaignSpecs(specs, pattern) - if len(specs) == 0 { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No campaign specs found. Add files under 'campaigns/*.campaign.md' to define campaigns.")) - return nil - } - - var results []CampaignValidationResult - var totalProblems int - - for i := range specs { - problems := validateCampaignSpec(&specs[i]) - if len(problems) > 0 { - campaignLog.Printf("Validation problems for campaign '%s' (%s): %v", specs[i].ID, specs[i].ConfigPath, problems) - } - - results = append(results, CampaignValidationResult{ - ID: specs[i].ID, - Name: specs[i].Name, - ConfigPath: specs[i].ConfigPath, - Problems: problems, - }) - totalProblems += len(problems) - } - - if jsonOutput { - jsonBytes, err := json.MarshalIndent(results, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal campaign validation results as JSON: %w", err) - } - fmt.Println(string(jsonBytes)) - } else { - output := console.RenderStruct(results) - fmt.Print(output) - } - - if strict && totalProblems > 0 { - return fmt.Errorf("campaign validation failed: %d problem(s) found across %d campaign(s)", totalProblems, len(results)) - } - - return nil -} - -// createCampaignSpecSkeleton creates a new campaign spec YAML file under -// campaigns/ with a minimal skeleton definition. It returns the -// relative file path created. -func createCampaignSpecSkeleton(rootDir, id string, force bool) (string, error) { - id = strings.TrimSpace(id) - if id == "" { - return "", fmt.Errorf("campaign id is required") - } - - // Reuse the same simple rules as validateCampaignSpec for IDs - for _, ch := range id { - if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' { - continue - } - return "", fmt.Errorf("campaign id must use only lowercase letters, digits, and hyphens (%s)", id) - } - - campaignsDir := filepath.Join(rootDir, "campaigns") - if err := os.MkdirAll(campaignsDir, 0o755); err != nil { - return "", fmt.Errorf("failed to create campaigns directory: %w", err) - } - - fileName := id + ".campaign.md" - fullPath := filepath.Join(campaignsDir, fileName) - relPath := filepath.ToSlash(filepath.Join("campaigns", fileName)) - - if _, err := os.Stat(fullPath); err == nil && !force { - return "", fmt.Errorf("campaign spec already exists at %s (use --force to overwrite)", relPath) - } - - name := strings.ReplaceAll(id, "-", " ") - if name != "" { - first := strings.ToUpper(name[:1]) - if len(name) > 1 { - name = first + name[1:] - } else { - name = first - } - } - - spec := CampaignSpec{ - ID: id, - Name: name, - Version: "v1", - State: "planned", - TrackerLabel: "campaign:" + id, - } - - data, err := yaml.Marshal(&spec) - if err != nil { - return "", fmt.Errorf("failed to marshal campaign spec: %w", err) - } - - var buf bytes.Buffer - buf.WriteString("---\n") - buf.Write(data) - buf.WriteString("---\n\n") - if name != "" { - buf.WriteString("# " + name + "\n\n") - } else { - buf.WriteString("# " + id + "\n\n") - } - buf.WriteString("Describe this campaign's goals, guardrails, stakeholders, and playbook.\n") - - if err := os.WriteFile(fullPath, buf.Bytes(), 0o644); err != nil { - return "", fmt.Errorf("failed to write campaign spec file '%s': %w", relPath, err) - } - - return relPath, nil -} - -// NewCampaignCommand creates the `gh aw campaign` command that surfaces -// first-class campaign definitions from YAML files. -func NewCampaignCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "campaign [filter]", - Short: "Inspect first-class campaign definitions from campaigns/*.campaign.md", - Long: `List and inspect first-class campaign definitions declared in YAML files. - -Campaigns are defined using Markdown files with YAML frontmatter under the local repository: - - campaigns/*.campaign.md - -Each file describes a campaign pattern (ID, name, owners, associated -workflows, repo-memory paths, and risk level). This command provides a -single place to see all campaigns configured for the repo. - -Examples: - ` + constants.CLIExtensionPrefix + ` campaign # List all campaigns - ` + constants.CLIExtensionPrefix + ` campaign security # Filter campaigns by ID or name - ` + constants.CLIExtensionPrefix + ` campaign --json # Output campaign definitions as JSON -`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - var pattern string - if len(args) > 0 { - pattern = args[0] - } - - jsonOutput, _ := cmd.Flags().GetBool("json") - return runCampaignStatus(pattern, jsonOutput) - }, - } - - cmd.Flags().Bool("json", false, "Output campaign definitions in JSON format") - - // Subcommand: campaign status - statusCmd := &cobra.Command{ - Use: "status [filter]", - Short: "Show live status for campaigns (compiled workflows, issues, PRs)", - Long: `Show live status for campaigns, including whether referenced workflows -are compiled and basic issue/PR counts derived from the campaign's -tracker label. - -Examples: - ` + constants.CLIExtensionPrefix + ` campaign status # Status for all campaigns - ` + constants.CLIExtensionPrefix + ` campaign status security # Filter by ID or name - ` + constants.CLIExtensionPrefix + ` campaign status --json # JSON status output -`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - var pattern string - if len(args) > 0 { - pattern = args[0] - } - - jsonOutput, _ := cmd.Flags().GetBool("json") - return runCampaignRuntimeStatus(pattern, jsonOutput) - }, - } - - statusCmd.Flags().Bool("json", false, "Output campaign status in JSON format") - cmd.AddCommand(statusCmd) - - // Subcommand: campaign new - newCmd := &cobra.Command{ - Use: "new ", - Short: "Create a new markdown campaign spec under campaigns/", - Long: `Create a new campaign spec markdown file under campaigns/. - -The file will be created as campaigns/.campaign.md with YAML -frontmatter (id, name, version, state, tracker_label) followed by a -markdown body. You can then -update owners, workflows, memory paths, metrics_glob, and governance -fields to match your initiative. - -Examples: - ` + constants.CLIExtensionPrefix + ` campaign new security-q1-2025 - ` + constants.CLIExtensionPrefix + ` campaign new modernization-winter2025 --force`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - id := args[0] - force, _ := cmd.Flags().GetBool("force") - - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current working directory: %w", err) - } - - path, err := createCampaignSpecSkeleton(cwd, id, force) - if err != nil { - return err - } - - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Created campaign spec at "+path)) - return nil - }, - } - - newCmd.Flags().Bool("force", false, "Overwrite existing spec file if it already exists") - cmd.AddCommand(newCmd) - - // Subcommand: campaign validate - validateCmd := &cobra.Command{ - Use: "validate [filter]", - Short: "Validate campaign spec files for common issues", - Long: `Validate campaign spec files under campaigns/*.campaign.md. - -This command performs lightweight semantic validation of campaign -definitions (IDs, tracker labels, workflows, lifecycle state, and -other key fields). By default it exits with a non-zero status when -problems are found. - -Examples: - ` + constants.CLIExtensionPrefix + ` campaign validate # Validate all campaigns - ` + constants.CLIExtensionPrefix + ` campaign validate security # Filter by ID or name - ` + constants.CLIExtensionPrefix + ` campaign validate --json # JSON validation report - ` + constants.CLIExtensionPrefix + ` campaign validate --no-strict # Report problems without failing`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - var pattern string - if len(args) > 0 { - pattern = args[0] - } - - jsonOutput, _ := cmd.Flags().GetBool("json") - strict, _ := cmd.Flags().GetBool("strict") - return runCampaignValidate(pattern, jsonOutput, strict) - }, - } - - validateCmd.Flags().Bool("json", false, "Output campaign validation results in JSON format") - validateCmd.Flags().Bool("strict", true, "Exit with non-zero status if any problems are found") - cmd.AddCommand(validateCmd) - - return cmd -}