diff --git a/.github/workflows/issue-monster.lock.yml b/.github/workflows/issue-monster.lock.yml index 6957092d2f5..bd566701d5d 100644 --- a/.github/workflows/issue-monster.lock.yml +++ b/.github/workflows/issue-monster.lock.yml @@ -87,7 +87,7 @@ on: # const { owner, repo } = context.repo; # const MAX_ISSUES_WITH_BODY_CONTEXT = 8; # const BODY_SNIPPET_MAX_LENGTH = 600; - # + # # try { # // Check for recent rate-limited PRs to avoid scheduling more work during rate limiting # core.info('Checking for recent rate-limited PRs...'); @@ -95,7 +95,7 @@ on: # rateLimitCheckDate.setHours(rateLimitCheckDate.getHours() - 1); // Check last hour # // Format as YYYY-MM-DDTHH:MM:SS for GitHub search API # const rateLimitCheckISO = rateLimitCheckDate.toISOString().split('.')[0] + 'Z'; - # + # # const recentPRsQuery = `is:pr author:app/copilot-swe-agent created:>${rateLimitCheckISO} repo:${owner}/${repo}`; # const recentPRsResponse = await github.rest.search.issuesAndPullRequests({ # q: recentPRsQuery, @@ -103,9 +103,9 @@ on: # sort: 'created', # order: 'desc' # }); - # + # # core.info(`Found ${recentPRsResponse.data.total_count} recent Copilot PRs to check for rate limiting`); - # + # # // Check if any recent PRs have rate limit indicators # let rateLimitDetected = false; # for (const pr of recentPRsResponse.data.items) { @@ -127,16 +127,16 @@ on: # } # } # `; - # + # # const prTimelineResult = await github.graphql(prTimelineQuery, { # owner, # repo, # number: pr.number # }); - # + # # const comments = prTimelineResult?.repository?.pullRequest?.timelineItems?.nodes || []; # const rateLimitPattern = /rate limit|API rate limit|secondary rate limit|abuse detection|\b429\b|too many requests/i; - # + # # for (const comment of comments) { # if (comment.body && rateLimitPattern.test(comment.body)) { # core.warning(`Rate limiting detected in PR #${pr.number}: ${comment.body.substring(0, 200)}`); @@ -144,13 +144,13 @@ on: # break; # } # } - # + # # if (rateLimitDetected) break; # } catch (error) { # core.warning(`Could not check PR #${pr.number} for rate limiting: ${error.message}`); # } # } - # + # # if (rateLimitDetected) { # core.warning('🛑 Rate limiting detected in recent PRs. Skipping issue assignment to prevent further rate limit issues.'); # core.setOutput('issue_count', 0); @@ -160,9 +160,9 @@ on: # core.setOutput('has_issues', 'false'); # return; # } - # + # # core.info('✓ No rate limiting detected. Proceeding with issue search.'); - # + # # // Labels that indicate an issue should NOT be auto-assigned # const excludeLabels = [ # 'wontfix', @@ -178,7 +178,7 @@ on: # 'no-bot', # 'no-campaign' # ]; - # + # # // Labels that indicate an issue is a GOOD candidate for auto-assignment # const priorityLabels = [ # 'community', @@ -193,7 +193,7 @@ on: # 'performance', # 'security' # ]; - # + # # // Search for open issues with "cookie" label and without excluded labels # // The "cookie" label indicates issues that are approved work queue items from automated workflows # const query = `is:issue is:open repo:${owner}/${repo} label:cookie -label:"${excludeLabels.join('" -label:"')}"`; @@ -205,7 +205,7 @@ on: # order: 'desc' # }); # core.info(`Found ${response.data.total_count} total issues matching basic criteria`); - # + # # // Fetch full details for each issue to get labels, assignees, sub-issues, and linked PRs # // Track integrity-filtered issues to emit a diagnostic summary # const integrityFilteredIssues = []; @@ -235,7 +235,7 @@ on: # } # return null; # } - # + # # // Check if this issue has sub-issues and linked PRs using GraphQL # let subIssuesCount = 0; # let linkedPRs = []; @@ -273,9 +273,9 @@ on: # repo, # number: issue.number # }); - # + # # subIssuesCount = issueDetailsResult?.repository?.issue?.subIssues?.totalCount || 0; - # + # # // Extract linked PRs from timeline # const timelineItems = issueDetailsResult?.repository?.issue?.timelineItems?.nodes || []; # linkedPRs = timelineItems @@ -286,13 +286,13 @@ on: # isDraft: item.source.isDraft, # author: item.source.author?.login # })); - # + # # core.info(`Issue #${issue.number} has ${linkedPRs.length} linked PR(s)`); # } catch (error) { # // If GraphQL query fails, continue with defaults # core.warning(`Could not check details for #${issue.number}: ${error.message}`); # } - # + # # return { # ...fullIssue.data, # subIssuesCount, @@ -300,12 +300,12 @@ on: # }; # }) # )).filter(Boolean); // Remove null entries (integrity-filtered or otherwise skipped) - # + # # // Emit diagnostic summary for integrity-filtered issues # if (integrityFilteredIssues.length > 0) { # core.warning(`🛡️ Integrity filter diagnostic: ${integrityFilteredIssues.length} issue(s) were skipped due to integrity policy: #${integrityFilteredIssues.join(', #')}. These issues will be excluded from this run.`); # } - # + # # // Filter and score issues # const scoredIssues = issuesWithDetails # .filter(issue => { @@ -314,34 +314,34 @@ on: # core.info(`Skipping #${issue.number}: already has assignees`); # return false; # } - # + # # // Exclude issues with excluded labels (double check) # const issueLabels = issue.labels.map(l => l.name.toLowerCase()); # if (issueLabels.some(label => excludeLabels.map(l => l.toLowerCase()).includes(label))) { # core.info(`Skipping #${issue.number}: has excluded label`); # return false; # } - # + # # // Exclude issues with campaign labels (campaign:*) # // Campaign items are managed by campaign orchestrators # if (issueLabels.some(label => label.startsWith('campaign:'))) { # core.info(`Skipping #${issue.number}: has campaign label (managed by campaign orchestrator)`); # return false; # } - # + # # // Exclude issues that have sub-issues (parent/organizing issues) # if (issue.subIssuesCount > 0) { # core.info(`Skipping #${issue.number}: has ${issue.subIssuesCount} sub-issue(s) - parent issues are used for organizing, not tasks`); # return false; # } - # + # # // Exclude issues with closed PRs (treat as complete) # const closedPRs = issue.linkedPRs?.filter(pr => pr.state === 'CLOSED' || pr.state === 'MERGED') || []; # if (closedPRs.length > 0) { # core.info(`Skipping #${issue.number}: has ${closedPRs.length} closed/merged PR(s) - treating as complete`); # return false; # } - # + # # // Exclude issues with open PRs from Copilot coding agent # const openCopilotPRs = issue.linkedPRs?.filter(pr => # pr.state === 'OPEN' && @@ -351,13 +351,13 @@ on: # core.info(`Skipping #${issue.number}: has ${openCopilotPRs.length} open PR(s) from Copilot - already being worked on`); # return false; # } - # + # # return true; # }) # .map(issue => { # const issueLabels = issue.labels.map(l => l.name.toLowerCase()); # let score = 0; - # + # # // Score based on priority labels (higher score = higher priority) # // Community issues always get the highest priority — these are # // requests from external contributors and should be addressed first. @@ -385,16 +385,16 @@ on: # if (issueLabels.includes('tech-debt') || issueLabels.includes('refactoring')) { # score += 20; # } - # + # # // Bonus for issues with clear labels (any priority label) # if (issueLabels.some(label => priorityLabels.map(l => l.toLowerCase()).includes(label))) { # score += 10; # } - # + # # // Age bonus: older issues get slight priority (days old / 10) # const ageInDays = Math.floor((Date.now() - new Date(issue.created_at)) / (1000 * 60 * 60 * 24)); # score += Math.min(ageInDays / 10, 20); // Cap age bonus at 20 points - # + # # return { # number: issue.number, # title: issue.title, @@ -405,13 +405,13 @@ on: # }; # }) # .sort((a, b) => b.score - a.score); // Sort by score descending - # + # # // Format output # const issueList = scoredIssues.map(i => { # const labelStr = i.labels.length > 0 ? ` [${i.labels.join(', ')}]` : ''; # return `#${i.number}: ${i.title}${labelStr} (score: ${i.score.toFixed(1)})`; # }).join('\n'); - # + # # // Pre-fetch compact body context for top candidates so the agent can # // triage without extra reads in most runs. # const issueContext = scoredIssues.slice(0, MAX_ISSUES_WITH_BODY_CONTEXT).map(i => { @@ -420,19 +420,19 @@ on: # const labelStr = i.labels.length > 0 ? i.labels.join(', ') : 'none'; # return `#${i.number} | score=${i.score.toFixed(1)} | labels=${labelStr}\nTitle: ${i.title}\nBody: ${bodySnippet || '(no body)'}`; # }).join('\n\n---\n\n'); - # + # # const issueNumbers = scoredIssues.map(i => i.number).join(','); - # + # # core.info(`Total candidate issues after filtering: ${scoredIssues.length}`); # if (scoredIssues.length > 0) { # core.info(`Top candidates:\n${issueList.split('\n').slice(0, 10).join('\n')}`); # } - # + # # core.setOutput('issue_count', scoredIssues.length); # core.setOutput('issue_numbers', issueNumbers); # core.setOutput('issue_list', issueList); # core.setOutput('issue_context', issueContext); - # + # # if (scoredIssues.length === 0) { # core.info('🍽️ No suitable candidate issues - the plate is empty!'); # core.setOutput('has_issues', 'false'); diff --git a/pkg/workflow/compiler_draft_test.go b/pkg/workflow/compiler_draft_test.go index f67c41752ef..0eec9e57fcd 100644 --- a/pkg/workflow/compiler_draft_test.go +++ b/pkg/workflow/compiler_draft_test.go @@ -11,6 +11,7 @@ import ( "github.com/github/gh-aw/pkg/stringutil" "github.com/github/gh-aw/pkg/testutil" + "github.com/stretchr/testify/assert" ) func TestPullRequestDraftFilter(t *testing.T) { @@ -539,5 +540,19 @@ func TestCommentOutProcessedFieldsInOnSection(t *testing.T) { } } +func TestCommentOutProcessedFieldsInOnSectionBlankLineInBlock(t *testing.T) { + compiler := NewCompiler() + + result := compiler.commentOutProcessedFieldsInOnSection(`on: + steps: | + echo hello + + echo world + workflow_dispatch:`, map[string]any{}) + + assert.Contains(t, result, "\n#\n") + assert.NotContains(t, result, "# \n") +} + // containsInNonCommentLines checks if a string appears in any non-comment lines // A comment line is one that starts with '#' (after trimming leading whitespace) diff --git a/pkg/workflow/frontmatter_extraction_yaml.go b/pkg/workflow/frontmatter_extraction_yaml.go index 9611e01a4ca..fd51f576b2f 100644 --- a/pkg/workflow/frontmatter_extraction_yaml.go +++ b/pkg/workflow/frontmatter_extraction_yaml.go @@ -34,7 +34,10 @@ func (c *Compiler) indentYAMLLines(yamlContent, indent string) string { if strings.TrimSpace(lines[i]) != "" { result.WriteString("\n" + indent + lines[i]) } else { - result.WriteString("\n" + lines[i]) + // Emit a bare newline for blank/whitespace-only lines so we don't + // carry the surrounding indentation as trailing whitespace, which + // yamllint flags as trailing-spaces. + result.WriteString("\n") } } @@ -754,6 +757,11 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat } commentedLine := indentation + "# " + trimmed + commentReason + // Blank lines inside multi-line blocks would otherwise become "# " + // with trailing whitespace, which yamllint flags as trailing-spaces. + if trimmed == "" { + commentedLine = strings.TrimRight(commentedLine, " \t") + } result = append(result, commentedLine) } else { result = append(result, line) diff --git a/pkg/workflow/frontmatter_extraction_yaml_test.go b/pkg/workflow/frontmatter_extraction_yaml_test.go index b8e8e66b36d..a0dd34af012 100644 --- a/pkg/workflow/frontmatter_extraction_yaml_test.go +++ b/pkg/workflow/frontmatter_extraction_yaml_test.go @@ -88,10 +88,10 @@ func TestIndentYAMLLines(t *testing.T) { wantOut: "name: test\n runs-on: ubuntu-latest\n steps:", }, { - name: "blank lines are preserved without extra indentation", + name: "blank lines are normalized without trailing whitespace", input: "first: value\n\nsecond: value\n \nthird: value", indent: " ", - wantOut: "first: value\n\n second: value\n \n third: value", + wantOut: "first: value\n\n second: value\n\n third: value", }, }