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
14 changes: 14 additions & 0 deletions actions/setup/js/safe_output_type_validator.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ function normalizeIssueClosingKeywordBackticks(content) {
* @property {string} [itemType] - For arrays, the type of items
* @property {boolean} [itemSanitize] - For arrays, whether to sanitize items
* @property {number} [itemMaxLength] - For arrays, max length per item
* @property {number} [minimum] - For numbers, minimum allowed value (inclusive)
* @property {number} [maximum] - For numbers, maximum allowed value (inclusive)
* @property {string} [pattern] - Regex pattern the value must match
* @property {string} [patternError] - Error message for pattern mismatch
*/
Expand Down Expand Up @@ -468,6 +470,18 @@ function validateField(value, fieldName, validation, itemType, lineNum, options)
error: `Line ${lineNum}: ${itemType} '${fieldName}' must be a number`,
};
}
if (validation.minimum !== undefined && value < validation.minimum) {
return {
isValid: false,
error: `Line ${lineNum}: ${itemType} '${fieldName}' must be >= ${validation.minimum}`,
};
}
if (validation.maximum !== undefined && value > validation.maximum) {
return {
isValid: false,
error: `Line ${lineNum}: ${itemType} '${fieldName}' must be <= ${validation.maximum}`,
};
}
return { isValid: true, normalizedValue: value };
}

Expand Down
24 changes: 24 additions & 0 deletions actions/setup/js/safe_output_type_validator.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const SAMPLE_VALIDATION_CONFIG = {
status: { type: "string", enum: ["open", "closed"] },
title: { type: "string", sanitize: true, maxLength: 128 },
body: { type: "string", sanitize: true, maxLength: 65000 },
confidence: { type: "number", minimum: 0, maximum: 100 },
issue_number: { issueOrPRNumber: true },
},
},
Expand Down Expand Up @@ -705,6 +706,29 @@ describe("safe_output_type_validator", () => {
expect(result.normalizedItem.status).toBe("open");
});

describe("number range validation", () => {
it("should accept confidence within range", async () => {
const { validateItem } = await import("./safe_output_type_validator.cjs");
const result = validateItem({ type: "update_issue", status: "open", confidence: 75 }, "update_issue", 1);
expect(result.isValid).toBe(true);
expect(result.normalizedItem.confidence).toBe(75);
});

it("should reject confidence below minimum", async () => {
const { validateItem } = await import("./safe_output_type_validator.cjs");
const result = validateItem({ type: "update_issue", status: "open", confidence: -1 }, "update_issue", 1);
expect(result.isValid).toBe(false);
expect(result.error).toContain("must be >= 0");
});

it("should reject confidence above maximum", async () => {
const { validateItem } = await import("./safe_output_type_validator.cjs");
const result = validateItem({ type: "update_issue", status: "open", confidence: 101 }, "update_issue", 1);
expect(result.isValid).toBe(false);
expect(result.error).toContain("must be <= 100");
});
});

it("should reject invalid enum value", async () => {
const { validateItem } = await import("./safe_output_type_validator.cjs");

Expand Down
24 changes: 21 additions & 3 deletions actions/setup/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -759,7 +759,7 @@
},
{
"name": "update_issue",
"description": "Update an existing GitHub issue's title, body, labels, assignees, or milestone WITHOUT closing it. This tool is primarily for editing issue metadata and content. While it supports changing status between 'open' and 'closed', use close_issue instead when you want to close an issue with a closing comment. Body updates support replacing, appending to, prepending content, or updating a per-run \"island\" section. 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 title, body, labels, assignees, or milestone WITHOUT closing it. This tool is primarily for editing issue metadata and content. While it supports changing status between 'open' and 'closed', use close_issue instead when you want to close an issue with a closing comment. Body updates support replacing, appending to, prepending content, or updating a per-run \"island\" section. Include `confidence` (0-100) when the update is inferred from analysis or heuristics rather than explicit user instruction. 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 @@ -803,6 +803,12 @@
"type": ["number", "string"],
"description": "Issue number to update. This is the numeric ID from the GitHub URL (e.g., 789 in github.com/owner/repo/issues/789). ONLY effective when the workflow is configured with `update-issue: target: '*'` in the frontmatter. When the workflow uses `target: triggering` (the default), this field is ignored and the tool updates the issue that triggered the workflow instead. If you need to update a specific issue in a scheduled or workflow_dispatch workflow, the workflow frontmatter must include `update-issue: target: '*'`."
},
"confidence": {
"type": "integer",
"minimum": 0,
"maximum": 100,
"description": "Optional confidence score from 0 to 100 that expresses how certain you are this issue update is correct. Use lower values (0-40) for weak evidence, mid-range values (41-79) for partial confidence, and high values (80-100) for strong confidence."
},
"secrecy": {
"type": "string",
"description": "Confidentiality level of the message content (e.g., \"public\", \"internal\", \"private\")."
Expand Down Expand Up @@ -1150,7 +1156,7 @@
},
{
"name": "set_issue_type",
"description": "Set the type of a GitHub issue. Pass an empty string \"\" to clear the issue type. Issue types must be configured in the repository or organization settings before they can be assigned.",
"description": "Set the type of a GitHub issue. Pass an empty string \"\" to clear the issue type. Issue types must be configured in the repository or organization settings before they can be assigned. Include `confidence` (0-100) when the selected type is inferred from issue signals rather than explicit user instruction.",
"inputSchema": {
"type": "object",
"required": ["issue_type"],
Expand All @@ -1163,6 +1169,12 @@
"type": "string",
"description": "Issue type name to set (e.g., \"Bug\", \"Feature\", \"Task\"). Use an empty string \"\" to clear the current issue type."
},
"confidence": {
"type": "integer",
"minimum": 0,
"maximum": 100,
"description": "Optional confidence score from 0 to 100 that expresses how certain you are this issue type assignment is correct. Use lower values (0-40) for weak evidence, mid-range values (41-79) for partial confidence, and high values (80-100) for strong confidence."
},
"secrecy": {
"type": "string",
"description": "Confidentiality level of the message content (e.g., \"public\", \"internal\", \"private\")."
Expand All @@ -1177,7 +1189,7 @@
},
{
"name": "set_issue_field",
"description": "Set a single GitHub issue field by name and value. Use field_name for discovery by field label (for example, \"Priority\"), or provide field_node_id to skip discovery. Supports text, number, date (YYYY-MM-DD), and single-select fields (value must match an option name).",
"description": "Set a single GitHub issue field by name and value. Use field_name for discovery by field label (for example, \"Priority\"), or provide field_node_id to skip discovery. Supports text, number, date (YYYY-MM-DD), and single-select fields (value must match an option name). Include `confidence` (0-100) when the field value is inferred rather than explicitly requested.",
"inputSchema": {
"type": "object",
"required": ["value"],
Expand All @@ -1198,6 +1210,12 @@
"type": "string",
"description": "Field value to set. For single-select fields, this must match an existing option name (e.g., \"P1\" or \"High\"). For date fields, use format: YYYY-MM-DD (for example, 2026-05-08)."
},
"confidence": {
"type": "integer",
"minimum": 0,
"maximum": 100,
"description": "Optional confidence score from 0 to 100 that expresses how certain you are this field update is correct. Use lower values (0-40) for weak evidence, mid-range values (41-79) for partial confidence, and high values (80-100) for strong confidence."
},
"secrecy": {
"type": "string",
"description": "Confidentiality level of the message content (e.g., \"public\", \"internal\", \"private\")."
Expand Down
58 changes: 46 additions & 12 deletions actions/setup/js/set_issue_field.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -181,23 +181,51 @@ function buildFieldUpdatePayload(field, rawValue) {
* Sets one issue field via GraphQL mutation.
* @param {Object} githubClient - Authenticated GitHub client
* @param {string} issueNodeId - GraphQL node ID of the issue
* @param {{fieldId: string, singleSelectOptionId?: string, numberValue?: number, dateValue?: string, textValue?: string}} fieldUpdate
* @param {{fieldId: string, singleSelectOptionId?: string, numberValue?: number, dateValue?: string, textValue?: string, confidence?: number}} fieldUpdate
* @returns {Promise<void>}
*/
async function setIssueFieldValue(githubClient, issueNodeId, fieldUpdate) {
await githubClient.graphql(
`mutation($issueId: ID!, $issueFields: [IssueFieldCreateOrUpdateInput!]!) {
setIssueFieldValue(input: { issueId: $issueId, issueFields: $issueFields }) {
issue {
id
const variables = {
issueId: issueNodeId,
issueFields: [fieldUpdate],
};

try {
await githubClient.graphql(
`mutation($issueId: ID!, $issueFields: [IssueFieldCreateOrUpdateInput!]!) {
setIssueFieldValue(input: { issueId: $issueId, issueFields: $issueFields }) {
issue {
id
}
}
}
}`,
{
issueId: issueNodeId,
issueFields: [fieldUpdate],
}`,
variables
);
} catch (error) {
const message = getErrorMessage(error).toLowerCase();
const hasConfidence = typeof fieldUpdate.confidence === "number";
const mentionsConfidence = message.includes("confidence");
if (!hasConfidence || !mentionsConfidence) {
throw error;
}
);

const retryFieldUpdate = { ...fieldUpdate };
delete retryFieldUpdate.confidence;
core.warning("Issue field confidence is not supported by this API; retrying without confidence.");
await githubClient.graphql(
`mutation($issueId: ID!, $issueFields: [IssueFieldCreateOrUpdateInput!]!) {
setIssueFieldValue(input: { issueId: $issueId, issueFields: $issueFields }) {
issue {
id
}
}
}`,
{
issueId: issueNodeId,
issueFields: [retryFieldUpdate],
}
);
}
}

/**
Expand Down Expand Up @@ -300,6 +328,7 @@ async function main(config = {}) {
field_name: fieldName,
field_node_id: fieldNodeId,
value,
confidence: typeof item.confidence === "number" ? item.confidence : undefined,
repo: itemRepo,
},
};
Expand Down Expand Up @@ -374,6 +403,10 @@ async function main(config = {}) {
...fieldUpdateResult.update,
};

const confidence = typeof item.confidence === "number" ? item.confidence : undefined;
if (confidence !== undefined) {
fieldUpdate.confidence = confidence;
}
await setIssueFieldValue(githubClient, issueNodeId, fieldUpdate);

core.info(`Successfully set issue field ${JSON.stringify(fieldName || fieldNodeId)} to ${JSON.stringify(value)} on issue #${issueNumber}`);
Expand All @@ -384,6 +417,7 @@ async function main(config = {}) {
field_name: fieldName,
field_node_id: fieldNodeId,
value,
confidence,
repo: itemRepo,
};
} catch (error) {
Expand Down
4 changes: 3 additions & 1 deletion actions/setup/js/set_issue_field.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ describe("set_issue_field (Handler Factory Architecture)", () => {
issue_number: 42,
field_name: "Customer Impact",
value: "High",
confidence: 72,
};

const result = await handler(message, {});
Expand All @@ -113,11 +114,12 @@ describe("set_issue_field (Handler Factory Architecture)", () => {
expect(result.issue_number).toBe(42);
expect(result.field_name).toBe("Customer Impact");
expect(result.field_node_id).toBe(textFieldId);
expect(result.confidence).toBe(72);
expect(mockGraphql).toHaveBeenCalledWith(
expect.stringContaining("setIssueFieldValue"),
expect.objectContaining({
issueId: issueNodeId,
issueFields: [expect.objectContaining({ fieldId: textFieldId, textValue: "High" })],
issueFields: [expect.objectContaining({ fieldId: textFieldId, textValue: "High", confidence: 72 })],
})
);
});
Expand Down
47 changes: 36 additions & 11 deletions actions/setup/js/set_issue_type.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,41 @@ async function fetchIssueTypes(githubClient, owner, repo) {
* @param {Object} githubClient - Authenticated GitHub client
* @param {string} issueNodeId - GraphQL node ID of the issue
* @param {string|null} typeId - GraphQL node ID of the issue type, or null to clear
* @param {number|undefined} confidence - Optional confidence score (0-100)
* @returns {Promise<void>}
*/
async function setIssueTypeById(githubClient, issueNodeId, typeId) {
await githubClient.graphql(
`mutation($issueId: ID!, $typeId: ID) {
updateIssue(input: { id: $issueId, issueTypeId: $typeId }) {
issue {
id
async function setIssueTypeById(githubClient, issueNodeId, typeId, confidence) {
try {
await githubClient.graphql(
`mutation($issueId: ID!, $typeId: ID, $confidence: Int) {
updateIssue(input: { id: $issueId, issueTypeId: $typeId, confidence: $confidence }) {
issue {
id
}
}
}
}`,
{ issueId: issueNodeId, typeId }
);
}`,
{ issueId: issueNodeId, typeId, confidence }
);
} catch (error) {
const message = getErrorMessage(error).toLowerCase();
const hasConfidence = typeof confidence === "number";
const mentionsConfidence = message.includes("confidence");
if (!hasConfidence || !mentionsConfidence) {
throw error;
}

core.warning("Issue type confidence is not supported by this API; retrying without confidence.");
await githubClient.graphql(
`mutation($issueId: ID!, $typeId: ID) {
updateIssue(input: { id: $issueId, issueTypeId: $typeId }) {
issue {
id
}
}
}`,
{ issueId: issueNodeId, typeId }
);
}
}

/**
Expand Down Expand Up @@ -197,6 +219,7 @@ async function main(config = {}) {
previewInfo: {
issue_number: issueNumber,
issue_type: issueTypeName,
confidence: typeof item.confidence === "number" ? item.confidence : undefined,
repo: itemRepo,
},
};
Expand Down Expand Up @@ -231,7 +254,8 @@ async function main(config = {}) {
core.info(`Resolved issue type ${JSON.stringify(issueTypeName)} to node ID: ${typeId}`);
}

await setIssueTypeById(githubClient, issueNodeId, typeId);
const confidence = typeof item.confidence === "number" ? item.confidence : undefined;
await setIssueTypeById(githubClient, issueNodeId, typeId, confidence);

const successMsg = isClear ? `Successfully cleared issue type on issue #${issueNumber}` : `Successfully set issue type to ${JSON.stringify(issueTypeName)} on issue #${issueNumber}`;
core.info(successMsg);
Expand All @@ -240,6 +264,7 @@ async function main(config = {}) {
success: true,
issue_number: issueNumber,
issue_type: issueTypeName,
confidence,
repo: itemRepo,
};
} catch (error) {
Expand Down
4 changes: 3 additions & 1 deletion actions/setup/js/set_issue_type.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,19 +88,21 @@ describe("set_issue_type (Handler Factory Architecture)", () => {
type: "set_issue_type",
issue_number: 42,
issue_type: "Bug",
confidence: 88,
};

const result = await handler(message, {});

expect(result.success).toBe(true);
expect(result.issue_number).toBe(42);
expect(result.issue_type).toBe("Bug");
expect(result.confidence).toBe(88);
expect(mockGithub.rest.issues.get).toHaveBeenCalledWith({
owner: "test-owner",
repo: "test-repo",
issue_number: 42,
});
expect(mockGraphql).toHaveBeenCalledWith(expect.stringContaining("updateIssue"), expect.objectContaining({ issueId: issueNodeId, typeId: bugTypeId }));
expect(mockGraphql).toHaveBeenCalledWith(expect.stringContaining("updateIssue"), expect.objectContaining({ issueId: issueNodeId, typeId: bugTypeId, confidence: 88 }));
});

it("should clear issue type when issue_type is empty string", async () => {
Expand Down
6 changes: 6 additions & 0 deletions actions/setup/js/types/safe-outputs.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ interface UpdateIssueItem extends BaseSafeOutputItem {
body?: string;
/** Optional issue number for target "*" */
issue_number?: number | string;
/** Optional confidence score (0-100) indicating certainty for this issue update. */
confidence?: number;
}

/**
Expand Down Expand Up @@ -322,6 +324,8 @@ interface SetIssueTypeItem extends BaseSafeOutputItem {
issue_type: string;
/** Issue number (optional - uses triggering issue if not provided) */
issue_number?: number | string;
/** Optional confidence score (0-100) indicating certainty in the issue type selection. */
confidence?: number;
}

/**
Expand All @@ -337,6 +341,8 @@ interface SetIssueFieldItem extends BaseSafeOutputItem {
value: string;
/** Issue number (optional - uses triggering issue if not provided) */
issue_number?: number | string;
/** Optional confidence score (0-100) indicating certainty in the field update value. */
confidence?: number;
}

/**
Expand Down
Loading