From 4b13752bab7746b0dc88d0e88f06ed977e812b8b Mon Sep 17 00:00:00 2001 From: Lizeth Vera <47796851+veralizeth@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:07:16 +0000 Subject: [PATCH] Adding typed issue artifact REST support --- github/gen-stringify-test.go | 4 +- github/github-accessors.go | 208 +++++++++++++++++ github/github-accessors_test.go | 241 ++++++++++++++++++++ github/github-iterators.go | 31 +++ github/github-iterators_test.go | 72 ++++++ github/github-stringify_test.go | 38 ++++ github/issues_artifacts.go | 187 ++++++++++++++++ github/issues_artifacts_test.go | 382 ++++++++++++++++++++++++++++++++ openapi_operations.yaml | 5 + 9 files changed, 1167 insertions(+), 1 deletion(-) create mode 100644 github/issues_artifacts.go create mode 100644 github/issues_artifacts_test.go diff --git a/github/gen-stringify-test.go b/github/gen-stringify-test.go index 26425a161eb..09637486123 100644 --- a/github/gen-stringify-test.go +++ b/github/gen-stringify-test.go @@ -45,7 +45,9 @@ var ( skipStructMethods = map[string]bool{} // skipStructs lists structs to skip. skipStructs = map[string]bool{ - "RateLimits": true, + // Its nested artifact has required value fields; tested manually. + "IssueArtifactWithVersions": true, + "RateLimits": true, } funcMap = template.FuncMap{ diff --git a/github/github-accessors.go b/github/github-accessors.go index d99ad2e5c99..e45435ec115 100644 --- a/github/github-accessors.go +++ b/github/github-accessors.go @@ -13182,6 +13182,30 @@ func (c *CreateHostedRunnerRequest) GetSize() string { return c.Size } +// GetBody returns the Body field. +func (c *CreateIssueArtifactRequest) GetBody() string { + if c == nil { + return "" + } + return c.Body +} + +// GetName returns the Name field. +func (c *CreateIssueArtifactRequest) GetName() string { + if c == nil { + return "" + } + return c.Name +} + +// GetType returns the Type field if it's non-nil, zero value otherwise. +func (c *CreateIssueArtifactRequest) GetType() string { + if c == nil || c.Type == nil { + return "" + } + return *c.Type +} + // GetColor returns the Color field if it's non-nil, zero value otherwise. func (c *CreateIssueLabelRequest) GetColor() string { if c == nil || c.Color == nil { @@ -23310,6 +23334,174 @@ func (i *Issue) GetUser() *User { return i.User } +// GetBody returns the Body field. +func (i *IssueArtifact) GetBody() string { + if i == nil { + return "" + } + return i.Body +} + +// GetBodyHTML returns the BodyHTML field if it's non-nil, zero value otherwise. +func (i *IssueArtifact) GetBodyHTML() string { + if i == nil || i.BodyHTML == nil { + return "" + } + return *i.BodyHTML +} + +// GetCreatedAt returns the CreatedAt field if it's non-nil, zero value otherwise. +func (i *IssueArtifact) GetCreatedAt() Timestamp { + if i == nil || i.CreatedAt == nil { + return Timestamp{} + } + return *i.CreatedAt +} + +// GetCreator returns the Creator field. +func (i *IssueArtifact) GetCreator() *User { + if i == nil { + return nil + } + return i.Creator +} + +// GetDescription returns the Description field if it's non-nil, zero value otherwise. +func (i *IssueArtifact) GetDescription() string { + if i == nil || i.Description == nil { + return "" + } + return *i.Description +} + +// GetHTMLURL returns the HTMLURL field if it's non-nil, zero value otherwise. +func (i *IssueArtifact) GetHTMLURL() string { + if i == nil || i.HTMLURL == nil { + return "" + } + return *i.HTMLURL +} + +// GetID returns the ID field. +func (i *IssueArtifact) GetID() int64 { + if i == nil { + return 0 + } + return i.ID +} + +// GetName returns the Name field. +func (i *IssueArtifact) GetName() string { + if i == nil { + return "" + } + return i.Name +} + +// GetNumber returns the Number field. +func (i *IssueArtifact) GetNumber() int { + if i == nil { + return 0 + } + return i.Number +} + +// GetType returns the Type field. +func (i *IssueArtifact) GetType() string { + if i == nil { + return "" + } + return i.Type +} + +// GetUpdatedAt returns the UpdatedAt field if it's non-nil, zero value otherwise. +func (i *IssueArtifact) GetUpdatedAt() Timestamp { + if i == nil || i.UpdatedAt == nil { + return Timestamp{} + } + return *i.UpdatedAt +} + +// GetUpdatedByActor returns the UpdatedByActor field. +func (i *IssueArtifact) GetUpdatedByActor() *User { + if i == nil { + return nil + } + return i.UpdatedByActor +} + +// GetType returns the Type field. +func (i *IssueArtifactListOptions) GetType() string { + if i == nil { + return "" + } + return i.Type +} + +// GetActor returns the Actor field. +func (i *IssueArtifactVersion) GetActor() *User { + if i == nil { + return nil + } + return i.Actor +} + +// GetBody returns the Body field. +func (i *IssueArtifactVersion) GetBody() string { + if i == nil { + return "" + } + return i.Body +} + +// GetBodyHTML returns the BodyHTML field if it's non-nil, zero value otherwise. +func (i *IssueArtifactVersion) GetBodyHTML() string { + if i == nil || i.BodyHTML == nil { + return "" + } + return *i.BodyHTML +} + +// GetCreatedAt returns the CreatedAt field if it's non-nil, zero value otherwise. +func (i *IssueArtifactVersion) GetCreatedAt() Timestamp { + if i == nil || i.CreatedAt == nil { + return Timestamp{} + } + return *i.CreatedAt +} + +// GetName returns the Name field. +func (i *IssueArtifactVersion) GetName() string { + if i == nil { + return "" + } + return i.Name +} + +// GetVersion returns the Version field. +func (i *IssueArtifactVersion) GetVersion() int { + if i == nil { + return 0 + } + return i.Version +} + +// GetArtifact returns the Artifact field. +func (i *IssueArtifactWithVersions) GetArtifact() *IssueArtifact { + if i == nil { + return nil + } + return i.Artifact +} + +// GetVersions returns the Versions slice if it's non-nil, nil otherwise. +func (i *IssueArtifactWithVersions) GetVersions() []*IssueArtifactVersion { + if i == nil || i.Versions == nil { + return nil + } + return i.Versions +} + // GetAuthorAssociation returns the AuthorAssociation field if it's non-nil, zero value otherwise. func (i *IssueComment) GetAuthorAssociation() string { if i == nil || i.AuthorAssociation == nil { @@ -47198,6 +47390,22 @@ func (u *UpdateHostedRunnerRequest) GetSize() string { return *u.Size } +// GetBody returns the Body field if it's non-nil, zero value otherwise. +func (u *UpdateIssueArtifactRequest) GetBody() string { + if u == nil || u.Body == nil { + return "" + } + return *u.Body +} + +// GetName returns the Name field if it's non-nil, zero value otherwise. +func (u *UpdateIssueArtifactRequest) GetName() string { + if u == nil || u.Name == nil { + return "" + } + return *u.Name +} + // GetColor returns the Color field if it's non-nil, zero value otherwise. func (u *UpdateIssueLabelRequest) GetColor() string { if u == nil || u.Color == nil { diff --git a/github/github-accessors_test.go b/github/github-accessors_test.go index 51b88ad3f3c..8cecaf87cef 100644 --- a/github/github-accessors_test.go +++ b/github/github-accessors_test.go @@ -16567,6 +16567,33 @@ func TestCreateHostedRunnerRequest_GetSize(tt *testing.T) { c.GetSize() } +func TestCreateIssueArtifactRequest_GetBody(tt *testing.T) { + tt.Parallel() + c := &CreateIssueArtifactRequest{} + c.GetBody() + c = nil + c.GetBody() +} + +func TestCreateIssueArtifactRequest_GetName(tt *testing.T) { + tt.Parallel() + c := &CreateIssueArtifactRequest{} + c.GetName() + c = nil + c.GetName() +} + +func TestCreateIssueArtifactRequest_GetType(tt *testing.T) { + tt.Parallel() + var zeroValue string + c := &CreateIssueArtifactRequest{Type: &zeroValue} + c.GetType() + c = &CreateIssueArtifactRequest{} + c.GetType() + c = nil + c.GetType() +} + func TestCreateIssueLabelRequest_GetColor(tt *testing.T) { tt.Parallel() var zeroValue string @@ -29254,6 +29281,198 @@ func TestIssue_GetUser(tt *testing.T) { i.GetUser() } +func TestIssueArtifact_GetBody(tt *testing.T) { + tt.Parallel() + i := &IssueArtifact{} + i.GetBody() + i = nil + i.GetBody() +} + +func TestIssueArtifact_GetBodyHTML(tt *testing.T) { + tt.Parallel() + var zeroValue string + i := &IssueArtifact{BodyHTML: &zeroValue} + i.GetBodyHTML() + i = &IssueArtifact{} + i.GetBodyHTML() + i = nil + i.GetBodyHTML() +} + +func TestIssueArtifact_GetCreatedAt(tt *testing.T) { + tt.Parallel() + var zeroValue Timestamp + i := &IssueArtifact{CreatedAt: &zeroValue} + i.GetCreatedAt() + i = &IssueArtifact{} + i.GetCreatedAt() + i = nil + i.GetCreatedAt() +} + +func TestIssueArtifact_GetCreator(tt *testing.T) { + tt.Parallel() + i := &IssueArtifact{} + i.GetCreator() + i = nil + i.GetCreator() +} + +func TestIssueArtifact_GetDescription(tt *testing.T) { + tt.Parallel() + var zeroValue string + i := &IssueArtifact{Description: &zeroValue} + i.GetDescription() + i = &IssueArtifact{} + i.GetDescription() + i = nil + i.GetDescription() +} + +func TestIssueArtifact_GetHTMLURL(tt *testing.T) { + tt.Parallel() + var zeroValue string + i := &IssueArtifact{HTMLURL: &zeroValue} + i.GetHTMLURL() + i = &IssueArtifact{} + i.GetHTMLURL() + i = nil + i.GetHTMLURL() +} + +func TestIssueArtifact_GetID(tt *testing.T) { + tt.Parallel() + i := &IssueArtifact{} + i.GetID() + i = nil + i.GetID() +} + +func TestIssueArtifact_GetName(tt *testing.T) { + tt.Parallel() + i := &IssueArtifact{} + i.GetName() + i = nil + i.GetName() +} + +func TestIssueArtifact_GetNumber(tt *testing.T) { + tt.Parallel() + i := &IssueArtifact{} + i.GetNumber() + i = nil + i.GetNumber() +} + +func TestIssueArtifact_GetType(tt *testing.T) { + tt.Parallel() + i := &IssueArtifact{} + i.GetType() + i = nil + i.GetType() +} + +func TestIssueArtifact_GetUpdatedAt(tt *testing.T) { + tt.Parallel() + var zeroValue Timestamp + i := &IssueArtifact{UpdatedAt: &zeroValue} + i.GetUpdatedAt() + i = &IssueArtifact{} + i.GetUpdatedAt() + i = nil + i.GetUpdatedAt() +} + +func TestIssueArtifact_GetUpdatedByActor(tt *testing.T) { + tt.Parallel() + i := &IssueArtifact{} + i.GetUpdatedByActor() + i = nil + i.GetUpdatedByActor() +} + +func TestIssueArtifactListOptions_GetType(tt *testing.T) { + tt.Parallel() + i := &IssueArtifactListOptions{} + i.GetType() + i = nil + i.GetType() +} + +func TestIssueArtifactVersion_GetActor(tt *testing.T) { + tt.Parallel() + i := &IssueArtifactVersion{} + i.GetActor() + i = nil + i.GetActor() +} + +func TestIssueArtifactVersion_GetBody(tt *testing.T) { + tt.Parallel() + i := &IssueArtifactVersion{} + i.GetBody() + i = nil + i.GetBody() +} + +func TestIssueArtifactVersion_GetBodyHTML(tt *testing.T) { + tt.Parallel() + var zeroValue string + i := &IssueArtifactVersion{BodyHTML: &zeroValue} + i.GetBodyHTML() + i = &IssueArtifactVersion{} + i.GetBodyHTML() + i = nil + i.GetBodyHTML() +} + +func TestIssueArtifactVersion_GetCreatedAt(tt *testing.T) { + tt.Parallel() + var zeroValue Timestamp + i := &IssueArtifactVersion{CreatedAt: &zeroValue} + i.GetCreatedAt() + i = &IssueArtifactVersion{} + i.GetCreatedAt() + i = nil + i.GetCreatedAt() +} + +func TestIssueArtifactVersion_GetName(tt *testing.T) { + tt.Parallel() + i := &IssueArtifactVersion{} + i.GetName() + i = nil + i.GetName() +} + +func TestIssueArtifactVersion_GetVersion(tt *testing.T) { + tt.Parallel() + i := &IssueArtifactVersion{} + i.GetVersion() + i = nil + i.GetVersion() +} + +func TestIssueArtifactWithVersions_GetArtifact(tt *testing.T) { + tt.Parallel() + i := &IssueArtifactWithVersions{} + i.GetArtifact() + i = nil + i.GetArtifact() +} + +func TestIssueArtifactWithVersions_GetVersions(tt *testing.T) { + tt.Parallel() + zeroValue := []*IssueArtifactVersion{} + i := &IssueArtifactWithVersions{Versions: zeroValue} + i.GetVersions() + i = &IssueArtifactWithVersions{} + i.GetVersions() + i = nil + i.GetVersions() +} + func TestIssueComment_GetAuthorAssociation(tt *testing.T) { tt.Parallel() var zeroValue string @@ -58884,6 +59103,28 @@ func TestUpdateHostedRunnerRequest_GetSize(tt *testing.T) { u.GetSize() } +func TestUpdateIssueArtifactRequest_GetBody(tt *testing.T) { + tt.Parallel() + var zeroValue string + u := &UpdateIssueArtifactRequest{Body: &zeroValue} + u.GetBody() + u = &UpdateIssueArtifactRequest{} + u.GetBody() + u = nil + u.GetBody() +} + +func TestUpdateIssueArtifactRequest_GetName(tt *testing.T) { + tt.Parallel() + var zeroValue string + u := &UpdateIssueArtifactRequest{Name: &zeroValue} + u.GetName() + u = &UpdateIssueArtifactRequest{} + u.GetName() + u = nil + u.GetName() +} + func TestUpdateIssueLabelRequest_GetColor(tt *testing.T) { tt.Parallel() var zeroValue string diff --git a/github/github-iterators.go b/github/github-iterators.go index 108221871d3..110a418bc1d 100644 --- a/github/github-iterators.go +++ b/github/github-iterators.go @@ -4135,6 +4135,37 @@ func (s *IssuesService) ListAllIssuesIter(ctx context.Context, opts *ListAllIssu } } +// ListArtifactsIter returns an iterator that paginates through all results of ListArtifacts. +func (s *IssuesService) ListArtifactsIter(ctx context.Context, owner string, repo string, issueNumber int, opts *IssueArtifactListOptions) iter.Seq2[*IssueArtifact, error] { + return func(yield func(*IssueArtifact, error) bool) { + // Create a copy of opts to avoid mutating the caller's struct + if opts == nil { + opts = &IssueArtifactListOptions{} + } else { + opts = new(*opts) + } + + for { + results, resp, err := s.ListArtifacts(ctx, owner, repo, issueNumber, opts) + if err != nil { + yield(nil, err) + return + } + + for _, item := range results { + if !yield(item, nil) { + return + } + } + + if resp.NextPage == 0 { + break + } + opts.ListOptions.Page = resp.NextPage + } + } +} + // ListAssigneesIter returns an iterator that paginates through all results of ListAssignees. func (s *IssuesService) ListAssigneesIter(ctx context.Context, owner string, repo string, opts *ListOptions) iter.Seq2[*User, error] { return func(yield func(*User, error) bool) { diff --git a/github/github-iterators_test.go b/github/github-iterators_test.go index d4f19cb9313..3b9ae4ceaf9 100644 --- a/github/github-iterators_test.go +++ b/github/github-iterators_test.go @@ -8871,6 +8871,78 @@ func TestIssuesService_ListAllIssuesIter(t *testing.T) { } } +func TestIssuesService_ListArtifactsIter(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.ListArtifactsIter(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.ListArtifactsIter call 1 got %v items; want %v", gotItems, want) + } + + opts := &IssueArtifactListOptions{} + iter = client.Issues.ListArtifactsIter(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.ListArtifactsIter call 2 got %v items; want %v", gotItems, want) + } + + iter = client.Issues.ListArtifactsIter(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.ListArtifactsIter call 3 got %v items; want 1 (an error)", gotItems) + } + + iter = client.Issues.ListArtifactsIter(t.Context(), "", "", 0, nil) + gotItems = 0 + iter(func(item *IssueArtifact, err error) bool { + gotItems++ + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + return false + }) + if gotItems != 1 { + t.Errorf("client.Issues.ListArtifactsIter call 4 got %v items; want 1 (an error)", gotItems) + } +} + func TestIssuesService_ListAssigneesIter(t *testing.T) { t.Parallel() client, mux, _ := setup(t) diff --git a/github/github-stringify_test.go b/github/github-stringify_test.go index fdcbc0e33ba..5064e20354f 100644 --- a/github/github-stringify_test.go +++ b/github/github-stringify_test.go @@ -1063,6 +1063,44 @@ func TestIssue_String(t *testing.T) { } } +func TestIssueArtifact_String(t *testing.T) { + t.Parallel() + v := IssueArtifact{ + ID: 0, + Number: 0, + Type: "", + Name: "", + Body: "", + BodyHTML: new(""), + Description: new(""), + HTMLURL: new(""), + Creator: &User{}, + UpdatedByActor: &User{}, + CreatedAt: &Timestamp{}, + UpdatedAt: &Timestamp{}, + } + want := `github.IssueArtifact{ID:0, Number:0, Type:"", Name:"", Body:"", BodyHTML:"", Description:"", HTMLURL:"", Creator:github.User{}, UpdatedByActor:github.User{}, CreatedAt:github.Timestamp{0001-01-01 00:00:00 +0000 UTC}, UpdatedAt:github.Timestamp{0001-01-01 00:00:00 +0000 UTC}}` + if got := v.String(); got != want { + t.Errorf("IssueArtifact.String = %v, want %v", got, want) + } +} + +func TestIssueArtifactVersion_String(t *testing.T) { + t.Parallel() + v := IssueArtifactVersion{ + Version: 0, + Name: "", + Body: "", + BodyHTML: new(""), + CreatedAt: &Timestamp{}, + Actor: &User{}, + } + want := `github.IssueArtifactVersion{Version:0, Name:"", Body:"", BodyHTML:"", CreatedAt:github.Timestamp{0001-01-01 00:00:00 +0000 UTC}, Actor:github.User{}}` + if got := v.String(); got != want { + t.Errorf("IssueArtifactVersion.String = %v, want %v", got, want) + } +} + func TestIssueComment_String(t *testing.T) { t.Parallel() v := IssueComment{ diff --git a/github/issues_artifacts.go b/github/issues_artifacts.go new file mode 100644 index 00000000000..ae037893eca --- /dev/null +++ b/github/issues_artifacts.go @@ -0,0 +1,187 @@ +// 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" +) + +// IssueArtifact represents an artifact attached to an issue. +// Number identifies the artifact within its issue; ID is its global identifier. +type IssueArtifact struct { + ID int64 `json:"id"` + Number int `json:"number"` + Type string `json:"type"` + Name string `json:"name"` + Body string `json:"body"` + BodyHTML *string `json:"body_html,omitempty"` + Description *string `json:"description,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + Creator *User `json:"creator,omitempty"` + UpdatedByActor *User `json:"updated_by_actor,omitempty"` + CreatedAt *Timestamp `json:"created_at,omitempty"` + UpdatedAt *Timestamp `json:"updated_at,omitempty"` +} + +func (a IssueArtifact) String() string { + return Stringify(a) +} + +// IssueArtifactVersion represents a saved version of an issue artifact. +type IssueArtifactVersion struct { + Version int `json:"version"` + Name string `json:"name"` + Body string `json:"body"` + BodyHTML *string `json:"body_html,omitempty"` + CreatedAt *Timestamp `json:"created_at,omitempty"` + Actor *User `json:"actor,omitempty"` +} + +func (v IssueArtifactVersion) String() string { + return Stringify(v) +} + +// IssueArtifactWithVersions contains an artifact and its edit history. +type IssueArtifactWithVersions struct { + Artifact *IssueArtifact `json:"artifact"` + Versions []*IssueArtifactVersion `json:"versions"` +} + +func (a IssueArtifactWithVersions) String() string { + return Stringify(a) +} + +// IssueArtifactListOptions specifies optional parameters to +// IssuesService.ListArtifacts. +type IssueArtifactListOptions struct { + Type string `url:"type,omitempty"` + ListOptions +} + +// CreateIssueArtifactRequest specifies an artifact to create. +type CreateIssueArtifactRequest struct { + // Type can be "generic", "plan", "link", or "embedded_html". + Type *string `json:"type,omitempty"` + Name string `json:"name"` + Body string `json:"body"` +} + +// UpdateIssueArtifactRequest specifies changes to an artifact. +// Nil fields preserve existing values; non-nil pointers send even empty strings. +// An artifact's type cannot be changed. +type UpdateIssueArtifactRequest struct { + Name *string `json:"name,omitempty"` + Body *string `json:"body,omitempty"` +} + +// ListArtifacts lists artifacts attached to an issue. +// +// The response uses standard Link headers for pagination, which are exposed +// through Response and supported by ListArtifactsIter. Offset pagination has +// no snapshot guarantee. +// +// Note: ListArtifacts uses the undocumented GitHub API endpoint "GET /repos/{owner}/{repo}/issues/{issue_number}/artifacts". +// +//meta:operation GET /repos/{owner}/{repo}/issues/{issue_number}/artifacts +func (s *IssuesService) ListArtifacts(ctx context.Context, owner, repo string, issueNumber int, opts *IssueArtifactListOptions) ([]*IssueArtifact, *Response, error) { + u := fmt.Sprintf("repos/%v/%v/issues/%v/artifacts", owner, repo, issueNumber) + u, err := addOptions(u, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(ctx, "GET", u, nil) + if err != nil { + return nil, nil, err + } + + var artifacts []*IssueArtifact + resp, err := s.client.Do(req, &artifacts) + if err != nil { + return nil, resp, err + } + + return artifacts, resp, nil +} + +// GetArtifact gets an artifact and its edit history using its issue-scoped number. +// +// Note: GetArtifact uses the undocumented GitHub API endpoint "GET /repos/{owner}/{repo}/issues/{issue_number}/artifacts/{artifact_number}". +// +//meta:operation GET /repos/{owner}/{repo}/issues/{issue_number}/artifacts/{artifact_number} +func (s *IssuesService) GetArtifact(ctx context.Context, owner, repo string, issueNumber, artifactNumber int) (*IssueArtifactWithVersions, *Response, error) { + u := fmt.Sprintf("repos/%v/%v/issues/%v/artifacts/%v", owner, repo, issueNumber, artifactNumber) + req, err := s.client.NewRequest(ctx, "GET", u, nil) + if err != nil { + return nil, nil, err + } + + var artifact *IssueArtifactWithVersions + resp, err := s.client.Do(req, &artifact) + if err != nil { + return nil, resp, err + } + + return artifact, resp, nil +} + +// CreateArtifact creates an artifact attached to an issue. +// +// Note: CreateArtifact uses the undocumented GitHub API endpoint "POST /repos/{owner}/{repo}/issues/{issue_number}/artifacts". +// +//meta:operation POST /repos/{owner}/{repo}/issues/{issue_number}/artifacts +func (s *IssuesService) CreateArtifact(ctx context.Context, owner, repo string, issueNumber int, body CreateIssueArtifactRequest) (*IssueArtifact, *Response, error) { + u := fmt.Sprintf("repos/%v/%v/issues/%v/artifacts", owner, repo, issueNumber) + req, err := s.client.NewRequest(ctx, "POST", u, body) + if err != nil { + return nil, nil, err + } + + var artifact *IssueArtifact + resp, err := s.client.Do(req, &artifact) + if err != nil { + return nil, resp, err + } + + return artifact, resp, nil +} + +// UpdateArtifact updates an artifact using its issue-scoped number. +// +// Note: UpdateArtifact uses the undocumented GitHub API endpoint "PATCH /repos/{owner}/{repo}/issues/{issue_number}/artifacts/{artifact_number}". +// +//meta:operation PATCH /repos/{owner}/{repo}/issues/{issue_number}/artifacts/{artifact_number} +func (s *IssuesService) UpdateArtifact(ctx context.Context, owner, repo string, issueNumber, artifactNumber int, body UpdateIssueArtifactRequest) (*IssueArtifact, *Response, error) { + u := fmt.Sprintf("repos/%v/%v/issues/%v/artifacts/%v", owner, repo, issueNumber, artifactNumber) + req, err := s.client.NewRequest(ctx, "PATCH", u, body) + if err != nil { + return nil, nil, err + } + + var artifact *IssueArtifact + resp, err := s.client.Do(req, &artifact) + if err != nil { + return nil, resp, err + } + + return artifact, resp, nil +} + +// DeleteArtifact deletes an artifact using its issue-scoped number. +// +// Note: DeleteArtifact uses the undocumented GitHub API endpoint "DELETE /repos/{owner}/{repo}/issues/{issue_number}/artifacts/{artifact_number}". +// +//meta:operation DELETE /repos/{owner}/{repo}/issues/{issue_number}/artifacts/{artifact_number} +func (s *IssuesService) DeleteArtifact(ctx context.Context, owner, repo string, issueNumber, artifactNumber int) (*Response, error) { + u := fmt.Sprintf("repos/%v/%v/issues/%v/artifacts/%v", owner, repo, issueNumber, artifactNumber) + req, err := s.client.NewRequest(ctx, "DELETE", u, nil) + if err != nil { + return nil, err + } + + return s.client.Do(req, nil) +} diff --git a/github/issues_artifacts_test.go b/github/issues_artifacts_test.go new file mode 100644 index 00000000000..2484e090a73 --- /dev/null +++ b/github/issues_artifacts_test.go @@ -0,0 +1,382 @@ +// 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 ( + "errors" + "fmt" + "net/http" + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +func TestIssueArtifactWithVersions_String(t *testing.T) { + t.Parallel() + v := IssueArtifactWithVersions{Artifact: &IssueArtifact{}} + want := `github.IssueArtifactWithVersions{Artifact:github.IssueArtifact{ID:0, Number:0, Type:"", Name:"", Body:""}}` + if got := v.String(); got != want { + t.Errorf("IssueArtifactWithVersions.String = %v, want %v", got, want) + } +} + +func TestIssuesService_ListArtifacts(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/repos/o/r/issues/42/artifacts", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testFormValues(t, r, values{"page": "2", "per_page": "50", "type": "link"}) + fmt.Fprint(w, `[{"id":9001,"number":3,"type":"link","name":"Runbook","body":"https://example.com"}]`) + }) + + opts := &IssueArtifactListOptions{Type: "link", ListOptions: ListOptions{Page: 2, PerPage: 50}} + got, resp, err := client.Issues.ListArtifacts(t.Context(), "o", "r", 42, opts) + if err != nil { + t.Fatalf("Issues.ListArtifacts returned error: %v", err) + } + want := []*IssueArtifact{{ID: 9001, Number: 3, Type: "link", Name: "Runbook", Body: "https://example.com"}} + if !cmp.Equal(got, want) { + t.Errorf("Issues.ListArtifacts = %+v, want %+v", got, want) + } + if resp.StatusCode != http.StatusOK || resp.NextPage != 0 { + t.Errorf("response status = %v, NextPage = %v; want 200, 0", resp.StatusCode, resp.NextPage) + } + + const methodName = "ListArtifacts" + testBadOptions(t, methodName, func() error { + _, _, err := client.Issues.ListArtifacts(t.Context(), "\n", "\n", 42, opts) + return err + }) + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Issues.ListArtifacts(t.Context(), "o", "r", 42, opts) + if got != nil { + t.Errorf("%v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + +func TestIssuesService_ListArtifacts_Pages(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + name string + body string + opts *IssueArtifactListOptions + want int + }{ + {name: "empty", body: `[]`, want: 0}, + {name: "full", body: `[{"id":9001,"number":3}]`, opts: &IssueArtifactListOptions{ListOptions: ListOptions{PerPage: 1}}, want: 1}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + requests := 0 + mux.HandleFunc("/repos/o/r/issues/42/artifacts", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + query := values{} + if tt.opts != nil { + query["per_page"] = "1" + } + testFormValues(t, r, query) + requests++ + fmt.Fprint(w, tt.body) + }) + got, resp, err := client.Issues.ListArtifacts(t.Context(), "o", "r", 42, tt.opts) + if err != nil { + t.Fatalf("Issues.ListArtifacts returned error: %v", err) + } + if got == nil || len(got) != tt.want || requests != 1 || resp.NextPage != 0 { + t.Errorf("got %v, requests = %v, NextPage = %v; want non-nil slice of length %v, 1, 0", got, requests, resp.NextPage, tt.want) + } + }) + } +} + +func TestIssuesService_ListArtifactsIter_LinkPagination(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + requests := 0 + mux.HandleFunc("/repos/o/r/issues/42/artifacts", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + requests++ + query := values{"per_page": "2", "type": "link"} + if requests == 1 { + query["page"] = "1" + w.Header().Set("Link", `; rel="next"`) + fmt.Fprint(w, `[{"id":9001,"number":1,"type":"link","name":"One"},{"id":9002,"number":2,"type":"link","name":"Two"}]`) + } else { + query["page"] = "2" + fmt.Fprint(w, `[{"id":9003,"number":3,"type":"link","name":"Three"}]`) + } + testFormValues(t, r, query) + }) + + opts := &IssueArtifactListOptions{Type: "link", ListOptions: ListOptions{Page: 1, PerPage: 2}} + var got []*IssueArtifact + for artifact, err := range client.Issues.ListArtifactsIter(t.Context(), "o", "r", 42, opts) { + if err != nil { + t.Fatalf("ListArtifactsIter returned error: %v", err) + } + got = append(got, artifact) + } + if len(got) != 3 || requests != 2 { + t.Fatalf("ListArtifactsIter returned %d artifacts in %d requests; want 3 in 2 requests", len(got), requests) + } + if opts.Page != 1 || opts.PerPage != 2 || opts.Type != "link" { + t.Errorf("ListArtifactsIter mutated options: %+v", opts) + } +} + +func TestIssuesService_GetArtifact(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + mux.HandleFunc("/repos/o/r/issues/42/artifacts/3", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testFormValues(t, r, values{}) + fmt.Fprint(w, `{ + "artifact":{ + "id":9001,"number":3,"type":"plan","name":"Plan","body":"# Plan", + "body_html":"

Plan

","description":"Summary", + "html_url":"https://github.com/o/r/issues/42/artifacts/3", + "creator":{"id":12,"login":"author"}, + "updated_by_actor":{"id":13,"login":"editor"}, + "created_at":"2026-09-01T10:00:00Z","updated_at":"2026-09-02T10:00:00Z" + }, + "versions":[ + {"version":4,"name":"Plan","body":"# Plan","body_html":"

Plan

","created_at":"2026-09-02T10:00:00Z","actor":{"id":13,"login":"editor"}}, + {"version":1,"name":"Draft","body":"","body_html":null,"created_at":null,"actor":null} + ] + }`) + }) + created := &Timestamp{time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)} + updated := &Timestamp{time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC)} + editor := &User{ID: new(int64(13)), Login: new("editor")} + want := &IssueArtifactWithVersions{ + Artifact: &IssueArtifact{ + ID: 9001, Number: 3, Type: "plan", Name: "Plan", Body: "# Plan", + BodyHTML: new("

Plan

"), Description: new("Summary"), + HTMLURL: new("https://github.com/o/r/issues/42/artifacts/3"), + Creator: &User{ID: new(int64(12)), Login: new("author")}, UpdatedByActor: editor, + CreatedAt: created, UpdatedAt: updated, + }, + Versions: []*IssueArtifactVersion{ + {Version: 4, Name: "Plan", Body: "# Plan", BodyHTML: new("

Plan

"), CreatedAt: updated, Actor: editor}, + {Version: 1, Name: "Draft", Body: ""}, + }, + } + got, resp, err := client.Issues.GetArtifact(t.Context(), "o", "r", 42, 3) + if err != nil { + t.Fatalf("Issues.GetArtifact returned error: %v", err) + } + if !cmp.Equal(got, want) { + t.Errorf("Issues.GetArtifact mismatch (-got +want):\n%v", cmp.Diff(got, want)) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("response status = %v, want 200", resp.StatusCode) + } + const methodName = "GetArtifact" + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Issues.GetArtifact(t.Context(), "o", "r", 42, 3) + if got != nil { + t.Errorf("%v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + +func TestIssuesService_GetArtifact_NullableFields(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + mux.HandleFunc("/repos/o/r/issues/42/artifacts/3", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"artifact":{"id":9001,"number":3,"type":"generic","name":"Draft","body":"","body_html":null,"creator":null,"updated_by_actor":null,"created_at":null,"updated_at":null},"versions":[]}`) + }) + got, _, err := client.Issues.GetArtifact(t.Context(), "o", "r", 42, 3) + if err != nil { + t.Fatalf("Issues.GetArtifact returned error: %v", err) + } + want := &IssueArtifactWithVersions{ + Artifact: &IssueArtifact{ID: 9001, Number: 3, Type: "generic", Name: "Draft", Body: ""}, + Versions: []*IssueArtifactVersion{}, + } + if !cmp.Equal(got, want) { + t.Errorf("Issues.GetArtifact mismatch (-got +want):\n%v", cmp.Diff(got, want)) + } +} + +func TestIssuesService_CreateArtifact(t *testing.T) { + t.Parallel() + for _, artifactType := range []string{"generic", "plan", "link", "embedded_html"} { + t.Run(artifactType, func(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + input := CreateIssueArtifactRequest{Type: new(artifactType), Name: "Draft", Body: ""} + mux.HandleFunc("/repos/o/r/issues/42/artifacts", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + testFormValues(t, r, values{}) + testJSONBodyRaw(t, r, fmt.Sprintf(`{"type":%q,"name":"Draft","body":""}`, artifactType)) + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{"id":9001,"number":3,"type":%q,"name":"Draft","body":""}`, artifactType) + }) + got, resp, err := client.Issues.CreateArtifact(t.Context(), "o", "r", 42, input) + if err != nil { + t.Fatalf("Issues.CreateArtifact returned error: %v", err) + } + want := &IssueArtifact{ID: 9001, Number: 3, Type: artifactType, Name: "Draft", Body: ""} + if !cmp.Equal(got, want) { + t.Errorf("Issues.CreateArtifact = %+v, want %+v", got, want) + } + if resp.StatusCode != http.StatusCreated { + t.Errorf("response status = %v, want 201", resp.StatusCode) + } + const methodName = "CreateArtifact" + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Issues.CreateArtifact(t.Context(), "o", "r", 42, input) + if got != nil { + t.Errorf("%v = %#v, want nil", methodName, got) + } + return resp, err + }) + }) + } +} + +func TestIssuesService_CreateArtifact_DefaultType(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + mux.HandleFunc("/repos/o/r/issues/42/artifacts", func(w http.ResponseWriter, r *http.Request) { + testJSONBodyRaw(t, r, `{"name":"Draft","body":""}`) + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"id":9001,"number":3,"type":"generic","name":"Draft","body":""}`) + }) + got, _, err := client.Issues.CreateArtifact(t.Context(), "o", "r", 42, CreateIssueArtifactRequest{Name: "Draft"}) + if err != nil { + t.Fatalf("Issues.CreateArtifact returned error: %v", err) + } + if got.Type != "generic" { + t.Errorf("artifact type = %q, want generic", got.Type) + } +} + +func TestIssuesService_UpdateArtifact(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + name string + input UpdateIssueArtifactRequest + body string + }{ + {name: "name only", input: UpdateIssueArtifactRequest{Name: new("Renamed")}, body: `{"name":"Renamed"}`}, + {name: "body only", input: UpdateIssueArtifactRequest{Body: new("Revised")}, body: `{"body":"Revised"}`}, + {name: "both", input: UpdateIssueArtifactRequest{Name: new("Renamed"), Body: new("Revised")}, body: `{"name":"Renamed","body":"Revised"}`}, + {name: "empty name", input: UpdateIssueArtifactRequest{Name: new("")}, body: `{"name":""}`}, + {name: "empty body", input: UpdateIssueArtifactRequest{Body: new("")}, body: `{"body":""}`}, + {name: "both empty", input: UpdateIssueArtifactRequest{Name: new(""), Body: new("")}, body: `{"name":"","body":""}`}, + {name: "omitted", body: `{}`}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + mux.HandleFunc("/repos/o/r/issues/42/artifacts/3", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PATCH") + testFormValues(t, r, values{}) + testJSONBodyRaw(t, r, tt.body) + fmt.Fprint(w, `{"id":9001,"number":3,"type":"plan","name":"Updated","body":""}`) + }) + got, resp, err := client.Issues.UpdateArtifact(t.Context(), "o", "r", 42, 3, tt.input) + if err != nil { + t.Fatalf("Issues.UpdateArtifact returned error: %v", err) + } + want := &IssueArtifact{ID: 9001, Number: 3, Type: "plan", Name: "Updated", Body: ""} + if !cmp.Equal(got, want) { + t.Errorf("Issues.UpdateArtifact = %+v, want %+v", got, want) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("response status = %v, want 200", resp.StatusCode) + } + const methodName = "UpdateArtifact" + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Issues.UpdateArtifact(t.Context(), "o", "r", 42, 3, tt.input) + if got != nil { + t.Errorf("%v = %#v, want nil", methodName, got) + } + return resp, err + }) + }) + } +} + +func TestIssuesService_DeleteArtifact(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + mux.HandleFunc("/repos/o/r/issues/42/artifacts/3", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "DELETE") + testFormValues(t, r, values{}) + testPlainBody(t, r, "") + w.WriteHeader(http.StatusNoContent) + }) + resp, err := client.Issues.DeleteArtifact(t.Context(), "o", "r", 42, 3) + if err != nil { + t.Fatalf("Issues.DeleteArtifact returned error: %v", err) + } + if resp.StatusCode != http.StatusNoContent { + t.Errorf("response status = %v, want 204", resp.StatusCode) + } + testNewRequestAndDoFailure(t, "DeleteArtifact", client, func() (*Response, error) { + return client.Issues.DeleteArtifact(t.Context(), "o", "r", 42, 3) + }) +} + +func TestIssuesService_Artifacts_APIErrors(t *testing.T) { + t.Parallel() + for _, method := range []string{"GET", "POST", "PATCH", "DELETE", "LIST"} { + t.Run(method, func(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, `{"message":"Forbidden"}`) + }) + var resp *Response + var err error + switch method { + case "GET": + var got *IssueArtifactWithVersions + got, resp, err = client.Issues.GetArtifact(t.Context(), "o", "r", 42, 3) + if got != nil { + t.Errorf("GetArtifact = %+v, want nil", got) + } + case "POST": + var got *IssueArtifact + got, resp, err = client.Issues.CreateArtifact(t.Context(), "o", "r", 42, CreateIssueArtifactRequest{}) + if got != nil { + t.Errorf("CreateArtifact = %+v, want nil", got) + } + case "PATCH": + var got *IssueArtifact + got, resp, err = client.Issues.UpdateArtifact(t.Context(), "o", "r", 42, 3, UpdateIssueArtifactRequest{}) + if got != nil { + t.Errorf("UpdateArtifact = %+v, want nil", got) + } + case "DELETE": + resp, err = client.Issues.DeleteArtifact(t.Context(), "o", "r", 42, 3) + case "LIST": + var got []*IssueArtifact + got, resp, err = client.Issues.ListArtifacts(t.Context(), "o", "r", 42, nil) + if got != nil { + t.Errorf("ListArtifacts = %+v, want nil", got) + } + } + var apiErr *ErrorResponse + if !errors.As(err, &apiErr) || apiErr.Message != "Forbidden" { + t.Errorf("error = %v, want ErrorResponse with message Forbidden", err) + } + if resp == nil || resp.StatusCode != http.StatusForbidden { + t.Errorf("response = %+v, want status 403", resp) + } + }) + } +} diff --git a/openapi_operations.yaml b/openapi_operations.yaml index a53b770ba0d..ca1ad909da3 100644 --- a/openapi_operations.yaml +++ b/openapi_operations.yaml @@ -68,6 +68,11 @@ operations: documentation_url: https://gist.github.com/jonmagic/5282384165e0f86ef105#start-an-issue-import - name: GET /repos/{owner}/{repo}/import/issues/{issue_number} documentation_url: https://gist.github.com/jonmagic/5282384165e0f86ef105#import-status-request + - name: GET /repos/{owner}/{repo}/issues/{issue_number}/artifacts + - name: POST /repos/{owner}/{repo}/issues/{issue_number}/artifacts + - name: DELETE /repos/{owner}/{repo}/issues/{issue_number}/artifacts/{artifact_number} + - name: GET /repos/{owner}/{repo}/issues/{issue_number}/artifacts/{artifact_number} + - name: PATCH /repos/{owner}/{repo}/issues/{issue_number}/artifacts/{artifact_number} - name: GET /repositories/{repository_id} - name: GET /repositories/{repository_id}/installation - name: GET /users/{username}/settings/billing/packages