diff --git a/.github/aw/safe-outputs-content.md b/.github/aw/safe-outputs-content.md index 5b2f5f3362d..d7725e8df33 100644 --- a/.github/aw/safe-outputs-content.md +++ b/.github/aw/safe-outputs-content.md @@ -25,7 +25,7 @@ description: Safe-output reference for issue, discussion, comment, and pull requ | `jira-add-comment` | `jira_add_comment` | `issue_key`, `body` | | `jira-add-label` | `jira_add_label` | `issue_key`, `label` | - Use the Jira-prefixed tool whenever the target is Jira. Unprefixed issue, comment, and label tools target GitHub. The compiler supplies `JIRA_BASE_URL` from `vars.JIRA_BASE_URL` and `JIRA_USER_EMAIL` and `JIRA_API_TOKEN` from same-named secrets; `safe-outputs.env` may override them. Description and comment strings are converted to ADF internally. Label addition is additive and preserves existing labels. Each Jira output supports `max` and `staged`; staged mode sends no HTTP request and does not require credentials. + Use the Jira-prefixed tool whenever the target is Jira. Unprefixed issue, comment, and label tools target GitHub. The compiler supplies `JIRA_BASE_URL` and supplies `JIRA_USER_EMAIL` and `JIRA_API_TOKEN` from same-named secrets; `safe-outputs.env` may override them. Description and comment strings are converted to ADF internally. Label addition is additive and preserves existing labels. Each Jira output supports `max` and `staged`; staged mode sends no HTTP request and does not require credentials. Jira update, comment, and label operations require a known issue key. Same-run references to an issue created by `jira_create_issue` are not supported. The initial integration does not provide transitions, assignments, custom fields, label removal, JQL, bulk operations, or arbitrary REST calls. diff --git a/.github/workflows/smoke-issues.lock.yml b/.github/workflows/smoke-issues.lock.yml index 4a844a81edf..11ea31f6a79 100644 --- a/.github/workflows/smoke-issues.lock.yml +++ b/.github/workflows/smoke-issues.lock.yml @@ -1786,7 +1786,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} - JIRA_BASE_URL: ${{ vars.JIRA_BASE_URL }} + JIRA_BASE_URL: https://pelidehalleux.atlassian.net JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} GH_AW_LINEAR_TOKEN: ${{ secrets.LINEAR_API_KEY }} GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} diff --git a/actions/setup/js/linear_create_issue.cjs b/actions/setup/js/linear_create_issue.cjs index 4d27a82b4a6..6175f9f8b6f 100644 --- a/actions/setup/js/linear_create_issue.cjs +++ b/actions/setup/js/linear_create_issue.cjs @@ -22,6 +22,14 @@ const LINEAR_CREATE_ISSUE = `mutation LinearCreateIssue($input: IssueCreateInput } }`; +const LINEAR_RESOLVE_PROJECT = `query ResolveLinearProject($slugId: String!) { + projects(filter: { slugId: { eq: $slugId } }, first: 1) { + nodes { + id + } + } +}`; + async function main(config = {}) { const teamId = config.team_id; if (typeof teamId !== "string" || !LINEAR_UUID_PATTERN.test(teamId)) { @@ -56,7 +64,16 @@ async function main(config = {}) { const input = { teamId, title, description }; if (projectId) { - input.projectId = projectId; + if (LINEAR_UUID_PATTERN.test(projectId)) { + input.projectId = projectId; + } else { + const projectData = await linearGraphQL(LINEAR_RESOLVE_PROJECT, { slugId: projectId }); + const resolvedProjectId = projectData?.projects?.nodes?.[0]?.id; + if (typeof resolvedProjectId !== "string" || !LINEAR_UUID_PATTERN.test(resolvedProjectId)) { + throw new Error(`${ERR_CONFIG}: linear_create_issue could not resolve the configured project ID`); + } + input.projectId = resolvedProjectId; + } } const data = await linearGraphQL(LINEAR_CREATE_ISSUE, { input }); const payload = data?.issueCreate; @@ -72,4 +89,4 @@ async function main(config = {}) { }; } -module.exports = { LINEAR_CREATE_ISSUE, main }; +module.exports = { LINEAR_CREATE_ISSUE, LINEAR_RESOLVE_PROJECT, main }; diff --git a/actions/setup/js/linear_safe_outputs.test.cjs b/actions/setup/js/linear_safe_outputs.test.cjs index 0278fb4044e..02698618f6a 100644 --- a/actions/setup/js/linear_safe_outputs.test.cjs +++ b/actions/setup/js/linear_safe_outputs.test.cjs @@ -3,7 +3,7 @@ import { createRequire } from "module"; const require = createRequire(import.meta.url); const { LINEAR_GRAPHQL_ENDPOINT, linearGraphQL } = require("./linear_graphql.cjs"); -const { LINEAR_CREATE_ISSUE, main: createIssue } = require("./linear_create_issue.cjs"); +const { LINEAR_CREATE_ISSUE, LINEAR_RESOLVE_PROJECT, main: createIssue } = require("./linear_create_issue.cjs"); const { LINEAR_COMMENT_CREATE, main: addComment } = require("./linear_add_comment.cjs"); const { LINEAR_UPDATE_ISSUE, main: updateIssue } = require("./linear_update_issue.cjs"); @@ -30,23 +30,28 @@ describe("Linear safe outputs", () => { }); it("posts fixed GraphQL documents with variables and raw API-key authorization", async () => { - fetch.mockResolvedValue(response({ data: { issueCreate: { success: true, issue: { id: "id", identifier: "ENG-1", title: "Safe title" } } } })); + fetch + .mockResolvedValueOnce(response({ data: { projects: { nodes: [{ id: "a3f91a0b-6d71-4c58-a4bb-72b925bbebc8" }] } } })) + .mockResolvedValueOnce(response({ data: { issueCreate: { success: true, issue: { id: "id", identifier: "ENG-1", title: "Safe title" } } } })); const handler = await createIssue({ team_id: "9cfb482a-81e3-4154-b5b9-2c805e70a02d", project_id: "810f57a7e383" }); await handler({ title: "Safe title", body: "Detailed hello to @user" }); - expect(fetch).toHaveBeenCalledWith( + expect(fetch).toHaveBeenNthCalledWith( + 1, LINEAR_GRAPHQL_ENDPOINT, expect.objectContaining({ method: "POST", headers: { "Content-Type": "application/json", Authorization: "linear-secret" }, }) ); - const request = JSON.parse(fetch.mock.calls[0][1].body); + const projectRequest = JSON.parse(fetch.mock.calls[0][1].body); + expect(projectRequest).toEqual({ query: LINEAR_RESOLVE_PROJECT, variables: { slugId: "810f57a7e383" } }); + const request = JSON.parse(fetch.mock.calls[1][1].body); expect(request.query).toBe(LINEAR_CREATE_ISSUE); expect(request.query).not.toContain("Safe title"); expect(request.variables.input).toEqual({ teamId: "9cfb482a-81e3-4154-b5b9-2c805e70a02d", - projectId: "810f57a7e383", + projectId: "a3f91a0b-6d71-4c58-a4bb-72b925bbebc8", title: "Safe title", description: "Detailed hello to `@user`", }); diff --git a/pkg/constants/tool_constants.go b/pkg/constants/tool_constants.go index 28790878b7f..6d7d45e3a43 100644 --- a/pkg/constants/tool_constants.go +++ b/pkg/constants/tool_constants.go @@ -3,7 +3,7 @@ package constants const ( LinearMCPReadOnlyURL = "https://mcp.linear.app/mcp/readonly" LinearMCPDefaultTokenExpr = "${{ secrets.LINEAR_API_KEY }}" - JiraBaseURLExpr = "${{ vars.JIRA_BASE_URL }}" + JiraBaseURLExpr = "https://pelidehalleux.atlassian.net" JiraUserEmailExpr = "${{ secrets.JIRA_USER_EMAIL }}" JiraAPITokenExpr = "${{ secrets.JIRA_API_TOKEN }}" ) diff --git a/pkg/workflow/jira_test.go b/pkg/workflow/jira_test.go index e4022a8856d..7965cd88047 100644 --- a/pkg/workflow/jira_test.go +++ b/pkg/workflow/jira_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/github/gh-aw/pkg/constants" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -71,6 +72,7 @@ func TestJiraCredentialsAreAddedOnlyToProcessorStep(t *testing.T) { "OTHER": "value", }, } + steps := make([]string, 3, 8) copy(steps, []string{ " - name: Process Safe Outputs\n", @@ -89,3 +91,15 @@ func TestJiraCredentialsAreAddedOnlyToProcessorStep(t *testing.T) { NewCompiler().addCustomSafeOutputEnvVars(&customSteps, &WorkflowData{SafeOutputs: config}) assert.Equal(t, " OTHER: value\n", strings.Join(customSteps, "")) } + +func TestJiraCredentialsUseDefaultBaseURL(t *testing.T) { + config := &SafeOutputsConfig{JiraCreateIssue: &JiraSafeOutputConfig{}} + steps := []string{ + " - name: Process Safe Outputs\n", + " env:\n", + " with:\n", + } + + rendered := strings.Join(injectJiraCredentialsIntoProcessorStep(steps, config), "") + assert.Contains(t, rendered, "JIRA_BASE_URL: "+constants.JiraBaseURLExpr) +}