diff --git a/github/github-accessors.go b/github/github-accessors.go index 37801159bdc..573442acde1 100644 --- a/github/github-accessors.go +++ b/github/github-accessors.go @@ -18166,6 +18166,14 @@ func (i *IssueCommentEvent) GetSender() *User { return i.Sender } +// GetIssueID returns the IssueID field if it's non-nil, zero value otherwise. +func (i *IssueDependencyRequest) GetIssueID() int64 { + if i == nil || i.IssueID == nil { + return 0 + } + return *i.IssueID +} + // GetAction returns the Action field. func (i *IssueEvent) GetAction() string { if i == nil { diff --git a/github/github-accessors_test.go b/github/github-accessors_test.go index b1dc2e00138..f1052ca039e 100644 --- a/github/github-accessors_test.go +++ b/github/github-accessors_test.go @@ -22982,6 +22982,17 @@ func TestIssueCommentEvent_GetSender(tt *testing.T) { i.GetSender() } +func TestIssueDependencyRequest_GetIssueID(tt *testing.T) { + tt.Parallel() + var zeroValue int64 + i := &IssueDependencyRequest{IssueID: &zeroValue} + i.GetIssueID() + i = &IssueDependencyRequest{} + i.GetIssueID() + i = nil + i.GetIssueID() +} + func TestIssueEvent_GetAction(tt *testing.T) { tt.Parallel() i := &IssueEvent{} diff --git a/github/github-iterators.go b/github/github-iterators.go index f33c9edc47c..b6670c8a6b6 100644 --- a/github/github-iterators.go +++ b/github/github-iterators.go @@ -3404,6 +3404,68 @@ func (s *IssuesService) ListAssigneesIter(ctx context.Context, owner string, rep } } +// ListBlockedByIter returns an iterator that paginates through all results of ListBlockedBy. +func (s *IssuesService) ListBlockedByIter(ctx context.Context, owner string, repo string, number int, opts *ListOptions) iter.Seq2[*Issue, error] { + return func(yield func(*Issue, error) bool) { + // Create a copy of opts to avoid mutating the caller's struct + if opts == nil { + opts = &ListOptions{} + } else { + opts = Ptr(*opts) + } + + for { + results, resp, err := s.ListBlockedBy(ctx, owner, repo, number, opts) + if err != nil { + yield(nil, err) + return + } + + for _, item := range results { + if !yield(item, nil) { + return + } + } + + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + } +} + +// ListBlockingIter returns an iterator that paginates through all results of ListBlocking. +func (s *IssuesService) ListBlockingIter(ctx context.Context, owner string, repo string, number int, opts *ListOptions) iter.Seq2[*Issue, error] { + return func(yield func(*Issue, error) bool) { + // Create a copy of opts to avoid mutating the caller's struct + if opts == nil { + opts = &ListOptions{} + } else { + opts = Ptr(*opts) + } + + for { + results, resp, err := s.ListBlocking(ctx, owner, repo, number, opts) + if err != nil { + yield(nil, err) + return + } + + for _, item := range results { + if !yield(item, nil) { + return + } + } + + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + } +} + // ListByOrgIter returns an iterator that paginates through all results of ListByOrg. func (s *IssuesService) ListByOrgIter(ctx context.Context, org string, opts *IssueListByOrgOptions) iter.Seq2[*Issue, error] { return func(yield func(*Issue, error) bool) { diff --git a/github/github-iterators_test.go b/github/github-iterators_test.go index c48f02bcefc..6d0ce281044 100644 --- a/github/github-iterators_test.go +++ b/github/github-iterators_test.go @@ -7359,6 +7359,150 @@ func TestIssuesService_ListAssigneesIter(t *testing.T) { } } +func TestIssuesService_ListBlockedByIter(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + var callNum int + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + callNum++ + switch callNum { + case 1: + w.Header().Set("Link", `; rel="next"`) + fmt.Fprint(w, `[{},{},{}]`) + case 2: + fmt.Fprint(w, `[{},{},{},{}]`) + case 3: + fmt.Fprint(w, `[{},{}]`) + case 4: + w.WriteHeader(http.StatusNotFound) + case 5: + fmt.Fprint(w, `[{},{}]`) + } + }) + + iter := client.Issues.ListBlockedByIter(t.Context(), "", "", 0, nil) + var gotItems int + for _, err := range iter { + gotItems++ + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + if want := 7; gotItems != want { + t.Errorf("client.Issues.ListBlockedByIter call 1 got %v items; want %v", gotItems, want) + } + + opts := &ListOptions{} + iter = client.Issues.ListBlockedByIter(t.Context(), "", "", 0, opts) + gotItems = 0 + for _, err := range iter { + gotItems++ + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + if want := 2; gotItems != want { + t.Errorf("client.Issues.ListBlockedByIter call 2 got %v items; want %v", gotItems, want) + } + + iter = client.Issues.ListBlockedByIter(t.Context(), "", "", 0, nil) + gotItems = 0 + for _, err := range iter { + gotItems++ + if err == nil { + t.Error("expected error; got nil") + } + } + if gotItems != 1 { + t.Errorf("client.Issues.ListBlockedByIter call 3 got %v items; want 1 (an error)", gotItems) + } + + iter = client.Issues.ListBlockedByIter(t.Context(), "", "", 0, nil) + gotItems = 0 + iter(func(item *Issue, err error) bool { + gotItems++ + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + return false + }) + if gotItems != 1 { + t.Errorf("client.Issues.ListBlockedByIter call 4 got %v items; want 1 (an error)", gotItems) + } +} + +func TestIssuesService_ListBlockingIter(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + var callNum int + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + callNum++ + switch callNum { + case 1: + w.Header().Set("Link", `; rel="next"`) + fmt.Fprint(w, `[{},{},{}]`) + case 2: + fmt.Fprint(w, `[{},{},{},{}]`) + case 3: + fmt.Fprint(w, `[{},{}]`) + case 4: + w.WriteHeader(http.StatusNotFound) + case 5: + fmt.Fprint(w, `[{},{}]`) + } + }) + + iter := client.Issues.ListBlockingIter(t.Context(), "", "", 0, nil) + var gotItems int + for _, err := range iter { + gotItems++ + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + if want := 7; gotItems != want { + t.Errorf("client.Issues.ListBlockingIter call 1 got %v items; want %v", gotItems, want) + } + + opts := &ListOptions{} + iter = client.Issues.ListBlockingIter(t.Context(), "", "", 0, opts) + gotItems = 0 + for _, err := range iter { + gotItems++ + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + if want := 2; gotItems != want { + t.Errorf("client.Issues.ListBlockingIter call 2 got %v items; want %v", gotItems, want) + } + + iter = client.Issues.ListBlockingIter(t.Context(), "", "", 0, nil) + gotItems = 0 + for _, err := range iter { + gotItems++ + if err == nil { + t.Error("expected error; got nil") + } + } + if gotItems != 1 { + t.Errorf("client.Issues.ListBlockingIter call 3 got %v items; want 1 (an error)", gotItems) + } + + iter = client.Issues.ListBlockingIter(t.Context(), "", "", 0, nil) + gotItems = 0 + iter(func(item *Issue, err error) bool { + gotItems++ + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + return false + }) + if gotItems != 1 { + t.Errorf("client.Issues.ListBlockingIter call 4 got %v items; want 1 (an error)", gotItems) + } +} + func TestIssuesService_ListByOrgIter(t *testing.T) { t.Parallel() client, mux, _ := setup(t) diff --git a/github/issues_dependencies.go b/github/issues_dependencies.go new file mode 100644 index 00000000000..18ee754b0ce --- /dev/null +++ b/github/issues_dependencies.go @@ -0,0 +1,110 @@ +// Copyright 2026 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" +) + +// IssueDependencyRequest represents a request to add a dependency to an issue. +type IssueDependencyRequest struct { + IssueID *int64 `json:"issue_id,omitempty"` +} + +// ListBlockedBy lists the dependencies that block the specified issue. +// +// GitHub API docs: https://docs.github.com/rest/issues/issue-dependencies#list-dependencies-an-issue-is-blocked-by +// +//meta:operation GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by +func (s *IssuesService) ListBlockedBy(ctx context.Context, owner, repo string, number int, opts *ListOptions) ([]*Issue, *Response, error) { + u := fmt.Sprintf("repos/%v/%v/issues/%v/dependencies/blocked_by", owner, repo, number) + 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 issues []*Issue + resp, err := s.client.Do(ctx, req, &issues) + if err != nil { + return nil, resp, err + } + + return issues, resp, nil +} + +// AddBlockedBy adds a "blocked by" dependency to the specified issue. +// +// GitHub API docs: https://docs.github.com/rest/issues/issue-dependencies#add-a-dependency-an-issue-is-blocked-by +// +//meta:operation POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by +func (s *IssuesService) AddBlockedBy(ctx context.Context, owner, repo string, number int, issueDepReq *IssueDependencyRequest) (*Issue, *Response, error) { + u := fmt.Sprintf("repos/%v/%v/issues/%v/dependencies/blocked_by", owner, repo, number) + req, err := s.client.NewRequest("POST", u, issueDepReq) + if err != nil { + return nil, nil, err + } + + var issue *Issue + resp, err := s.client.Do(ctx, req, &issue) + if err != nil { + return nil, resp, err + } + + return issue, resp, nil +} + +// RemoveBlockedBy removes a "blocked by" dependency from the specified issue. +// +// GitHub API docs: https://docs.github.com/rest/issues/issue-dependencies#remove-dependency-an-issue-is-blocked-by +// +//meta:operation DELETE /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by/{issue_id} +func (s *IssuesService) RemoveBlockedBy(ctx context.Context, owner, repo string, number int, issueID int64) (*Issue, *Response, error) { + u := fmt.Sprintf("repos/%v/%v/issues/%v/dependencies/blocked_by/%v", owner, repo, number, issueID) + req, err := s.client.NewRequest("DELETE", u, nil) + if err != nil { + return nil, nil, err + } + + var issue *Issue + resp, err := s.client.Do(ctx, req, &issue) + if err != nil { + return nil, resp, err + } + + return issue, resp, nil +} + +// ListBlocking lists the issues that the specified issue is blocking. +// +// GitHub API docs: https://docs.github.com/rest/issues/issue-dependencies#list-dependencies-an-issue-is-blocking +// +//meta:operation GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocking +func (s *IssuesService) ListBlocking(ctx context.Context, owner, repo string, number int, opts *ListOptions) ([]*Issue, *Response, error) { + u := fmt.Sprintf("repos/%v/%v/issues/%v/dependencies/blocking", owner, repo, number) + 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 issues []*Issue + resp, err := s.client.Do(ctx, req, &issues) + if err != nil { + return nil, resp, err + } + + return issues, resp, nil +} diff --git a/github/issues_dependencies_test.go b/github/issues_dependencies_test.go new file mode 100644 index 00000000000..0c44b3f72e4 --- /dev/null +++ b/github/issues_dependencies_test.go @@ -0,0 +1,220 @@ +// Copyright 2026 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 ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestIssuesService_ListBlockedBy(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/repos/o/r/issues/1/dependencies/blocked_by", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testFormValues(t, r, values{"page": "2"}) + fmt.Fprint(w, `[{"number":1347,"title":"Found a bug"}]`) + }) + + opt := &ListOptions{Page: 2} + ctx := t.Context() + issues, _, err := client.Issues.ListBlockedBy(ctx, "o", "r", 1, opt) + if err != nil { + t.Errorf("Issues.ListBlockedBy returned error: %v", err) + } + + want := []*Issue{{Number: Ptr(1347), Title: Ptr("Found a bug")}} + if !cmp.Equal(issues, want) { + t.Errorf("Issues.ListBlockedBy returned %+v, want %+v", issues, want) + } + + const methodName = "ListBlockedBy" + testBadOptions(t, methodName, func() (err error) { + _, _, err = client.Issues.ListBlockedBy(ctx, "\n", "\n", -1, opt) + return err + }) + + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Issues.ListBlockedBy(ctx, "o", "r", 1, opt) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + +func TestIssuesService_ListBlockedBy_invalidOwner(t *testing.T) { + t.Parallel() + client, _, _ := setup(t) + + ctx := t.Context() + _, _, err := client.Issues.ListBlockedBy(ctx, "%", "%", 1, nil) + testURLParseError(t, err) +} + +func TestIssuesService_AddBlockedBy(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + input := &IssueDependencyRequest{IssueID: Ptr(int64(42))} + + mux.HandleFunc("/repos/o/r/issues/1/dependencies/blocked_by", func(w http.ResponseWriter, r *http.Request) { + var v *IssueDependencyRequest + 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) + } + + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"number":42,"title":"Dependency issue"}`) + }) + + ctx := t.Context() + issue, _, err := client.Issues.AddBlockedBy(ctx, "o", "r", 1, input) + if err != nil { + t.Errorf("Issues.AddBlockedBy returned error: %v", err) + } + + want := &Issue{Number: Ptr(42), Title: Ptr("Dependency issue")} + if !cmp.Equal(issue, want) { + t.Errorf("Issues.AddBlockedBy returned %+v, want %+v", issue, want) + } + + const methodName = "AddBlockedBy" + testBadOptions(t, methodName, func() (err error) { + _, _, err = client.Issues.AddBlockedBy(ctx, "\n", "\n", -1, input) + return err + }) + + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Issues.AddBlockedBy(ctx, "o", "r", 1, input) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + +func TestIssuesService_AddBlockedBy_invalidOwner(t *testing.T) { + t.Parallel() + client, _, _ := setup(t) + + ctx := t.Context() + _, _, err := client.Issues.AddBlockedBy(ctx, "%", "%", 1, nil) + testURLParseError(t, err) +} + +func TestIssuesService_RemoveBlockedBy(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/repos/o/r/issues/1/dependencies/blocked_by/42", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "DELETE") + fmt.Fprint(w, `{"number":1,"title":"Original issue"}`) + }) + + ctx := t.Context() + issue, _, err := client.Issues.RemoveBlockedBy(ctx, "o", "r", 1, 42) + if err != nil { + t.Errorf("Issues.RemoveBlockedBy returned error: %v", err) + } + + want := &Issue{Number: Ptr(1), Title: Ptr("Original issue")} + if !cmp.Equal(issue, want) { + t.Errorf("Issues.RemoveBlockedBy returned %+v, want %+v", issue, want) + } + + const methodName = "RemoveBlockedBy" + testBadOptions(t, methodName, func() (err error) { + _, _, err = client.Issues.RemoveBlockedBy(ctx, "\n", "\n", -1, 42) + return err + }) + + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Issues.RemoveBlockedBy(ctx, "o", "r", 1, 42) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + +func TestIssuesService_RemoveBlockedBy_invalidOwner(t *testing.T) { + t.Parallel() + client, _, _ := setup(t) + + ctx := t.Context() + _, _, err := client.Issues.RemoveBlockedBy(ctx, "%", "%", 1, 42) + testURLParseError(t, err) +} + +func TestIssuesService_ListBlocking(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/repos/o/r/issues/1/dependencies/blocking", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testFormValues(t, r, values{"page": "2"}) + fmt.Fprint(w, `[{"number":1348,"title":"Blocked issue"}]`) + }) + + opt := &ListOptions{Page: 2} + ctx := t.Context() + issues, _, err := client.Issues.ListBlocking(ctx, "o", "r", 1, opt) + if err != nil { + t.Errorf("Issues.ListBlocking returned error: %v", err) + } + + want := []*Issue{{Number: Ptr(1348), Title: Ptr("Blocked issue")}} + if !cmp.Equal(issues, want) { + t.Errorf("Issues.ListBlocking returned %+v, want %+v", issues, want) + } + + const methodName = "ListBlocking" + testBadOptions(t, methodName, func() (err error) { + _, _, err = client.Issues.ListBlocking(ctx, "\n", "\n", -1, opt) + return err + }) + + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Issues.ListBlocking(ctx, "o", "r", 1, opt) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + +func TestIssuesService_ListBlocking_invalidOwner(t *testing.T) { + t.Parallel() + client, _, _ := setup(t) + + ctx := t.Context() + _, _, err := client.Issues.ListBlocking(ctx, "%", "%", 1, nil) + testURLParseError(t, err) +} + +func TestIssueDependencyRequest_Marshal(t *testing.T) { + t.Parallel() + testJSONMarshal(t, &IssueDependencyRequest{}, "{}") + + u := &IssueDependencyRequest{ + IssueID: Ptr(int64(1)), + } + + want := `{ + "issue_id": 1 + }` + + testJSONMarshal(t, u, want) +}