diff --git a/github/github-accessors.go b/github/github-accessors.go index 6378b40b44c..307d6e339d3 100644 --- a/github/github-accessors.go +++ b/github/github-accessors.go @@ -25942,6 +25942,30 @@ func (s *StatusEvent) GetUpdatedAt() Timestamp { return *s.UpdatedAt } +// GetAfterID returns the AfterID field if it's non-nil, zero value otherwise. +func (s *SubIssueRequest) GetAfterID() int64 { + if s == nil || s.AfterID == nil { + return 0 + } + return *s.AfterID +} + +// GetBeforeID returns the BeforeID field if it's non-nil, zero value otherwise. +func (s *SubIssueRequest) GetBeforeID() int64 { + if s == nil || s.BeforeID == nil { + return 0 + } + return *s.BeforeID +} + +// GetReplaceParent returns the ReplaceParent field if it's non-nil, zero value otherwise. +func (s *SubIssueRequest) GetReplaceParent() bool { + if s == nil || s.ReplaceParent == nil { + return false + } + return *s.ReplaceParent +} + // GetCreatedAt returns the CreatedAt field if it's non-nil, zero value otherwise. func (s *Subscription) GetCreatedAt() Timestamp { if s == nil || s.CreatedAt == nil { diff --git a/github/github-accessors_test.go b/github/github-accessors_test.go index 3588b7909f1..f2a51a6028c 100644 --- a/github/github-accessors_test.go +++ b/github/github-accessors_test.go @@ -33332,6 +33332,39 @@ func TestStatusEvent_GetUpdatedAt(tt *testing.T) { s.GetUpdatedAt() } +func TestSubIssueRequest_GetAfterID(tt *testing.T) { + tt.Parallel() + var zeroValue int64 + s := &SubIssueRequest{AfterID: &zeroValue} + s.GetAfterID() + s = &SubIssueRequest{} + s.GetAfterID() + s = nil + s.GetAfterID() +} + +func TestSubIssueRequest_GetBeforeID(tt *testing.T) { + tt.Parallel() + var zeroValue int64 + s := &SubIssueRequest{BeforeID: &zeroValue} + s.GetBeforeID() + s = &SubIssueRequest{} + s.GetBeforeID() + s = nil + s.GetBeforeID() +} + +func TestSubIssueRequest_GetReplaceParent(tt *testing.T) { + tt.Parallel() + var zeroValue bool + s := &SubIssueRequest{ReplaceParent: &zeroValue} + s.GetReplaceParent() + s = &SubIssueRequest{} + s.GetReplaceParent() + s = nil + s.GetReplaceParent() +} + func TestSubscription_GetCreatedAt(tt *testing.T) { tt.Parallel() var zeroValue Timestamp diff --git a/github/github.go b/github/github.go index a3b1941eac1..c366820383d 100644 --- a/github/github.go +++ b/github/github.go @@ -222,6 +222,7 @@ type Client struct { Search *SearchService SecretScanning *SecretScanningService SecurityAdvisories *SecurityAdvisoriesService + SubIssue *SubIssueService Teams *TeamsService Users *UsersService } @@ -458,6 +459,7 @@ func (c *Client) initialize() { c.Search = (*SearchService)(&c.common) c.SecretScanning = (*SecretScanningService)(&c.common) c.SecurityAdvisories = (*SecurityAdvisoriesService)(&c.common) + c.SubIssue = (*SubIssueService)(&c.common) c.Teams = (*TeamsService)(&c.common) c.Users = (*UsersService)(&c.common) } diff --git a/github/strings_test.go b/github/strings_test.go index a164cebcac0..98906b4d41d 100644 --- a/github/strings_test.go +++ b/github/strings_test.go @@ -107,6 +107,7 @@ func TestString(t *testing.T) { {Hook{ID: Ptr(int64(1))}, `github.Hook{ID:1}`}, {IssueComment{ID: Ptr(int64(1))}, `github.IssueComment{ID:1}`}, {Issue{Number: Ptr(1)}, `github.Issue{Number:1}`}, + {SubIssue{ID: Ptr(int64(1))}, `github.SubIssue{ID:1}`}, {Key{ID: Ptr(int64(1))}, `github.Key{ID:1}`}, {Label{ID: Ptr(int64(1)), Name: Ptr("l")}, `github.Label{ID:1, Name:"l"}`}, {Organization{ID: Ptr(int64(1))}, `github.Organization{ID:1}`}, diff --git a/github/sub_issue.go b/github/sub_issue.go new file mode 100644 index 00000000000..8b24adb899b --- /dev/null +++ b/github/sub_issue.go @@ -0,0 +1,140 @@ +// Copyright 2025 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package github + +import ( + "context" + "fmt" +) + +// SubIssueService handles communication with the sub-issue related +// methods of the GitHub API. +// +// Sub-issues help you group and manage your issues with a parent/child relationship. +// +// GitHub API docs: https://docs.github.com/rest/issues/sub-issues +type SubIssueService service + +// SubIssue represents a GitHub sub-issue on a repository. +// Note: As far as the GitHub API is concerned, every pull request is an issue, +// but not every issue is a pull request. Some endpoints, events, and webhooks +// may also return pull requests via this struct. If PullRequestLinks is nil, +// this is an issue, and if PullRequestLinks is not nil, this is a pull request. +// The IsPullRequest helper method can be used to check that. +type SubIssue Issue + +func (i SubIssue) String() string { + return Stringify(i) +} + +// SubIssueListByIssueOptions specifies the optional parameters to the +// SubIssueService.ListByIssue method. +type SubIssueListByIssueOptions struct { + IssueListByRepoOptions +} + +// SubIssueRequest represents a request to add, remove, or reprioritize sub-issues. +type SubIssueRequest struct { + SubIssueID int64 `json:"sub_issue_id"` // Required: The ID of the sub-issue + AfterID *int64 `json:"after_id,omitempty"` // Optional: Position after this sub-issue ID + BeforeID *int64 `json:"before_id,omitempty"` // Optional: Position before this sub-issue ID + ReplaceParent *bool `json:"replace_parent,omitempty"` // Optional: Whether to replace the existing parent +} + +// Remove a sub-issue from the specified repository. +// +// GitHub API docs: https://docs.github.com/rest/issues/sub-issues#remove-sub-issue +// +//meta:operation DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issue +func (s *SubIssueService) Remove(ctx context.Context, owner, repo string, subIssueNumber int64, subIssue SubIssueRequest) (*SubIssue, *Response, error) { + u := fmt.Sprintf("repos/%v/%v/issues/%v/sub_issues", owner, repo, subIssueNumber) + + req, err := s.client.NewRequest("DELETE", u, subIssue) + if err != nil { + return nil, nil, err + } + + si := new(SubIssue) + resp, err := s.client.Do(ctx, req, si) + if err != nil { + return nil, resp, err + } + + return si, resp, nil +} + +// ListByIssue lists all sub-issues for the specified issue. +// +// GitHub API docs: https://docs.github.com/rest/issues/sub-issues#list-sub-issues +// +//meta:operation GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues +func (s *SubIssueService) ListByIssue(ctx context.Context, owner, repo string, issueNumber int64, opts *IssueListOptions) ([]*SubIssue, *Response, error) { + u := fmt.Sprintf("repos/%v/%v/issues/%v/sub_issues", owner, repo, issueNumber) + u, err := addOptions(u, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + var subIssues []*SubIssue + resp, err := s.client.Do(ctx, req, &subIssues) + if err != nil { + return nil, resp, err + } + + return subIssues, resp, nil +} + +// Add adds a sub-issue to the specified issue. +// +// The sub-issue to be added must belong to the same repository owner as the parent issue. +// To replace the existing parent of a sub-issue, set replaceParent to true. +// +// GitHub API docs: https://docs.github.com/rest/issues/sub-issues#add-sub-issue +// +//meta:operation POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues +func (s *SubIssueService) Add(ctx context.Context, owner, repo string, issueNumber int64, subIssue SubIssueRequest) (*SubIssue, *Response, error) { + u := fmt.Sprintf("repos/%v/%v/issues/%v/sub_issues", owner, repo, issueNumber) + req, err := s.client.NewRequest("POST", u, subIssue) + if err != nil { + return nil, nil, err + } + + si := new(SubIssue) + resp, err := s.client.Do(ctx, req, si) + if err != nil { + return nil, resp, err + } + + return si, resp, nil +} + +// Reprioritize changes a sub-issue's priority to a different position in the parent list. +// +// Either afterId or beforeId must be specified to determine the new position of the sub-issue. +// +// GitHub API docs: https://docs.github.com/rest/issues/sub-issues#reprioritize-sub-issue +// +//meta:operation PATCH /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/priority +func (s *SubIssueService) Reprioritize(ctx context.Context, owner, repo string, issueNumber int64, subIssue SubIssueRequest) (*SubIssue, *Response, error) { + u := fmt.Sprintf("repos/%v/%v/issues/%v/sub_issues/priority", owner, repo, issueNumber) + req, err := s.client.NewRequest("PATCH", u, subIssue) + if err != nil { + return nil, nil, err + } + + si := new(SubIssue) + resp, err := s.client.Do(ctx, req, si) + if err != nil { + return nil, resp, err + } + + return si, resp, nil +} diff --git a/github/sub_issue_test.go b/github/sub_issue_test.go new file mode 100644 index 00000000000..9a88b2441d0 --- /dev/null +++ b/github/sub_issue_test.go @@ -0,0 +1,173 @@ +// Copyright 2025 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestSubIssuesService_Add(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + input := &SubIssueRequest{SubIssueID: 42} + + mux.HandleFunc("/repos/o/r/issues/1/sub_issues", func(w http.ResponseWriter, r *http.Request) { + v := new(SubIssueRequest) + assertNilError(t, json.NewDecoder(r.Body).Decode(v)) + + testMethod(t, r, "POST") + if !cmp.Equal(v, input) { + t.Errorf("Request body = %+v, want %+v", v, input) + } + + fmt.Fprint(w, `{"id":42, "number":1}`) + }) + + ctx := context.Background() + got, _, err := client.SubIssue.Add(ctx, "o", "r", 1, *input) + if err != nil { + t.Errorf("SubIssues.Add returned error: %v", err) + } + + want := &SubIssue{Number: Ptr(1), ID: Int64(42)} + if !cmp.Equal(got, want) { + t.Errorf("SubIssues.Add = %+v, want %+v", got, want) + } + + const methodName = "Add" + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.SubIssue.Add(ctx, "o", "r", 1, *input) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + +func TestSubIssuesService_ListByIssue(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/repos/o/r/issues/1/sub_issues", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + + fmt.Fprint(w, `[{"id":1},{"id":2}]`) + }) + + ctx := context.Background() + opt := &IssueListOptions{} + issues, _, err := client.SubIssue.ListByIssue(ctx, "o", "r", 1, opt) + if err != nil { + t.Errorf("SubIssues.ListByIssue returned error: %v", err) + } + + want := []*SubIssue{{ID: Int64(1)}, {ID: Int64(2)}} + if !cmp.Equal(issues, want) { + t.Errorf("SubIssues.ListByIssue = %+v, want %+v", issues, want) + } + + const methodName = "ListByIssue" + testBadOptions(t, methodName, func() (err error) { + _, _, err = client.SubIssue.ListByIssue(ctx, "\n", "\n", 1, opt) + return err + }) + + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.SubIssue.ListByIssue(ctx, "o", "r", 1, opt) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + +func TestSubIssuesService_Remove(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + input := &SubIssueRequest{SubIssueID: 42} + + mux.HandleFunc("/repos/o/r/issues/1/sub_issues", func(w http.ResponseWriter, r *http.Request) { + v := new(SubIssueRequest) + assertNilError(t, json.NewDecoder(r.Body).Decode(v)) + + testMethod(t, r, "DELETE") + if !cmp.Equal(v, input) { + t.Errorf("Request body = %+v, want %+v", v, input) + } + + fmt.Fprint(w, `{"id":42, "number":1}`) + }) + + ctx := context.Background() + got, _, err := client.SubIssue.Remove(ctx, "o", "r", 1, *input) + if err != nil { + t.Errorf("SubIssues.Remove returned error: %v", err) + } + + want := &SubIssue{ID: Int64(42), Number: Ptr(1)} + if !cmp.Equal(got, want) { + t.Errorf("SubIssues.Remove = %+v, want %+v", got, want) + } + + const methodName = "Remove" + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.SubIssue.Remove(ctx, "o", "r", 1, *input) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + +func TestSubIssuesService_Reprioritize(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + input := &SubIssueRequest{SubIssueID: 42, AfterID: Int64(5)} + + mux.HandleFunc("/repos/o/r/issues/1/sub_issues/priority", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PATCH") + + v := new(SubIssueRequest) + assertNilError(t, json.NewDecoder(r.Body).Decode(v)) + + testMethod(t, r, "PATCH") + if !cmp.Equal(v, input) { + t.Errorf("Request body = %+v, want %+v", v, input) + } + + fmt.Fprint(w, `{"id":42, "number":1}`) + }) + + ctx := context.Background() + got, _, err := client.SubIssue.Reprioritize(ctx, "o", "r", 1, *input) + if err != nil { + t.Errorf("SubIssues.Reprioritize returned error: %v", err) + } + + want := &SubIssue{ID: Int64(42), Number: Ptr(1)} + if !cmp.Equal(got, want) { + t.Errorf("SubIssues.Reprioritize = %+v, want %+v", got, want) + } + + const methodName = "Reprioritize" + + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.SubIssue.Reprioritize(ctx, "o", "r", 1, *input) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +}