Skip to content
Merged
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
15 changes: 14 additions & 1 deletion .github/workflows/dependabot-go-checker.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 14 additions & 1 deletion .github/workflows/objective-impact-report.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 14 additions & 1 deletion .github/workflows/semantic-function-refactor.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 56 additions & 4 deletions actions/setup/js/close_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -194,14 +194,35 @@ async function closeIssue(github, owner, repo, issueNumber, stateReason, intentM
* @type {HandlerFactoryFunction}
*/
async function main(config = {}) {
const configStateReason = config.state_reason || "COMPLETED";
// Determine the state-reason configuration mode:
// - config.state_reason (string): scalar — fixed reason, agent cannot override
// - config.allowed_state_reason (string[]): list — agent may select from this subset
// - neither set: omitted — agent may select from all three supported values
const configStateReason = config.state_reason || null;
/** @type {string[]|null} */
const configStateReasons = Array.isArray(config.allowed_state_reason) && config.allowed_state_reason.length > 0 ? config.allowed_state_reason : null;

// The fallback used when the agent does not supply state_reason at item level.
// Scalar config → use the configured value.
// List config → use the first item in the list.
// Omitted → default to "COMPLETED" (backward compatible).
const defaultStateReason = configStateReason || (configStateReasons ? configStateReasons[0] : "COMPLETED");

const issueIntentEnabled = config.issue_intent !== false;
const requiredLabels = config.required_labels || [];
const requiredTitlePrefix = config.required_title_prefix || "";
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
const githubClient = await createAuthenticatedGitHubClient(config);

core.info(`Close issue configuration: max=${config.max || 10}, state_reason=${configStateReason}, issue_intent=${issueIntentEnabled}`);
let stateReasonMode;
if (configStateReason) {
stateReasonMode = `scalar(${configStateReason})`;
} else if (configStateReasons) {
stateReasonMode = `list(${configStateReasons.join(",")})`;
} else {
stateReasonMode = "omitted";
}
core.info(`Close issue configuration: max=${config.max || 10}, state_reason=${stateReasonMode}, issue_intent=${issueIntentEnabled}`);
if (requiredLabels.length > 0) {
core.info(`Required labels: ${requiredLabels.join(", ")}`);
}
Expand Down Expand Up @@ -283,8 +304,39 @@ async function main(config = {}) {
addComment: addIssueComment,

closeEntity(github, owner, repo, entityNumber, item) {
// Support item-level state_reason override, falling back to config-level default
const stateReason = item.state_reason || configStateReason;
// Determine effective state_reason, validating against permitted values when applicable.
let stateReason;
if (item.state_reason !== undefined && item.state_reason !== null) {
const provided = String(item.state_reason);
if (configStateReason) {
// Scalar config: state_reason is fixed by config; the agent cannot override it.
// The field was not exposed in the tool schema, so enforce the configured value
// regardless of what the agent supplied.
stateReason = configStateReason;
if (provided.toLowerCase() !== stateReason.toLowerCase()) {
core.debug(`state_reason agent value "${provided.toLowerCase()}" overridden by scalar config value "${stateReason.toLowerCase()}"`);
}
} else if (configStateReasons) {
// List config: validate against the configured subset.
const upperProvided = provided.toUpperCase();
const upperAllowed = configStateReasons.map(r => r.toUpperCase());
if (!upperAllowed.includes(upperProvided)) {
throw new Error(`state_reason "${provided}" is not permitted. Allowed values: ${configStateReasons.join(", ")}`);
}
stateReason = provided.toLowerCase();
} else {
// Omitted config: validate against all three supported values.
const upperProvided = provided.toUpperCase();
const supportedUpper = ["COMPLETED", "NOT_PLANNED", "DUPLICATE"];
if (!supportedUpper.includes(upperProvided)) {
throw new Error(`state_reason "${provided}" is not a supported value. Supported values: completed, not_planned, duplicate`);
}
stateReason = provided.toLowerCase();
}
} else {
stateReason = defaultStateReason;
}

const intentMetadata = issueIntentEnabled ? normalizeIssueIntentMetadata(item) : {};
core.info(`Closing issue #${entityNumber} with state_reason=${stateReason}`);

Expand Down
166 changes: 165 additions & 1 deletion actions/setup/js/close_issue.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ describe("close_issue", () => {
info: () => {},
warning: () => {},
error: () => {},
debug: () => {},
messages: [],
infos: [],
warnings: [],
Expand Down Expand Up @@ -789,7 +790,7 @@ describe("close_issue", () => {
expect(updateCalls[0].state_reason).toBe("duplicate");
});

it("should prefer item-level state_reason over config-level default", async () => {
it("should enforce scalar state_reason from config regardless of item-level value", async () => {
const handler = await main({ max: 10, state_reason: "NOT_PLANNED" });
const updateCalls = [];

Expand All @@ -804,12 +805,118 @@ describe("close_issue", () => {
};
};

// Agent provides DUPLICATE but scalar config is NOT_PLANNED — config wins.
const result = await handler({ issue_number: 100, body: "Duplicate of #50", state_reason: "DUPLICATE" }, {});

expect(result.success).toBe(true);
expect(updateCalls[0].state_reason).toBe("not_planned");
});

it("should use first item in allowed_state_reason list as default when agent omits state_reason", async () => {
const handler = await main({ max: 10, allowed_state_reason: ["not_planned", "duplicate"] });
const updateCalls = [];

mockGithub.rest.issues.update = async params => {
updateCalls.push(params);
return {
data: {
number: params.issue_number,
title: "Test Issue",
html_url: `https://github.com/${params.owner}/${params.repo}/issues/${params.issue_number}`,
},
};
};

const result = await handler({ issue_number: 100, body: "Closing" }, {});

expect(result.success).toBe(true);
expect(updateCalls[0].state_reason).toBe("not_planned");
});

it("should accept item-level state_reason from allowed_state_reason list", async () => {
const handler = await main({ max: 10, allowed_state_reason: ["not_planned", "duplicate"] });
const updateCalls = [];

mockGithub.rest.issues.update = async params => {
updateCalls.push(params);
return {
data: {
number: params.issue_number,
title: "Test Issue",
html_url: `https://github.com/${params.owner}/${params.repo}/issues/${params.issue_number}`,
},
};
};

const result = await handler({ issue_number: 100, body: "Duplicate", state_reason: "DUPLICATE" }, {});

expect(result.success).toBe(true);
expect(updateCalls[0].state_reason).toBe("duplicate");
});

it("should reject item-level state_reason not in allowed_state_reason list", async () => {
const handler = await main({ max: 10, allowed_state_reason: ["not_planned", "duplicate"] });

const result = await handler({ issue_number: 100, body: "Completed", state_reason: "COMPLETED" }, {});

expect(result.success).toBe(false);
expect(result.error).toMatch(/not permitted/i);
expect(result.error).toMatch(/COMPLETED/);
});

it("should accept item-level state_reason for omitted config (all three values)", async () => {
const handler = await main({ max: 10 });
const updateCalls = [];

mockGithub.rest.issues.update = async params => {
updateCalls.push(params);
return {
data: {
number: params.issue_number,
title: "Test Issue",
html_url: `https://github.com/${params.owner}/${params.repo}/issues/${params.issue_number}`,
},
};
};

for (const reason of ["COMPLETED", "NOT_PLANNED", "DUPLICATE"]) {
updateCalls.length = 0;
const result = await handler({ issue_number: 100, body: "Closing", state_reason: reason }, {});
expect(result.success).toBe(true);
expect(updateCalls[0].state_reason).toBe(reason.toLowerCase());
}
});

it("should reject invalid state_reason for omitted config", async () => {
const handler = await main({ max: 10 });

const result = await handler({ issue_number: 100, body: "Closing", state_reason: "INVALID" }, {});

expect(result.success).toBe(false);
expect(result.error).toMatch(/not a supported value/i);
});

it("should default to COMPLETED when omitted config and agent omits state_reason", async () => {
const handler = await main({ max: 10 });
const updateCalls = [];

mockGithub.rest.issues.update = async params => {
updateCalls.push(params);
return {
data: {
number: params.issue_number,
title: "Test Issue",
html_url: `https://github.com/${params.owner}/${params.repo}/issues/${params.issue_number}`,
},
};
};

const result = await handler({ issue_number: 100, body: "Closing" }, {});

expect(result.success).toBe(true);
expect(updateCalls[0].state_reason).toBe("completed");
});

it("should resolve temporary ID in issue_number field", async () => {
const handler = await main({ max: 10 });
const updateCalls = [];
Expand Down Expand Up @@ -1320,5 +1427,62 @@ describe("close_issue", () => {
expect(graphqlCalls.length).toBe(0);
expect(mockCore.warnings.some(w => w.includes("not DUPLICATE"))).toBe(true);
});

it("should mark native duplicate when using allowed_state_reason list with DUPLICATE and duplicate_of", async () => {
const handler = await main({ max: 10, allowed_state_reason: ["not_planned", "duplicate"] });
const graphqlCalls = [];
const requestCalls = [];

mockGithub.rest.issues.get = async ({ owner, repo, issue_number }) => ({
data: {
number: issue_number,
title: "Test Issue",
labels: [],
html_url: `https://github.com/${owner}/${repo}/issues/${issue_number}`,
state: "open",
node_id: `node_${owner}_${repo}_${issue_number}`,
},
});

mockGithub.rest.issues.update = async params => ({
data: {
number: params.issue_number,
title: "Test Issue",
html_url: `https://github.com/${params.owner}/${params.repo}/issues/${params.issue_number}`,
node_id: `node_${params.owner}_${params.repo}_${params.issue_number}`,
},
});

mockGithub.request = async (route, params) => {
requestCalls.push({ route, params });
return {
data: {
number: params.issue_number,
title: "Test Issue",
html_url: `https://github.com/${params.owner}/${params.repo}/issues/${params.issue_number}`,
node_id: `node_${params.owner}_${params.repo}_${params.issue_number}`,
},
};
};

mockGithub.graphql = async (mutation, variables) => {
graphqlCalls.push(variables);
return { markAsDuplicate: { duplicate: { id: variables.duplicateId, number: 1003 } } };
};

const result = await handler({ issue_number: 1003, body: "Duplicate report", state_reason: "DUPLICATE", duplicate_of: 50, suggest: true, rationale: "Same CSV defect.", confidence: "HIGH" }, {});

expect(result.success).toBe(true);

// Verify issue-intent PATCH was called with the intent metadata intact (suggest, rationale, confidence)
expect(requestCalls).toHaveLength(1);
expect(requestCalls[0].route).toBe("PATCH /repos/{owner}/{repo}/issues/{issue_number}");
expect(requestCalls[0].params.state).toMatchObject({ value: "closed", suggest: true, rationale: "Same CSV defect.", confidence: "HIGH" });

// Verify the native duplicate GraphQL mutation was also executed
expect(graphqlCalls.length).toBeGreaterThan(0);
const mutation = graphqlCalls.find(c => "duplicateId" in c || "canonicalId" in c);
expect(mutation).toBeDefined();
});
});
});
17 changes: 16 additions & 1 deletion actions/setup/js/generate_safe_outputs_tools.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ async function main() {
}

// Load tools meta (description suffixes, repo params, dynamic tools)
/** @type {{description_suffixes?: Record<string, string>, repo_params?: Record<string, {type: string, description: string}>, dynamic_tools?: Array<unknown>, required_field_removals?: Record<string, string[]>, required_field_additions?: Record<string, string[]>}} */
/** @type {{description_suffixes?: Record<string, string>, repo_params?: Record<string, {type: string, description: string}>, dynamic_tools?: Array<unknown>, required_field_removals?: Record<string, string[]>, required_field_additions?: Record<string, string[]>, property_injections?: Record<string, Record<string, unknown>>}} */
let toolsMeta = { description_suffixes: {}, repo_params: {}, dynamic_tools: [] };
if (fs.existsSync(toolsMetaPath)) {
try {
Expand Down Expand Up @@ -367,6 +367,21 @@ async function main() {
enhancedTool.inputSchema.required = Array.from(new Set([...existingRequired, ...requiredAdditions]));
}

// Inject or override properties in inputSchema based on workflow configuration.
// Used for dynamic fields like state_reason whose schema depends on config.
const propertyInjections = toolsMeta.property_injections?.[tool.name];
if (propertyInjections && typeof propertyInjections === "object") {
if (!enhancedTool.inputSchema) {
enhancedTool.inputSchema = { type: "object", properties: {} };
}
if (!enhancedTool.inputSchema.properties) {
enhancedTool.inputSchema.properties = {};
}
for (const [propName, propSchema] of Object.entries(propertyInjections)) {
enhancedTool.inputSchema.properties[propName] = propSchema;
}
}

applyAssignMilestoneAlternativeRequirements(enhancedTool);

return enhancedTool;
Expand Down
Loading
Loading