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
47 changes: 42 additions & 5 deletions actions/setup/js/assign_to_agent.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ const { generateStagedPreview } = require("./staged_preview.cjs");
const { AGENT_LOGIN_NAMES, getAvailableAgentLogins, findAgent, getIssueDetails, getPullRequestDetails, assignAgentToIssue, generatePermissionErrorSummary } = require("./assign_agent_helpers.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { resolveTarget } = require("./safe_output_helpers.cjs");
const { loadTemporaryIdMap, resolveRepoIssueTarget } = require("./temporary_id.cjs");

async function main() {
const result = loadAgentOutput();
if (!result.success) {
return;
}

// Load temporary ID map once (used to resolve aw_... IDs to real issue numbers)
const temporaryIdMap = loadTemporaryIdMap();

const assignItems = result.items.filter(item => item.type === "assign_to_agent");
if (assignItems.length === 0) {
core.info("No assign_to_agent items found in agent output");
Expand Down Expand Up @@ -110,6 +114,14 @@ async function main() {
for (const item of itemsToProcess) {
const agentName = item.agent ?? defaultAgent;

// Use these variables to allow temporary IDs to override target repo per-item.
// Default to the configured target repo.
let effectiveOwner = targetOwner;
let effectiveRepo = targetRepo;

// Use a copy for target resolution so we never mutate the original item.
let itemForTarget = item;

// Validate that both issue_number and pull_number are not specified simultaneously
if (item.issue_number != null && item.pull_number != null) {
core.error("Cannot specify both issue_number and pull_number in the same assign_to_agent item");
Expand All @@ -123,17 +135,42 @@ async function main() {
continue;
}

// If issue_number is a temporary ID (aw_...), resolve it to a real issue number before calling resolveTarget.
// resolveTarget parses issue_number as a number, so we must resolve temporary IDs first.
// Note: We only support temporary IDs for issues, not PRs.
if (item.issue_number != null) {
const resolvedTarget = resolveRepoIssueTarget(item.issue_number, temporaryIdMap, targetOwner, targetRepo);
if (!resolvedTarget.resolved) {
core.error(resolvedTarget.errorMessage || `Failed to resolve issue target: ${item.issue_number}`);
results.push({
issue_number: item.issue_number,
pull_number: item.pull_number ?? null,
agent: agentName,
success: false,
error: resolvedTarget.errorMessage || `Failed to resolve issue target: ${item.issue_number}`,
});
continue;
}

effectiveOwner = resolvedTarget.resolved.owner;
effectiveRepo = resolvedTarget.resolved.repo;
itemForTarget = { ...item, issue_number: resolvedTarget.resolved.number };
if (resolvedTarget.wasTemporaryId) {
core.info(`Resolved temporary issue id to ${effectiveOwner}/${effectiveRepo}#${resolvedTarget.resolved.number}`);
}
}

// Determine the effective target configuration:
// - If issue_number or pull_number is explicitly provided, use "*" (explicit mode)
// - Otherwise use the configured target (defaults to "triggering")
const hasExplicitTarget = item.issue_number != null || item.pull_number != null;
const hasExplicitTarget = itemForTarget.issue_number != null || itemForTarget.pull_number != null;
const effectiveTarget = hasExplicitTarget ? "*" : targetConfig;

// Resolve target number using the same logic as other safe outputs
// This allows automatic resolution from workflow context when issue_number/pull_number is not explicitly provided
const targetResult = resolveTarget({
targetConfig: effectiveTarget,
item,
item: itemForTarget,
context,
itemType: "assign_to_agent",
supportsPR: true, // Supports both issues and PRs
Expand Down Expand Up @@ -206,7 +243,7 @@ async function main() {
let agentId = agentCache[agentName];
if (!agentId) {
core.info(`Looking for ${agentName} coding agent...`);
agentId = await findAgent(targetOwner, targetRepo, agentName);
agentId = await findAgent(effectiveOwner, effectiveRepo, agentName);
if (!agentId) {
throw new Error(`${agentName} coding agent is not available for this repository`);
}
Expand All @@ -220,14 +257,14 @@ async function main() {
let currentAssignees;

if (issueNumber) {
const issueDetails = await getIssueDetails(targetOwner, targetRepo, issueNumber);
const issueDetails = await getIssueDetails(effectiveOwner, effectiveRepo, issueNumber);
if (!issueDetails) {
throw new Error(`Failed to get issue details`);
}
assignableId = issueDetails.issueId;
currentAssignees = issueDetails.currentAssignees;
} else if (pullNumber) {
const prDetails = await getPullRequestDetails(targetOwner, targetRepo, pullNumber);
const prDetails = await getPullRequestDetails(effectiveOwner, effectiveRepo, pullNumber);
if (!prDetails) {
throw new Error(`Failed to get pull request details`);
}
Expand Down
51 changes: 51 additions & 0 deletions actions/setup/js/assign_to_agent.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ describe("assign_to_agent", () => {
delete process.env.GH_AW_AGENT_ALLOWED;
delete process.env.GH_AW_TARGET_REPO;
delete process.env.GH_AW_AGENT_IGNORE_IF_ERROR;
delete process.env.GH_AW_TEMPORARY_ID_MAP;

// Reset context to default
mockContext.eventName = "issues";
Expand Down Expand Up @@ -208,6 +209,56 @@ describe("assign_to_agent", () => {
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Found 3 agent assignments, but max is 2"));
});

it("should resolve temporary issue IDs (aw_...) using GH_AW_TEMPORARY_ID_MAP", async () => {
process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({
aw_abc123def456: { repo: "test-owner/test-repo", number: 99 },
});

setAgentOutput({
items: [
{
type: "assign_to_agent",
issue_number: "aw_abc123def456",
agent: "copilot",
},
],
errors: [],
});

// Mock GraphQL responses: findAgent -> getIssueDetails (issueNumber 99) -> addAssignees
mockGithub.graphql
.mockResolvedValueOnce({
repository: {
suggestedActors: {
nodes: [{ login: "copilot-swe-agent", id: "MDQ6VXNlcjE=" }],
},
},
})
.mockResolvedValueOnce({
repository: {
issue: {
id: "issue-id-99",
assignees: { nodes: [] },
},
},
})
.mockResolvedValueOnce({
addAssigneesToAssignable: {
assignable: { assignees: { nodes: [{ login: "copilot-swe-agent" }] } },
},
});

await eval(`(async () => { ${assignToAgentScript}; await main(); })()`);

expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved temporary issue id"));

// Ensure the issue lookup used the resolved issue number
const secondCallArgs = mockGithub.graphql.mock.calls[1];
expect(secondCallArgs).toBeDefined();
const variables = secondCallArgs[1];
expect(variables.issueNumber).toBe(99);
});

it("should reject unsupported agents", async () => {
setAgentOutput({
items: [
Expand Down
25 changes: 23 additions & 2 deletions actions/setup/js/collect_ndjson_output.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,15 @@ describe("collect_ndjson_output.cjs", () => {
ruleIdSuffix: { type: "string", pattern: "^[a-zA-Z0-9_-]+$", patternError: "must contain only alphanumeric characters, hyphens, and underscores", sanitize: !0, maxLength: 128 },
},
},
assign_to_agent: { defaultMax: 1, fields: { issue_number: { required: !0, positiveInteger: !0 }, agent: { type: "string", sanitize: !0, maxLength: 128 } } },
assign_to_agent: {
defaultMax: 1,
customValidation: "requiresOneOf:issue_number,pull_number",
fields: {
issue_number: { issueNumberOrTemporaryId: !0 },
pull_number: { optionalPositiveInteger: !0 },
agent: { type: "string", sanitize: !0, maxLength: 128 },
},
},
create_discussion: {
defaultMax: 1,
fields: { title: { required: !0, type: "string", sanitize: !0, maxLength: 128 }, body: { required: !0, type: "string", sanitize: !0, maxLength: 65e3 }, category: { type: "string", sanitize: !0, maxLength: 128 } },
Expand Down Expand Up @@ -1521,6 +1529,19 @@ describe("collect_ndjson_output.cjs", () => {
const parsedOutput = JSON.parse(outputCall[1]);
(expect(parsedOutput.items).toHaveLength(1), expect(parsedOutput.items[0].issue_number).toBe(42), expect(parsedOutput.errors).toHaveLength(0));
}),
it("should validate assign_to_agent with temporary_id issue_number", async () => {
const testFile = "/tmp/gh-aw/test-ndjson-output.txt",
ndjsonContent = '{"type": "assign_to_agent", "issue_number": "aw_abc123def456"}';
(fs.writeFileSync(testFile, ndjsonContent), (process.env.GH_AW_SAFE_OUTPUTS = testFile));
const __config = '{"assign_to_agent": true}',
configPath = "/opt/gh-aw/safeoutputs/config.json";
(fs.mkdirSync("/opt/gh-aw/safeoutputs", { recursive: !0 }), fs.writeFileSync(configPath, __config), await eval(`(async () => { ${collectScript}; await main(); })()`));
const setOutputCalls = mockCore.setOutput.mock.calls,
outputCall = setOutputCalls.find(call => "output" === call[0]);
expect(outputCall).toBeDefined();
const parsedOutput = JSON.parse(outputCall[1]);
(expect(parsedOutput.items).toHaveLength(1), expect(parsedOutput.items[0].issue_number).toBe("aw_abc123def456"), expect(parsedOutput.errors).toHaveLength(0));
}),
it("should validate assign_to_agent with optional fields", async () => {
const testFile = "/tmp/gh-aw/test-ndjson-output.txt",
ndjsonContent = '{"type": "assign_to_agent", "issue_number": 42, "agent": "my-agent"}';
Expand Down Expand Up @@ -1549,7 +1570,7 @@ describe("collect_ndjson_output.cjs", () => {
outputCall = setOutputCalls.find(call => "output" === call[0]);
expect(outputCall).toBeDefined();
const parsedOutput = JSON.parse(outputCall[1]);
(expect(parsedOutput.items).toHaveLength(0), expect(parsedOutput.errors.length).toBeGreaterThan(0), expect(parsedOutput.errors.some(e => e.includes("assign_to_agent 'issue_number' is required"))).toBe(!0));
(expect(parsedOutput.items).toHaveLength(0), expect(parsedOutput.errors.length).toBeGreaterThan(0), expect(parsedOutput.errors.some(e => e.includes("assign_to_agent requires at least one of"))).toBe(!0));
}));
}),
describe("link_sub_issue temporary ID validation", () => {
Expand Down
45 changes: 32 additions & 13 deletions actions/setup/js/link_sub_issue.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { resolveIssueNumber } = require("./temporary_id.cjs");
const { loadTemporaryIdMapFromResolved, resolveRepoIssueTarget } = require("./temporary_id.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");

/**
Expand Down Expand Up @@ -55,12 +55,12 @@ async function main(config = {}) {

const item = message;

// Convert resolvedTemporaryIds object to Map for resolveIssueNumber
const temporaryIdMap = new Map(Object.entries(resolvedTemporaryIds || {}));
// Convert resolvedTemporaryIds to a normalized Map for resolveIssueNumber
const temporaryIdMap = loadTemporaryIdMapFromResolved(resolvedTemporaryIds);

// Resolve issue numbers, supporting temporary IDs from create_issue job
const parentResolved = resolveIssueNumber(item.parent_issue_number, temporaryIdMap);
const subResolved = resolveIssueNumber(item.sub_issue_number, temporaryIdMap);
const parentResolved = resolveRepoIssueTarget(item.parent_issue_number, temporaryIdMap, context.repo.owner, context.repo.repo);
const subResolved = resolveRepoIssueTarget(item.sub_issue_number, temporaryIdMap, context.repo.owner, context.repo.repo);

// Check if either parent or sub issue is an unresolved temporary ID
// If so, defer the operation to allow for resolution later
Expand Down Expand Up @@ -122,18 +122,37 @@ async function main(config = {}) {
}

if (parentResolved.wasTemporaryId && parentResolved.resolved) {
core.info(`Resolved parent temporary ID '${item.parent_issue_number}' to ${parentResolved.resolved.repo}#${parentIssueNumber}`);
core.info(`Resolved parent temporary ID '${item.parent_issue_number}' to ${parentResolved.resolved.owner}/${parentResolved.resolved.repo}#${parentIssueNumber}`);
}
if (subResolved.wasTemporaryId && subResolved.resolved) {
core.info(`Resolved sub-issue temporary ID '${item.sub_issue_number}' to ${subResolved.resolved.repo}#${subIssueNumber}`);
core.info(`Resolved sub-issue temporary ID '${item.sub_issue_number}' to ${subResolved.resolved.owner}/${subResolved.resolved.repo}#${subIssueNumber}`);
}

// Sub-issue linking is only supported within the same repository.
if (parentResolved.resolved && subResolved.resolved) {
const parentRepoSlug = `${parentResolved.resolved.owner}/${parentResolved.resolved.repo}`;
const subRepoSlug = `${subResolved.resolved.owner}/${subResolved.resolved.repo}`;
if (parentRepoSlug !== subRepoSlug) {
const error = `Parent and sub-issue must be in the same repository for link_sub_issue (got ${parentRepoSlug} and ${subRepoSlug})`;
core.warning(error);
return {
parent_issue_number: item.parent_issue_number,
sub_issue_number: item.sub_issue_number,
success: false,
error,
};
}
}

const owner = parentResolved.resolved?.owner || context.repo.owner;
const repo = parentResolved.resolved?.repo || context.repo.repo;

// Fetch parent issue to validate filters
let parentIssue;
try {
const parentResponse = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
owner,
repo,
issue_number: parentIssueNumber,
});
parentIssue = parentResponse.data;
Expand Down Expand Up @@ -177,8 +196,8 @@ async function main(config = {}) {
let subIssue;
try {
const subResponse = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
owner,
repo,
issue_number: subIssueNumber,
});
subIssue = subResponse.data;
Expand Down Expand Up @@ -208,8 +227,8 @@ async function main(config = {}) {
}
`;
const parentCheckResult = await github.graphql(parentCheckQuery, {
owner: context.repo.owner,
repo: context.repo.repo,
owner,
repo,
number: subIssueNumber,
});

Expand Down
16 changes: 16 additions & 0 deletions actions/setup/js/link_sub_issue.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,20 @@ const mockCore = {
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved parent temporary ID"));
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved sub-issue temporary ID"));
});

it("should fail when parent and sub temporary IDs resolve to different repos", async () => {
const message = { type: "link_sub_issue", parent_issue_number: "aw_123456789abc", sub_issue_number: "aw_456789abcdef" };

const resolvedIds = {
aw_123456789abc: { repo: "org-a/repo-a", number: 100 },
aw_456789abcdef: { repo: "org-b/repo-b", number: 50 },
};

const result = await handler(message, resolvedIds);

expect(result.success).toBe(false);
expect(result.error).toContain("must be in the same repository");
expect(mockGithub.rest.issues.get).not.toHaveBeenCalled();
expect(mockGithub.graphql).not.toHaveBeenCalled();
});
}));
4 changes: 4 additions & 0 deletions actions/setup/js/safe_output_type_validator.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ function validateField(value, fieldName, validation, itemType, lineNum, options)

// For issueNumberOrTemporaryId fields, delegate required check to validateIssueNumberOrTemporaryId
if (validation.issueNumberOrTemporaryId) {
// If the field is optional and not present, skip validation
if (!validation.required && (value === undefined || value === null)) {
return { isValid: true };
}
return validateIssueNumberOrTemporaryId(value, `${itemType} '${fieldName}'`, lineNum);
}

Expand Down
Loading
Loading