Reject cross-host issue refs in ResolveIssueRef

Relationship mutations (parent, sub-issue, blocked-by, blocking) run
against baseRepo's host with node IDs that must come from that same
host. Catch a different-host ref up front with a clear error instead
of letting the mutation fail with a confusing node-ID error.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kynan Ware 2026-05-06 18:04:48 -06:00
parent eff9d48f6e
commit 628325920d
2 changed files with 87 additions and 0 deletions

View file

@ -1496,6 +1496,90 @@ func mockProjectV2ItemUpdate(t *testing.T, reg *httpmock.Registry) {
)
}
// Test_editRun_crossHostRelationshipRefs verifies that every relationship
// flag rejects a cross-host issue URL with the same clear error. Lives as
// its own table rather than additional cases in Test_editRun because each
// case shares identical setup and asserts the same error, varying only in
// which input field carries the cross-host URL.
func Test_editRun_crossHostRelationshipRefs(t *testing.T) {
const crossHostURL = "https://example.com/OWNER/REPO/issues/9"
// Each case exercises one relationship-bearing flag with a cross-host
// URL. ResolveIssueRef should short-circuit before any GraphQL request,
// and the per-issue failure must surface to stderr.
tests := []struct {
name string
input *EditOptions
}{
{
name: "set parent",
input: &EditOptions{
Editable: prShared.Editable{
Parent: prShared.EditableString{
Value: crossHostURL,
Edited: true,
},
},
},
},
{
name: "add sub-issue",
input: &EditOptions{AddSubIssues: []string{crossHostURL}},
},
{
name: "remove sub-issue",
input: &EditOptions{RemoveSubIssues: []string{crossHostURL}},
},
{
name: "add blocked-by",
input: &EditOptions{AddBlockedBy: []string{crossHostURL}},
},
{
name: "remove blocked-by",
input: &EditOptions{RemoveBlockedBy: []string{crossHostURL}},
},
{
name: "add blocking",
input: &EditOptions{AddBlocking: []string{crossHostURL}},
},
{
name: "remove blocking",
input: &EditOptions{RemoveBlocking: []string{crossHostURL}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, _, stderr := iostreams.Test()
ios.SetStdoutTTY(true)
reg := &httpmock.Registry{}
defer reg.Verify(t)
mockIssueGet(t, reg)
// No IssueNodeID stub on purpose: the cross-host guard must
// short-circuit before any resolution request goes out.
tt.input.Detector = &fd.EnabledDetectorMock{}
tt.input.IssueNumbers = []int{123}
tt.input.Interactive = false
tt.input.FetchOptions = func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error {
return nil
}
tt.input.IO = ios
tt.input.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
tt.input.BaseRepo = func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
}
err := editRun(tt.input)
require.Error(t, err)
assert.Regexp(t, `belongs to a different host \(example\.com\) than the current repository \(github\.com\)`, stderr.String())
})
}
}
func TestApiActorsSupported(t *testing.T) {
t.Run("when actors are assignable, query includes assignedActors", func(t *testing.T) {
ios, _, _, _ := iostreams.Test()

View file

@ -17,6 +17,9 @@ func ResolveIssueRef(client *api.Client, baseRepo ghrepo.Interface, ref string)
targetRepo := baseRepo
if r, ok := repo.Value(); ok {
if r.RepoHost() != baseRepo.RepoHost() {
return "", fmt.Errorf("issue reference %q belongs to a different host (%s) than the current repository (%s)", ref, r.RepoHost(), baseRepo.RepoHost())
}
targetRepo = r
}