Skip to content

Commit 1cb97b7

Browse files
authored
feat(cmd,internal/librarianops): create librarianops CLI (#3604)
Introduce an initial librarianops CLI to support scaling Librarian operations across multiple repositories. The CLI currently supports `librarian generate` for google-cloud-rust, following the workflow described in: https://github.com/googleapis/google-cloud-rust/blob/main/doc/contributor/howto-guide-generated-code-maintenance.md It also supports a fake repo for testing.
1 parent dd1424e commit 1cb97b7

File tree

3 files changed

+351
-0
lines changed

3 files changed

+351
-0
lines changed

cmd/librarianops/main.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright 2026 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+
// Command librarianops orchestrates librarian operations across multiple repositories.
16+
package main
17+
18+
import (
19+
"context"
20+
"log"
21+
"os"
22+
23+
"github.com/googleapis/librarian/internal/librarianops"
24+
)
25+
26+
func main() {
27+
ctx := context.Background()
28+
if err := librarianops.Run(ctx, os.Args...); err != nil {
29+
log.Fatalf("librarianops: %v", err)
30+
}
31+
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Copyright 2026 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 librarianops provides orchestration for running librarian across
16+
// multiple repositories.
17+
package librarianops
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"os"
23+
"time"
24+
25+
"github.com/googleapis/librarian/internal/command"
26+
"github.com/googleapis/librarian/internal/librarian"
27+
"github.com/urfave/cli/v3"
28+
)
29+
30+
const (
31+
repoRust = "google-cloud-rust"
32+
repoFake = "fake-repo" // used for testing
33+
34+
branchPrefix = "librarianops-generateall-"
35+
commitTitle = "chore: run librarian update and generate --all"
36+
)
37+
38+
var supportedRepositories = map[string]bool{
39+
repoRust: true,
40+
repoFake: true, // used for testing
41+
}
42+
43+
// Run executes the librarianops command with the given arguments.
44+
func Run(ctx context.Context, args ...string) error {
45+
cmd := &cli.Command{
46+
Name: "librarianops",
47+
Usage: "orchestrate librarian operations across multiple repositories",
48+
UsageText: "librarianops [command]",
49+
Commands: []*cli.Command{
50+
generateCommand(),
51+
},
52+
}
53+
return cmd.Run(ctx, args)
54+
}
55+
56+
func generateCommand() *cli.Command {
57+
return &cli.Command{
58+
Name: "generate",
59+
Usage: "generate libraries across repositories",
60+
UsageText: "librarianops generate [<repo> | --all]",
61+
Description: `Examples:
62+
librarianops generate google-cloud-rust
63+
librarianops generate --all
64+
librarianops generate -C ~/workspace/google-cloud-rust google-cloud-rust
65+
66+
Specify a repository name (e.g., google-cloud-rust) to process a single repository,
67+
or use --all to process all repositories.
68+
69+
Use -C to work in a specific directory (assumes repository already exists there).
70+
71+
For each repository, librarianops will:
72+
1. Clone the repository to a temporary directory
73+
2. Create a branch: librarianops-generateall-YYYY-MM-DD
74+
3. Run librarian update discovery (google-cloud-rust only)
75+
4. Run librarian update googleapis
76+
5. Run librarian generate --all
77+
6. Run cargo update --workspace (google-cloud-rust only)
78+
7. Commit changes
79+
8. Create a pull request (pushes branch automatically)`,
80+
Flags: []cli.Flag{
81+
&cli.BoolFlag{
82+
Name: "all",
83+
Usage: "process all repositories",
84+
},
85+
&cli.StringFlag{
86+
Name: "C",
87+
Usage: "work in `directory` (assumes repo exists)",
88+
},
89+
},
90+
Action: func(ctx context.Context, cmd *cli.Command) error {
91+
all := cmd.Bool("all")
92+
workDir := cmd.String("C")
93+
repoName := ""
94+
if cmd.Args().Len() > 0 {
95+
repoName = cmd.Args().Get(0)
96+
}
97+
if all && repoName != "" {
98+
return fmt.Errorf("cannot specify both <repo> and --all")
99+
}
100+
if !all && repoName == "" {
101+
return fmt.Errorf("usage: librarianops generate [<repo> | --all]")
102+
}
103+
if all && workDir != "" {
104+
return fmt.Errorf("cannot use -C with --all")
105+
}
106+
return runGenerate(ctx, all, repoName, workDir)
107+
},
108+
}
109+
}
110+
111+
func runGenerate(ctx context.Context, all bool, repoName, repoDir string) error {
112+
if all {
113+
for name := range supportedRepositories {
114+
if err := processRepo(ctx, name, ""); err != nil {
115+
return err
116+
}
117+
}
118+
return nil
119+
}
120+
121+
if !supportedRepositories[repoName] {
122+
return fmt.Errorf("repository %q not found in supported repositories list", repoName)
123+
}
124+
if err := processRepo(ctx, repoName, repoDir); err != nil {
125+
return err
126+
}
127+
return nil
128+
}
129+
130+
func processRepo(ctx context.Context, repoName, repoDir string) (err error) {
131+
if repoDir == "" {
132+
repoDir, err = os.MkdirTemp("", "librarianops-"+repoName+"-*")
133+
if err != nil {
134+
return fmt.Errorf("failed to create temp directory: %w", err)
135+
}
136+
defer func() {
137+
cerr := os.RemoveAll(repoDir)
138+
if err == nil {
139+
err = cerr
140+
}
141+
}()
142+
if err := cloneRepo(ctx, repoDir, repoName); err != nil {
143+
return err
144+
}
145+
}
146+
originalWD, err := os.Getwd()
147+
if err != nil {
148+
return fmt.Errorf("failed to get current directory: %w", err)
149+
}
150+
if err := os.Chdir(repoDir); err != nil {
151+
return fmt.Errorf("failed to change directory to %s: %w", repoDir, err)
152+
}
153+
defer os.Chdir(originalWD)
154+
155+
if err := createBranch(ctx, time.Now()); err != nil {
156+
return err
157+
}
158+
if repoName == repoRust {
159+
if err := librarian.Run(ctx, "librarian", "update", "discovery"); err != nil {
160+
return err
161+
}
162+
}
163+
if err := librarian.Run(ctx, "librarian", "update", "googleapis"); err != nil {
164+
return err
165+
}
166+
if err := librarian.Run(ctx, "librarian", "generate", "--all"); err != nil {
167+
return err
168+
}
169+
if repoName == repoRust {
170+
if err := runCargoUpdate(ctx); err != nil {
171+
return err
172+
}
173+
}
174+
175+
if err := commitChanges(ctx); err != nil {
176+
return err
177+
}
178+
if repoName != repoFake {
179+
if err := createPR(ctx, repoName); err != nil {
180+
return err
181+
}
182+
}
183+
return nil
184+
}
185+
186+
func cloneRepo(ctx context.Context, repoDir, repoName string) error {
187+
return command.Run(ctx, "gh", "repo", "clone", fmt.Sprintf("googleapis/%s", repoName), repoDir)
188+
}
189+
190+
func createBranch(ctx context.Context, now time.Time) error {
191+
branchName := fmt.Sprintf("%s%s", branchPrefix, now.Format("2006-01-02"))
192+
return command.Run(ctx, "git", "checkout", "-b", branchName)
193+
}
194+
195+
func commitChanges(ctx context.Context) error {
196+
if err := command.Run(ctx, "git", "add", "."); err != nil {
197+
return err
198+
}
199+
return command.Run(ctx, "git", "commit", "-m", commitTitle)
200+
}
201+
202+
func createPR(ctx context.Context, repoName string) error {
203+
var body string
204+
if repoName == repoRust {
205+
body = `Update googleapis/googleapis and googleapis/discovery-artifact-manager
206+
to the latest commit and regenerate all client libraries.`
207+
} else {
208+
body = `Update googleapis/googleapis to the latest commit and regenerate all client libraries.`
209+
}
210+
return command.Run(ctx, "gh", "pr", "create", "--title", commitTitle, "--body", body)
211+
}
212+
213+
func runCargoUpdate(ctx context.Context) error {
214+
return command.Run(ctx, "cargo", "update", "--workspace")
215+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright 2026 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 librarianops
16+
17+
import (
18+
"fmt"
19+
"os"
20+
"path/filepath"
21+
"testing"
22+
23+
"github.com/googleapis/librarian/internal/command"
24+
)
25+
26+
func TestGenerateCommand(t *testing.T) {
27+
repoDir := t.TempDir()
28+
if err := command.Run(t.Context(), "git", "init", repoDir); err != nil {
29+
t.Fatal(err)
30+
}
31+
if err := command.Run(t.Context(), "git", "-C", repoDir, "config", "user.email", "[email protected]"); err != nil {
32+
t.Fatal(err)
33+
}
34+
if err := command.Run(t.Context(), "git", "-C", repoDir, "config", "user.name", "Test User"); err != nil {
35+
t.Fatal(err)
36+
}
37+
if err := command.Run(t.Context(), "git", "-C", repoDir, "checkout", "-b", "main"); err != nil {
38+
t.Fatal(err)
39+
}
40+
41+
wd, err := os.Getwd()
42+
if err != nil {
43+
t.Fatal(err)
44+
}
45+
googleapisDir := filepath.Join(wd, "..", "testdata", "googleapis")
46+
configContent := fmt.Sprintf(`language: fake
47+
sources:
48+
googleapis:
49+
dir: %s
50+
libraries:
51+
- name: test-library
52+
output: output
53+
channels:
54+
- path: google/cloud/secretmanager/v1
55+
`, googleapisDir)
56+
if err := os.WriteFile(filepath.Join(repoDir, "librarian.yaml"), []byte(configContent), 0644); err != nil {
57+
t.Fatal(err)
58+
}
59+
if err := command.Run(t.Context(), "git", "-C", repoDir, "add", "."); err != nil {
60+
t.Fatal(err)
61+
}
62+
if err := command.Run(t.Context(), "git", "-C", repoDir, "commit", "-m", "initial commit"); err != nil {
63+
t.Fatal(err)
64+
}
65+
66+
args := []string{"librarianops", "generate", "-C", repoDir, "fake-repo"}
67+
if err := Run(t.Context(), args...); err != nil {
68+
t.Fatal(err)
69+
}
70+
readmePath := filepath.Join(repoDir, "output", "README.md")
71+
if _, err := os.Stat(readmePath); err != nil {
72+
t.Errorf("expected README.md to be generated: %v", err)
73+
}
74+
}
75+
76+
func TestGenerateCommand_Errors(t *testing.T) {
77+
for _, test := range []struct {
78+
name string
79+
args []string
80+
}{
81+
{
82+
name: "both repo and all flag",
83+
args: []string{"librarianops", "generate", "--all", "fake-repo"},
84+
},
85+
{
86+
name: "neither repo nor all flag",
87+
args: []string{"librarianops", "generate"},
88+
},
89+
{
90+
name: "all flag with C flag",
91+
args: []string{"librarianops", "generate", "--all", "-C", "/tmp/foo"},
92+
},
93+
{
94+
name: "unsupported repo",
95+
args: []string{"librarianops", "generate", "unsupported-repo"},
96+
},
97+
} {
98+
t.Run(test.name, func(t *testing.T) {
99+
err := Run(t.Context(), test.args...)
100+
if err == nil {
101+
t.Errorf("expected error, got nil")
102+
}
103+
})
104+
}
105+
}

0 commit comments

Comments
 (0)