diff --git a/internal/config/config.go b/internal/config/config.go index 0e9abae270..cf1169ef02 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ import ( "fmt" "os" "os/user" + "regexp" "strings" ) @@ -49,6 +50,11 @@ const ( ReleaseInitRequest = "release-init-request.json" ) +var ( + // pullRequestRegexp is regular expression that describes a uri of a pull request. + pullRequestRegexp = regexp.MustCompile(`^https://github\.com/([a-zA-Z0-9-._]+)/([a-zA-Z0-9-._]+)/pull/([0-9]+)$`) +) + // Config holds all configuration values parsed from flags or environment // variables. When adding members to this struct, please keep them in // alphabetical order. @@ -130,6 +136,14 @@ type Config struct { // Requires the --library flag to be specified. LibraryVersion string + // PullRequest to target and operate one in the context of a release. + // + // The pull request should be in the format `https://github.com/{owner}/{repo}/pull/{number}`. + // Setting this field for `tag-and-release` means librarian will only attempt + // to process this exact pull request and not search for other pull requests + // that may be ready for tagging and releasing. + PullRequest string + // Push determines whether to push changes to GitHub. It is used in // all commands that create commits in a language repository: // configure and update-apis. @@ -222,6 +236,13 @@ func (c *Config) IsValid() (bool, error) { return false, errors.New("specified library version without library id") } + if c.PullRequest != "" { + matched := pullRequestRegexp.MatchString(c.PullRequest) + if !matched { + return false, errors.New("pull request URL is not valid") + } + } + if _, err := validateHostMount(c.HostMount, ""); err != nil { return false, err } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5da4a7b02d..1179b0b2e2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -8,7 +8,7 @@ // // 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. +// 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. @@ -156,6 +156,12 @@ func TestIsValid(t *testing.T) { Library: "library-id", }, }, + { + name: "Valid config - valid pull request", + cfg: Config{ + PullRequest: "https://github.com/owner/repo/pull/123", + }, + }, { name: "Invalid config - Push true, token missing", cfg: Config{ @@ -202,6 +208,14 @@ func TestIsValid(t *testing.T) { wantErr: true, wantErrMsg: "unable to parse host mount", }, + { + name: "Invalid config - invalid pull request url", + cfg: Config{ + PullRequest: "https://github.com/owner/repo/issues/123", + }, + wantErr: true, + wantErrMsg: "pull request URL is not valid", + }, } { t.Run(test.name, func(t *testing.T) { gotValid, err := test.cfg.IsValid() diff --git a/internal/librarian/flags.go b/internal/librarian/flags.go index fe6d51457c..91277aaa7d 100644 --- a/internal/librarian/flags.go +++ b/internal/librarian/flags.go @@ -58,3 +58,7 @@ func addFlagRepo(fs *flag.FlagSet, cfg *config.Config) { func addFlagWorkRoot(fs *flag.FlagSet, cfg *config.Config) { fs.StringVar(&cfg.WorkRoot, "output", "", "Working directory root. When this is not specified, a working directory will be created in /tmp.") } + +func addFlagPR(fs *flag.FlagSet, cfg *config.Config) { + fs.StringVar(&cfg.PullRequest, "pr", "", "a pull request to operate on. It should be in the format of a uri https://github.com/{owner}/{repo}/pull/{number}. If not specified, will search for all merged pull requests with the label `release:pending` in the last 30 days.") +} diff --git a/internal/librarian/librarian.go b/internal/librarian/librarian.go index 94d1f6afdb..68cf8d684b 100644 --- a/internal/librarian/librarian.go +++ b/internal/librarian/librarian.go @@ -40,6 +40,7 @@ func init() { CmdLibrarian.Init() CmdLibrarian.Commands = append(CmdLibrarian.Commands, cmdGenerate, + cmdRelease, cmdVersion, ) } diff --git a/internal/librarian/release.go b/internal/librarian/release.go new file mode 100644 index 0000000000..5d0c0d3945 --- /dev/null +++ b/internal/librarian/release.go @@ -0,0 +1,33 @@ +// 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 ( + "github.com/googleapis/librarian/internal/cli" +) + +// cmdRelease is the command for the `release` subcommand. +var cmdRelease = &cli.Command{ + Short: "release manages releases of libraries.", + UsageLine: "librarian release [arguments]", + Long: "Manages releases of libraries.", +} + +func init() { + cmdRelease.Init() + CmdLibrarian.Commands = append(CmdLibrarian.Commands, + cmdTagAndRelease, + ) +} diff --git a/internal/librarian/tag_and_release.go b/internal/librarian/tag_and_release.go new file mode 100644 index 0000000000..beee73fdcc --- /dev/null +++ b/internal/librarian/tag_and_release.go @@ -0,0 +1,63 @@ +// 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" + + "github.com/googleapis/librarian/internal/cli" + "github.com/googleapis/librarian/internal/config" +) + +// cmdTagAndRelease is the command for the `release tag-and-release` subcommand. +var cmdTagAndRelease = &cli.Command{ + Short: "release tag-and-release tags and creates a GitHub release for a merged pull request.", + UsageLine: "librarian release tag-and-release [arguments]", + Long: "Tags and creates a GitHub release for a merged pull request.", + Run: func(ctx context.Context, cfg *config.Config) error { + runner, err := newTagAndReleaseRunner(cfg) + if err != nil { + return err + } + return runner.run(ctx) + }, +} + +func init() { + cmdTagAndRelease.Init() + fs := cmdTagAndRelease.Flags + cfg := cmdGenerate.Config + + addFlagRepo(fs, cfg) + addFlagPR(fs, cfg) +} + +type tagAndReleaseRunner struct { + cfg *config.Config +} + +func newTagAndReleaseRunner(cfg *config.Config) (*tagAndReleaseRunner, error) { + if cfg.GitHubToken == "" { + return nil, fmt.Errorf("`LIBRARIAN_GITHUB_TOKEN` must be set") + } + return &tagAndReleaseRunner{ + cfg: cfg, + }, nil +} + +func (r *tagAndReleaseRunner) run(ctx context.Context) error { + return nil +} diff --git a/internal/librarian/tag_and_release_test.go b/internal/librarian/tag_and_release_test.go new file mode 100644 index 0000000000..4b1d741386 --- /dev/null +++ b/internal/librarian/tag_and_release_test.go @@ -0,0 +1,54 @@ +// 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 ( + "testing" + + "github.com/googleapis/librarian/internal/config" +) + +func TestNewTagAndReleaseRunner(t *testing.T) { + testcases := []struct { + name string + cfg *config.Config + wantErr bool + }{ + { + name: "valid config", + cfg: &config.Config{ + GitHubToken: "some-token", + }, + wantErr: false, + }, + { + name: "missing github token", + cfg: &config.Config{}, + wantErr: true, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + r, err := newTagAndReleaseRunner(tc.cfg) + if (err != nil) != tc.wantErr { + t.Errorf("newTagAndReleaseRunner() error = %v, wantErr %v", err, tc.wantErr) + return + } + if !tc.wantErr && r == nil { + t.Errorf("newTagAndReleaseRunner() got nil runner, want non-nil") + } + }) + } +} \ No newline at end of file