Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions internal/librariangen/languagecontainer/languagecontainer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// 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
//
// http://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 languagecontainer defines LanguageContainer interface and
// the Run function to execute commands within the container.
// This package should not have any language-specific implementation or
// Librarian CLI's implementation.
// TODO(b/447404382): Move this package to the https://github.com/googleapis/librarian
// GitHub repository once the interface is finalized.
package languagecontainer

import (
"context"
"encoding/json"
"flag"
"fmt"
"log/slog"
"os"
"path/filepath"

"cloud.google.com/java/internal/librariangen/languagecontainer/release"
"cloud.google.com/java/internal/librariangen/message"
)

// LanguageContainer defines the functions for language-specific container operations.
type LanguageContainer struct {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use an interface here?

Copy link
Member Author

@suztomo suztomo Oct 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this 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 when I tried to introduce languagecontainer/release and release packages. The naming conflict happened and I had to rename one of them to release2:

package main

import (

... (omit)...

	"cloud.google.com/java/internal/librariangen/generate"
	release2 "cloud.google.com/java/internal/librariangen/languagecontainer/release"
	"cloud.google.com/java/internal/librariangen/message"
	"cloud.google.com/java/internal/librariangen/release"
)

... (omit)...

// javaContainer implements the LanguageContainer interface for Java.
type javaContainer struct{}

// ReleaseInit implements the LanguageContainer interface for Java.
func (c *javaContainer) ReleaseInit(ctx context.Context, cfg *release2.Config) (*message.ReleaseInitResponse, error) {
	return release.Init(ctx, cfg)
}

I just added this observation in the description of this pull requests.

CC: @codyoss We talked about interface v.s. a struct with functions. I now think the struct is better because we don't have to rename "languagecontainer/release" package when we use (language-specific) "release" package used in main.go.

ReleaseInit func(context.Context, *release.Config) (*message.ReleaseInitResponse, error)
// Other container functions like Generate and Build will also be part of the struct.
}

// Run accepts an implementation of the LanguageContainer.
func Run(args []string, container *LanguageContainer) int {
// Logic to parse args and call the appropriate method on the container.
// For example, if args[1] is "generate":
// request := ... // unmarshal the request from the expected location
// err := container.Generate(context.Background(), request)
// ...
if len(args) < 1 {
panic("args must not be empty")
}
cmd := args[0]
flags := args[1:]
switch cmd {
case "generate":
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll move the generate command parsing here. Once Mike's generate command work stop having conflict.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have any outstanding PRs right now. I think it's OK to move it. I can deal with the merge with my WIP.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll change that in a subsequent pull request.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would imagine you would check if the passed in LanguageContainer struct includes the corresponding functions, and if so invoke them, otherwise log the warning.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's the plan. In this pull requests, LanguageContainer only has ReleaseInit function.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added that in #3968.

slog.Warn("librariangen: generate command is not yet implemented")
return 1
case "configure":
slog.Warn("librariangen: configure command is not yet implemented")
return 1
case "release-init":
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a lot code to put into a switch statement. Do you think it would make sense to move it to a separate function, like it was done for generate?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me work on that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added handleReleaseInit here.

return handleReleaseInit(flags, container)
case "build":
slog.Warn("librariangen: build command is not yet implemented")
return 1
default:
slog.Error(fmt.Sprintf("librariangen: unknown command: %s (with flags %v)", cmd, flags))
return 1
}
return 0
}

func handleReleaseInit(flags []string, container *LanguageContainer) int {
cfg := &release.Context{}
releaseInitFlags := flag.NewFlagSet("release-init", flag.ContinueOnError)
releaseInitFlags.StringVar(&cfg.LibrarianDir, "librarian", "/librarian", "Path to the librarian-tool input directory. Contains release-init-request.json.")
releaseInitFlags.StringVar(&cfg.RepoDir, "repo", "/repo", "Path to the language repo.")
releaseInitFlags.StringVar(&cfg.OutputDir, "output", "/output", "Path to the output directory.")
if err := releaseInitFlags.Parse(flags); err != nil {
slog.Error("failed to parse flags", "error", err)
return 1
}
requestPath := filepath.Join(cfg.LibrarianDir, "release-init-request.json")
bytes, err := os.ReadFile(requestPath)
if err != nil {
slog.Error("failed to read request file", "path", requestPath, "error", err)
return 1
}
request := &message.ReleaseInitRequest{}
if err := json.Unmarshal(bytes, request); err != nil {
slog.Error("failed to parse request JSON", "error", err)
return 1
}
config := &release.Config{
Context: cfg,
Request: request,
}
response, err := container.ReleaseInit(context.Background(), config)
if err != nil {
slog.Error("release-init failed", "error", err)
return 1
}
bytes, err = json.MarshalIndent(response, "", " ")
if err != nil {
slog.Error("failed to marshal response JSON", "error", err)
return 1
}
responsePath := filepath.Join(cfg.LibrarianDir, "release-init-response.json")
if err := os.WriteFile(responsePath, bytes, 0644); err != nil {
slog.Error("failed to write response file", "path", responsePath, "error", err)
return 1
}
slog.Info("librariangen: release-init command executed successfully")
return 0
}
166 changes: 166 additions & 0 deletions internal/librariangen/languagecontainer/languagecontainer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// 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
//
// http://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 languagecontainer

import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"

"cloud.google.com/java/internal/librariangen/languagecontainer/release"
"cloud.google.com/java/internal/librariangen/message"
"github.com/google/go-cmp/cmp"
)

func TestRun(t *testing.T) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is from main_test.go

tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "release-init-request.json"), []byte("{}"), 0644); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
args []string
wantCode int
wantErr bool
}{
{
name: "unknown command",
args: []string{"foo"},
wantCode: 1,
},
{
name: "build command",
args: []string{"build"},
wantCode: 1, // Not implemented yet
},
{
name: "configure command",
args: []string{"configure"},
wantCode: 1, // Not implemented yet
},
{
name: "generate command",
args: []string{"generate"},
wantCode: 1, // Not implemented yet
},
{
name: "release-init command success",
args: []string{"release-init", "-librarian", tmpDir},
wantCode: 0,
},
{
name: "release-init command failure",
args: []string{"release-init", "-librarian", tmpDir},
wantCode: 1,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
container := LanguageContainer{
ReleaseInit: func(ctx context.Context, c *release.Config) (*message.ReleaseInitResponse, error) {
if tt.wantErr {
return nil, os.ErrNotExist
}
return &message.ReleaseInitResponse{}, nil
},
}
if gotCode := Run(tt.args, &container); gotCode != tt.wantCode {
t.Errorf("Run() = %v, want %v", gotCode, tt.wantCode)
}
})
}
}

func TestRun_noArgs(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Errorf("The code did not panic")
}
}()
Run([]string{}, &LanguageContainer{})
}

func TestRun_ReleaseInitWritesResponse(t *testing.T) {
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "release-init-request.json"), []byte("{}"), 0644); err != nil {
t.Fatal(err)
}
args := []string{"release-init", "-librarian", tmpDir}
want := &message.ReleaseInitResponse{Error: "test error"}
container := LanguageContainer{
ReleaseInit: func(ctx context.Context, c *release.Config) (*message.ReleaseInitResponse, error) {
return want, nil
},
}

if code := Run(args, &container); code != 0 {
t.Errorf("Run() = %v, want 0", code)
}

responsePath := filepath.Join(tmpDir, "release-init-response.json")
bytes, err := os.ReadFile(responsePath)
if err != nil {
t.Fatal(err)
}
got := &message.ReleaseInitResponse{}
if err := json.Unmarshal(bytes, got); err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("response mismatch (-want +got):\n%s", diff)
}
}

func TestRun_ReleaseInitReadsContextArgs(t *testing.T) {
tmpDir := t.TempDir()
librarianDir := filepath.Join(tmpDir, "librarian")
if err := os.Mkdir(librarianDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(librarianDir, "release-init-request.json"), []byte("{}"), 0644); err != nil {
t.Fatal(err)
}
repoDir := filepath.Join(tmpDir, "repo")
if err := os.Mkdir(repoDir, 0755); err != nil {
t.Fatal(err)
}
outputDir := filepath.Join(tmpDir, "output")
if err := os.Mkdir(outputDir, 0755); err != nil {
t.Fatal(err)
}
args := []string{"release-init", "-librarian", librarianDir, "-repo", repoDir, "-output", outputDir}
var gotConfig *release.Config
container := LanguageContainer{
ReleaseInit: func(ctx context.Context, c *release.Config) (*message.ReleaseInitResponse, error) {
gotConfig = c
return &message.ReleaseInitResponse{}, nil
},
}
if code := Run(args, &container); code != 0 {
t.Errorf("Run() = %v, want 0", code)
}
if got, want := gotConfig.Context.LibrarianDir, librarianDir; got != want {
t.Errorf("gotConfig.Context.LibrarianDir = %q, want %q", got, want)
}
if got, want := gotConfig.Context.RepoDir, repoDir; got != want {
t.Errorf("gotConfig.Context.RepoDir = %q, want %q", got, want)
}
if got, want := gotConfig.Context.OutputDir, outputDir; got != want {
t.Errorf("gotConfig.Context.OutputDir = %q, want %q", got, want)
}
}
35 changes: 35 additions & 0 deletions internal/librariangen/languagecontainer/release/release.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// 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
//
// http://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 release contains types for language container's release command.
package release

import "cloud.google.com/java/internal/librariangen/message"

// Context has the directory paths for the release-init command.
// https://github.com/googleapis/librarian/blob/main/doc/language-onboarding.md#release-init
type Context struct {
LibrarianDir string
RepoDir string
OutputDir string
}

// The Config for the release-init command. This holds the context (the directory paths)
// and the request parsed from the release-init-request.json file.
type Config struct {
Context *Context
// This request is parsed from the release-init-request.json file in
// the LibrarianDir of the context.
Request *message.ReleaseInitRequest
}
76 changes: 76 additions & 0 deletions internal/librariangen/languagecontainer/release/release_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// 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
//
// http://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 release

import (
"encoding/json"
"os"
"path/filepath"
"testing"

"cloud.google.com/java/internal/librariangen/message"
"github.com/google/go-cmp/cmp"
)

func TestReadReleaseInitRequest(t *testing.T) {
want := &message.ReleaseInitRequest{
Libraries: []*message.Library{
{
ID: "google-cloud-secretmanager-v1",
Version: "1.3.0",
Changes: []*message.Change{
{
Type: "feat",
Subject: "add new UpdateRepository API",
Body: "This adds the ability to update a repository's properties.",
PiperCLNumber: "786353207",
CommitHash: "9461532e7d19c8d71709ec3b502e5d81340fb661",
},
{
Type: "docs",
Subject: "fix typo in BranchRule comment",
Body: "",
PiperCLNumber: "786353207",
CommitHash: "9461532e7d19c8d71709ec3b502e5d81340fb661",
},
},
APIs: []message.API{
{
Path: "google/cloud/secretmanager/v1",
},
{
Path: "google/cloud/secretmanager/v1beta",
},
},
SourcePaths: []string{
"secretmanager",
"other/location/secretmanager",
},
ReleaseTriggered: true,
},
},
}
bytes, err := os.ReadFile(filepath.Join("..", "testdata", "release-init-request.json"))
if err != nil {
t.Fatal(err)
}
got := &message.ReleaseInitRequest{}
if err := json.Unmarshal(bytes, got); err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("Unmarshal() mismatch (-want +got):\n%s", diff)
}
}
Loading
Loading