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
1,271 changes: 1,271 additions & 0 deletions .github/workflows/smoke-agent.lock.yml

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions .github/workflows/smoke-agent.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
---
description: Smoke test that validates assign-to-agent with the agentic-workflows custom agent
on:
workflow_dispatch:
pull_request:
types: [labeled]
names: ["metal"]
status-comment: true
permissions:
contents: read
issues: read
pull-requests: read
name: Smoke Agent
engine: codex
strict: true
tools:
github:
network:
allowed:
- defaults
- github
safe-outputs:
assign-to-agent:
target: "*"
max: 1
allowed: [copilot]
custom-agent: agentic-workflows
add-comment:
hide-older-comments: true
max: 2
messages:
footer: "> 🤖 *Smoke test by [{workflow_name}]({run_url})*"
run-started: "🤖 [{workflow_name}]({run_url}) is looking for a Smoke issue to assign..."
run-success: "✅ [{workflow_name}]({run_url}) completed. Issue assigned to the agentic-workflows agent."
run-failure: "❌ [{workflow_name}]({run_url}) {status}. Check the logs for details."
timeout-minutes: 10
---

# Smoke Agent: assign-to-agent with agentic-workflows

This workflow validates that `assign-to-agent` works correctly with the `agentic-workflows` custom agent.

## Instructions

1. **Find a Smoke issue**: Use the GitHub MCP tools to search for an open issue in ${{ github.repository }} whose title starts with "Smoke". Use the search query: `is:issue is:open in:title Smoke repo:${{ github.repository }}`. Pick the first result.

2. **Assign the issue**: Use the `assign_to_agent` safe-output tool to assign the issue to copilot using the `agentic-workflows` custom agent:

```json
{
"type": "assign_to_agent",
"issue_number": <issue_number>,
"agent": "copilot"
}
```

3. **Report**: Add a brief comment to the current pull request confirming the issue number that was assigned and which agent was used.

If no Smoke* issue is found, use the `noop` tool to report that no matching issue was found.
6 changes: 4 additions & 2 deletions .github/workflows/smoke-copilot-arm.lock.yml

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

11 changes: 9 additions & 2 deletions actions/setup/js/assign_agent_helpers.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -250,9 +250,10 @@ async function getPullRequestDetails(owner, repo, pullNumber) {
* @param {string|null} model - Optional AI model to use (e.g., "claude-opus-4.6", "auto")
* @param {string|null} customAgent - Optional custom agent ID for custom agents
* @param {string|null} customInstructions - Optional custom instructions for the agent
* @param {string|null} baseBranch - Optional base branch for the PR (uses GraphQL baseRef field)
* @returns {Promise<boolean>} True if successful
*/
async function assignAgentToIssue(assignableId, agentId, currentAssignees, agentName, allowedAgents = null, pullRequestRepoId = null, model = null, customAgent = null, customInstructions = null) {
async function assignAgentToIssue(assignableId, agentId, currentAssignees, agentName, allowedAgents = null, pullRequestRepoId = null, model = null, customAgent = null, customInstructions = null, baseBranch = null) {
// Filter current assignees based on allowed list (if configured)
let filteredAssignees = currentAssignees;
if (allowedAgents && allowedAgents.length > 0) {
Expand All@@ -276,7 +277,7 @@ async function assignAgentToIssue(assignableId, agentId, currentAssignees, agent
const actorIds = [agentId, ...filteredAssignees.map(a => a.id).filter(id => id !== agentId)];

// Build the agentAssignment object if any agent-specific parameters are provided
const hasAgentAssignment = pullRequestRepoId || model || customAgent || customInstructions;
const hasAgentAssignment = pullRequestRepoId || model || customAgent || customInstructions || baseBranch;

// Build the mutation - conditionally include agentAssignment if any parameters are provided
let mutation;
Expand All@@ -303,6 +304,10 @@ async function assignAgentToIssue(assignableId, agentId, currentAssignees, agent
agentAssignmentFields.push("customInstructions: $customInstructions");
agentAssignmentParams.push("$customInstructions: String!");
}
if (baseBranch) {
agentAssignmentFields.push("baseRef: $baseRef");
agentAssignmentParams.push("$baseRef: String!");
}

// Build the mutation with agentAssignment
const allParams = ["$assignableId: ID!", "$actorIds: [ID!]!", ...agentAssignmentParams].join(", ");
Expand All@@ -329,6 +334,7 @@ async function assignAgentToIssue(assignableId, agentId, currentAssignees, agent
...(model && { model }),
...(customAgent && { customAgent }),
...(customInstructions && { customInstructions }),
...(baseBranch && { baseRef: baseBranch }),
};
} else {
// Standard mutation without agentAssignment
Expand DownExpand Up@@ -357,6 +363,7 @@ async function assignAgentToIssue(assignableId, agentId, currentAssignees, agent
if (model) debugMsg += `, model=${model}`;
if (customAgent) debugMsg += `, customAgent=${customAgent}`;
if (customInstructions) debugMsg += `, customInstructions=${customInstructions.substring(0, 50)}...`;
if (baseBranch) debugMsg += `, baseRef=${baseBranch}`;
core.debug(debugMsg);

// Build GraphQL-Features header - include coding_agent_model_selection when model is provided
Expand Down
59 changes: 59 additions & 0 deletions actions/setup/js/assign_agent_helpers.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -397,6 +397,65 @@ describe("assign_agent_helpers.cjs", () => {
expect(variables.customInstructions).toBe("Focus on performance");
});

it("should include baseBranch as baseRef in agentAssignment when provided", async () => {
mockGithub.graphql.mockResolvedValueOnce({
replaceActorsForAssignable: {
__typename: "ReplaceActorsForAssignablePayload",
},
});

await assignAgentToIssue("ISSUE_123", "AGENT_456", [{ id: "USER_1", login: "user1" }], "copilot", null, null, null, null, null, "develop");

const calledArgs = mockGithub.graphql.mock.calls[0];
const mutation = calledArgs[0];
const variables = calledArgs[1];

// Mutation should include agentAssignment with baseRef
expect(mutation).toContain("agentAssignment");
expect(mutation).toContain("baseRef: $baseRef");
expect(variables.baseRef).toBe("develop");
});

it("should not include baseRef in mutation when baseBranch is not provided", async () => {
mockGithub.graphql.mockResolvedValueOnce({
replaceActorsForAssignable: {
__typename: "ReplaceActorsForAssignablePayload",
},
});

await assignAgentToIssue("ISSUE_123", "AGENT_456", [{ id: "USER_1", login: "user1" }], "copilot", null, null, null, null, null, null);

const calledArgs = mockGithub.graphql.mock.calls[0];
const mutation = calledArgs[0];
const variables = calledArgs[1];

expect(mutation).not.toContain("baseRef");
expect(variables.baseRef).toBeUndefined();
});

it("should include baseRef alongside other agentAssignment parameters", async () => {
mockGithub.graphql.mockResolvedValueOnce({
replaceActorsForAssignable: {
__typename: "ReplaceActorsForAssignablePayload",
},
});

await assignAgentToIssue("ISSUE_123", "AGENT_456", [{ id: "USER_1", login: "user1" }], "copilot", null, "REPO_ID_789", "claude-opus-4.6", null, "Fix the bug", "develop");

const calledArgs = mockGithub.graphql.mock.calls[0];
const mutation = calledArgs[0];
const variables = calledArgs[1];

expect(mutation).toContain("targetRepositoryId: $targetRepoId");
expect(mutation).toContain("model: $model");
expect(mutation).toContain("customInstructions: $customInstructions");
expect(mutation).toContain("baseRef: $baseRef");
expect(variables.targetRepoId).toBe("REPO_ID_789");
expect(variables.model).toBe("claude-opus-4.6");
expect(variables.customInstructions).toBe("Fix the bug");
expect(variables.baseRef).toBe("develop");
});

it("should omit agentAssignment when no agent-specific parameters provided", async () => {
mockGithub.graphql.mockResolvedValueOnce({
replaceActorsForAssignable: {
Expand Down
17 changes: 6 additions & 11 deletions actions/setup/js/assign_to_agent.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,7 @@ const { resolveTarget } = require("./safe_output_helpers.cjs");
const { loadTemporaryIdMap, resolveRepoIssueTarget } = require("./temporary_id.cjs");
const { sleep } = require("./error_recovery.cjs");
const { parseAllowedRepos, validateRepo, resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");
const { resolvePullRequestRepo, buildBranchInstruction } = require("./pr_helpers.cjs");
const { resolvePullRequestRepo } = require("./pr_helpers.cjs");
Comment thread
pelikhan marked this conversation as resolved.

async function main() {
const result = loadAgentOutput();
Expand DownExpand Up@@ -147,8 +147,6 @@ async function main() {
let pullRequestRepoId = null;
// Effective base branch: explicit config > fetched default branch from PR repo
let effectiveBaseBranch = configuredBaseBranch || null;
// Resolved default branch fetched from the target PR repo (used in NOT clause of branch instructions)
let resolvedDefaultBranch = null;

// Get allowed PR repos configuration for cross-repo validation
const allowedPullRequestReposEnv = process.env.GH_AW_AGENT_ALLOWED_PULL_REQUEST_REPOS?.trim();
Expand All@@ -175,7 +173,6 @@ async function main() {
const resolved = await resolvePullRequestRepo(github, pullRequestOwner, pullRequestRepo, configuredBaseBranch);
pullRequestRepoId = resolved.repoId;
effectiveBaseBranch = resolved.effectiveBaseBranch;
resolvedDefaultBranch = resolved.resolvedDefaultBranch;
core.info(`Pull request repository ID: ${pullRequestRepoId}`);
if (!configuredBaseBranch && effectiveBaseBranch) {
core.info(`Resolved pull request repository default branch: ${effectiveBaseBranch}`);
Expand All@@ -201,12 +198,7 @@ async function main() {
// They are NOT available as per-item overrides in the tool call
const model = defaultModel;
const customAgent = defaultCustomAgent;
// Build effective custom instructions: prepend base-branch instruction when needed
let customInstructions = defaultCustomInstructions;
if (effectiveBaseBranch) {
const branchInstruction = buildBranchInstruction(effectiveBaseBranch, resolvedDefaultBranch);
customInstructions = customInstructions ? `${branchInstruction}\n\n${customInstructions}` : branchInstruction;
}
const customInstructions = defaultCustomInstructions || null;

// Use these variables to allow temporary IDs to override target repo per-item.
// Default to the per-item resolved repo (from item.repo or defaultTargetRepo).
Expand DownExpand Up@@ -466,7 +458,10 @@ async function main() {
if (customInstructions) {
core.info(`Using custom instructions: ${customInstructions.substring(0, 100)}${customInstructions.length > 100 ? "..." : ""}`);
}
Comment thread
pelikhan marked this conversation as resolved.
const success = await assignAgentToIssue(assignableId, agentId, currentAssignees, agentName, allowedAgents, effectivePullRequestRepoId, model, customAgent, customInstructions);
if (effectiveBaseBranch) {
core.info(`Using base branch: ${effectiveBaseBranch}`);
}
const success = await assignAgentToIssue(assignableId, agentId, currentAssignees, agentName, allowedAgents, effectivePullRequestRepoId, model, customAgent, customInstructions, effectiveBaseBranch);

if (!success) {
throw new Error(`Failed to assign ${agentName} via GraphQL`);
Expand Down
28 changes: 14 additions & 14 deletions actions/setup/js/assign_to_agent.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1346,15 +1346,15 @@ describe("assign_to_agent", () => {
await eval(`(async () => { ${assignToAgentScript}; await main(); })()`);

expect(mockCore.setFailed).not.toHaveBeenCalled();
// Verify the mutation was called with custom instructions containing the branch instruction
// Verify the mutation was called with baseRef set to the explicit base-branch
const lastCall = mockGithub.graphql.mock.calls[mockGithub.graphql.mock.calls.length - 1];
expect(lastCall[0]).toContain("customInstructions");
expect(lastCall[1].customInstructions).toContain("develop");
// NOT clause should reference the resolved default branch, not hardcoded 'main'
expect(lastCall[1].customInstructions).toContain("NOT from 'main'");
expect(lastCall[0]).toContain("baseRef: $baseRef");
expect(lastCall[1].baseRef).toBe("develop");
// customInstructions should NOT contain the branch instruction text
expect(lastCall[1].customInstructions).toBeUndefined();
});

it("should auto-resolve non-main default branch from pull-request-repo and pass as instruction", async () => {
it("should auto-resolve non-main default branch from pull-request-repo and set as baseRef", async () => {
process.env.GH_AW_AGENT_PULL_REQUEST_REPO = "test-owner/code-repo";
// No GH_AW_AGENT_BASE_BRANCH set - should use repo's default branch
setAgentOutput({
Expand All@@ -1376,13 +1376,13 @@ describe("assign_to_agent", () => {

expect(mockCore.setFailed).not.toHaveBeenCalled();
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved pull request repository default branch: develop"));
// Verify the mutation was called with custom instructions containing branch info
// Verify the mutation was called with baseRef set to the resolved default branch
const lastCall = mockGithub.graphql.mock.calls[mockGithub.graphql.mock.calls.length - 1];
expect(lastCall[0]).toContain("customInstructions");
expect(lastCall[1].customInstructions).toContain("develop");
expect(lastCall[0]).toContain("baseRef: $baseRef");
expect(lastCall[1].baseRef).toBe("develop");
});

it("should inject branch instruction even when pull-request-repo default branch is main (no explicit base-branch)", async () => {
it("should set baseRef when pull-request-repo default branch is main (no explicit base-branch)", async () => {
process.env.GH_AW_AGENT_PULL_REQUEST_REPO = "test-owner/code-repo";
// No GH_AW_AGENT_BASE_BRANCH set; repo default is main
setAgentOutput({
Expand All@@ -1403,10 +1403,10 @@ describe("assign_to_agent", () => {
await eval(`(async () => { ${assignToAgentScript}; await main(); })()`);

expect(mockCore.setFailed).not.toHaveBeenCalled();
// Instruction is injected with the resolved default branch name (no NOT clause since it matches)
// Verify the mutation was called with baseRef set to the repo's default branch
const lastCall = mockGithub.graphql.mock.calls[mockGithub.graphql.mock.calls.length - 1];
expect(lastCall[0]).toContain("customInstructions");
expect(lastCall[1].customInstructions).toContain("main");
expect(lastCall[1].customInstructions).not.toContain("NOT from");
expect(lastCall[0]).toContain("baseRef: $baseRef");
expect(lastCall[1].baseRef).toBe("main");
expect(lastCall[1].customInstructions).toBeUndefined();
});
});
4 changes: 3 additions & 1 deletion pkg/workflow/compiler_safe_outputs.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work
var hasCommand bool
var hasReaction bool
var hasStopAfter bool
var hasStatusComment bool
var otherEvents map[string]any

// Use cached On field from ParsedFrontmatter if available, otherwise fall back to map access
Expand DownExpand Up@@ -60,6 +61,7 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work

// Extract status-comment from on section
if statusCommentValue, hasStatusCommentField := onMap["status-comment"]; hasStatusCommentField {
hasStatusComment = true
if statusCommentBool, ok := statusCommentValue.(bool); ok {
workflowData.StatusComment = &statusCommentBool
compilerSafeOutputsLog.Printf("status-comment set to: %v", statusCommentBool)
Expand DownExpand Up@@ -156,7 +158,7 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work
// We'll store this and handle it in applyDefaults
workflowData.On = "" // This will trigger command handling in applyDefaults
workflowData.CommandOtherEvents = otherEvents
} else if (hasReaction || hasStopAfter) && len(otherEvents) > 0 {
} else if (hasReaction || hasStopAfter || hasStatusComment) && len(otherEvents) > 0 {
// Only re-marshal the "on" if we have to
onEventsYAML, err := yaml.Marshal(map[string]any{"on": otherEvents})
if err == nil {
Expand Down
Loading