Skip to content

Commit dca37dd

Browse files
committed
feat: add a GistOverflowHandler for uploading content that is too long into a gist
1 parent 7246ddc commit dca37dd

File tree

6 files changed

+334
-6
lines changed

6 files changed

+334
-6
lines changed

internal/github/github.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -337,8 +337,14 @@ func (c *Client) ClosePullRequest(ctx context.Context, number int) error {
337337
return err
338338
}
339339

340+
type Gist struct {
341+
ID string
342+
Owner string
343+
Url string
344+
}
345+
340346
// CreateGist creates a new gist with one or more files.
341-
func (c *Client) CreateGist(ctx context.Context, contents map[string]string, isPublic bool) (string, error) {
347+
func (c *Client) CreateGist(ctx context.Context, contents map[string]string, isPublic bool) (*Gist, error) {
342348
var files = make(map[github.GistFilename]github.GistFile, len(contents))
343349
for filename, content := range contents {
344350
files[github.GistFilename(filename)] = github.GistFile{
@@ -350,9 +356,13 @@ func (c *Client) CreateGist(ctx context.Context, contents map[string]string, isP
350356
Public: github.Ptr(isPublic),
351357
})
352358
if err != nil {
353-
return "", err
359+
return nil, err
354360
}
355-
return gist.GetID(), nil
361+
return &Gist{
362+
ID: gist.GetID(),
363+
Owner: gist.GetOwner().GetName(),
364+
Url: gist.GetHTMLURL(),
365+
}, nil
356366
}
357367

358368
// GetGistContent fetches all the file contents for a specific gist.

internal/librarian/command.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ type GitHubClient interface {
8888
CreateRelease(ctx context.Context, tagName, name, body, commitish string) (*github.RepositoryRelease, error)
8989
CreateIssueComment(ctx context.Context, number int, comment string) error
9090
CreateTag(ctx context.Context, tag, commitish string) error
91+
CreateGist(ctx context.Context, contents map[string]string, isPublic bool) (*github.Gist, error)
92+
GetGistContent(ctx context.Context, gistID string) (map[string]string, error)
9193
}
9294

9395
// ContainerClient is an abstraction over the Docker client.

internal/librarian/mocks_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ type mockGitHubClient struct {
4242
createReleaseCalls int
4343
createIssueCalls int
4444
createTagCalls int
45+
createGistCalls int
46+
getGistContentCalls int
4547
createPullRequestErr error
4648
addLabelsToIssuesErr error
4749
getLabelsErr error
@@ -51,7 +53,11 @@ type mockGitHubClient struct {
5153
createReleaseErr error
5254
createIssueErr error
5355
createTagErr error
56+
createGistError error
57+
getGistError error
5458
createdPR *github.PullRequestMetadata
59+
createdGist *github.Gist
60+
getGistContent map[string]string
5561
labels []string
5662
pullRequests []*github.PullRequest
5763
pullRequest *github.PullRequest
@@ -115,6 +121,16 @@ func (m *mockGitHubClient) CreateTag(ctx context.Context, tagName, commitish str
115121
return m.createTagErr
116122
}
117123

124+
func (m *mockGitHubClient) CreateGist(ctx context.Context, contents map[string]string, isPublic bool) (*github.Gist, error) {
125+
m.createGistCalls++
126+
return m.createdGist, m.createGistError
127+
}
128+
129+
func (m *mockGitHubClient) GetGistContent(ctx context.Context, gistID string) (map[string]string, error) {
130+
m.getGistContentCalls++
131+
return m.getGistContent, m.getGistError
132+
}
133+
118134
// mockContainerClient is a mock implementation of the ContainerClient interface for testing.
119135
type mockContainerClient struct {
120136
ContainerClient
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package librarian
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"log/slog"
21+
"regexp"
22+
)
23+
24+
// NewGistOverflowHandler handles storing overflow content in a gist.
25+
type GistOverflowHandler struct {
26+
github GitHubClient
27+
maxContentSize int
28+
}
29+
30+
const maxPullRequestBodySize = 65536
31+
32+
var overflowPullRequestRegex = regexp.MustCompile(`See full release notes at: https://gist.github.com/[^\/]+/([0-9a-f]+)`)
33+
34+
// NewGistOverflowHandler returns a handler for storing overflow content in a gist.
35+
func NewGistOverflowHandler(github GitHubClient, maxContentSize int) (*GistOverflowHandler, error) {
36+
if maxContentSize == 0 {
37+
maxContentSize = maxPullRequestBodySize
38+
}
39+
return &GistOverflowHandler{
40+
github: github,
41+
maxContentSize: maxContentSize,
42+
}, nil
43+
}
44+
45+
// SavePullRequestBody stores content in a gist if it's too big and returns a minimized version.
46+
func (g *GistOverflowHandler) SavePullRequestBody(ctx context.Context, body string) (string, error) {
47+
if len(body) > g.maxContentSize {
48+
slog.Info("content is too big, saving to gist", slog.Int("len", len(body)))
49+
contents := map[string]string{
50+
"release-notes.md": body,
51+
}
52+
gist, err := g.github.CreateGist(ctx, contents, true)
53+
if err != nil {
54+
return "", err
55+
}
56+
return fmt.Sprintf("See full release notes at: %s", gist.Url), nil
57+
}
58+
return body, nil
59+
}
60+
61+
// FetchPullRequestBody restores content from a stroed gist.
62+
func (g *GistOverflowHandler) FetchPullRequestBody(ctx context.Context, body string) (string, error) {
63+
matches := overflowPullRequestRegex.FindStringSubmatch(body)
64+
if len(matches) == 2 {
65+
slog.Info("found gist in pull request body", "gistID", matches[1])
66+
contents, err := g.github.GetGistContent(ctx, matches[1])
67+
if err != nil {
68+
return "", err
69+
}
70+
content, found := contents["release-notes.md"]
71+
if found {
72+
return content, nil
73+
}
74+
return "", fmt.Errorf("unable to find release-notes.md from gist %s", matches[1])
75+
}
76+
return body, nil
77+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package librarian
16+
17+
import (
18+
"fmt"
19+
"strings"
20+
"testing"
21+
22+
"github.com/google/go-cmp/cmp"
23+
"github.com/googleapis/librarian/internal/github"
24+
)
25+
26+
func TestNewGistOverflowHandler(t *testing.T) {
27+
t.Parallel()
28+
for _, test := range []struct {
29+
name string
30+
client *mockGitHubClient
31+
maxContentSize int
32+
wantMaxContentSize int
33+
}{
34+
{
35+
name: "default configs",
36+
client: &mockGitHubClient{},
37+
wantMaxContentSize: maxPullRequestBodySize,
38+
},
39+
{
40+
name: "custom length configs",
41+
client: &mockGitHubClient{},
42+
maxContentSize: 1234,
43+
wantMaxContentSize: 1234,
44+
},
45+
} {
46+
t.Run(test.name, func(t *testing.T) {
47+
got, err := NewGistOverflowHandler(test.client, test.maxContentSize)
48+
if err != nil {
49+
t.Fatalf("unexpected error in NewGistOverflowHandler() %v", err)
50+
}
51+
52+
if diff := cmp.Diff(test.wantMaxContentSize, got.maxContentSize); diff != "" {
53+
t.Errorf("%s: NewGistOverflowHandler() maxContentSize mismatch (-want +got):%s", test.name, diff)
54+
}
55+
})
56+
}
57+
}
58+
59+
func TestSavePullRequestBody(t *testing.T) {
60+
t.Parallel()
61+
for _, test := range []struct {
62+
name string
63+
client *mockGitHubClient
64+
maxContentSize int
65+
content string
66+
want string
67+
wantErr bool
68+
wantErrMsg string
69+
wantCreateGistCalls int
70+
}{
71+
{
72+
name: "short content",
73+
client: &mockGitHubClient{},
74+
content: "some-content",
75+
want: "some-content",
76+
},
77+
{
78+
name: "content too long content",
79+
client: &mockGitHubClient{
80+
createdGist: &github.Gist{
81+
ID: "abcd1234",
82+
Url: "https://gist.github.com/some-user/abcd1234",
83+
},
84+
},
85+
content: "super long content",
86+
maxContentSize: 5, // force overflow handling
87+
want: "See full release notes at: https://gist.github.com/some-user/abcd1234",
88+
wantCreateGistCalls: 1,
89+
},
90+
{
91+
name: "content too long content, error creating gist",
92+
client: &mockGitHubClient{
93+
createGistError: fmt.Errorf("some create gist error"),
94+
createdGist: &github.Gist{
95+
ID: "abcd1234",
96+
Url: "https://gist.github.com/some-user/abcd1234",
97+
},
98+
},
99+
content: "super long content",
100+
maxContentSize: 5, // force overflow handling
101+
wantErr: true,
102+
wantErrMsg: "some create gist error",
103+
wantCreateGistCalls: 1,
104+
},
105+
} {
106+
t.Run(test.name, func(t *testing.T) {
107+
handler, err := NewGistOverflowHandler(test.client, test.maxContentSize)
108+
if err != nil {
109+
t.Fatalf("unexpected error in NewGistOverflowHandler() %v", err)
110+
}
111+
112+
got, err := handler.SavePullRequestBody(t.Context(), test.content)
113+
if test.wantErr {
114+
if err == nil {
115+
t.Fatalf("SavePullRequestBody() error = %v, wantErr %v", err, test.wantErr)
116+
}
117+
118+
if !strings.Contains(err.Error(), test.wantErrMsg) {
119+
t.Fatalf("want error message: %s, got: %s", test.wantErrMsg, err.Error())
120+
}
121+
return
122+
}
123+
124+
if err != nil {
125+
t.Fatalf("unexpected error in SavePullRequestBody() %v", err)
126+
}
127+
128+
if diff := cmp.Diff(got, test.want); diff != "" {
129+
t.Errorf("%s: SavePullRequestBody() mismatch (-want +got):%s", test.name, diff)
130+
}
131+
132+
if diff := cmp.Diff(test.wantCreateGistCalls, test.client.createGistCalls); diff != "" {
133+
t.Errorf("%s: SavePullRequestBody() createGistCalls mismatch (-want +got):%s", test.name, diff)
134+
}
135+
})
136+
}
137+
}
138+
139+
func TestFetchPullRequestBody(t *testing.T) {
140+
t.Parallel()
141+
for _, test := range []struct {
142+
name string
143+
client *mockGitHubClient
144+
maxContentSize int
145+
content string
146+
want string
147+
wantErr bool
148+
wantErrMsg string
149+
wantGetGistContentCalls int
150+
}{
151+
{
152+
name: "non overflow",
153+
client: &mockGitHubClient{},
154+
content: "some release notes",
155+
want: "some release notes",
156+
},
157+
{
158+
name: "with overflow",
159+
client: &mockGitHubClient{
160+
getGistContent: map[string]string{
161+
"release-notes.md": "some release notes",
162+
},
163+
},
164+
content: "See full release notes at: https://gist.github.com/some-user/abcd1234",
165+
want: "some release notes",
166+
wantGetGistContentCalls: 1,
167+
},
168+
{
169+
name: "with overflow, error fetching gist",
170+
client: &mockGitHubClient{
171+
getGistError: fmt.Errorf("some fetch gist error"),
172+
},
173+
content: "See full release notes at: https://gist.github.com/some-user/abcd1234",
174+
wantErr: true,
175+
wantErrMsg: "some fetch gist error",
176+
wantGetGistContentCalls: 1,
177+
},
178+
{
179+
name: "with overflow, wrong file",
180+
client: &mockGitHubClient{
181+
getGistContent: map[string]string{
182+
"unexpected file": "some release notes",
183+
},
184+
},
185+
content: "See full release notes at: https://gist.github.com/some-user/abcd1234",
186+
wantErr: true,
187+
wantErrMsg: "unable to find",
188+
wantGetGistContentCalls: 1,
189+
},
190+
} {
191+
t.Run(test.name, func(t *testing.T) {
192+
handler, err := NewGistOverflowHandler(test.client, test.maxContentSize)
193+
if err != nil {
194+
t.Fatalf("unexpected error in NewGistOverflowHandler() %v", err)
195+
}
196+
197+
got, err := handler.FetchPullRequestBody(t.Context(), test.content)
198+
199+
if test.wantErr {
200+
if err == nil {
201+
t.Fatalf("FetchPullRequestBody() error = %v, wantErr %v", err, test.wantErr)
202+
}
203+
204+
if !strings.Contains(err.Error(), test.wantErrMsg) {
205+
t.Fatalf("want error message: %s, got: %s", test.wantErrMsg, err.Error())
206+
}
207+
return
208+
}
209+
210+
if err != nil {
211+
t.Fatalf("unexpected error in FetchPullRequestBody() %v", err)
212+
}
213+
214+
if diff := cmp.Diff(got, test.want); diff != "" {
215+
t.Errorf("%s: FetchPullRequestBody() mismatch (-want +got):%s", test.name, diff)
216+
}
217+
218+
if diff := cmp.Diff(test.wantGetGistContentCalls, test.client.getGistContentCalls); diff != "" {
219+
t.Errorf("%s: FetchPullRequestBody() createGistCalls mismatch (-want +got):%s", test.name, diff)
220+
}
221+
})
222+
}
223+
}

system_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -455,13 +455,13 @@ func TestCreateGist(t *testing.T) {
455455
"README.md": "some contents go here",
456456
}
457457
client := github.NewClient(testToken, &github.Repository{})
458-
gistID, err := client.CreateGist(t.Context(), files, true)
458+
gist, err := client.CreateGist(t.Context(), files, false)
459459
if err != nil {
460460
t.Fatalf("unexpected error in CreateGist() %s", err)
461461
}
462-
slog.Info("created gist", "ID", gistID)
462+
slog.Info("created gist", "ID", gist.ID, "URL", gist.Url)
463463

464-
contents, err := client.GetGistContent(t.Context(), gistID)
464+
contents, err := client.GetGistContent(t.Context(), gist.ID)
465465
if err != nil {
466466
t.Fatalf("unexpected error in GetGistContent() %s", err)
467467
}

0 commit comments

Comments
 (0)