Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions actions/setup/js/collect_ndjson_output.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,17 @@ describe("collect_ndjson_output.cjs", () => {
},
update_issue: {
defaultMax: 1,
customValidation: "requiresOneOf:status,title,body",
fields: { status: { type: "string", enum: ["open", "closed"] }, title: { type: "string", sanitize: !0, maxLength: 128 }, body: { type: "string", sanitize: !0, maxLength: 65e3 }, issue_number: { issueOrPRNumber: !0 } },
customValidation: "requiresOneOf:status,title,body,labels,assignees,milestone,fields",
fields: {
status: { type: "string", enum: ["open", "closed"] },
title: { type: "string", sanitize: !0, maxLength: 128 },
body: { type: "string", sanitize: !0, maxLength: 65e3 },
labels: { type: "array", itemType: "string", itemSanitize: !0, itemMaxLength: 128 },
assignees: { type: "array", itemType: "string", itemSanitize: !0, itemMaxLength: 39 },
milestone: { optionalPositiveInteger: !0 },
fields: { type: "array" },
issue_number: { issueOrPRNumber: !0 },
},
},
create_pull_request_review_comment: {
defaultMax: 1,
Expand Down
14 changes: 13 additions & 1 deletion actions/setup/js/safe_output_type_validator.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,15 @@ const SAMPLE_VALIDATION_CONFIG = {
},
update_issue: {
defaultMax: 1,
customValidation: "requiresOneOf:status,title,body",
customValidation: "requiresOneOf:status,title,body,labels,assignees,milestone,fields",
fields: {
status: { type: "string", enum: ["open", "closed"] },
title: { type: "string", sanitize: true, maxLength: 128 },
body: { type: "string", sanitize: true, maxLength: 65000 },
labels: { type: "array", itemType: "string", itemSanitize: true, itemMaxLength: 128 },
assignees: { type: "array", itemType: "string", itemSanitize: true, itemMaxLength: 39 },
milestone: { optionalPositiveInteger: true },
fields: { type: "array" },
issue_number: { issueOrPRNumber: true },
},
},
Expand Down Expand Up @@ -374,6 +378,14 @@ describe("safe_output_type_validator", () => {
expect(result.isValid).toBe(true);
});

it("should pass for update_issue with fields-only payload", async () => {
const { validateItem } = await import("./safe_output_type_validator.cjs");

const result = validateItem({ type: "update_issue", fields: [{ name: "Priority", value: "High" }] }, "update_issue", 1);

expect(result.isValid).toBe(true);
});

it("should fail when none of the required fields are present", async () => {
const { validateItem } = await import("./safe_output_type_validator.cjs");

Expand Down
21 changes: 20 additions & 1 deletion actions/setup/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,7 @@
},
{
"name": "update_issue",
"description": "Update an existing GitHub issue's status, title, or body. Use this to modify issue properties after creation. Only the fields you specify will be updated; other fields remain unchanged. IMPORTANT: The behavior of this tool depends on the workflow's `update-issue: target:` configuration. When `target: triggering` (the default), the tool always updates the issue that triggered the workflow and `issue_number` is ignored. When `target: '*'`, the `issue_number` field controls which issue is updated. The tool will fail (not skip silently) when `target: triggering` and there is no triggering issue (e.g., in scheduled or workflow_dispatch workflows).",
"description": "Update an existing GitHub issue's status, title, body, labels, assignees, milestone, or issue fields. Use this to modify issue properties after creation. Only the fields you specify will be updated; other fields remain unchanged. IMPORTANT: The behavior of this tool depends on the workflow's `update-issue: target:` configuration. When `target: triggering` (the default), the tool always updates the issue that triggered the workflow and `issue_number` is ignored. When `target: '*'`, the `issue_number` field controls which issue is updated. The tool will fail (not skip silently) when `target: triggering` and there is no triggering issue (e.g., in scheduled or workflow_dispatch workflows).",
"inputSchema": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -777,6 +777,25 @@
"type": ["number", "string"],
"description": "Milestone number to assign (e.g., 1). Use null to clear."
},
"fields": {
"type": "array",
"description": "Optional issue fields to set (e.g., Priority, Iteration, Start Date). Only specified fields are changed.",
"items": {
"type": "object",
"required": ["name", "value"],
"properties": {
"name": {
"type": "string",
"description": "Issue field name exactly as configured in the repository (e.g., \"Priority\", \"Iteration\")."
},
"value": {
"type": ["string", "number"],
"description": "Field value. Use string for text, single-select, iteration, and date (YYYY-MM-DD) fields; use number for numeric fields."
}
},
"additionalProperties": false
}
},
Comment on lines +780 to +798
"secrecy": {
"type": "string",
"description": "Confidentiality level of the message content (e.g., \"public\", \"internal\", \"private\")."
Expand Down
7 changes: 7 additions & 0 deletions actions/setup/js/types/safe-outputs.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,13 @@ interface UpdateIssueItem extends BaseSafeOutputItem {
title?: string;
/** Optional new issue body */
body?: string;
/** Optional issue fields to set */
fields?: Array<{
/** Issue field display name */
name: string;
/** Field value (string for text/single-select/iteration/date, number for numeric fields) */
value: string | number;
}>;
/** Optional issue number for target "*" */
issue_number?: number | string;
}
Expand Down
248 changes: 241 additions & 7 deletions actions/setup/js/update_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,211 @@ const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs");
const { generateHistoryUrl } = require("./generate_history_link.cjs");
const { MAX_LABELS, MAX_ASSIGNEES } = require("./constants.cjs");

const ISSUE_FIELD_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;

/**
* Normalize and validate issue fields payload for update_issue.
* Ensures fields are objects with a non-empty name and string/number value.
* @param {any} fields
* @returns {Array<{name: string, value: string|number}>}
*/
function normalizeIssueFields(fields) {
if (fields == null) {
return [];
}
if (!Array.isArray(fields)) {
throw new Error(`${ERR_VALIDATION}: update_issue 'fields' must be an array of objects`);
}

return fields.map((field, index) => {
if (!field || typeof field !== "object" || Array.isArray(field)) {
throw new Error(`${ERR_VALIDATION}: update_issue 'fields[${index}]' must be an object with 'name' and 'value'`);
}

const name = typeof field.name === "string" ? field.name.trim() : "";
if (!name) {
throw new Error(`${ERR_VALIDATION}: update_issue 'fields[${index}].name' must be a non-empty string`);
}

if (!Object.prototype.hasOwnProperty.call(field, "value")) {
throw new Error(`${ERR_VALIDATION}: update_issue 'fields[${index}]' is missing required 'value'`);
}

const value = field.value;
if ((typeof value !== "string" && typeof value !== "number") || (typeof value === "number" && !Number.isFinite(value))) {
throw new Error(`${ERR_VALIDATION}: update_issue 'fields[${index}].value' for "${name}" must be a string or number`);
}

return { name, value };
});
Comment on lines +31 to +59
}
Comment on lines +23 to +60

/**
* Resolve issue node ID from issue number.
* @param {Object} githubClient
* @param {string} owner
* @param {string} repo
* @param {number} issueNumber
* @returns {Promise<string>}
*/
async function resolveIssueNodeId(githubClient, owner, repo, issueNumber) {
const result = await githubClient.graphql(
`query($owner: String!, $repo: String!, $issueNumber: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $issueNumber) {
id
}
}
}`,
{ owner, repo, issueNumber }
);

const issueId = result?.repository?.issue?.id;
if (!issueId) {
throw new Error(`${ERR_VALIDATION}: could not resolve node ID for issue #${issueNumber}`);
}
return issueId;
}

/**
* Fetch issue field metadata from repository.
* @param {Object} githubClient
* @param {string} owner
* @param {string} repo
* @returns {Promise<Array<any>>}
*/
async function fetchIssueFields(githubClient, owner, repo) {
const result = await githubClient.graphql(
`query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
issueFields(first: 100) {
nodes {
__typename
... on IssueField {
id
name
dataType
}
... on IssueFieldSingleSelect {
id
name
dataType
options {
id
name
}
}
... on IssueFieldIteration {
id
name
dataType
configuration {
iterations {
id
title
}
}
}
}
}
}
}`,
{ owner, repo }
);

return Array.isArray(result?.repository?.issueFields?.nodes) ? result.repository.issueFields.nodes.filter(Boolean) : [];
}

/**
* Build GraphQL setIssueFieldValue mutation input from named field values.
* @param {Array<{name: string, value: string|number}>} requestedFields
* @param {Array<any>} availableFields
* @returns {Array<any>}
*/
function buildIssueFieldMutationInput(requestedFields, availableFields) {
const availableNames = availableFields.map(field => field?.name).filter(Boolean);

return requestedFields.map(field => {
const matchedField = availableFields.find(available => typeof available?.name === "string" && available.name.toLowerCase() === field.name.toLowerCase());
if (!matchedField) {
throw new Error(`${ERR_VALIDATION}: unknown issue field "${field.name}". Available fields: ${availableNames.join(", ") || "(none)"}`);
}

const dataType = typeof matchedField.dataType === "string" ? matchedField.dataType.toUpperCase() : "TEXT";

if (dataType === "NUMBER") {
const numberValue = Number(field.value);
if (!Number.isFinite(numberValue)) {
throw new Error(`${ERR_VALIDATION}: issue field "${field.name}" requires a numeric value`);
}
return { fieldId: matchedField.id, numberValue };
}

if (dataType === "DATE") {
if (typeof field.value !== "string" || !ISSUE_FIELD_DATE_PATTERN.test(field.value)) {
throw new Error(`${ERR_VALIDATION}: issue field "${field.name}" requires a date value in YYYY-MM-DD format`);
}
return { fieldId: matchedField.id, dateValue: field.value };
}

if (dataType === "SINGLE_SELECT") {
const options = Array.isArray(matchedField.options) ? matchedField.options : [];
const selectedOption = options.find(option => typeof option?.name === "string" && option.name.toLowerCase() === String(field.value).toLowerCase());
if (!selectedOption) {
throw new Error(`${ERR_VALIDATION}: invalid option "${field.value}" for issue field "${field.name}". Available options: ${options.map(option => option.name).join(", ") || "(none)"}`);
}
return { fieldId: matchedField.id, singleSelectOptionId: selectedOption.id };
}

if (dataType === "ITERATION") {
const iterations = matchedField?.configuration?.iterations;
const availableIterations = Array.isArray(iterations) ? iterations : [];
const selectedIteration = availableIterations.find(iteration => typeof iteration?.title === "string" && iteration.title.toLowerCase() === String(field.value).toLowerCase());
if (!selectedIteration) {
throw new Error(`${ERR_VALIDATION}: invalid iteration "${field.value}" for issue field "${field.name}". Available iterations: ${availableIterations.map(iteration => iteration.title).join(", ") || "(none)"}`);
}
return { fieldId: matchedField.id, singleSelectOptionId: selectedIteration.id };
}

return { fieldId: matchedField.id, textValue: String(field.value) };
});
}

/**
* Apply issue field values to an existing issue.
* @param {{githubClient: Object, owner: string, repo: string, issueNumber: number, fields: Array<{name: string, value: string|number}>}} params
* @returns {Promise<void>}
*/
async function applyIssueFields({ githubClient, owner, repo, issueNumber, fields }) {
if (!Array.isArray(fields) || fields.length === 0) {
return;
}

if (typeof githubClient.graphql !== "function") {
throw new Error(`${ERR_VALIDATION}: update_issue 'fields' requires GraphQL access`);
}

const issueId = await resolveIssueNodeId(githubClient, owner, repo, issueNumber);
const availableFields = await fetchIssueFields(githubClient, owner, repo);
const issueFields = buildIssueFieldMutationInput(fields, availableFields);

await githubClient.graphql(
`mutation($input: SetIssueFieldValueInput!) {
setIssueFieldValue(input: $input) {
issue {
id
}
}
}`,
{
input: {
issueId,
issueFields,
},
}
);
}

/**
* Execute the issue update API call
* @param {any} github - GitHub API client
Expand All @@ -37,7 +242,7 @@ async function executeIssueUpdate(github, context, issueNumber, updateData) {
const titlePrefix = updateData._titlePrefix || "";

// Remove internal fields
const { _operation, _rawBody, _includeFooter, _titlePrefix, _workflowRepo, ...apiData } = updateData;
const { _operation, _rawBody, _includeFooter, _titlePrefix, _workflowRepo, fields, ...apiData } = updateData;

// Fetch current issue if needed (title prefix validation or body update)
if (titlePrefix || rawBody !== undefined) {
Expand Down Expand Up @@ -102,12 +307,34 @@ async function executeIssueUpdate(github, context, issueNumber, updateData) {
}
}

const { data: issue } = await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
...apiData,
});
let issue;
if (Object.keys(apiData).length > 0) {
const { data } = await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
...apiData,
});
issue = data;
} else {
const { data } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
issue = data;
}

if (Array.isArray(fields) && fields.length > 0) {
await applyIssueFields({
githubClient: github,
owner: context.repo.owner,
repo: context.repo.repo,
issueNumber,
fields,
});
core.info(`Applied ${fields.length} issue field(s) to issue #${issueNumber}`);
}

return issue;
}
Expand Down Expand Up @@ -163,6 +390,13 @@ function buildIssueUpdateData(item, config) {
if (item.milestone !== undefined) {
updateData.milestone = item.milestone;
}
if (item.fields !== undefined) {
try {
updateData.fields = normalizeIssueFields(item.fields);
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}

// Enforce max limits on labels and assignees before API calls
const labelsLimitResult = tryEnforceArrayLimit(updateData.labels, MAX_LABELS, "labels");
Expand Down
Loading