Skip to content

Conversation

@kooksee
Copy link
Contributor

@kooksee kooksee commented Jan 21, 2026

No description provided.

@kooksee kooksee merged commit 0b827e7 into main Jan 21, 2026
1 check passed
@gemini-code-assist
Copy link

Summary of Changes

Hello @kooksee, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the protobuild tool by introducing new commands and a web-based interface to streamline the management and development of Protocol Buffer projects. The changes aim to improve user experience by providing visual configuration, guided project setup, and automated environment diagnostics, making it easier for developers to get started and maintain their protobuf-driven services.

Highlights

  • Web-based Configuration UI: Introduced a new web command that launches a local web server, providing a visual interface for managing protobuild configurations, including editing settings, managing dependencies, configuring plugins, and browsing proto files with syntax highlighting.
  • Project Initialization Command: Added an init command to quickly set up new protobuild projects using predefined templates (basic, grpc-gateway, minimal) or an interactive setup, simplifying the initial project configuration.
  • Environment Diagnostic Tool: Implemented a doctor command to check the development environment for necessary tools and dependencies (like protoc, protoc-gen-go, buf, api-linter), offering platform-specific installation help and auto-fixing capabilities for missing Go plugins.
  • Configuration Refactoring: Refactored core configuration types into a new internal/config package, improving modularity and maintainability of the configuration schema.
  • README Updates: Updated both English and Chinese README files to document the new web, init, and doctor commands, including usage examples and a new roadmap section.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

kooksee added a commit that referenced this pull request Jan 21, 2026
…1 22:56:12\n\n* chore: quick update feat/manager at 2026-01-21 23:01:56
Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces several significant enhancements, including a new Web UI for configuration management, init and doctor commands for project setup and environment diagnostics, and a comprehensive refactoring of the configuration system into an internal package. The changes improve user experience, streamline project initialization, and centralize configuration definitions. The README.md and README_CN.md files have been updated to reflect these new features and commands, providing clear documentation for users.

Comment on lines +26 to +35
string email = 3 [
(retag.tags) = {
name: "xml"
value: "email"
},
(retag.tags) = {
name: "validate"
value: "required,email"
}
];

Choose a reason for hiding this comment

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

high

The (retag.tags) options for xml and validate on the email field are excellent additions. The required,email validation tag ensures data integrity, which is a significant correctness improvement.

Comment on lines +73 to +81
string website = 4 [
(retag.tags) = {
name: "xml"
value: "website,omitempty"
},
(retag.tags) = {
name: "validate"
value: "omitempty,url"
}

Choose a reason for hiding this comment

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

high

The (retag.tags) options for xml and validate on the website field are excellent additions. The omitempty,url validation tag ensures data integrity, which is a significant correctness improvement.

Comment on lines +1 to +554
// Package webcmd provides a web-based configuration UI for protobuild.
package webcmd

import (
"bufio"
"context"
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"

"github.com/pubgo/protobuild/internal/config"
)

//go:embed templates/*
var templateFS embed.FS

// CommandResult represents the result of a command execution.
type CommandResult struct {
Success bool `json:"success"`
Output string `json:"output"`
Error string `json:"error,omitempty"`
}

// Server represents the web server.
type Server struct {
configPath string
config *config.Config
mu sync.RWMutex
server *http.Server
templates *template.Template
}

// NewServer creates a new web server.
func NewServer(configPath string) (*Server, error) {
s := &Server{
configPath: configPath,
}

// Load templates
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("failed to parse templates: %w", err)
}
s.templates = tmpl

// Load initial config
if err := s.loadConfig(); err != nil {
// Create default config if not exists
s.config = config.Default()
}

return s, nil
}

// loadConfig loads the configuration from file.
func (s *Server) loadConfig() error {
s.mu.Lock()
defer s.mu.Unlock()

cfg, err := config.Load(s.configPath)
if err != nil {
return err
}

s.config = cfg
return nil
}

// saveConfig saves the configuration to file.
func (s *Server) saveConfig() error {
s.mu.RLock()
cfg := s.config
s.mu.RUnlock()

return config.Save(s.configPath, cfg)
}

// Start starts the web server.
func (s *Server) Start(ctx context.Context, port int) error {
mux := http.NewServeMux()

// Static files
mux.HandleFunc("/", s.handleIndex)

// API endpoints
mux.HandleFunc("/api/config", s.handleConfig)
mux.HandleFunc("/api/config/save", s.handleSaveConfig)
mux.HandleFunc("/api/command/", s.handleCommand)
mux.HandleFunc("/api/command-stream/", s.handleCommandStream)
mux.HandleFunc("/api/proto-files", s.handleProtoFiles)
mux.HandleFunc("/api/proto-content", s.handleProtoContent)
mux.HandleFunc("/api/deps/status", s.handleDepsStatus)
mux.HandleFunc("/api/project/stats", s.handleProjectStats)

addr := fmt.Sprintf(":%d", port)
s.server = &http.Server{
Addr: addr,
Handler: mux,
}

// Find available port if default is in use
listener, err := net.Listen("tcp", addr)
if err != nil {
// Try to find an available port
listener, err = net.Listen("tcp", ":0")
if err != nil {
return fmt.Errorf("failed to find available port: %w", err)
}
}

actualPort := listener.Addr().(*net.TCPAddr).Port
url := fmt.Sprintf("http://localhost:%d", actualPort)

slog.Info("Starting web server", "url", url)
fmt.Printf("\n🌐 Web UI available at: %s\n\n", url)

// Open browser
go func() {
time.Sleep(500 * time.Millisecond)
openBrowser(url)
}()

// Handle graceful shutdown
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.server.Shutdown(shutdownCtx)
}()

return s.server.Serve(listener)
}

// handleIndex serves the main page.
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}

s.mu.RLock()
cfg := s.config
s.mu.RUnlock()

data := map[string]interface{}{
"Config": cfg,
"ConfigPath": s.configPath,
}

w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.templates.ExecuteTemplate(w, "index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

// handleConfig returns the current configuration.
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
// Reload config from file
s.loadConfig()

s.mu.RLock()
cfg := s.config
s.mu.RUnlock()

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cfg)
return
}

http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}

// handleSaveConfig saves the configuration.
func (s *Server) handleSaveConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

var cfg config.Config
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

s.mu.Lock()
s.config = &cfg
s.mu.Unlock()

if err := s.saveConfig(); err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(CommandResult{
Success: false,
Error: err.Error(),
})
return
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(CommandResult{
Success: true,
Output: "Configuration saved successfully",
})
}

// handleCommand executes protobuild commands.
func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

// Extract command from URL path
cmdName := strings.TrimPrefix(r.URL.Path, "/api/command/")
if cmdName == "" {
http.Error(w, "Command name required", http.StatusBadRequest)
return
}

// Build command arguments
args := []string{"-c", s.configPath, cmdName}

// Parse additional flags from request body
var flags map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&flags); err == nil {
for key, val := range flags {
switch v := val.(type) {
case bool:
if v {
args = append(args, "--"+key)
}
case string:
if v != "" {
args = append(args, "--"+key, v)
}
}
}
}

// Get executable path
executable, err := os.Executable()
if err != nil {
executable = "protobuild"
}

// Execute command
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, executable, args...)
cmd.Dir = filepath.Dir(s.configPath)

output, err := cmd.CombinedOutput()

result := CommandResult{
Success: err == nil,
Output: string(output),
}
if err != nil {
result.Error = err.Error()
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}

// handleProtoFiles returns a list of proto files in the project.
func (s *Server) handleProtoFiles(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
cfg := s.config
s.mu.RUnlock()

var files []string
baseDir := filepath.Dir(s.configPath)

for _, root := range cfg.Root {
rootPath := filepath.Join(baseDir, root)
filepath.Walk(rootPath, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() && strings.HasSuffix(path, ".proto") {
relPath, _ := filepath.Rel(baseDir, path)
files = append(files, relPath)
}
return nil
})
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(files)
}

// handleDepsStatus returns the status of dependencies.
func (s *Server) handleDepsStatus(w http.ResponseWriter, r *http.Request) {
// Get executable path
executable, err := os.Executable()
if err != nil {
executable = "protobuild"
}

cmd := exec.Command(executable, "-c", s.configPath, "deps")
cmd.Dir = filepath.Dir(s.configPath)
output, _ := cmd.CombinedOutput()

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"output": string(output),
})
}

// openBrowser opens the URL in the default browser.
func openBrowser(url string) error {
var cmd string
var args []string

switch runtime.GOOS {
case "darwin":
cmd = "open"
args = []string{url}
case "linux":
cmd = "xdg-open"
args = []string{url}
case "windows":
cmd = "rundll32"
args = []string{"url.dll,FileProtocolHandler", url}
default:
return fmt.Errorf("unsupported platform")
}

return exec.Command(cmd, args...).Start()
}

// handleCommandStream executes a command and streams output via SSE.
func (s *Server) handleCommandStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

// Extract command from URL path
cmdName := strings.TrimPrefix(r.URL.Path, "/api/command-stream/")
if cmdName == "" {
http.Error(w, "Command name required", http.StatusBadRequest)
return
}

// Set SSE headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")

flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
return
}

// Build command arguments
args := []string{"-c", s.configPath, cmdName}

// Get executable path
executable, err := os.Executable()
if err != nil {
executable = "protobuild"
}

// Execute command with streaming output
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, executable, args...)
cmd.Dir = filepath.Dir(s.configPath)

// Create pipes for stdout and stderr
stdout, _ := cmd.StdoutPipe()
stderr, _ := cmd.StderrPipe()

if err := cmd.Start(); err != nil {
fmt.Fprintf(w, "data: {\"type\":\"error\",\"data\":\"%s\"}\n\n", err.Error())
flusher.Flush()
return
}

// Stream output
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
fmt.Fprintf(w, "data: {\"type\":\"stdout\",\"data\":\"%s\"}\n\n", escapeJSON(line))
flusher.Flush()
}
}()

go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
line := scanner.Text()
fmt.Fprintf(w, "data: {\"type\":\"stderr\",\"data\":\"%s\"}\n\n", escapeJSON(line))
flusher.Flush()
}
}()

err = cmd.Wait()
if err != nil {
fmt.Fprintf(w, "data: {\"type\":\"error\",\"data\":\"%s\"}\n\n", err.Error())
} else {
fmt.Fprintf(w, "data: {\"type\":\"done\",\"data\":\"Command completed successfully\"}\n\n")
}
flusher.Flush()
}

// escapeJSON escapes special characters for JSON string.
func escapeJSON(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "\"", "\\\"")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "\\r")
s = strings.ReplaceAll(s, "\t", "\\t")
return s
}

// handleProtoContent returns the content of a specific proto file.
func (s *Server) handleProtoContent(w http.ResponseWriter, r *http.Request) {
filePath := r.URL.Query().Get("file")
if filePath == "" {
http.Error(w, "File path required", http.StatusBadRequest)
return
}

baseDir := filepath.Dir(s.configPath)
fullPath := filepath.Join(baseDir, filePath)

// Security check: ensure the path is within the project
absBase, _ := filepath.Abs(baseDir)
absPath, _ := filepath.Abs(fullPath)
if !strings.HasPrefix(absPath, absBase) {
http.Error(w, "Invalid file path", http.StatusForbidden)
return
}

// Check file extension
if !strings.HasSuffix(fullPath, ".proto") {
http.Error(w, "Only .proto files allowed", http.StatusForbidden)
return
}

content, err := os.ReadFile(fullPath)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}

// Get file info
info, _ := os.Stat(fullPath)

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"path": filePath,
"content": string(content),
"size": info.Size(),
"modified": info.ModTime().Format(time.RFC3339),
})
}

// ProjectStats represents project statistics.
type ProjectStats struct {
ProtoFiles int `json:"proto_files"`
TotalLines int `json:"total_lines"`
MessageCount int `json:"message_count"`
ServiceCount int `json:"service_count"`
DependencyCount int `json:"dependency_count"`
PluginCount int `json:"plugin_count"`
ProtoRoots []string `json:"proto_roots"`
VendorDir string `json:"vendor_dir"`
VendorFiles int `json:"vendor_files"`
}

// handleProjectStats returns project statistics.
func (s *Server) handleProjectStats(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
cfg := s.config
s.mu.RUnlock()

stats := ProjectStats{
ProtoRoots: cfg.Root,
VendorDir: cfg.Vendor,
DependencyCount: len(cfg.Depends),
PluginCount: len(cfg.Plugins),
}

baseDir := filepath.Dir(s.configPath)

// Count proto files in root directories
for _, root := range cfg.Root {
rootPath := filepath.Join(baseDir, root)
filepath.Walk(rootPath, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() && strings.HasSuffix(path, ".proto") {
stats.ProtoFiles++

// Count lines, messages, and services
content, err := os.ReadFile(path)
if err == nil {
lines := strings.Split(string(content), "\n")
stats.TotalLines += len(lines)

for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "message ") {
stats.MessageCount++
} else if strings.HasPrefix(trimmed, "service ") {
stats.ServiceCount++
}
}
}
}
return nil
})
}

// Count vendor files
if cfg.Vendor != "" {
vendorPath := filepath.Join(baseDir, cfg.Vendor)
filepath.Walk(vendorPath, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() && strings.HasSuffix(path, ".proto") {
stats.VendorFiles++
}
return nil
})
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}

Choose a reason for hiding this comment

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

medium

The server.go file implements a robust web server for the configuration UI. Key features like embed for templates, SSE for command streaming, and the path traversal security check in handleProtoContent are well-implemented. The handleProjectStats endpoint provides valuable insights.

Comment on lines +77 to +84
| `web` | Start web-based configuration UI |
| `web --port 9090` | Start web UI on custom port |
| `clean` | Clean dependency cache |
| `clean --dry-run` | Show what would be cleaned without deleting |
| `init` | Initialize a new protobuild project |
| `init --template grpc` | Initialize with specific template (basic, grpc, minimal) |
| `doctor` | Check development environment and dependencies |
| `doctor --fix` | Auto-install missing Go plugins |

Choose a reason for hiding this comment

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

medium

The addition of web, init, and doctor commands to the command table provides a clear overview of the new functionalities. This is a good update to the documentation.

Comment on lines +434 to +446
## Roadmap
Upcoming features planned for future releases:
| Feature | Description | Status |
|---------|-------------|--------|
| 🔗 **Dependency Graph** | Visualize proto file import dependencies | Planned |
| ⚠️ **Breaking Change Detection** | Detect incompatible changes between versions | Planned |
| 📚 **API Documentation Generator** | Auto-generate Markdown/HTML docs from proto comments | Planned |
| 🎭 **Mock Server** | Auto-start mock gRPC/HTTP server for testing | Planned |
| 📝 **Proto Templates** | Quick generation of common proto patterns (CRUD, pagination) | Planned |
| 📊 **Field Statistics** | Analyze field naming conventions and type distribution | Planned |
| ✏️ **Online Editor** | Edit proto files directly in Web UI | Planned |

Choose a reason for hiding this comment

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

medium

The new "Roadmap" section is a great addition, providing transparency about future development and giving users an idea of what to expect. This enhances community engagement.

Comment on lines 19 to 20
- name: go
out: pkg
opt:
- paths=source_relative
- name: retag

Choose a reason for hiding this comment

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

medium

The simplification of the go plugin configuration by removing out and opt fields is a direct benefit of the new base configuration. These settings are now inherited from the base section, making individual plugin configurations cleaner.

out: pkg
opt:
- paths=source_relative
- name: retag

Choose a reason for hiding this comment

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

medium

The simplification of the retag plugin configuration by removing out and opt fields is a direct benefit of the new base configuration. These settings are now inherited from the base section, making individual plugin configurations cleaner.

- output=pkg
- name: test
linter:
rules:

Choose a reason for hiding this comment

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

medium

The test plugin was removed, which is fine if it was a temporary entry or no longer needed.

Comment on lines 23 to 24
enabled_rules:

Choose a reason for hiding this comment

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

medium

The removal of included_paths and excluded_paths from linter.rules is consistent with the updated LinterRules struct in internal/config/config.go, which no longer includes these fields. This reflects a streamlined linter configuration.

- core::0131::http-body
- core::0235::plural-method-name
disabled_rules:
- all

Choose a reason for hiding this comment

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

medium

The ignore_comment_disables_flag is removed from the example linter configuration. While this field is still present in the internal/config.Linter struct, its removal from the example might imply it's less commonly used or intended to be omitted by default. It's a minor inconsistency in the example, but not a functional issue.

kooksee added a commit that referenced this pull request Jan 21, 2026
* Feat/manager (#20)\n\n* chore: quick update feat/manager at 2026-01-21 22:56:12\n\n* chore: quick update feat/manager at 2026-01-21 23:01:56

* chore: quick update fix/release_token at 2026-01-21 23:06:18

* chore: quick update fix/release_token at 2026-01-21 23:15:58

* chore: quick update fix/release_token at 2026-01-21 23:17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants