Skip to content

Commit a26a6d9

Browse files
authored
chore(librariangen): languagecontainer package to parse release-init request (#3965)
Introducing languagecontainer package. * **New languagecontainer package**: A new `languagecontainer` package has been introduced to encapsulate language-specific container operations and command execution logic, promoting modularity. This package parses the request JSON file and calls the corresponding implementation method in each language container. The languagecontainer package itself should not have language-specific implementation. * **release-init command parsing**: The `languagecontainer.Run` function now includes robust logic to parse `release-init` requests from JSON files, handle command-line flags, and invoke the `ReleaseInit` function. * Why isn't this providing an interface? It's because if `LanguageContainer` is an interface, then there would be package name conflict of `languagecontainer/release` package (language agostic) and `release` package (language-specific. In this case Java-specific). [Here is a piece of code](googleapis/librarian#2516 (comment)). * **Main command dispatch refactor**: The `librariangen` `main.go` has been refactored to delegate non-`generate` commands to the new `languagecontainer.Run` function, centralizing command execution and wiring up `release.Init` for the `release-init` command. * **message.Change struct update**: The `SourceCommitHash` field in the `message.Change` struct has been renamed to `CommitHash` for improved clarity and consistency. This is due to the recent renaming of the field and [google-cloud-go/internal/librariangen/request.Change](https://github.com/googleapis/google-cloud-go/blob/7a85df39319e3a4870d4ad413f714ae5edd78ac8/internal/librariangen/request/request.go#L60) already has the field. The user (Java's language container in this case) doesn't have to implement the parsing logic and the tests. I moved the argument parsing tests to languagecontainer/languagecontainer_test.go from main_test.go.
1 parent c86b4ea commit a26a6d9

File tree

9 files changed

+477
-99
lines changed

9 files changed

+477
-99
lines changed
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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+
// http://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 languagecontainer defines LanguageContainer interface and
16+
// the Run function to execute commands within the container.
17+
// This package should not have any language-specific implementation or
18+
// Librarian CLI's implementation.
19+
// TODO(b/447404382): Move this package to the https://github.com/googleapis/librarian
20+
// GitHub repository once the interface is finalized.
21+
package languagecontainer
22+
23+
import (
24+
"context"
25+
"encoding/json"
26+
"flag"
27+
"fmt"
28+
"log/slog"
29+
"os"
30+
"path/filepath"
31+
32+
"cloud.google.com/java/internal/librariangen/languagecontainer/release"
33+
"cloud.google.com/java/internal/librariangen/message"
34+
)
35+
36+
// LanguageContainer defines the functions for language-specific container operations.
37+
type LanguageContainer struct {
38+
ReleaseInit func(context.Context, *release.Config) (*message.ReleaseInitResponse, error)
39+
// Other container functions like Generate and Build will also be part of the struct.
40+
}
41+
42+
// Run accepts an implementation of the LanguageContainer.
43+
func Run(args []string, container *LanguageContainer) int {
44+
// Logic to parse args and call the appropriate method on the container.
45+
// For example, if args[1] is "generate":
46+
// request := ... // unmarshal the request from the expected location
47+
// err := container.Generate(context.Background(), request)
48+
// ...
49+
if len(args) < 1 {
50+
panic("args must not be empty")
51+
}
52+
cmd := args[0]
53+
flags := args[1:]
54+
switch cmd {
55+
case "generate":
56+
slog.Warn("librariangen: generate command is not yet implemented")
57+
return 1
58+
case "configure":
59+
slog.Warn("librariangen: configure command is not yet implemented")
60+
return 1
61+
case "release-init":
62+
return handleReleaseInit(flags, container)
63+
case "build":
64+
slog.Warn("librariangen: build command is not yet implemented")
65+
return 1
66+
default:
67+
slog.Error(fmt.Sprintf("librariangen: unknown command: %s (with flags %v)", cmd, flags))
68+
return 1
69+
}
70+
return 0
71+
}
72+
73+
func handleReleaseInit(flags []string, container *LanguageContainer) int {
74+
cfg := &release.Context{}
75+
releaseInitFlags := flag.NewFlagSet("release-init", flag.ContinueOnError)
76+
releaseInitFlags.StringVar(&cfg.LibrarianDir, "librarian", "/librarian", "Path to the librarian-tool input directory. Contains release-init-request.json.")
77+
releaseInitFlags.StringVar(&cfg.RepoDir, "repo", "/repo", "Path to the language repo.")
78+
releaseInitFlags.StringVar(&cfg.OutputDir, "output", "/output", "Path to the output directory.")
79+
if err := releaseInitFlags.Parse(flags); err != nil {
80+
slog.Error("failed to parse flags", "error", err)
81+
return 1
82+
}
83+
requestPath := filepath.Join(cfg.LibrarianDir, "release-init-request.json")
84+
bytes, err := os.ReadFile(requestPath)
85+
if err != nil {
86+
slog.Error("failed to read request file", "path", requestPath, "error", err)
87+
return 1
88+
}
89+
request := &message.ReleaseInitRequest{}
90+
if err := json.Unmarshal(bytes, request); err != nil {
91+
slog.Error("failed to parse request JSON", "error", err)
92+
return 1
93+
}
94+
config := &release.Config{
95+
Context: cfg,
96+
Request: request,
97+
}
98+
response, err := container.ReleaseInit(context.Background(), config)
99+
if err != nil {
100+
slog.Error("release-init failed", "error", err)
101+
return 1
102+
}
103+
bytes, err = json.MarshalIndent(response, "", " ")
104+
if err != nil {
105+
slog.Error("failed to marshal response JSON", "error", err)
106+
return 1
107+
}
108+
responsePath := filepath.Join(cfg.LibrarianDir, "release-init-response.json")
109+
if err := os.WriteFile(responsePath, bytes, 0644); err != nil {
110+
slog.Error("failed to write response file", "path", responsePath, "error", err)
111+
return 1
112+
}
113+
slog.Info("librariangen: release-init command executed successfully")
114+
return 0
115+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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+
// http://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 languagecontainer
16+
17+
import (
18+
"context"
19+
"encoding/json"
20+
"os"
21+
"path/filepath"
22+
"testing"
23+
24+
"cloud.google.com/java/internal/librariangen/languagecontainer/release"
25+
"cloud.google.com/java/internal/librariangen/message"
26+
"github.com/google/go-cmp/cmp"
27+
)
28+
29+
func TestRun(t *testing.T) {
30+
tmpDir := t.TempDir()
31+
if err := os.WriteFile(filepath.Join(tmpDir, "release-init-request.json"), []byte("{}"), 0644); err != nil {
32+
t.Fatal(err)
33+
}
34+
tests := []struct {
35+
name string
36+
args []string
37+
wantCode int
38+
wantErr bool
39+
}{
40+
{
41+
name: "unknown command",
42+
args: []string{"foo"},
43+
wantCode: 1,
44+
},
45+
{
46+
name: "build command",
47+
args: []string{"build"},
48+
wantCode: 1, // Not implemented yet
49+
},
50+
{
51+
name: "configure command",
52+
args: []string{"configure"},
53+
wantCode: 1, // Not implemented yet
54+
},
55+
{
56+
name: "generate command",
57+
args: []string{"generate"},
58+
wantCode: 1, // Not implemented yet
59+
},
60+
{
61+
name: "release-init command success",
62+
args: []string{"release-init", "-librarian", tmpDir},
63+
wantCode: 0,
64+
},
65+
{
66+
name: "release-init command failure",
67+
args: []string{"release-init", "-librarian", tmpDir},
68+
wantCode: 1,
69+
wantErr: true,
70+
},
71+
}
72+
for _, tt := range tests {
73+
t.Run(tt.name, func(t *testing.T) {
74+
container := LanguageContainer{
75+
ReleaseInit: func(ctx context.Context, c *release.Config) (*message.ReleaseInitResponse, error) {
76+
if tt.wantErr {
77+
return nil, os.ErrNotExist
78+
}
79+
return &message.ReleaseInitResponse{}, nil
80+
},
81+
}
82+
if gotCode := Run(tt.args, &container); gotCode != tt.wantCode {
83+
t.Errorf("Run() = %v, want %v", gotCode, tt.wantCode)
84+
}
85+
})
86+
}
87+
}
88+
89+
func TestRun_noArgs(t *testing.T) {
90+
defer func() {
91+
if r := recover(); r == nil {
92+
t.Errorf("The code did not panic")
93+
}
94+
}()
95+
Run([]string{}, &LanguageContainer{})
96+
}
97+
98+
func TestRun_ReleaseInitWritesResponse(t *testing.T) {
99+
tmpDir := t.TempDir()
100+
if err := os.WriteFile(filepath.Join(tmpDir, "release-init-request.json"), []byte("{}"), 0644); err != nil {
101+
t.Fatal(err)
102+
}
103+
args := []string{"release-init", "-librarian", tmpDir}
104+
want := &message.ReleaseInitResponse{Error: "test error"}
105+
container := LanguageContainer{
106+
ReleaseInit: func(ctx context.Context, c *release.Config) (*message.ReleaseInitResponse, error) {
107+
return want, nil
108+
},
109+
}
110+
111+
if code := Run(args, &container); code != 0 {
112+
t.Errorf("Run() = %v, want 0", code)
113+
}
114+
115+
responsePath := filepath.Join(tmpDir, "release-init-response.json")
116+
bytes, err := os.ReadFile(responsePath)
117+
if err != nil {
118+
t.Fatal(err)
119+
}
120+
got := &message.ReleaseInitResponse{}
121+
if err := json.Unmarshal(bytes, got); err != nil {
122+
t.Fatal(err)
123+
}
124+
if diff := cmp.Diff(want, got); diff != "" {
125+
t.Errorf("response mismatch (-want +got):\n%s", diff)
126+
}
127+
}
128+
129+
func TestRun_ReleaseInitReadsContextArgs(t *testing.T) {
130+
tmpDir := t.TempDir()
131+
librarianDir := filepath.Join(tmpDir, "librarian")
132+
if err := os.Mkdir(librarianDir, 0755); err != nil {
133+
t.Fatal(err)
134+
}
135+
if err := os.WriteFile(filepath.Join(librarianDir, "release-init-request.json"), []byte("{}"), 0644); err != nil {
136+
t.Fatal(err)
137+
}
138+
repoDir := filepath.Join(tmpDir, "repo")
139+
if err := os.Mkdir(repoDir, 0755); err != nil {
140+
t.Fatal(err)
141+
}
142+
outputDir := filepath.Join(tmpDir, "output")
143+
if err := os.Mkdir(outputDir, 0755); err != nil {
144+
t.Fatal(err)
145+
}
146+
args := []string{"release-init", "-librarian", librarianDir, "-repo", repoDir, "-output", outputDir}
147+
var gotConfig *release.Config
148+
container := LanguageContainer{
149+
ReleaseInit: func(ctx context.Context, c *release.Config) (*message.ReleaseInitResponse, error) {
150+
gotConfig = c
151+
return &message.ReleaseInitResponse{}, nil
152+
},
153+
}
154+
if code := Run(args, &container); code != 0 {
155+
t.Errorf("Run() = %v, want 0", code)
156+
}
157+
if got, want := gotConfig.Context.LibrarianDir, librarianDir; got != want {
158+
t.Errorf("gotConfig.Context.LibrarianDir = %q, want %q", got, want)
159+
}
160+
if got, want := gotConfig.Context.RepoDir, repoDir; got != want {
161+
t.Errorf("gotConfig.Context.RepoDir = %q, want %q", got, want)
162+
}
163+
if got, want := gotConfig.Context.OutputDir, outputDir; got != want {
164+
t.Errorf("gotConfig.Context.OutputDir = %q, want %q", got, want)
165+
}
166+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
// http://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 release contains types for language container's release command.
16+
package release
17+
18+
import "cloud.google.com/java/internal/librariangen/message"
19+
20+
// Context has the directory paths for the release-init command.
21+
// https://github.com/googleapis/librarian/blob/main/doc/language-onboarding.md#release-init
22+
type Context struct {
23+
LibrarianDir string
24+
RepoDir string
25+
OutputDir string
26+
}
27+
28+
// The Config for the release-init command. This holds the context (the directory paths)
29+
// and the request parsed from the release-init-request.json file.
30+
type Config struct {
31+
Context *Context
32+
// This request is parsed from the release-init-request.json file in
33+
// the LibrarianDir of the context.
34+
Request *message.ReleaseInitRequest
35+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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+
// http://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 release
16+
17+
import (
18+
"encoding/json"
19+
"os"
20+
"path/filepath"
21+
"testing"
22+
23+
"cloud.google.com/java/internal/librariangen/message"
24+
"github.com/google/go-cmp/cmp"
25+
)
26+
27+
func TestReadReleaseInitRequest(t *testing.T) {
28+
want := &message.ReleaseInitRequest{
29+
Libraries: []*message.Library{
30+
{
31+
ID: "google-cloud-secretmanager-v1",
32+
Version: "1.3.0",
33+
Changes: []*message.Change{
34+
{
35+
Type: "feat",
36+
Subject: "add new UpdateRepository API",
37+
Body: "This adds the ability to update a repository's properties.",
38+
PiperCLNumber: "786353207",
39+
CommitHash: "9461532e7d19c8d71709ec3b502e5d81340fb661",
40+
},
41+
{
42+
Type: "docs",
43+
Subject: "fix typo in BranchRule comment",
44+
Body: "",
45+
PiperCLNumber: "786353207",
46+
CommitHash: "9461532e7d19c8d71709ec3b502e5d81340fb661",
47+
},
48+
},
49+
APIs: []message.API{
50+
{
51+
Path: "google/cloud/secretmanager/v1",
52+
},
53+
{
54+
Path: "google/cloud/secretmanager/v1beta",
55+
},
56+
},
57+
SourcePaths: []string{
58+
"secretmanager",
59+
"other/location/secretmanager",
60+
},
61+
ReleaseTriggered: true,
62+
},
63+
},
64+
}
65+
bytes, err := os.ReadFile(filepath.Join("..", "testdata", "release-init-request.json"))
66+
if err != nil {
67+
t.Fatal(err)
68+
}
69+
got := &message.ReleaseInitRequest{}
70+
if err := json.Unmarshal(bytes, got); err != nil {
71+
t.Fatal(err)
72+
}
73+
if diff := cmp.Diff(want, got); diff != "" {
74+
t.Errorf("Unmarshal() mismatch (-want +got):\n%s", diff)
75+
}
76+
}

0 commit comments

Comments
 (0)