Add unassign-first option to assign-to-user safe output#16542
Conversation
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds an unassign-first boolean configuration option to the assign-to-user safe output to address the issue where GitHub's addAssignees API is additive rather than replacing existing assignees. When enabled, the handler fetches current assignees via issues.get() and removes them via removeAssignees() before calling addAssignees().
Changes:
- Added
unassign-firstboolean field toAssignToUserConfigstruct (default: false) - Updated handler logic to fetch and remove current assignees when the option is enabled
- Added JSON schema validation and comprehensive test coverage
- Updated documentation to describe the new feature
Reviewed changes
Copilot reviewed 43 out of 43 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
pkg/workflow/assign_to_user.go |
Added UnassignFirst boolean field to the configuration struct |
pkg/workflow/compiler_safe_outputs_config.go |
Added config builder call to include unassign_first in handler config when true |
actions/setup/js/assign_to_user.cjs |
Implemented unassign logic: fetch current assignees and remove them before assigning new ones |
actions/setup/js/assign_to_user.test.cjs |
Added unit tests for unassign-first enabled/disabled scenarios |
pkg/workflow/compiler_safe_outputs_config_test.go |
Added test to verify unassign_first is correctly serialized in handler config |
pkg/parser/schemas/main_workflow_schema.json |
Added schema validation for the new unassign-first boolean field |
docs/src/content/docs/reference/safe-outputs.md |
Documented the new configuration option with usage example |
docs/src/content/docs/reference/safe-outputs-specification.md |
Added specification details for the unassign-first feature |
pkg/cli/workflows/test-assign-unassign-first.md |
Added test workflow to validate the feature |
.changeset/patch-assign-to-user-unassign-first.md |
Added changeset entry describing the patch |
Multiple .lock.yml files |
Regenerated lock files (includes unrelated updates to add_comment/hide_comment descriptions) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| it("should unassign current assignees when unassign_first is true", async () => { | ||
| vi.clearAllMocks(); // Clear all mocks before this test | ||
|
|
||
| const { main } = require("./assign_to_user.cjs"); | ||
| const handler = await main({ | ||
| max: 10, | ||
| unassign_first: true, | ||
| }); | ||
|
|
||
| // Mock getting current assignees | ||
| mockGithub.rest.issues.get = vi.fn().mockResolvedValue({ | ||
| data: { | ||
| assignees: [{ login: "old-user1" }, { login: "old-user2" }], | ||
| }, | ||
| }); | ||
|
|
||
| mockGithub.rest.issues.removeAssignees = vi.fn().mockResolvedValue({}); | ||
| mockGithub.rest.issues.addAssignees = vi.fn().mockResolvedValue({}); // Recreate the mock | ||
|
|
||
| const message = { | ||
| type: "assign_to_user", | ||
| assignees: ["new-user1"], | ||
| }; | ||
|
|
||
| const result = await handler(message, {}); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(result.assigneesAdded).toEqual(["new-user1"]); | ||
|
|
||
| // Verify that get was called to fetch current assignees | ||
| expect(mockGithub.rest.issues.get).toHaveBeenCalledWith({ | ||
| owner: "test-owner", | ||
| repo: "test-repo", | ||
| issue_number: 123, | ||
| }); | ||
|
|
||
| // Verify that removeAssignees was called with current assignees | ||
| expect(mockGithub.rest.issues.removeAssignees).toHaveBeenCalledWith({ | ||
| owner: "test-owner", | ||
| repo: "test-repo", | ||
| issue_number: 123, | ||
| assignees: ["old-user1", "old-user2"], | ||
| }); | ||
|
|
||
| // Verify that addAssignees was called with new assignees | ||
| expect(mockGithub.rest.issues.addAssignees).toHaveBeenCalledWith({ | ||
| owner: "test-owner", | ||
| repo: "test-repo", | ||
| issue_number: 123, | ||
| assignees: ["new-user1"], | ||
| }); | ||
| }); | ||
|
|
||
| it("should skip unassignment when there are no current assignees", async () => { | ||
| vi.clearAllMocks(); // Clear all mocks before this test | ||
|
|
||
| const { main } = require("./assign_to_user.cjs"); | ||
| const handler = await main({ | ||
| max: 10, | ||
| unassign_first: true, | ||
| }); | ||
|
|
||
| // Mock getting no current assignees | ||
| mockGithub.rest.issues.get = vi.fn().mockResolvedValue({ | ||
| data: { | ||
| assignees: [], | ||
| }, | ||
| }); | ||
|
|
||
| mockGithub.rest.issues.removeAssignees = vi.fn().mockResolvedValue({}); | ||
| mockGithub.rest.issues.addAssignees = vi.fn().mockResolvedValue({}); // Recreate the mock | ||
|
|
||
| const message = { | ||
| type: "assign_to_user", | ||
| assignees: ["new-user1"], | ||
| }; | ||
|
|
||
| const result = await handler(message, {}); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(result.assigneesAdded).toEqual(["new-user1"]); | ||
|
|
||
| // Verify that get was called | ||
| expect(mockGithub.rest.issues.get).toHaveBeenCalledWith({ | ||
| owner: "test-owner", | ||
| repo: "test-repo", | ||
| issue_number: 123, | ||
| }); | ||
|
|
||
| // Verify that removeAssignees was NOT called since there are no current assignees | ||
| expect(mockGithub.rest.issues.removeAssignees).not.toHaveBeenCalled(); | ||
|
|
||
| // Verify that addAssignees was still called | ||
| expect(mockGithub.rest.issues.addAssignees).toHaveBeenCalledWith({ | ||
| owner: "test-owner", | ||
| repo: "test-repo", | ||
| issue_number: 123, | ||
| assignees: ["new-user1"], | ||
| }); | ||
| }); | ||
|
|
||
| it("should not unassign when unassign_first is false (default)", async () => { | ||
| vi.clearAllMocks(); // Clear all mocks before this test | ||
|
|
||
| const { main } = require("./assign_to_user.cjs"); | ||
| const handler = await main({ | ||
| max: 10, | ||
| unassign_first: false, // explicitly false | ||
| }); | ||
|
|
||
| mockGithub.rest.issues.get = vi.fn().mockResolvedValue({}); | ||
| mockGithub.rest.issues.removeAssignees = vi.fn().mockResolvedValue({}); | ||
| mockGithub.rest.issues.addAssignees = vi.fn().mockResolvedValue({}); // Recreate the mock | ||
|
|
||
| const message = { | ||
| type: "assign_to_user", | ||
| assignees: ["new-user1"], | ||
| }; | ||
|
|
||
| const result = await handler(message, {}); | ||
|
|
||
| expect(result.success).toBe(true); | ||
|
|
||
| // Verify that get was NOT called | ||
| expect(mockGithub.rest.issues.get).not.toHaveBeenCalled(); | ||
|
|
||
| // Verify that removeAssignees was NOT called | ||
| expect(mockGithub.rest.issues.removeAssignees).not.toHaveBeenCalled(); | ||
|
|
||
| // Verify that addAssignees was called directly | ||
| expect(mockGithub.rest.issues.addAssignees).toHaveBeenCalledWith({ | ||
| owner: "test-owner", | ||
| repo: "test-repo", | ||
| issue_number: 123, | ||
| assignees: ["new-user1"], | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Missing test coverage for error scenarios when unassign_first is enabled. Consider adding tests for the following cases:
- When
issues.get()fails while fetching current assignees - When
removeAssignees()fails during the unassignment operation
These error scenarios should be caught by the existing try-catch block, but explicit test coverage would ensure the error handling works as expected and would make the code more robust. The current tests only cover the happy path (successful unassignment) and the case where there are no assignees to remove.
GitHub's
issues.addAssigneesAPI is additive - it doesn't replace existing assignees. Reassigning issues requires explicit unassignment first, but users were reporting that assign-to-user operations appeared successful yet had no effect when assignees already existed.Changes
unassign-firstboolean field toassign-to-usersafe output configissues.get()and removes them viaremoveAssignees()before callingaddAssignees()false)Usage
Backward compatible - existing workflows continue unchanged with default
falsebehavior.Warning
Firewall rules blocked me from connecting to one or more addresses (expand for details)
I tried to connect to the following addresses, but was blocked by firewall rules:
https://api.github.com/graphql/usr/bin/gh /usr/bin/gh api graphql -f query=query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled } } -f owner=github -f name=gh-aw -nolocalimports -importcfg git conf�� user.name Test User(http block)/usr/bin/gh /usr/bin/gh api graphql -f query=query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled } } -f owner=github -f name=gh-aw /tmp/go-build139rev-parse x_amd64/compile git rev-�� --show-toplevel x_amd64/compile /usr/bin/git 3972128/b001/exeiptables --conditions ache/go/1.25.0/x-t git(http block)/usr/bin/gh /usr/bin/gh api graphql -f query=query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled } } -f owner=github -f name=gh-aw /tmp/go-build139rev-parse k/gh-aw/gh-aw/ac--show-toplevel git rev-�� gh-aw/actions/setup/sh/sanitize_path.sh' '/usr/bin:/usr/local/bin' && echo "$PATH" /opt/hostedtoolcTest User /usr/bin/git test-DKA2zw/compiptables --conditions ndor/bin/git git(http block)https://api.github.com/repos/actions/ai-inference/git/ref/tags/v1/usr/bin/gh gh api /repos/actions/ai-inference/git/ref/tags/v1 --jq .object.sha 0tF5/xM7--4q6idt8vjQJ0tF5 sh /opt/pipx_bin/sh om/user/repo.gitgit 64/pkg/tool/linurev-parse 64/pkg/tool/linu--show-toplevel sh 8672�� git status --porcelain --ignore-submodules | head -n 10 867259/b371/_testmain.go ache/go/1.25.0/x64/pkg/tool/linux_amd64/link ep .cfg modules/@npmcli/--show-toplevel ache/go/1.25.0/x64/pkg/tool/linux_amd64/link(http block)https://api.github.com/repos/actions/ai-inference/git/ref/tags/v2/usr/bin/gh gh api /repos/actions/ai-inference/git/ref/tags/v2 --jq .object.sha --show-toplevel 64/pkg/tool/linuowner=github /usr/bin/git 85/001/test-frongit --conditions bin/sh git rev-�� --show-toplevel /opt/hostedtoolcache/go/1.25.0/x64/pkg/tool/linurev-parse /usr/bin/git 5140-16884/test-infocmp -importcfg cfg git(http block)/usr/bin/gh gh api /repos/actions/ai-inference/git/ref/tags/v2 --jq .object.sha --show-toplevel 64/pkg/tool/linux_amd64/vet /usr/bin/git node --conditions tartedAt,updated/home/REDACTED/work/gh-aw/gh-aw/.github/workflows git rev-�� port PATH="$(finremote.origin.url go r: $owner, name: $name) { hasDiscussionsEnabled } } k && vitest run infocmp bash cfg git(http block)https://api.github.com/repos/actions/checkout/git/ref/tags/11bd71901bbe5b1630ceea73d27597364c9af683/usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/11bd71901bbe5b1630ceea73d27597364c9af683 --jq .object.sha --show-toplevel 64/pkg/tool/linuowner=github /usr/bin/git 85/001/test-compinfocmp --conditions 64/pkg/tool/linuxterm-color git rev-�� nner/work/gh-aw/gh-aw/actions/setup/sh/sanitize_path.sh' '/usr/bin:/usr/local/bin:::' && echo "$/usr/bin/gh 64/pkg/tool/linux_amd64/vet x_amd64/compile /tmp/go-handler-infocmp bash cfg x_amd64/compile(http block)https://api.github.com/repos/actions/checkout/git/ref/tags/v3/usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v3 --jq .object.sha node ser:token@github.com/repo.git.ur-c=4 k/node_modules/.bin/node --experimental-igit --require /home/REDACTED/wor--show-toplevel /opt/hostedtoolcache/node/24.13.Test User ache�� 5140-16884/test-3578867552/.github/workflows --conditions /home/REDACTED/work/gh-aw/gh-aw/actions/setup/js/node_modules/.bin/git --experimental-igit --require /home/REDACTED/wor--show-toplevel git(http block)https://api.github.com/repos/actions/checkout/git/ref/tags/v4/usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v4 --jq .object.sha ings.cjs forks.js /usr/local/sbin/bash hacked --check x_amd64/vet bash --no�� --noprofile x_amd64/vet 0/x64/bin/node vil.com 64/pkg/tool/linurev-parse 64/pkg/tool/linu--show-toplevel git(http block)/usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v4 --jq .object.sha vaScript2481029385/001/test-complex-frontmatter-with-tools.md forks.js Name,createdAt,startedAt,updatedAt,event,headBranch,headSha,displayTitle hacked grep x_amd64/vet ache/go/1.25.0/x64/pkg/tool/linux_amd64/link t-ha�� ring3323831704/001/test1.md x_amd64/vet 867259/b404/importcfg.link vil.com 64/pkg/tool/linurev-parse 64/pkg/tool/linu--show-toplevel -JPGyIl2vB7_F/G9sEDD2pLhLMDAgEAiXu/X6R5OoPF4X40d7UERB47/kI2eOA--echo(http block)/usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v4 --jq .object.sha --show-toplevel /tmp/go-build505867259/b401/_testmain.go /usr/bin/git se 4540027/b074/vetrev-parse ules/.bin/go git rev-�� --git-dir ache/go/1.25.0/x-tests /usr/bin/git .go l 0/x64/bin/node git(http block)https://api.github.com/repos/actions/checkout/git/ref/tags/v5/usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v5 --jq .object.sha test-DKA2zw/complex.go --conditions nfig/composer/vendor/bin/git --experimental-igit --require /home/REDACTED/wor--show-toplevel vo/9kfMsjtzGOFIi51OI_-c/tFE_Q0UwmvwlVjEnf88q show�� / --quiet 64/pkg/tool/linux_amd64/vet assign-to-user\|docker ./pkg/parser/schpull k/node_modules/.test/concurrent-image:v1.0.0 64/pkg/tool/linux_amd64/vet(http block)/usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v5 --jq .object.sha -v origin 0/x64/bin/node x55To0DC_ .cfg 64/pkg/tool/linu--show-toplevel 0/x64/bin/node -o /tmp/go-build505867259/b386/fileutil.test -importcfg /usr/bin/git -s -w -buildmode=exe git(http block)/usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v5 --jq .object.sha image:v1.0.0 test-branch(http block)https://api.github.com/repos/actions/checkout/git/ref/tags/v6/usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v6 --jq .object.sha --show-toplevel 64/pkg/tool/linux_amd64/cgo /usr/bin/git node --conditions ptables git rev-�� --show-toplevel go /usr/bin/git /tmp/go-handler-/usr/bin/unpigz tail es/.bin/node git(http block)https://api.github.com/repos/actions/github-script/git/ref/tags/v7/usr/bin/gh gh api /repos/actions/github-script/git/ref/tags/v7 --jq .object.sha go1.25.0 -c=4 -nolocalimports -importcfg /tmp/go-build2047890279/b001/importcfg -pack /tmp/go-build2047890279/b001/_testmain.go rev-�� ref/tags/v1 git /usr/bin/git --no-file-parall/opt/hostedtoolcache/go/1.25.0/x64/pkg/tool/linux_amd64/link --quiet t git(http block)/usr/bin/gh gh api /repos/actions/github-script/git/ref/tags/v7 --jq .object.sha --show-toplevel x_amd64/compile /usr/bin/git -bool -buildtags tions/setup/js/n--noprofile git rev-�� --show-toplevel ache/go/1.25.0/x64/pkg/tool/linurev-parse repository(owner: $owner, name: $name) { hasDiscussionsEnabled } } 1906378282/.githgit --quiet tnet/tools/git git(http block)/usr/bin/gh gh api /repos/actions/github-script/git/ref/tags/v7 --jq .object.sha --show-toplevel go /usr/bin/git un8-gFRi_ ode_modules/viteapi cal/bin/sh git rev-�� --show-toplevel rtcfg /usr/bin/git g/cli/fileutil/fgit g/cli/fileutil/f-C ache/go/1.25.0/x/home/REDACTED/work/gh-aw/gh-aw/.github/workflows git(http block)https://api.github.com/repos/actions/github-script/git/ref/tags/v8/usr/bin/gh gh api /repos/actions/github-script/git/ref/tags/v8 --jq .object.sha featurewhoami.cfg 0/x64/bin/node .js' --ignore-panode om/spf13/cobra@v/home/REDACTED/work/gh-aw/gh-aw/actions/setup/js/node_modules/.bin/tsc�� ache/go/1.25.0/x--noEmit 0/x64/bin/node(http block)Co-authored-by: pelikhan-ifaceassert /tsc�� -m Update"; echo hacked 0/x64/bin/node on' --ignore-pat/opt/hostedtoolcache/go/1.25.0/x64/pkg/tool/linux_amd64/vet .json 64/bin/bash 0/x64/bin/node` (http block)
Co-authored-by: pelikhan-ifaceassert` (http block)
Original prompt
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.