diff --git a/internal/github/github.go b/internal/github/github.go index 59f628db7..753771642 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -336,3 +336,51 @@ func (c *Client) ClosePullRequest(ctx context.Context, number int) error { }) return err } + +// Gist represents a created GitHub gist. +type Gist struct { + // ID is the guid of the gist. + ID string + // Owner is the GitHub user name that owns the gist. + Owner string + // Url is the HTML Url of the created gist. + Url string +} + +// CreateGist creates a new gist with one or more files. +func (c *Client) CreateGist(ctx context.Context, contents map[string]string, isPublic bool) (*Gist, error) { + var files = make(map[github.GistFilename]github.GistFile, len(contents)) + for filename, content := range contents { + files[github.GistFilename(filename)] = github.GistFile{ + Content: github.Ptr(content), + } + } + gist, _, err := c.Gists.Create(ctx, &github.Gist{ + Files: files, + Public: github.Ptr(isPublic), + }) + if err != nil { + return nil, err + } + return &Gist{ + ID: gist.GetID(), + Owner: gist.GetOwner().GetName(), + Url: gist.GetHTMLURL(), + }, nil +} + +// GetGistContent fetches all the file contents for a specific gist. +func (c *Client) GetGistContent(ctx context.Context, gistID string) (map[string]string, error) { + gist, _, err := c.Gists.Get(ctx, gistID) + if err != nil { + return nil, err + } + + files := gist.GetFiles() + var contents = make(map[string]string, len(files)) + for _, file := range files { + contents[file.GetFilename()] = file.GetContent() + } + + return contents, nil +} diff --git a/internal/librarian/command.go b/internal/librarian/command.go index be8b4d8c9..4e1ab18ad 100644 --- a/internal/librarian/command.go +++ b/internal/librarian/command.go @@ -88,6 +88,8 @@ type GitHubClient interface { CreateRelease(ctx context.Context, tagName, name, body, commitish string) (*github.RepositoryRelease, error) CreateIssueComment(ctx context.Context, number int, comment string) error CreateTag(ctx context.Context, tag, commitish string) error + CreateGist(ctx context.Context, contents map[string]string, isPublic bool) (*github.Gist, error) + GetGistContent(ctx context.Context, gistID string) (map[string]string, error) } // ContainerClient is an abstraction over the Docker client. diff --git a/internal/librarian/mocks_test.go b/internal/librarian/mocks_test.go index f8f07d020..67e2f4ee4 100644 --- a/internal/librarian/mocks_test.go +++ b/internal/librarian/mocks_test.go @@ -42,6 +42,8 @@ type mockGitHubClient struct { createReleaseCalls int createIssueCalls int createTagCalls int + createGistCalls int + getGistContentCalls int createPullRequestErr error addLabelsToIssuesErr error getLabelsErr error @@ -51,7 +53,11 @@ type mockGitHubClient struct { createReleaseErr error createIssueErr error createTagErr error + createGistError error + getGistError error createdPR *github.PullRequestMetadata + createdGist *github.Gist + getGistContent map[string]string labels []string pullRequests []*github.PullRequest pullRequest *github.PullRequest @@ -115,6 +121,16 @@ func (m *mockGitHubClient) CreateTag(ctx context.Context, tagName, commitish str return m.createTagErr } +func (m *mockGitHubClient) CreateGist(ctx context.Context, contents map[string]string, isPublic bool) (*github.Gist, error) { + m.createGistCalls++ + return m.createdGist, m.createGistError +} + +func (m *mockGitHubClient) GetGistContent(ctx context.Context, gistID string) (map[string]string, error) { + m.getGistContentCalls++ + return m.getGistContent, m.getGistError +} + // mockContainerClient is a mock implementation of the ContainerClient interface for testing. type mockContainerClient struct { ContainerClient diff --git a/internal/librarian/pull_request_serializer.go b/internal/librarian/pull_request_serializer.go new file mode 100644 index 000000000..4f1271029 --- /dev/null +++ b/internal/librarian/pull_request_serializer.go @@ -0,0 +1,77 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package librarian + +import ( + "context" + "fmt" + "log/slog" + "regexp" +) + +// GistOverflowHandler handles storing overflow content in a gist. +type GistOverflowHandler struct { + github GitHubClient + maxContentSize int +} + +const maxPullRequestBodySize = 65536 + +var overflowPullRequestRegex = regexp.MustCompile(`See full release notes at: https://gist.github.com/[^\/]+/([0-9a-f]+)`) + +// NewGistOverflowHandler returns a handler for storing overflow content in a gist. +func NewGistOverflowHandler(github GitHubClient, maxContentSize int) (*GistOverflowHandler, error) { + if maxContentSize == 0 { + maxContentSize = maxPullRequestBodySize + } + return &GistOverflowHandler{ + github: github, + maxContentSize: maxContentSize, + }, nil +} + +// SavePullRequestBody stores content in a gist if it's too big and returns a minimized version. +func (g *GistOverflowHandler) SavePullRequestBody(ctx context.Context, body string) (string, error) { + if len(body) > g.maxContentSize { + slog.Info("content is too big, saving to gist", slog.Int("len", len(body))) + contents := map[string]string{ + "release-notes.md": body, + } + gist, err := g.github.CreateGist(ctx, contents, true) + if err != nil { + return "", err + } + return fmt.Sprintf("See full release notes at: %s", gist.Url), nil + } + return body, nil +} + +// FetchPullRequestBody restores content from a stroed gist. +func (g *GistOverflowHandler) FetchPullRequestBody(ctx context.Context, body string) (string, error) { + matches := overflowPullRequestRegex.FindStringSubmatch(body) + if len(matches) == 2 { + slog.Info("found gist in pull request body", "gistID", matches[1]) + contents, err := g.github.GetGistContent(ctx, matches[1]) + if err != nil { + return "", err + } + content, found := contents["release-notes.md"] + if found { + return content, nil + } + return "", fmt.Errorf("unable to find release-notes.md from gist %s", matches[1]) + } + return body, nil +} diff --git a/internal/librarian/pull_request_serializer_test.go b/internal/librarian/pull_request_serializer_test.go new file mode 100644 index 000000000..369133cdb --- /dev/null +++ b/internal/librarian/pull_request_serializer_test.go @@ -0,0 +1,223 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package librarian + +import ( + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/googleapis/librarian/internal/github" +) + +func TestNewGistOverflowHandler(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + client *mockGitHubClient + maxContentSize int + wantMaxContentSize int + }{ + { + name: "default configs", + client: &mockGitHubClient{}, + wantMaxContentSize: maxPullRequestBodySize, + }, + { + name: "custom length configs", + client: &mockGitHubClient{}, + maxContentSize: 1234, + wantMaxContentSize: 1234, + }, + } { + t.Run(test.name, func(t *testing.T) { + got, err := NewGistOverflowHandler(test.client, test.maxContentSize) + if err != nil { + t.Fatalf("unexpected error in NewGistOverflowHandler() %v", err) + } + + if diff := cmp.Diff(test.wantMaxContentSize, got.maxContentSize); diff != "" { + t.Errorf("%s: NewGistOverflowHandler() maxContentSize mismatch (-want +got):%s", test.name, diff) + } + }) + } +} + +func TestSavePullRequestBody(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + client *mockGitHubClient + maxContentSize int + content string + want string + wantErr bool + wantErrMsg string + wantCreateGistCalls int + }{ + { + name: "short content", + client: &mockGitHubClient{}, + content: "some-content", + want: "some-content", + }, + { + name: "content too long content", + client: &mockGitHubClient{ + createdGist: &github.Gist{ + ID: "abcd1234", + Url: "https://gist.github.com/some-user/abcd1234", + }, + }, + content: "super long content", + maxContentSize: 5, // force overflow handling + want: "See full release notes at: https://gist.github.com/some-user/abcd1234", + wantCreateGistCalls: 1, + }, + { + name: "content too long content, error creating gist", + client: &mockGitHubClient{ + createGistError: fmt.Errorf("some create gist error"), + createdGist: &github.Gist{ + ID: "abcd1234", + Url: "https://gist.github.com/some-user/abcd1234", + }, + }, + content: "super long content", + maxContentSize: 5, // force overflow handling + wantErr: true, + wantErrMsg: "some create gist error", + wantCreateGistCalls: 1, + }, + } { + t.Run(test.name, func(t *testing.T) { + handler, err := NewGistOverflowHandler(test.client, test.maxContentSize) + if err != nil { + t.Fatalf("unexpected error in NewGistOverflowHandler() %v", err) + } + + got, err := handler.SavePullRequestBody(t.Context(), test.content) + if test.wantErr { + if err == nil { + t.Fatalf("SavePullRequestBody() error = %v, wantErr %v", err, test.wantErr) + } + + if !strings.Contains(err.Error(), test.wantErrMsg) { + t.Fatalf("want error message: %s, got: %s", test.wantErrMsg, err.Error()) + } + return + } + + if err != nil { + t.Fatalf("unexpected error in SavePullRequestBody() %v", err) + } + + if diff := cmp.Diff(got, test.want); diff != "" { + t.Errorf("%s: SavePullRequestBody() mismatch (-want +got):%s", test.name, diff) + } + + if diff := cmp.Diff(test.wantCreateGistCalls, test.client.createGistCalls); diff != "" { + t.Errorf("%s: SavePullRequestBody() createGistCalls mismatch (-want +got):%s", test.name, diff) + } + }) + } +} + +func TestFetchPullRequestBody(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + client *mockGitHubClient + maxContentSize int + content string + want string + wantErr bool + wantErrMsg string + wantGetGistContentCalls int + }{ + { + name: "non overflow", + client: &mockGitHubClient{}, + content: "some release notes", + want: "some release notes", + }, + { + name: "with overflow", + client: &mockGitHubClient{ + getGistContent: map[string]string{ + "release-notes.md": "some release notes", + }, + }, + content: "See full release notes at: https://gist.github.com/some-user/abcd1234", + want: "some release notes", + wantGetGistContentCalls: 1, + }, + { + name: "with overflow, error fetching gist", + client: &mockGitHubClient{ + getGistError: fmt.Errorf("some fetch gist error"), + }, + content: "See full release notes at: https://gist.github.com/some-user/abcd1234", + wantErr: true, + wantErrMsg: "some fetch gist error", + wantGetGistContentCalls: 1, + }, + { + name: "with overflow, wrong file", + client: &mockGitHubClient{ + getGistContent: map[string]string{ + "unexpected file": "some release notes", + }, + }, + content: "See full release notes at: https://gist.github.com/some-user/abcd1234", + wantErr: true, + wantErrMsg: "unable to find", + wantGetGistContentCalls: 1, + }, + } { + t.Run(test.name, func(t *testing.T) { + handler, err := NewGistOverflowHandler(test.client, test.maxContentSize) + if err != nil { + t.Fatalf("unexpected error in NewGistOverflowHandler() %v", err) + } + + got, err := handler.FetchPullRequestBody(t.Context(), test.content) + + if test.wantErr { + if err == nil { + t.Fatalf("FetchPullRequestBody() error = %v, wantErr %v", err, test.wantErr) + } + + if !strings.Contains(err.Error(), test.wantErrMsg) { + t.Fatalf("want error message: %s, got: %s", test.wantErrMsg, err.Error()) + } + return + } + + if err != nil { + t.Fatalf("unexpected error in FetchPullRequestBody() %v", err) + } + + if diff := cmp.Diff(got, test.want); diff != "" { + t.Errorf("%s: FetchPullRequestBody() mismatch (-want +got):%s", test.name, diff) + } + + if diff := cmp.Diff(test.wantGetGistContentCalls, test.client.getGistContentCalls); diff != "" { + t.Errorf("%s: FetchPullRequestBody() createGistCalls mismatch (-want +got):%s", test.name, diff) + } + }) + } +} diff --git a/system_test.go b/system_test.go index da5679cbe..a802c21bb 100644 --- a/system_test.go +++ b/system_test.go @@ -446,6 +446,31 @@ func TestCreateRelease(t *testing.T) { } } +func TestCreateGist(t *testing.T) { + if testToken == "" { + t.Skip("TEST_GITHUB_TOKEN not set, skipping GitHub integration test") + } + + files := map[string]string{ + "README.md": "some contents go here", + } + client := github.NewClient(testToken, &github.Repository{}) + gist, err := client.CreateGist(t.Context(), files, false) + if err != nil { + t.Fatalf("unexpected error in CreateGist() %s", err) + } + slog.Info("created gist", "ID", gist.ID, "URL", gist.Url) + + contents, err := client.GetGistContent(t.Context(), gist.ID) + if err != nil { + t.Fatalf("unexpected error in GetGistContent() %s", err) + } + + if diff := cmp.Diff(contents, files); diff != "" { + t.Fatalf("gist content mismatch (-want + got):\n%s", diff) + } +} + func TestFindLatestImage(t *testing.T) { // If we are able to configure system tests on GitHub actions, then update this // guard clause.