Skip to content

Commit 949f02f

Browse files
authored
feat(internal/librarian): tag-and-release logic (#1812)
Fixes: #1009
1 parent 900187b commit 949f02f

File tree

7 files changed

+310
-13
lines changed

7 files changed

+310
-13
lines changed

internal/config/state.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ func (s *LibrarianState) ImageRefAndTag() (ref string, tag string) {
6767
return parseImage(s.Image)
6868
}
6969

70+
// LibraryByID returns the library with the given ID, or nil if not found.
71+
func (s *LibrarianState) LibraryByID(id string) *LibraryState {
72+
for _, lib := range s.Libraries {
73+
if lib.ID == id {
74+
return lib
75+
}
76+
}
77+
return nil
78+
}
79+
7080
// parseImage splits an image string into its reference and tag.
7181
// It correctly handles port numbers in the reference.
7282
// If no tag is found, the tag part is an empty string.

internal/config/state_test.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ package config
1717
import (
1818
"strings"
1919
"testing"
20+
21+
"github.com/google/go-cmp/cmp"
2022
)
2123

2224
func TestLibrarianState_Validate(t *testing.T) {
@@ -198,7 +200,7 @@ func TestLibrary_Validate(t *testing.T) {
198200
ID: "a/b",
199201
SourceRoots: []string{"src/a"},
200202
APIs: []*API{{Path: "a/b/v1"}},
201-
PreserveRegex: []string{".*\\.txt"},
203+
PreserveRegex: []string{`. * \\.txt`},
202204
},
203205
},
204206
{
@@ -218,7 +220,7 @@ func TestLibrary_Validate(t *testing.T) {
218220
ID: "a/b",
219221
SourceRoots: []string{"src/a"},
220222
APIs: []*API{{Path: "a/b/v1"}},
221-
RemoveRegex: []string{".*\\.log"},
223+
RemoveRegex: []string{`. * \\.log`},
222224
},
223225
},
224226
{
@@ -408,3 +410,39 @@ func TestIsValidImage(t *testing.T) {
408410
})
409411
}
410412
}
413+
414+
func TestLibraryState_LibraryByID(t *testing.T) {
415+
for _, test := range []struct {
416+
name string
417+
libraries []*LibraryState
418+
id string
419+
want *LibraryState
420+
}{
421+
{
422+
name: "found",
423+
libraries: []*LibraryState{
424+
{ID: "foo"},
425+
{ID: "bar"},
426+
},
427+
id: "foo",
428+
want: &LibraryState{ID: "foo"},
429+
},
430+
{
431+
name: "not found",
432+
libraries: []*LibraryState{
433+
{ID: "foo"},
434+
{ID: "bar"},
435+
},
436+
id: "baz",
437+
want: nil,
438+
},
439+
} {
440+
t.Run(test.name, func(t *testing.T) {
441+
state := &LibrarianState{Libraries: test.libraries}
442+
got := state.LibraryByID(test.id)
443+
if diff := cmp.Diff(test.want, got); diff != "" {
444+
t.Errorf("LibraryState.LibraryByID() mismatch (-want +got):\n%s", diff)
445+
}
446+
})
447+
}
448+
}

internal/librarian/mocks_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,16 +39,19 @@ type mockGitHubClient struct {
3939
replaceLabelsCalls int
4040
searchPullRequestsCalls int
4141
getPullRequestCalls int
42+
createReleaseCalls int
4243
createPullRequestErr error
4344
addLabelsToIssuesErr error
4445
getLabelsErr error
4546
replaceLabelsErr error
4647
searchPullRequestsErr error
4748
getPullRequestErr error
49+
createReleaseErr error
4850
createdPR *github.PullRequestMetadata
4951
labels []string
5052
pullRequests []*github.PullRequest
5153
pullRequest *github.PullRequest
54+
createdRelease *github.RepositoryRelease
5255
}
5356

5457
func (m *mockGitHubClient) GetRawContent(ctx context.Context, path, ref string) ([]byte, error) {
@@ -88,6 +91,11 @@ func (m *mockGitHubClient) GetPullRequest(ctx context.Context, number int) (*git
8891
return m.pullRequest, m.getPullRequestErr
8992
}
9093

94+
func (m *mockGitHubClient) CreateRelease(ctx context.Context, tagName, releaseName, body, commitish string) (*github.RepositoryRelease, error) {
95+
m.createReleaseCalls++
96+
return m.createdRelease, m.createReleaseErr
97+
}
98+
9199
// mockContainerClient is a mock implementation of the ContainerClient interface for testing.
92100
type mockContainerClient struct {
93101
ContainerClient

internal/librarian/release_please_lite.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const defaultTagFormat = "{id}-{version}"
2929
// GetConventionalCommitsSinceLastRelease returns all conventional commits for the given library since the
3030
// version specified in the state file.
3131
func GetConventionalCommitsSinceLastRelease(repo gitrepo.Repository, library *config.LibraryState) ([]*conventionalcommits.ConventionalCommit, error) {
32-
tag := formatTag(library)
32+
tag := formatTag(library, "")
3333
commits, err := repo.GetCommitsForPathsSinceTag(library.SourceRoots, tag)
3434
if err != nil {
3535
return nil, fmt.Errorf("failed to get commits for library %s: %w", library.ID, err)
@@ -74,12 +74,16 @@ func shouldExclude(files, excludePaths []string) bool {
7474
}
7575

7676
// formatTag returns the git tag for a given library version.
77-
func formatTag(library *config.LibraryState) string {
77+
func formatTag(library *config.LibraryState, versionOverride string) string {
78+
version := library.Version
79+
if versionOverride != "" {
80+
version = versionOverride
81+
}
7882
tagFormat := library.TagFormat
7983
if tagFormat == "" {
8084
tagFormat = defaultTagFormat
8185
}
82-
r := strings.NewReplacer("{id}", library.ID, "{version}", library.Version)
86+
r := strings.NewReplacer("{id}", library.ID, "{version}", version)
8387
return r.Replace(tagFormat)
8488
}
8589

internal/librarian/release_please_lite_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ func TestFormatTag(t *testing.T) {
110110
},
111111
} {
112112
t.Run(test.name, func(t *testing.T) {
113-
got := formatTag(test.library)
113+
got := formatTag(test.library, "")
114114
if got != test.want {
115115
t.Errorf("formatTag() = %q, want %q", got, test.want)
116116
}

internal/librarian/tag_and_release.go

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"fmt"
2121
"log/slog"
2222
"regexp"
23+
"slices"
2324
"strconv"
2425
"strings"
2526
"time"
@@ -33,6 +34,8 @@ import (
3334
const (
3435
pullRequestSegments = 5
3536
tagAndReleaseCmdName = "tag-and-release"
37+
releasePendingLabel = "release:pending"
38+
releaseDoneLabel = "release:done"
3639
)
3740

3841
var (
@@ -135,22 +138,39 @@ func (r *tagAndReleaseRunner) determinePullRequestsToProcess(ctx context.Context
135138

136139
slog.Info("searching for pull requests to tag and release")
137140
thirtyDaysAgo := time.Now().Add(-30 * 24 * time.Hour).Format(time.RFC3339)
138-
query := fmt.Sprintf("label:release:pending merged:>=%s", thirtyDaysAgo)
141+
query := fmt.Sprintf("label:%s merged:>=%s", releasePendingLabel, thirtyDaysAgo)
139142
prs, err := r.ghClient.SearchPullRequests(ctx, query)
140143
if err != nil {
141144
return nil, fmt.Errorf("failed to search pull requests: %w", err)
142145
}
143146
return prs, nil
144147
}
145148

146-
func (r *tagAndReleaseRunner) processPullRequest(_ context.Context, p *github.PullRequest) error {
149+
func (r *tagAndReleaseRunner) processPullRequest(ctx context.Context, p *github.PullRequest) error {
147150
slog.Info("processing pull request", "pr", p.GetNumber())
148-
// hack to make CI happy until we impl
149-
// TODO(https://github.com/googleapis/librarian/issues/1009)
150-
if p.GetNumber() != 0 {
151-
return fmt.Errorf("skipping pull request %d", p.GetNumber())
151+
releases := parsePullRequestBody(p.GetBody())
152+
if len(releases) == 0 {
153+
slog.Warn("no release details found in pull request body, skipping")
154+
return nil
152155
}
153-
return nil
156+
for _, release := range releases {
157+
slog.Info("creating release", "library", release.Library, "version", release.Version)
158+
commitish := p.GetMergeCommitSHA()
159+
160+
lib := r.state.LibraryByID(release.Library)
161+
if lib == nil {
162+
return fmt.Errorf("library %s not found", release.Library)
163+
}
164+
165+
// Create the release.
166+
tagName := formatTag(lib, release.Version)
167+
releaseName := fmt.Sprintf("%s %s", release.Library, release.Version)
168+
if _, err := r.ghClient.CreateRelease(ctx, tagName, releaseName, release.Body, commitish); err != nil {
169+
return fmt.Errorf("failed to create release: %w", err)
170+
}
171+
172+
}
173+
return r.replacePendingLabel(ctx, p)
154174
}
155175

156176
// libraryRelease holds the parsed information from a pull request body.
@@ -188,3 +208,19 @@ func parsePullRequestBody(body string) []libraryRelease {
188208

189209
return parsedBodies
190210
}
211+
212+
// replacePendingLabel is a helper function that replaces the `release:pending` label with `release:done`.
213+
func (r *tagAndReleaseRunner) replacePendingLabel(ctx context.Context, p *github.PullRequest) error {
214+
var currentLabels []string
215+
for _, label := range p.Labels {
216+
currentLabels = append(currentLabels, label.GetName())
217+
}
218+
currentLabels = slices.DeleteFunc(currentLabels, func(s string) bool {
219+
return s == releasePendingLabel
220+
})
221+
currentLabels = append(currentLabels, releaseDoneLabel)
222+
if err := r.ghClient.ReplaceLabels(ctx, p.GetNumber(), currentLabels); err != nil {
223+
return fmt.Errorf("failed to replace labels: %w", err)
224+
}
225+
return nil
226+
}

0 commit comments

Comments
 (0)