diff --git a/.github/workflows/create-failing-test-issue.js b/.github/workflows/create-failing-test-issue.js index ec98e014aa4..b5e7e744b46 100644 --- a/.github/workflows/create-failing-test-issue.js +++ b/.github/workflows/create-failing-test-issue.js @@ -57,10 +57,22 @@ function parseCommand(body, defaultSourceUrl = null) { break; default: - return { - success: false, - errorMessage: `Unknown argument '${token}'. Supported arguments are --test, --url, --workflow, and --force-new.`, - }; + if (token.startsWith('--')) { + return { + success: false, + errorMessage: `Unknown argument '${token}'. Supported arguments are --test, --url, --workflow, and --force-new.`, + }; + } + + if (result.testQuery) { + return { + success: false, + errorMessage: 'Positional input is ambiguous. Use /create-issue --test "" [--url ] [--workflow ] [--force-new].', + }; + } + + result.testQuery = token; + break; } } @@ -94,6 +106,27 @@ function parseCommand(body, defaultSourceUrl = null) { }; } +function formatListResponse(resolverOutcome, resultJson) { + if (resolverOutcome === 'failure' && !resultJson) { + return { error: true, message: 'The failing-test resolver failed to run.' }; + } + + const tests = resultJson?.allFailures?.tests?.map(t => t.canonicalTestName ?? t.displayTestName) + ?? resultJson?.diagnostics?.availableFailedTests + ?? []; + + if (tests.length > 0) { + return { + error: false, + message: '**Failed tests found on this PR:**\n\n' + + tests.map(name => `/create-issue ${name}`).join('\n'), + tests, + }; + } + + return { error: false, message: 'No test failures were found. Use `--url` to point to a specific workflow run.' }; +} + function buildIssueSearchQuery(owner, repo, metadataMarker) { const escapedMarker = String(metadataMarker ?? '').replaceAll('"', '\\"'); return `repo:${owner}/${repo} is:issue label:failing-test in:body "${escapedMarker}"`; @@ -111,6 +144,7 @@ function isSupportedSourceUrl(value) { module.exports = { buildIssueSearchQuery, + formatListResponse, isSupportedSourceUrl, parseCommand, }; diff --git a/.github/workflows/create-failing-test-issue.yml b/.github/workflows/create-failing-test-issue.yml index 986566d65a0..28403a82069 100644 --- a/.github/workflows/create-failing-test-issue.yml +++ b/.github/workflows/create-failing-test-issue.yml @@ -59,7 +59,7 @@ jobs: - name: Verify user has write access if: github.event_name == 'issue_comment' id: verify-permission - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | const commentUser = context.payload.comment.user.login; @@ -91,7 +91,7 @@ jobs: - name: Extract command if: success() id: extract-command - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | if (context.eventName === 'workflow_dispatch') { @@ -137,7 +137,7 @@ jobs: - name: Add processing reaction if: success() && github.event_name == 'issue_comment' - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | await github.rest.reactions.createForIssueComment({ @@ -147,9 +147,11 @@ jobs: content: 'eyes' }); - - name: Restore SDK + - name: Setup .NET SDK if: success() - run: ./restore.sh + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 + with: + global-json-file: global.json - name: Resolve failing test details if: success() @@ -179,7 +181,7 @@ jobs: - name: Create or update failing-test issue if: steps.resolve-failure.outcome != 'skipped' - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: RESULT_PATH: ${{ runner.temp }}/failing-test-result.json FORCE_NEW: ${{ steps.extract-command.outputs.force_new }} @@ -232,24 +234,25 @@ jobs: // List-only mode: show available failures + help when no --test was given if (process.env.LIST_ONLY === 'true') { - let body = ''; - - if (hasResultFile) { - const result = JSON.parse(fs.readFileSync(process.env.RESULT_PATH, 'utf8')); - const tests = result.allFailures?.tests?.map(t => t.canonicalTestName ?? t.displayTestName) - ?? result.diagnostics?.availableFailedTests - ?? []; - - if (tests.length > 0) { - body = '**Failed tests found on this PR:**\n\n' - + tests.map(name => `- \`/create-issue ${name}\``).join('\n') - + '\n\n'; - } + const resultJson = hasResultFile + ? JSON.parse(fs.readFileSync(process.env.RESULT_PATH, 'utf8')) + : null; + const listResult = helper.formatListResponse(process.env.RESOLVE_OUTCOME, resultJson); + + if (listResult.error) { + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const message = `❌ ${listResult.message} See the [workflow run](${runUrl}) for details.`; + await postComment(message); + core.setFailed(message); + return; } - if (!body) { - body = 'No test failures were found. Use `--url` to point to a specific workflow run.\n\n'; - } + // Format test names as clickable commands in list mode + let body = listResult.tests?.length > 0 + ? '**Failed tests found on this PR:**\n\n' + + listResult.tests.map(name => `- \`/create-issue ${name}\``).join('\n') + + '\n\n' + : listResult.message + '\n\n'; await postComment(`${body}${helpBlock}`); return; @@ -351,7 +354,7 @@ jobs: - name: Post failure comment on unexpected error if: failure() && steps.extract-command.outcome == 'success' && (steps.verify-permission.outcome == 'success' || steps.verify-permission.outcome == 'skipped') - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: PR_NUMBER: ${{ steps.extract-command.outputs.pr_number }} with: diff --git a/tests/Infrastructure.Tests/WorkflowScripts/CreateFailingTestIssueWorkflowTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/CreateFailingTestIssueWorkflowTests.cs index bf7a3c2a5a2..b0a7cbd595d 100644 --- a/tests/Infrastructure.Tests/WorkflowScripts/CreateFailingTestIssueWorkflowTests.cs +++ b/tests/Infrastructure.Tests/WorkflowScripts/CreateFailingTestIssueWorkflowTests.cs @@ -80,6 +80,25 @@ public async Task ParseCommandUsesTrailingUrlForCompatibilitySyntax() Assert.Equal("https://github.com/microsoft/aspire/actions/runs/123/job/456", result.SourceUrl); } + [Fact] + [RequiresTools(["node"])] + public async Task ParseCommandSupportsPositionalTestNameWithFlags() + { + var result = await InvokeHarnessAsync( + "parseCommand", + new + { + body = "/create-issue Tests.Namespace.Type.Method --force-new", + defaultSourceUrl = "https://github.com/microsoft/aspire/pull/999" + }); + + Assert.True(result.Success); + Assert.Equal("Tests.Namespace.Type.Method", result.TestQuery); + Assert.Equal("https://github.com/microsoft/aspire/pull/999", result.SourceUrl); + Assert.True(result.ForceNew); + Assert.False(result.ListOnly); + } + [Fact] [RequiresTools(["node"])] public async Task ParseCommandRejectsAmbiguousPositionalSyntax() @@ -131,6 +150,68 @@ public async Task ParseCommandReturnsListOnlyWhenFlagBasedWithoutTest() Assert.Equal("custom.yml", result.Workflow); } + [Fact] + [RequiresTools(["node"])] + public async Task FormatListResponseReturnsErrorWhenResolverFailed() + { + var result = await InvokeHarnessAsync( + "formatListResponse", + new + { + resolverOutcome = "failure", + resultJson = (object?)null + }); + + Assert.True(result.Error); + Assert.Contains("resolver failed", result.Message); + } + + [Fact] + [RequiresTools(["node"])] + public async Task FormatListResponseReturnsTestNamesFromResult() + { + var result = await InvokeHarnessAsync( + "formatListResponse", + new + { + resolverOutcome = "success", + resultJson = new + { + allFailures = new + { + tests = new[] + { + new { canonicalTestName = "Namespace.Class.MethodA", displayTestName = "MethodA" }, + new { canonicalTestName = "Namespace.Class.MethodB", displayTestName = "MethodB" } + } + } + } + }); + + Assert.False(result.Error); + Assert.NotNull(result.Tests); + Assert.Equal(2, result.Tests!.Length); + Assert.Contains("Namespace.Class.MethodA", result.Tests); + Assert.Contains("Namespace.Class.MethodB", result.Tests); + } + + [Fact] + [RequiresTools(["node"])] + public async Task FormatListResponseReturnsNoFailuresWhenResultIsEmpty() + { + var result = await InvokeHarnessAsync( + "formatListResponse", + new + { + resolverOutcome = "success", + resultJson = new { allFailures = new { tests = Array.Empty() } } + }); + + Assert.False(result.Error); + Assert.Contains("No test failures", result.Message); + Assert.Null(result.Tests); + } + [Fact] [RequiresTools(["node"])] public async Task BuildIssueSearchQueryTargetsFailingTestIssuesByMetadataMarker() @@ -184,4 +265,6 @@ private static string FindRepoRoot() private sealed record HarnessResponse(T Result); private sealed record ParseCommandResult(bool Success, string TestQuery, string? SourceUrl, string Workflow, bool ForceNew, bool ListOnly, string? ErrorMessage); + + private sealed record FormatListResponseResult(bool Error, string Message, string[]? Tests); } diff --git a/tests/Infrastructure.Tests/WorkflowScripts/create-failing-test-issue.harness.js b/tests/Infrastructure.Tests/WorkflowScripts/create-failing-test-issue.harness.js index 9d8c6483047..a51356da69b 100644 --- a/tests/Infrastructure.Tests/WorkflowScripts/create-failing-test-issue.harness.js +++ b/tests/Infrastructure.Tests/WorkflowScripts/create-failing-test-issue.harness.js @@ -20,6 +20,9 @@ async function dispatch(operation, payload) { case 'buildIssueSearchQuery': return helper.buildIssueSearchQuery(payload.owner ?? 'microsoft', payload.repo ?? 'aspire', payload.metadataMarker); + case 'formatListResponse': + return helper.formatListResponse(payload.resolverOutcome, payload.resultJson ?? null); + default: throw new Error(`Unsupported operation '${operation}'.`); } diff --git a/tools/CreateFailingTestIssue/CreateFailingTestIssue.csproj b/tools/CreateFailingTestIssue/CreateFailingTestIssue.csproj index 7b37a13df28..eb3af36f183 100644 --- a/tools/CreateFailingTestIssue/CreateFailingTestIssue.csproj +++ b/tools/CreateFailingTestIssue/CreateFailingTestIssue.csproj @@ -13,7 +13,7 @@ - + diff --git a/tools/CreateFailingTestIssue/Directory.Build.props b/tools/CreateFailingTestIssue/Directory.Build.props new file mode 100644 index 00000000000..6ed2903ca80 --- /dev/null +++ b/tools/CreateFailingTestIssue/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/tools/CreateFailingTestIssue/Directory.Build.targets b/tools/CreateFailingTestIssue/Directory.Build.targets new file mode 100644 index 00000000000..6ed2903ca80 --- /dev/null +++ b/tools/CreateFailingTestIssue/Directory.Build.targets @@ -0,0 +1,3 @@ + + + diff --git a/tools/CreateFailingTestIssue/Directory.Packages.props b/tools/CreateFailingTestIssue/Directory.Packages.props new file mode 100644 index 00000000000..7b709c8debe --- /dev/null +++ b/tools/CreateFailingTestIssue/Directory.Packages.props @@ -0,0 +1,9 @@ + + + true + + + + + +