diff --git a/.github/workflows/campaign-generator.lock.yml b/.github/workflows/campaign-generator.lock.yml index ff29806712f..9e17f015198 100644 --- a/.github/workflows/campaign-generator.lock.yml +++ b/.github/workflows/campaign-generator.lock.yml @@ -7522,7 +7522,7 @@ jobs: }; EOF_4d21ccbd - cat > /tmp/gh-aw/scripts/update_runner.cjs << 'EOF_006d32d7' + cat > /tmp/gh-aw/scripts/update_runner.cjs << 'EOF_60283df2' // @ts-check /// @@ -7607,11 +7607,12 @@ jobs: * @param {boolean} params.canUpdateStatus - Whether status updates are allowed * @param {boolean} params.canUpdateTitle - Whether title updates are allowed * @param {boolean} params.canUpdateBody - Whether body updates are allowed + * @param {boolean} [params.canUpdateLabels] - Whether label updates are allowed * @param {boolean} params.supportsStatus - Whether this type supports status * @returns {{hasUpdates: boolean, updateData: any, logMessages: string[]}} */ function buildUpdateData(params) { - const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; + const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, canUpdateLabels, supportsStatus } = params; /** @type {any} */ const updateData = {}; @@ -7661,6 +7662,17 @@ jobs: } } + // Handle labels update + if (canUpdateLabels && item.labels !== undefined) { + if (Array.isArray(item.labels)) { + updateData.labels = item.labels; + hasUpdates = true; + logMessages.push(`Will update labels to: ${item.labels.join(", ")}`); + } else { + logMessages.push("Invalid labels value: must be an array"); + } + } + return { hasUpdates, updateData, logMessages }; } @@ -7705,12 +7717,13 @@ jobs: const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; + const canUpdateLabels = process.env.GH_AW_UPDATE_LABELS === "true"; core.info(`Update target configuration: ${updateTarget}`); if (supportsStatus) { - core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); + core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}, labels: ${canUpdateLabels}`); } else { - core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); + core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}, labels: ${canUpdateLabels}`); } // Check context validity @@ -7754,6 +7767,7 @@ jobs: canUpdateStatus, canUpdateTitle, canUpdateBody, + canUpdateLabels, supportsStatus, }); @@ -7875,7 +7889,7 @@ jobs: createGetSummaryLine, }; - EOF_006d32d7 + EOF_60283df2 - name: Assign To Agent id: assign_to_agent if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'assign_to_agent')) diff --git a/.github/workflows/changeset.lock.yml b/.github/workflows/changeset.lock.yml index a11cbf99991..5778d0a4d89 100644 --- a/.github/workflows/changeset.lock.yml +++ b/.github/workflows/changeset.lock.yml @@ -8269,7 +8269,7 @@ jobs: }; EOF_d0693c3b - cat > /tmp/gh-aw/scripts/update_runner.cjs << 'EOF_006d32d7' + cat > /tmp/gh-aw/scripts/update_runner.cjs << 'EOF_60283df2' // @ts-check /// @@ -8354,11 +8354,12 @@ jobs: * @param {boolean} params.canUpdateStatus - Whether status updates are allowed * @param {boolean} params.canUpdateTitle - Whether title updates are allowed * @param {boolean} params.canUpdateBody - Whether body updates are allowed + * @param {boolean} [params.canUpdateLabels] - Whether label updates are allowed * @param {boolean} params.supportsStatus - Whether this type supports status * @returns {{hasUpdates: boolean, updateData: any, logMessages: string[]}} */ function buildUpdateData(params) { - const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; + const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, canUpdateLabels, supportsStatus } = params; /** @type {any} */ const updateData = {}; @@ -8408,6 +8409,17 @@ jobs: } } + // Handle labels update + if (canUpdateLabels && item.labels !== undefined) { + if (Array.isArray(item.labels)) { + updateData.labels = item.labels; + hasUpdates = true; + logMessages.push(`Will update labels to: ${item.labels.join(", ")}`); + } else { + logMessages.push("Invalid labels value: must be an array"); + } + } + return { hasUpdates, updateData, logMessages }; } @@ -8452,12 +8464,13 @@ jobs: const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; + const canUpdateLabels = process.env.GH_AW_UPDATE_LABELS === "true"; core.info(`Update target configuration: ${updateTarget}`); if (supportsStatus) { - core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); + core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}, labels: ${canUpdateLabels}`); } else { - core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); + core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}, labels: ${canUpdateLabels}`); } // Check context validity @@ -8501,6 +8514,7 @@ jobs: canUpdateStatus, canUpdateTitle, canUpdateBody, + canUpdateLabels, supportsStatus, }); @@ -8622,7 +8636,7 @@ jobs: createGetSummaryLine, }; - EOF_006d32d7 + EOF_60283df2 - name: Update Pull Request id: update_pull_request if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'update_pull_request')) diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 27f005a58f6..db50ba0729a 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -8404,7 +8404,7 @@ jobs: }; EOF_4d21ccbd - cat > /tmp/gh-aw/scripts/update_runner.cjs << 'EOF_006d32d7' + cat > /tmp/gh-aw/scripts/update_runner.cjs << 'EOF_60283df2' // @ts-check /// @@ -8489,11 +8489,12 @@ jobs: * @param {boolean} params.canUpdateStatus - Whether status updates are allowed * @param {boolean} params.canUpdateTitle - Whether title updates are allowed * @param {boolean} params.canUpdateBody - Whether body updates are allowed + * @param {boolean} [params.canUpdateLabels] - Whether label updates are allowed * @param {boolean} params.supportsStatus - Whether this type supports status * @returns {{hasUpdates: boolean, updateData: any, logMessages: string[]}} */ function buildUpdateData(params) { - const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; + const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, canUpdateLabels, supportsStatus } = params; /** @type {any} */ const updateData = {}; @@ -8543,6 +8544,17 @@ jobs: } } + // Handle labels update + if (canUpdateLabels && item.labels !== undefined) { + if (Array.isArray(item.labels)) { + updateData.labels = item.labels; + hasUpdates = true; + logMessages.push(`Will update labels to: ${item.labels.join(", ")}`); + } else { + logMessages.push("Invalid labels value: must be an array"); + } + } + return { hasUpdates, updateData, logMessages }; } @@ -8587,12 +8599,13 @@ jobs: const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; + const canUpdateLabels = process.env.GH_AW_UPDATE_LABELS === "true"; core.info(`Update target configuration: ${updateTarget}`); if (supportsStatus) { - core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); + core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}, labels: ${canUpdateLabels}`); } else { - core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); + core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}, labels: ${canUpdateLabels}`); } // Check context validity @@ -8636,6 +8649,7 @@ jobs: canUpdateStatus, canUpdateTitle, canUpdateBody, + canUpdateLabels, supportsStatus, }); @@ -8757,7 +8771,7 @@ jobs: createGetSummaryLine, }; - EOF_006d32d7 + EOF_60283df2 - name: Update Discussion id: update_discussion if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'update_discussion')) @@ -8788,8 +8802,28 @@ jobs: includeOperation: false, }); async function executeDiscussionUpdate(github, context, discussionNumber, updateData) { - const { _operation, _rawBody, ...fieldsToUpdate } = updateData; - const getDiscussionQuery = ` + const { _operation, _rawBody, labels, ...fieldsToUpdate } = updateData; + const shouldUpdateLabels = process.env.GH_AW_UPDATE_LABELS === "true" && labels !== undefined; + const getDiscussionQuery = shouldUpdateLabels + ? ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + discussion(number: $number) { + id + title + body + url + labels(first: 100) { + nodes { + id + name + } + } + } + } + } + ` + : ` query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { discussion(number: $number) { @@ -8809,9 +8843,11 @@ jobs: if (!queryResult?.repository?.discussion) { throw new Error(`Discussion #${discussionNumber} not found`); } - const discussionId = queryResult.repository.discussion.id; - if (fieldsToUpdate.title === undefined && fieldsToUpdate.body === undefined) { - throw new Error("At least one field (title or body) must be provided for update"); + const discussion = queryResult.repository.discussion; + const discussionId = discussion.id; + const currentLabels = shouldUpdateLabels ? discussion.labels?.nodes || [] : []; + if (fieldsToUpdate.title === undefined && fieldsToUpdate.body === undefined && !shouldUpdateLabels) { + throw new Error("At least one field (title, body, or labels) must be provided for update"); } if (fieldsToUpdate.body !== undefined) { const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; @@ -8826,22 +8862,130 @@ jobs: const footer = generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); fieldsToUpdate.body = fieldsToUpdate.body + footer; } - const mutationFields = []; - if (fieldsToUpdate.title !== undefined) { - mutationFields.push("title: $title"); + if (fieldsToUpdate.title !== undefined || fieldsToUpdate.body !== undefined) { + const mutationFields = []; + if (fieldsToUpdate.title !== undefined) { + mutationFields.push("title: $title"); + } + if (fieldsToUpdate.body !== undefined) { + mutationFields.push("body: $body"); + } + const updateDiscussionMutation = ` + mutation($discussionId: ID!${fieldsToUpdate.title !== undefined ? ", $title: String!" : ""}${fieldsToUpdate.body !== undefined ? ", $body: String!" : ""}) { + updateDiscussion(input: { + discussionId: $discussionId + ${mutationFields.join("\n ")} + }) { + discussion { + id + number + title + body + url + } + } + } + `; + const variables = { + discussionId: discussionId, + }; + if (fieldsToUpdate.title !== undefined) { + variables.title = fieldsToUpdate.title; + } + if (fieldsToUpdate.body !== undefined) { + variables.body = fieldsToUpdate.body; + } + const mutationResult = await github.graphql(updateDiscussionMutation, variables); + if (!mutationResult?.updateDiscussion?.discussion) { + throw new Error("Failed to update discussion"); + } } - if (fieldsToUpdate.body !== undefined) { - mutationFields.push("body: $body"); - } - const updateDiscussionMutation = ` - mutation($discussionId: ID!${fieldsToUpdate.title !== undefined ? ", $title: String!" : ""}${fieldsToUpdate.body !== undefined ? ", $body: String!" : ""}) { - updateDiscussion(input: { - discussionId: $discussionId - ${mutationFields.join("\n ")} - }) { - discussion { + if (shouldUpdateLabels && Array.isArray(labels)) { + const repoQuery = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + id + labels(first: 100) { + nodes { + id + name + } + } + } + } + `; + const repoResult = await github.graphql(repoQuery, { + owner: context.repo.owner, + repo: context.repo.repo, + }); + if (!repoResult?.repository) { + throw new Error(`Repository ${context.repo.owner}/${context.repo.repo} not found`); + } + const repoLabels = repoResult.repository.labels?.nodes || []; + const labelIds = labels.map(labelName => { + const label = repoLabels.find(l => l.name === labelName); + if (!label) { + throw new Error(`Label "${labelName}" not found in repository`); + } + return label.id; + }); + if (currentLabels.length > 0) { + const removeLabelsMutation = ` + mutation($labelableId: ID!, $labelIds: [ID!]!) { + removeLabelsFromLabelable(input: { + labelableId: $labelableId + labelIds: $labelIds + }) { + clientMutationId + } + } + `; + await github.graphql(removeLabelsMutation, { + labelableId: discussionId, + labelIds: currentLabels.map(l => l.id), + }); + } + if (labelIds.length > 0) { + const addLabelsMutation = ` + mutation($labelableId: ID!, $labelIds: [ID!]!) { + addLabelsToLabelable(input: { + labelableId: $labelableId + labelIds: $labelIds + }) { + clientMutationId + } + } + `; + await github.graphql(addLabelsMutation, { + labelableId: discussionId, + labelIds: labelIds, + }); + } + } + const finalQuery = shouldUpdateLabels + ? ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + discussion(number: $number) { + id + title + body + url + labels(first: 100) { + nodes { + id + name + } + } + } + } + } + ` + : ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + discussion(number: $number) { id - number title body url @@ -8849,23 +8993,15 @@ jobs: } } `; - const variables = { - discussionId: discussionId, - }; - if (fieldsToUpdate.title !== undefined) { - variables.title = fieldsToUpdate.title; - } - if (fieldsToUpdate.body !== undefined) { - variables.body = fieldsToUpdate.body; - } - const mutationResult = await github.graphql(updateDiscussionMutation, variables); - if (!mutationResult?.updateDiscussion?.discussion) { - throw new Error("Failed to update discussion"); - } - const discussion = mutationResult.updateDiscussion.discussion; + const finalQueryResult = await github.graphql(finalQuery, { + owner: context.repo.owner, + repo: context.repo.repo, + number: discussionNumber, + }); + const updatedDiscussion = finalQueryResult.repository.discussion; return { - ...discussion, - html_url: discussion.url, + ...updatedDiscussion, + html_url: updatedDiscussion.url, }; } const getSummaryLine = createGetSummaryLine({ diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index 4e1e5e46a50..2b6a7a4af85 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -10633,7 +10633,7 @@ jobs: }; EOF_4d21ccbd - cat > /tmp/gh-aw/scripts/update_runner.cjs << 'EOF_006d32d7' + cat > /tmp/gh-aw/scripts/update_runner.cjs << 'EOF_60283df2' // @ts-check /// @@ -10718,11 +10718,12 @@ jobs: * @param {boolean} params.canUpdateStatus - Whether status updates are allowed * @param {boolean} params.canUpdateTitle - Whether title updates are allowed * @param {boolean} params.canUpdateBody - Whether body updates are allowed + * @param {boolean} [params.canUpdateLabels] - Whether label updates are allowed * @param {boolean} params.supportsStatus - Whether this type supports status * @returns {{hasUpdates: boolean, updateData: any, logMessages: string[]}} */ function buildUpdateData(params) { - const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; + const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, canUpdateLabels, supportsStatus } = params; /** @type {any} */ const updateData = {}; @@ -10772,6 +10773,17 @@ jobs: } } + // Handle labels update + if (canUpdateLabels && item.labels !== undefined) { + if (Array.isArray(item.labels)) { + updateData.labels = item.labels; + hasUpdates = true; + logMessages.push(`Will update labels to: ${item.labels.join(", ")}`); + } else { + logMessages.push("Invalid labels value: must be an array"); + } + } + return { hasUpdates, updateData, logMessages }; } @@ -10816,12 +10828,13 @@ jobs: const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; + const canUpdateLabels = process.env.GH_AW_UPDATE_LABELS === "true"; core.info(`Update target configuration: ${updateTarget}`); if (supportsStatus) { - core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); + core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}, labels: ${canUpdateLabels}`); } else { - core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); + core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}, labels: ${canUpdateLabels}`); } // Check context validity @@ -10865,6 +10878,7 @@ jobs: canUpdateStatus, canUpdateTitle, canUpdateBody, + canUpdateLabels, supportsStatus, }); @@ -10986,7 +11000,7 @@ jobs: createGetSummaryLine, }; - EOF_006d32d7 + EOF_60283df2 - name: Create Issue id: create_issue if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) diff --git a/.github/workflows/sub-issue-closer.lock.yml b/.github/workflows/sub-issue-closer.lock.yml index 79ff3044722..68936429e28 100644 --- a/.github/workflows/sub-issue-closer.lock.yml +++ b/.github/workflows/sub-issue-closer.lock.yml @@ -7428,7 +7428,7 @@ jobs: }; EOF_4d21ccbd - cat > /tmp/gh-aw/scripts/update_runner.cjs << 'EOF_006d32d7' + cat > /tmp/gh-aw/scripts/update_runner.cjs << 'EOF_60283df2' // @ts-check /// @@ -7513,11 +7513,12 @@ jobs: * @param {boolean} params.canUpdateStatus - Whether status updates are allowed * @param {boolean} params.canUpdateTitle - Whether title updates are allowed * @param {boolean} params.canUpdateBody - Whether body updates are allowed + * @param {boolean} [params.canUpdateLabels] - Whether label updates are allowed * @param {boolean} params.supportsStatus - Whether this type supports status * @returns {{hasUpdates: boolean, updateData: any, logMessages: string[]}} */ function buildUpdateData(params) { - const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; + const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, canUpdateLabels, supportsStatus } = params; /** @type {any} */ const updateData = {}; @@ -7567,6 +7568,17 @@ jobs: } } + // Handle labels update + if (canUpdateLabels && item.labels !== undefined) { + if (Array.isArray(item.labels)) { + updateData.labels = item.labels; + hasUpdates = true; + logMessages.push(`Will update labels to: ${item.labels.join(", ")}`); + } else { + logMessages.push("Invalid labels value: must be an array"); + } + } + return { hasUpdates, updateData, logMessages }; } @@ -7611,12 +7623,13 @@ jobs: const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; + const canUpdateLabels = process.env.GH_AW_UPDATE_LABELS === "true"; core.info(`Update target configuration: ${updateTarget}`); if (supportsStatus) { - core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); + core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}, labels: ${canUpdateLabels}`); } else { - core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); + core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}, labels: ${canUpdateLabels}`); } // Check context validity @@ -7660,6 +7673,7 @@ jobs: canUpdateStatus, canUpdateTitle, canUpdateBody, + canUpdateLabels, supportsStatus, }); @@ -7781,7 +7795,7 @@ jobs: createGetSummaryLine, }; - EOF_006d32d7 + EOF_60283df2 - name: Add Comment id: add_comment if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 242abcbb833..d2f5ac0670e 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3369,6 +3369,17 @@ "type": "null", "description": "Allow updating discussion body - presence of key indicates field can be updated" }, + "labels": { + "type": "null", + "description": "Allow updating discussion labels - presence of key indicates field can be updated" + }, + "allowed-labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones)." + }, "max": { "type": "integer", "description": "Maximum number of discussions to update (default: 1)", diff --git a/pkg/workflow/compiler_safe_outputs_consolidated.go b/pkg/workflow/compiler_safe_outputs_consolidated.go index a1cc03341ae..de1f4231edf 100644 --- a/pkg/workflow/compiler_safe_outputs_consolidated.go +++ b/pkg/workflow/compiler_safe_outputs_consolidated.go @@ -983,6 +983,9 @@ func (c *Compiler) buildUpdateDiscussionStepConfig(data *WorkflowData, mainJobNa if cfg.Body != nil { customEnvVars = append(customEnvVars, " GH_AW_UPDATE_BODY: \"true\"\n") } + if cfg.Labels != nil { + customEnvVars = append(customEnvVars, " GH_AW_UPDATE_LABELS: \"true\"\n") + } condition := BuildSafeOutputType("update_discussion") diff --git a/pkg/workflow/js/update_discussion.cjs b/pkg/workflow/js/update_discussion.cjs index c8432e9f94f..f1eee2ff90b 100644 --- a/pkg/workflow/js/update_discussion.cjs +++ b/pkg/workflow/js/update_discussion.cjs @@ -24,10 +24,32 @@ const renderStagedItem = createRenderStagedItem({ */ async function executeDiscussionUpdate(github, context, discussionNumber, updateData) { // Remove internal fields used for operation handling - const { _operation, _rawBody, ...fieldsToUpdate } = updateData; + const { _operation, _rawBody, labels, ...fieldsToUpdate } = updateData; + + // Check if labels should be updated based on environment variable + const shouldUpdateLabels = process.env.GH_AW_UPDATE_LABELS === "true" && labels !== undefined; // First, fetch the discussion node ID using its number - const getDiscussionQuery = ` + const getDiscussionQuery = shouldUpdateLabels + ? ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + discussion(number: $number) { + id + title + body + url + labels(first: 100) { + nodes { + id + name + } + } + } + } + } + ` + : ` query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { discussion(number: $number) { @@ -50,11 +72,13 @@ async function executeDiscussionUpdate(github, context, discussionNumber, update throw new Error(`Discussion #${discussionNumber} not found`); } - const discussionId = queryResult.repository.discussion.id; + const discussion = queryResult.repository.discussion; + const discussionId = discussion.id; + const currentLabels = shouldUpdateLabels ? discussion.labels?.nodes || [] : []; // Ensure at least one field is being updated - if (fieldsToUpdate.title === undefined && fieldsToUpdate.body === undefined) { - throw new Error("At least one field (title or body) must be provided for update"); + if (fieldsToUpdate.title === undefined && fieldsToUpdate.body === undefined && !shouldUpdateLabels) { + throw new Error("At least one field (title, body, or labels) must be provided for update"); } // Add footer to body if body is being updated @@ -76,56 +100,174 @@ async function executeDiscussionUpdate(github, context, discussionNumber, update fieldsToUpdate.body = fieldsToUpdate.body + footer; } - // Build the update mutation dynamically based on which fields are being updated - const mutationFields = []; - if (fieldsToUpdate.title !== undefined) { - mutationFields.push("title: $title"); - } - if (fieldsToUpdate.body !== undefined) { - mutationFields.push("body: $body"); + // Update title and/or body if needed + if (fieldsToUpdate.title !== undefined || fieldsToUpdate.body !== undefined) { + // Build the update mutation dynamically based on which fields are being updated + const mutationFields = []; + if (fieldsToUpdate.title !== undefined) { + mutationFields.push("title: $title"); + } + if (fieldsToUpdate.body !== undefined) { + mutationFields.push("body: $body"); + } + + const updateDiscussionMutation = ` + mutation($discussionId: ID!${fieldsToUpdate.title !== undefined ? ", $title: String!" : ""}${fieldsToUpdate.body !== undefined ? ", $body: String!" : ""}) { + updateDiscussion(input: { + discussionId: $discussionId + ${mutationFields.join("\n ")} + }) { + discussion { + id + number + title + body + url + } + } + } + `; + + const variables = { + discussionId: discussionId, + }; + + if (fieldsToUpdate.title !== undefined) { + variables.title = fieldsToUpdate.title; + } + + if (fieldsToUpdate.body !== undefined) { + variables.body = fieldsToUpdate.body; + } + + const mutationResult = await github.graphql(updateDiscussionMutation, variables); + + if (!mutationResult?.updateDiscussion?.discussion) { + throw new Error("Failed to update discussion"); + } } - const updateDiscussionMutation = ` - mutation($discussionId: ID!${fieldsToUpdate.title !== undefined ? ", $title: String!" : ""}${fieldsToUpdate.body !== undefined ? ", $body: String!" : ""}) { - updateDiscussion(input: { - discussionId: $discussionId - ${mutationFields.join("\n ")} - }) { - discussion { + // Update labels if provided and enabled + if (shouldUpdateLabels && Array.isArray(labels)) { + // Get the repository ID to look up label IDs + const repoQuery = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { id - number - title - body - url + labels(first: 100) { + nodes { + id + name + } + } } } + `; + + const repoResult = await github.graphql(repoQuery, { + owner: context.repo.owner, + repo: context.repo.repo, + }); + + if (!repoResult?.repository) { + throw new Error(`Repository ${context.repo.owner}/${context.repo.repo} not found`); } - `; - const variables = { - discussionId: discussionId, - }; + const repoLabels = repoResult.repository.labels?.nodes || []; - if (fieldsToUpdate.title !== undefined) { - variables.title = fieldsToUpdate.title; - } + // Map label names to IDs + const labelIds = labels.map(labelName => { + const label = repoLabels.find(l => l.name === labelName); + if (!label) { + throw new Error(`Label "${labelName}" not found in repository`); + } + return label.id; + }); - if (fieldsToUpdate.body !== undefined) { - variables.body = fieldsToUpdate.body; - } + // Remove all current labels + if (currentLabels.length > 0) { + const removeLabelsMutation = ` + mutation($labelableId: ID!, $labelIds: [ID!]!) { + removeLabelsFromLabelable(input: { + labelableId: $labelableId + labelIds: $labelIds + }) { + clientMutationId + } + } + `; - const mutationResult = await github.graphql(updateDiscussionMutation, variables); + await github.graphql(removeLabelsMutation, { + labelableId: discussionId, + labelIds: currentLabels.map(l => l.id), + }); + } - if (!mutationResult?.updateDiscussion?.discussion) { - throw new Error("Failed to update discussion"); + // Add new labels + if (labelIds.length > 0) { + const addLabelsMutation = ` + mutation($labelableId: ID!, $labelIds: [ID!]!) { + addLabelsToLabelable(input: { + labelableId: $labelableId + labelIds: $labelIds + }) { + clientMutationId + } + } + `; + + await github.graphql(addLabelsMutation, { + labelableId: discussionId, + labelIds: labelIds, + }); + } } - const discussion = mutationResult.updateDiscussion.discussion; + // Fetch the updated discussion to return + const finalQuery = shouldUpdateLabels + ? ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + discussion(number: $number) { + id + title + body + url + labels(first: 100) { + nodes { + id + name + } + } + } + } + } + ` + : ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + discussion(number: $number) { + id + title + body + url + } + } + } + `; + + const finalQueryResult = await github.graphql(finalQuery, { + owner: context.repo.owner, + repo: context.repo.repo, + number: discussionNumber, + }); + + const updatedDiscussion = finalQueryResult.repository.discussion; // Return with html_url (which the GraphQL returns as 'url') return { - ...discussion, - html_url: discussion.url, + ...updatedDiscussion, + html_url: updatedDiscussion.url, }; } diff --git a/pkg/workflow/js/update_discussion.test.cjs b/pkg/workflow/js/update_discussion.test.cjs index 518469fac05..51e24ac69e0 100644 --- a/pkg/workflow/js/update_discussion.test.cjs +++ b/pkg/workflow/js/update_discussion.test.cjs @@ -63,9 +63,11 @@ describe("update_discussion.cjs", () => { delete process.env.GH_AW_AGENT_OUTPUT; delete process.env.GH_AW_UPDATE_TITLE; delete process.env.GH_AW_UPDATE_BODY; + delete process.env.GH_AW_UPDATE_LABELS; delete process.env.GH_AW_UPDATE_TARGET; process.env.GH_AW_UPDATE_TITLE = "false"; process.env.GH_AW_UPDATE_BODY = "false"; + process.env.GH_AW_UPDATE_LABELS = "false"; const scriptPath = path.join(__dirname, "update_discussion.cjs"); updateDiscussionScript = fs.readFileSync(scriptPath, "utf8"); @@ -134,9 +136,19 @@ describe("update_discussion.cjs", () => { }, }); + // Mock the final query to get updated discussion + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + discussion: { + ...mockDiscussion, + title: "Updated discussion title", + }, + }, + }); + await eval(`(async () => { ${updateDiscussionScript} })()`); - expect(mockGithub.graphql).toHaveBeenCalledTimes(2); + expect(mockGithub.graphql).toHaveBeenCalledTimes(3); expect(mockCore.setOutput).toHaveBeenCalledWith("discussion_number", 123); expect(mockCore.setOutput).toHaveBeenCalledWith("discussion_url", mockDiscussion.url); expect(mockCore.summary.addRaw).toHaveBeenCalled(); @@ -175,9 +187,19 @@ describe("update_discussion.cjs", () => { }, }); + // Mock the final query + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + discussion: { + ...mockDiscussion, + body: "New discussion body content", + }, + }, + }); + await eval(`(async () => { ${updateDiscussionScript} })()`); - expect(mockGithub.graphql).toHaveBeenCalledTimes(2); + expect(mockGithub.graphql).toHaveBeenCalledTimes(3); expect(mockCore.setOutput).toHaveBeenCalledWith("discussion_number", 123); }); @@ -221,9 +243,20 @@ describe("update_discussion.cjs", () => { }, }); + // Mock the final query + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + discussion: { + ...mockDiscussion, + title: "New title", + body: "New body content", + }, + }, + }); + await eval(`(async () => { ${updateDiscussionScript} })()`); - expect(mockGithub.graphql).toHaveBeenCalledTimes(2); + expect(mockGithub.graphql).toHaveBeenCalledTimes(3); }); it('should handle explicit discussion number with target "*"', async () => { @@ -265,9 +298,19 @@ describe("update_discussion.cjs", () => { }, }); + // Mock the final query + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + discussion: { + ...mockDiscussion, + title: "Updated title", + }, + }, + }); + await eval(`(async () => { ${updateDiscussionScript} })()`); - expect(mockGithub.graphql).toHaveBeenCalledTimes(2); + expect(mockGithub.graphql).toHaveBeenCalledTimes(3); // Should use the explicit discussion number 456 expect(mockGithub.graphql).toHaveBeenNthCalledWith( 1, @@ -333,11 +376,175 @@ describe("update_discussion.cjs", () => { }); }); + // Mock the final query + mockGithub.graphql.mockImplementationOnce(() => { + return Promise.resolve({ + repository: { + discussion: { + ...mockDiscussion, + body: capturedBody, + }, + }, + }); + }); + await eval(`(async () => { ${updateDiscussionScript} })()`); - expect(mockGithub.graphql).toHaveBeenCalledTimes(2); + expect(mockGithub.graphql).toHaveBeenCalledTimes(3); // Verify the custom footer was used expect(capturedBody).toContain("Custom footer by"); expect(capturedBody).toContain("Custom Workflow"); }); + + it("should update discussion labels successfully", async () => { + setAgentOutput({ + items: [{ type: "update_discussion", labels: ["bug", "enhancement"] }], + }); + process.env.GH_AW_UPDATE_LABELS = "true"; + global.context.eventName = "discussion"; + + const mockDiscussion = { + id: "D_kwDOABCD123", + number: 123, + title: "Test Discussion", + body: "Test body", + url: "https://github.com/testowner/testrepo/discussions/123", + labels: { + nodes: [{ id: "L_kwDOABCD001", name: "old-label" }], + }, + }; + + const mockLabels = [ + { id: "L_kwDOABCD002", name: "bug" }, + { id: "L_kwDOABCD003", name: "enhancement" }, + ]; + + // Mock the first query to get discussion with labels + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + discussion: mockDiscussion, + }, + }); + + // Mock the repository labels query + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + id: "R_kwDOABCD", + labels: { + nodes: mockLabels, + }, + }, + }); + + // Mock remove labels mutation + mockGithub.graphql.mockResolvedValueOnce({ + removeLabelsFromLabelable: { + clientMutationId: null, + }, + }); + + // Mock add labels mutation + mockGithub.graphql.mockResolvedValueOnce({ + addLabelsToLabelable: { + clientMutationId: null, + }, + }); + + // Mock the final query to get updated discussion + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + discussion: { + ...mockDiscussion, + labels: { + nodes: mockLabels, + }, + }, + }, + }); + + await eval(`(async () => { ${updateDiscussionScript} })()`); + + expect(mockGithub.graphql).toHaveBeenCalledTimes(5); + expect(mockCore.setOutput).toHaveBeenCalledWith("discussion_number", 123); + expect(mockCore.setOutput).toHaveBeenCalledWith("discussion_url", mockDiscussion.url); + }); + + it("should update both title and labels successfully", async () => { + setAgentOutput({ + items: [ + { + type: "update_discussion", + title: "New title", + labels: ["question"], + }, + ], + }); + process.env.GH_AW_UPDATE_TITLE = "true"; + process.env.GH_AW_UPDATE_LABELS = "true"; + global.context.eventName = "discussion"; + + const mockDiscussion = { + id: "D_kwDOABCD123", + number: 123, + title: "Old title", + body: "Old body", + url: "https://github.com/testowner/testrepo/discussions/123", + labels: { + nodes: [], + }, + }; + + const mockLabels = [{ id: "L_kwDOABCD004", name: "question" }]; + + // Mock the first query to get discussion + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + discussion: mockDiscussion, + }, + }); + + // Mock the title update mutation + mockGithub.graphql.mockResolvedValueOnce({ + updateDiscussion: { + discussion: { + ...mockDiscussion, + title: "New title", + }, + }, + }); + + // Mock the repository labels query + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + id: "R_kwDOABCD", + labels: { + nodes: mockLabels, + }, + }, + }); + + // Mock add labels mutation (no need to remove since there are no existing labels) + mockGithub.graphql.mockResolvedValueOnce({ + addLabelsToLabelable: { + clientMutationId: null, + }, + }); + + // Mock the final query to get updated discussion + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + discussion: { + ...mockDiscussion, + title: "New title", + labels: { + nodes: mockLabels, + }, + }, + }, + }); + + await eval(`(async () => { ${updateDiscussionScript} })()`); + + expect(mockGithub.graphql).toHaveBeenCalledTimes(5); + }); }); diff --git a/pkg/workflow/js/update_runner.cjs b/pkg/workflow/js/update_runner.cjs index ba20cfa0100..b055b9f2bc2 100644 --- a/pkg/workflow/js/update_runner.cjs +++ b/pkg/workflow/js/update_runner.cjs @@ -82,11 +82,12 @@ function resolveTargetNumber(params) { * @param {boolean} params.canUpdateStatus - Whether status updates are allowed * @param {boolean} params.canUpdateTitle - Whether title updates are allowed * @param {boolean} params.canUpdateBody - Whether body updates are allowed + * @param {boolean} [params.canUpdateLabels] - Whether label updates are allowed * @param {boolean} params.supportsStatus - Whether this type supports status * @returns {{hasUpdates: boolean, updateData: any, logMessages: string[]}} */ function buildUpdateData(params) { - const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; + const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, canUpdateLabels, supportsStatus } = params; /** @type {any} */ const updateData = {}; @@ -136,6 +137,17 @@ function buildUpdateData(params) { } } + // Handle labels update + if (canUpdateLabels && item.labels !== undefined) { + if (Array.isArray(item.labels)) { + updateData.labels = item.labels; + hasUpdates = true; + logMessages.push(`Will update labels to: ${item.labels.join(", ")}`); + } else { + logMessages.push("Invalid labels value: must be an array"); + } + } + return { hasUpdates, updateData, logMessages }; } @@ -180,12 +192,13 @@ async function runUpdateWorkflow(config) { const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; + const canUpdateLabels = process.env.GH_AW_UPDATE_LABELS === "true"; core.info(`Update target configuration: ${updateTarget}`); if (supportsStatus) { - core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); + core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}, labels: ${canUpdateLabels}`); } else { - core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); + core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}, labels: ${canUpdateLabels}`); } // Check context validity @@ -229,6 +242,7 @@ async function runUpdateWorkflow(config) { canUpdateStatus, canUpdateTitle, canUpdateBody, + canUpdateLabels, supportsStatus, }); diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index f73d478bd5c..3e05640523d 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -861,6 +861,9 @@ func generateSafeOutputsConfig(data *WorkflowData) string { maxValue = data.SafeOutputs.UpdateDiscussions.Max } updateDiscussionConfig["max"] = maxValue + if len(data.SafeOutputs.UpdateDiscussions.AllowedLabels) > 0 { + updateDiscussionConfig["allowed_labels"] = data.SafeOutputs.UpdateDiscussions.AllowedLabels + } safeOutputsConfig["update_discussion"] = updateDiscussionConfig } if data.SafeOutputs.UpdatePullRequests != nil { diff --git a/pkg/workflow/update_discussion.go b/pkg/workflow/update_discussion.go index 926bcd65ebf..14c9775eff2 100644 --- a/pkg/workflow/update_discussion.go +++ b/pkg/workflow/update_discussion.go @@ -9,8 +9,10 @@ var updateDiscussionLog = logger.New("workflow:update_discussion") // UpdateDiscussionsConfig holds configuration for updating GitHub discussions from agent output type UpdateDiscussionsConfig struct { UpdateEntityConfig `yaml:",inline"` - Title *bool `yaml:"title,omitempty"` // Allow updating discussion title - presence indicates field can be updated - Body *bool `yaml:"body,omitempty"` // Allow updating discussion body - presence indicates field can be updated + Title *bool `yaml:"title,omitempty"` // Allow updating discussion title - presence indicates field can be updated + Body *bool `yaml:"body,omitempty"` // Allow updating discussion body - presence indicates field can be updated + Labels *bool `yaml:"labels,omitempty"` // Allow updating discussion labels - presence indicates field can be updated + AllowedLabels []string `yaml:"allowed-labels,omitempty"` // Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones). } // parseUpdateDiscussionsConfig handles update-discussion configuration @@ -47,6 +49,21 @@ func (c *Compiler) parseUpdateDiscussionsConfig(outputMap map[string]any) *Updat if _, exists := configMap["body"]; exists { updateDiscussionsConfig.Body = new(bool) } + + // Parse labels - presence of the key (even if nil/empty) indicates field can be updated + if _, exists := configMap["labels"]; exists { + updateDiscussionsConfig.Labels = new(bool) + } + + // Parse allowed-labels using shared helper + updateDiscussionsConfig.AllowedLabels = parseAllowedLabelsFromConfig(configMap) + if len(updateDiscussionsConfig.AllowedLabels) > 0 { + updateDiscussionLog.Printf("Allowed labels configured: %v", updateDiscussionsConfig.AllowedLabels) + // If allowed-labels is specified, implicitly enable labels + if updateDiscussionsConfig.Labels == nil { + updateDiscussionsConfig.Labels = new(bool) + } + } } } diff --git a/pkg/workflow/update_discussion_test.go b/pkg/workflow/update_discussion_test.go new file mode 100644 index 00000000000..8dbfa941c11 --- /dev/null +++ b/pkg/workflow/update_discussion_test.go @@ -0,0 +1,349 @@ +package workflow + +import ( + "os" + "path/filepath" + "testing" + + "github.com/githubnext/gh-aw/pkg/testutil" +) + +func TestUpdateDiscussionConfigParsing(t *testing.T) { + // Create temporary directory for test files + tmpDir := testutil.TempDir(t, "output-update-discussion-test") + + // Test case with basic update-discussion configuration + testContent := `--- +on: + discussion: + types: [created] +permissions: + contents: read + discussions: write +engine: claude +strict: false +safe-outputs: + update-discussion: +--- + +# Test Update Discussion Configuration + +This workflow tests the update-discussion configuration parsing. +` + + testFile := filepath.Join(tmpDir, "test-update-discussion.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow data + workflowData, err := compiler.ParseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Unexpected error parsing workflow with update-discussion config: %v", err) + } + + // Verify output configuration is parsed correctly + if workflowData.SafeOutputs == nil { + t.Fatal("Expected output configuration to be parsed") + } + + if workflowData.SafeOutputs.UpdateDiscussions == nil { + t.Fatal("Expected update-discussion configuration to be parsed") + } + + // Check defaults + if workflowData.SafeOutputs.UpdateDiscussions.Max != 1 { + t.Fatalf("Expected max to be 1, got %d", workflowData.SafeOutputs.UpdateDiscussions.Max) + } + + if workflowData.SafeOutputs.UpdateDiscussions.Target != "" { + t.Fatalf("Expected target to be empty (default), got '%s'", workflowData.SafeOutputs.UpdateDiscussions.Target) + } + + if workflowData.SafeOutputs.UpdateDiscussions.Title != nil { + t.Fatal("Expected title to be nil by default (not updatable)") + } + + if workflowData.SafeOutputs.UpdateDiscussions.Body != nil { + t.Fatal("Expected body to be nil by default (not updatable)") + } + + if workflowData.SafeOutputs.UpdateDiscussions.Labels != nil { + t.Fatal("Expected labels to be nil by default (not updatable)") + } + + if len(workflowData.SafeOutputs.UpdateDiscussions.AllowedLabels) != 0 { + t.Fatal("Expected allowed-labels to be empty by default") + } +} + +func TestUpdateDiscussionConfigWithAllOptions(t *testing.T) { + // Create temporary directory for test files + tmpDir := testutil.TempDir(t, "output-update-discussion-all-test") + + // Test case with all options configured + testContent := `--- +on: + discussion: + types: [created] +permissions: + contents: read + discussions: write +engine: claude +strict: false +safe-outputs: + update-discussion: + max: 3 + target: "*" + title: + body: + labels: + allowed-labels: [bug, enhancement, documentation] +--- + +# Test Update Discussion Full Configuration + +This workflow tests the update-discussion configuration with all options. +` + + testFile := filepath.Join(tmpDir, "test-update-discussion-full.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow data + workflowData, err := compiler.ParseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Unexpected error parsing workflow with full update-discussion config: %v", err) + } + + // Verify output configuration is parsed correctly + if workflowData.SafeOutputs == nil { + t.Fatal("Expected output configuration to be parsed") + } + + if workflowData.SafeOutputs.UpdateDiscussions == nil { + t.Fatal("Expected update-discussion configuration to be parsed") + } + + // Check all options + if workflowData.SafeOutputs.UpdateDiscussions.Max != 3 { + t.Fatalf("Expected max to be 3, got %d", workflowData.SafeOutputs.UpdateDiscussions.Max) + } + + if workflowData.SafeOutputs.UpdateDiscussions.Target != "*" { + t.Fatalf("Expected target to be '*', got '%s'", workflowData.SafeOutputs.UpdateDiscussions.Target) + } + + if workflowData.SafeOutputs.UpdateDiscussions.Title == nil { + t.Fatal("Expected title to be non-nil (updatable)") + } + + if workflowData.SafeOutputs.UpdateDiscussions.Body == nil { + t.Fatal("Expected body to be non-nil (updatable)") + } + + if workflowData.SafeOutputs.UpdateDiscussions.Labels == nil { + t.Fatal("Expected labels to be non-nil (updatable)") + } + + // Check allowed-labels + expectedAllowedLabels := []string{"bug", "enhancement", "documentation"} + if len(workflowData.SafeOutputs.UpdateDiscussions.AllowedLabels) != len(expectedAllowedLabels) { + t.Fatalf("Expected %d allowed-labels, got %d", len(expectedAllowedLabels), len(workflowData.SafeOutputs.UpdateDiscussions.AllowedLabels)) + } + + for i, expected := range expectedAllowedLabels { + if workflowData.SafeOutputs.UpdateDiscussions.AllowedLabels[i] != expected { + t.Fatalf("Expected allowed-label[%d] to be '%s', got '%s'", i, expected, workflowData.SafeOutputs.UpdateDiscussions.AllowedLabels[i]) + } + } +} + +func TestUpdateDiscussionConfigTargetParsing(t *testing.T) { + // Create temporary directory for test files + tmpDir := testutil.TempDir(t, "output-update-discussion-target-test") + + // Test case with specific target number + testContent := `--- +on: + discussion: + types: [created] +permissions: + contents: read + discussions: write +engine: claude +strict: false +safe-outputs: + update-discussion: + target: "123" + title: +--- + +# Test Update Discussion Target Configuration + +This workflow tests the update-discussion target configuration parsing. +` + + testFile := filepath.Join(tmpDir, "test-update-discussion-target.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow data + workflowData, err := compiler.ParseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Unexpected error parsing workflow with target update-discussion config: %v", err) + } + + // Verify output configuration is parsed correctly + if workflowData.SafeOutputs == nil { + t.Fatal("Expected output configuration to be parsed") + } + + if workflowData.SafeOutputs.UpdateDiscussions == nil { + t.Fatal("Expected update-discussion configuration to be parsed") + } + + if workflowData.SafeOutputs.UpdateDiscussions.Target != "123" { + t.Fatalf("Expected target to be '123', got '%s'", workflowData.SafeOutputs.UpdateDiscussions.Target) + } + + if workflowData.SafeOutputs.UpdateDiscussions.Title == nil { + t.Fatal("Expected title to be non-nil (updatable)") + } +} + +func TestUpdateDiscussionConfigLabelsOnly(t *testing.T) { + // Create temporary directory for test files + tmpDir := testutil.TempDir(t, "output-update-discussion-labels-test") + + // Test case with only labels configuration + testContent := `--- +on: + discussion: + types: [created] +permissions: + contents: read + discussions: write +engine: claude +strict: false +safe-outputs: + update-discussion: + labels: + allowed-labels: [question, idea] +--- + +# Test Update Discussion Labels Configuration + +This workflow tests the update-discussion labels configuration. +` + + testFile := filepath.Join(tmpDir, "test-update-discussion-labels.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow data + workflowData, err := compiler.ParseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Unexpected error parsing workflow with labels update-discussion config: %v", err) + } + + // Verify output configuration is parsed correctly + if workflowData.SafeOutputs == nil { + t.Fatal("Expected output configuration to be parsed") + } + + if workflowData.SafeOutputs.UpdateDiscussions == nil { + t.Fatal("Expected update-discussion configuration to be parsed") + } + + if workflowData.SafeOutputs.UpdateDiscussions.Labels == nil { + t.Fatal("Expected labels to be non-nil (updatable)") + } + + // Check allowed-labels + expectedAllowedLabels := []string{"question", "idea"} + if len(workflowData.SafeOutputs.UpdateDiscussions.AllowedLabels) != len(expectedAllowedLabels) { + t.Fatalf("Expected %d allowed-labels, got %d", len(expectedAllowedLabels), len(workflowData.SafeOutputs.UpdateDiscussions.AllowedLabels)) + } + + for i, expected := range expectedAllowedLabels { + if workflowData.SafeOutputs.UpdateDiscussions.AllowedLabels[i] != expected { + t.Fatalf("Expected allowed-label[%d] to be '%s', got '%s'", i, expected, workflowData.SafeOutputs.UpdateDiscussions.AllowedLabels[i]) + } + } +} + +func TestUpdateDiscussionConfigAllowedLabelsImplicitlyEnablesLabels(t *testing.T) { + // Create temporary directory for test files + tmpDir := testutil.TempDir(t, "output-update-discussion-implicit-labels-test") + + // Test case with only allowed-labels (no explicit labels:) + testContent := `--- +on: + discussion: + types: [created] +permissions: + contents: read + discussions: write +engine: claude +strict: false +safe-outputs: + update-discussion: + allowed-labels: [bug, enhancement] +--- + +# Test Update Discussion Implicit Labels Configuration + +This workflow tests that allowed-labels implicitly enables labels. +` + + testFile := filepath.Join(tmpDir, "test-update-discussion-implicit-labels.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow data + workflowData, err := compiler.ParseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Unexpected error parsing workflow with implicit labels config: %v", err) + } + + // Verify output configuration is parsed correctly + if workflowData.SafeOutputs == nil { + t.Fatal("Expected output configuration to be parsed") + } + + if workflowData.SafeOutputs.UpdateDiscussions == nil { + t.Fatal("Expected update-discussion configuration to be parsed") + } + + // The key test: labels should be implicitly enabled when allowed-labels is present + if workflowData.SafeOutputs.UpdateDiscussions.Labels == nil { + t.Fatal("Expected labels to be implicitly enabled when allowed-labels is present") + } + + // Check allowed-labels + expectedAllowedLabels := []string{"bug", "enhancement"} + if len(workflowData.SafeOutputs.UpdateDiscussions.AllowedLabels) != len(expectedAllowedLabels) { + t.Fatalf("Expected %d allowed-labels, got %d", len(expectedAllowedLabels), len(workflowData.SafeOutputs.UpdateDiscussions.AllowedLabels)) + } + + for i, expected := range expectedAllowedLabels { + if workflowData.SafeOutputs.UpdateDiscussions.AllowedLabels[i] != expected { + t.Fatalf("Expected allowed-label[%d] to be '%s', got '%s'", i, expected, workflowData.SafeOutputs.UpdateDiscussions.AllowedLabels[i]) + } + } +} diff --git a/schemas/agent-output.json b/schemas/agent-output.json index f4a9df676c6..0dc62831291 100644 --- a/schemas/agent-output.json +++ b/schemas/agent-output.json @@ -330,7 +330,7 @@ }, "UpdateDiscussionOutput": { "title": "Update Discussion Output", - "description": "Output for updating an existing GitHub discussion's title and/or body. Note: The JavaScript validation ensures at least one of title or body is provided.", + "description": "Output for updating an existing GitHub discussion's title, body, and/or labels. Note: The JavaScript validation ensures at least one of title, body, or labels is provided.", "type": "object", "properties": { "type": { @@ -344,6 +344,13 @@ "type": "string", "description": "New discussion body to replace the existing content" }, + "labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of label names to set on the discussion (replaces existing labels)" + }, "discussion_number": { "oneOf": [ {"type": "number"},