Skip to content
Open
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
300 changes: 284 additions & 16 deletions cmd/bauer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,23 @@ import (
"bauer/internal/artifacts"
"bauer/internal/config"
"bauer/internal/copilotcli"
"bauer/internal/gdocs"
"bauer/internal/github"
"bauer/internal/orchestrator"
"bauer/internal/source"
"context"
"flag"
"fmt"
"os"
"strings"
"time"
)

func main() {
fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError)

docID := fs.String("doc-id", "", "Google Doc ID to extract feedback from (required, or set BAUER_DOC_ID)")
credentialsPath := fs.String("credentials", "", "Path to service account credentials JSON\n\t(falls back to BAUER_CREDENTIALS_PATH GOOGLE_APPLICATION_CREDENTIALS credentials.json)")
credentialsPath := fs.String("credentials", "", "Path to service account credentials JSON\n\t(falls back to BAUER_CREDENTIALS_PATH \u2192 GOOGLE_APPLICATION_CREDENTIALS \u2192 credentials.json)")
chunkSize := fs.Int("chunk-size", 0, "Total number of chunks (default: 1, or 5 if --page-refresh)")
pageRefresh := fs.Bool("page-refresh", false, "Use page-refresh-instructions template (default chunk-size: 5)")
model := fs.String("model", "", "Copilot model for sessions (default: gpt-5-mini-high)")
Expand All @@ -26,6 +30,7 @@ func main() {
openPR := fs.Bool("open-pr", false, "Apply changes and open a pull request (mutually exclusive with --open-issue)")
openIssue := fs.Bool("open-issue", false, "Generate a plan and open a GitHub issue without applying changes (mutually exclusive with --open-pr)")
branchPrefix := fs.String("branch-prefix", "", "Prefix for created branches (default: bauer)")
githubRepo := fs.String("github-repo", "", "GitHub repository in owner/repo format (required for --open-pr and --open-issue)")

fs.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage:\n\n")
Expand All @@ -41,22 +46,21 @@ func main() {
fmt.Fprintf(os.Stderr, "\tBAUER_DRY_RUN Override for --dry-run (true/false)\n")
fmt.Fprintf(os.Stderr, "\tBAUER_ARTIFACTS_DIR Override for --artifacts-dir\n")
fmt.Fprintf(os.Stderr, "\tBAUER_BRANCH_PREFIX Override for --branch-prefix\n")
fmt.Fprintf(os.Stderr, "\tBAUER_GITHUB_REPO Override for --github-repo\n")
fmt.Fprintf(os.Stderr, "\n")
}

if err := fs.Parse(os.Args[1:]); err != nil {
os.Exit(1)
}

// Mutual exclusion check — before any network calls
if *openPR && *openIssue {
fmt.Fprintln(os.Stderr, "Error: --open-pr and --open-issue are mutually exclusive.")
fmt.Fprintln(os.Stderr, " Use --open-pr to apply changes and open a PR.")
fmt.Fprintln(os.Stderr, " Use --open-issue to generate a plan and open an issue without applying changes.")
// Mutual exclusion check -- immediately after flag parsing, before any I/O or env resolution.
if err := validateFlags(*openPR, *openIssue); err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
os.Exit(1)
}

// Build CLIFlags *bool fields are only set for explicitly-provided flags
// Build CLIFlags -- *bool fields are only set for explicitly-provided flags
// so they don't override env vars when the user didn't pass the flag.
flags := config.CLIFlags{
DocID: *docID,
Expand All @@ -66,6 +70,7 @@ func main() {
SummaryModel: *summaryModel,
ArtifactsDir: *artifactsDir,
BranchPrefix: *branchPrefix,
GitHubRepo: *githubRepo,
}
fs.Visit(func(f *flag.Flag) {
switch f.Name {
Expand Down Expand Up @@ -117,7 +122,7 @@ func main() {
os.Exit(1)
}
case *openPR:
if err := runOpenPR(ctx, cfg, orch); err != nil {
if err := runOpenPR(ctx, cfg, orch, cwd); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
Expand All @@ -129,6 +134,15 @@ func main() {
}
}

// validateFlags validates mutual exclusion of --open-pr and --open-issue.
// Called immediately after flag parsing, before any I/O or env resolution.
func validateFlags(openPR, openIssue bool) error {
if openPR && openIssue {
return fmt.Errorf("--open-pr and --open-issue are mutually exclusive\n Use --open-pr to apply changes and open a PR.\n Use --open-issue to generate a plan and open an issue without applying changes.")
}
return nil
}

// resolveCLIConfig builds a Config from CLI flags, falling back to environment variables
// and then hardcoded defaults. FlagsSource has highest priority.
func resolveCLIConfig(flags config.CLIFlags) (*config.Config, error) {
Expand All @@ -140,20 +154,274 @@ func resolveCLIConfig(flags config.CLIFlags) (*config.Config, error) {
}

// openPRExecutionConfig returns a copy of cfg with DryRun disabled.
// In --open-pr mode, Copilot runs to apply changes locally; only PR creation
// is skipped when the original cfg has DryRun=true.
func openPRExecutionConfig(original *config.Config) *config.Config {
copy := *original
copy.DryRun = config.BoolPtr(false)
return &copy
}

// runOpenIssue is a stub — to be fully implemented in Phase 2.
func runOpenIssue(_ context.Context, _ *config.Config, _ orchestrator.Orchestrator) error {
return fmt.Errorf("--open-issue not yet implemented")
// runOpenIssue generates a documentation improvement plan and opens a GitHub issue.
// It runs the orchestrator in dry-run mode (extraction + prompt generation only, no Copilot).
func runOpenIssue(ctx context.Context, cfg *config.Config, orch orchestrator.Orchestrator) error {
token, err := github.GetGitHubToken()
if err != nil {
return fmt.Errorf("GitHub token not found: %w\nSet BAUER_GITHUB_TOKEN, GITHUB_TOKEN, or GH_TOKEN, or run 'gh auth login'", err)
}

if cfg.GitHubRepo == "" {
return fmt.Errorf("--github-repo (or BAUER_GITHUB_REPO) is required for --open-issue mode")
}

if _, err := github.ParseGitHubRepo(cfg.GitHubRepo); err != nil {
return fmt.Errorf("invalid --github-repo %q: %w", cfg.GitHubRepo, err)
}
Comment on lines +171 to +177
Comment on lines +175 to +177

// Run with dry-run=true: extract + generate prompts, but skip Copilot.
issueCfg := *cfg
issueCfg.DryRun = config.BoolPtr(true)

result, err := orch.Execute(ctx, &issueCfg)
if err != nil {
return fmt.Errorf("orchestration failed: %w", err)
}

if result.ExtractionBundle == nil || result.ExtractionBundle.Document == nil {
return fmt.Errorf("no document data returned by orchestrator")
}
doc := result.ExtractionBundle.Document

title := fmt.Sprintf("docs: %s \u2014 documentation suggestions review", doc.DocumentTitle)
body := buildIssueBody(doc, cfg, result.RunID)

issueURL, err := github.CreateIssue(ctx, token, cfg.GitHubRepo, title, body)
if err != nil {
Comment on lines +171 to +197
return fmt.Errorf("failed to create GitHub issue: %w", err)
}

fmt.Printf("GitHub issue created: %s\n", issueURL)
return nil
}

// buildIssueBody constructs the markdown body for the documentation suggestions issue.
func buildIssueBody(doc *gdocs.ProcessingResult, cfg *config.Config, runID string) string {
var sb strings.Builder

docURL := fmt.Sprintf("https://docs.google.com/document/d/%s", doc.DocumentID)
generated := time.Now().UTC().Format(time.RFC3339)

type suggEntry struct {
Section string
Brief string
ChangeType string
}
var copyChanges, designChanges []suggEntry

for _, loc := range doc.GroupedSuggestions {
section := loc.Location.ParentHeading
if section == "" {
section = loc.Location.Section
if section == "" {
section = "Document"
}
}
for _, s := range loc.Suggestions {
brief := s.Change.NewText
if len([]rune(brief)) > 100 {
brief = string([]rune(brief)[:97]) + "..."
}
if brief == "" {
brief = s.Change.OriginalText
if len([]rune(brief)) > 100 {
brief = string([]rune(brief)[:97]) + "..."
}
}
Comment on lines +227 to +237
entry := suggEntry{Section: section, Brief: brief, ChangeType: s.Change.Type}
if s.Change.Type == "insert" {
designChanges = append(designChanges, entry)
} else {
copyChanges = append(copyChanges, entry)
}
}
}

totalSuggestions := len(copyChanges) + len(designChanges)
sectionCount := len(doc.GroupedSuggestions)

fmt.Fprintf(&sb, "## Documentation Suggestions Review\n\n")
fmt.Fprintf(&sb, "Source: [Google Doc](%s)\n", docURL)
fmt.Fprintf(&sb, "Generated: %s\n", generated)
if runID != "" {
fmt.Fprintf(&sb, "Run ID: `%s`\n", runID)
}
fmt.Fprintf(&sb, "\n### Summary\n\n")
fmt.Fprintf(&sb, "%d suggestion(s) extracted from the document across %d section(s).\n", totalSuggestions, sectionCount)
fmt.Fprintf(&sb, "\n### Suggestions by Type\n\n")
fmt.Fprintf(&sb, "**Copy changes** (%d):\n", len(copyChanges))
if len(copyChanges) == 0 {
fmt.Fprintf(&sb, "- _(none)_\n")
}
for _, c := range copyChanges {
if c.Brief != "" {
fmt.Fprintf(&sb, "- Section \"%s\": %s\n", c.Section, c.Brief)
} else {
fmt.Fprintf(&sb, "- Section \"%s\": (%s change)\n", c.Section, c.ChangeType)
}
}
fmt.Fprintf(&sb, "\n**Design/content additions** (%d):\n", len(designChanges))
if len(designChanges) == 0 {
fmt.Fprintf(&sb, "- _(none)_\n")
}
for _, c := range designChanges {
if c.Brief != "" {
fmt.Fprintf(&sb, "- Section \"%s\": %s\n", c.Section, c.Brief)
} else {
fmt.Fprintf(&sb, "- Section \"%s\": (insertion)\n", c.Section)
}
}
if cfg.FigmaURL != "" {
fmt.Fprintf(&sb, "\n### Design Reference\n\n")
fmt.Fprintf(&sb, "Figma: %s\n", cfg.FigmaURL)
}
fmt.Fprintf(&sb, "\n### Next Steps\n\n")
fmt.Fprintf(&sb, "Review these suggestions, then run:\n\n")
fmt.Fprintf(&sb, "```sh\n")
fmt.Fprintf(&sb, "bauer --doc-id %s --open-pr --github-repo %s\n", doc.DocumentID, cfg.GitHubRepo)
fmt.Fprintf(&sb, "```\n")
fmt.Fprintf(&sb, "\nto apply them automatically via Copilot.\n")

return sb.String()
}

// runOpenPR runs Copilot to apply documentation changes, then creates a branch and opens a PR.
func runOpenPR(ctx context.Context, cfg *config.Config, orch orchestrator.Orchestrator, repoDir string) error {
token, err := github.GetGitHubToken()
if err != nil {
return fmt.Errorf("GitHub token not found: %w\nSet BAUER_GITHUB_TOKEN, GITHUB_TOKEN, or GH_TOKEN, or run 'gh auth login'", err)
}
Comment on lines +297 to +300

if cfg.GitHubRepo == "" {
return fmt.Errorf("--github-repo (or BAUER_GITHUB_REPO) is required for --open-pr mode")
}

repo, err := github.ParseGitHubRepo(cfg.GitHubRepo)
if err != nil {
return fmt.Errorf("invalid --github-repo %q: %w", cfg.GitHubRepo, err)
}

// Set token in environment so that gh CLI can authenticate.
if err := github.SetupGitHubAuth(token); err != nil {
return fmt.Errorf("failed to configure GitHub auth: %w", err)
}

// Run Copilot (disable dry-run for the execution phase).
execCfg := openPRExecutionConfig(cfg)
result, err := orch.Execute(ctx, execCfg)
if err != nil {
return fmt.Errorf("orchestration failed: %w", err)
}

// Determine branch name from the artifact run ID.
branchPrefix := cfg.BranchPrefix
if branchPrefix == "" {
branchPrefix = "bauer"
}
runID := result.RunID
if runID == "" {
runID = time.Now().UTC().Format("2006-01-02T15-04-05Z")
}
branchName := branchPrefix + "/" + runID

// Create the new branch.
if _, err := github.RunGit(ctx, repoDir, "checkout", "-b", branchName); err != nil {
return fmt.Errorf("failed to create branch %q: %w", branchName, err)
Comment on lines +334 to +336

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is by design. The workflow assumes the user has the target repository checked out on its default branch (typically main). We branch from the current checkout state, and the PR targets main. This matches the intended usage: run bauer --open-pr from within your repo on main. If we fetched and reset to the remote default branch automatically, we risk discarding local state the user did not expect to lose.

}

// Stage all changes.
if _, err := github.RunGit(ctx, repoDir, "add", "-A"); err != nil {
return fmt.Errorf("failed to stage changes: %w", err)
}

// Commit. If there is nothing to commit, report and exit cleanly.
commitMsg := "docs(bauer): apply documentation suggestions"
out, err := github.RunGit(ctx, repoDir, "commit", "-m", commitMsg)
if err != nil {
if strings.Contains(out, "nothing to commit") {
fmt.Println("No changes to commit. Exiting.")
return nil
Comment on lines +344 to +350
}
return fmt.Errorf("failed to commit: %w", err)
}

// In dry-run mode, skip push and PR creation.
if cfg.DryRun != nil && *cfg.DryRun {
fmt.Printf("Dry-run: changes committed locally on branch %q (push and PR creation skipped)\n", branchName)
return nil
}

// Push the branch.
if _, err := github.RunGit(ctx, repoDir, "push", "origin", branchName); err != nil {
return fmt.Errorf("failed to push branch %q: %w", branchName, err)
}

// Create the pull request.
prBody := buildPRBody(result, branchName)
// NOTE: BaseBranch is hardcoded to "main". If targeting repos with a different
// default branch (e.g. "master"), this should be made configurable via a flag or
// config field in a future iteration.
prURL, err := github.CreatePR(repo.Owner, repo.Name, github.CreatePROptions{
Title: "docs: apply documentation suggestions from Copilot",
Body: prBody,
BaseBranch: "main",
Comment on lines +366 to +374

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Acknowledged — added a comment documenting the limitation. A proper fix (reading the default branch from config or via git symbolic-ref refs/remotes/origin/HEAD) will be added when multi-branch support is needed. For now, all target repos in our workflow use main.

HeadBranch: branchName,
})
if err != nil {
return fmt.Errorf("failed to create PR: %w", err)
}
Comment on lines +361 to +379

fmt.Printf("Pull request created: %s\n", prURL)
return nil
}

// buildPRBody constructs the markdown body for the documentation suggestions PR.
func buildPRBody(result *orchestrator.OrchestrationResult, branchName string) string {
var sb strings.Builder

fmt.Fprintf(&sb, "## Documentation Suggestions \u2014 Automated Apply\n\n")
fmt.Fprintf(&sb, "Applied by: Bauer + GitHub Copilot\n")
fmt.Fprintf(&sb, "Branch: `%s`\n", branchName)
if result.RunID != "" {
fmt.Fprintf(&sb, "Run ID: `%s`\n", result.RunID)
}
fmt.Fprintf(&sb, "Timestamp: %s\n", time.Now().UTC().Format(time.RFC3339))

if result.ExtractionBundle != nil && result.ExtractionBundle.Document != nil {
doc := result.ExtractionBundle.Document
docURL := fmt.Sprintf("https://docs.google.com/document/d/%s", doc.DocumentID)
fmt.Fprintf(&sb, "\n### Source Document\n\n")
fmt.Fprintf(&sb, "[%s](%s)\n", doc.DocumentTitle, docURL)
fmt.Fprintf(&sb, "\n%d suggestion(s) from %d section(s) were applied.\n",
countAllSuggestions(doc), len(doc.GroupedSuggestions))
}

if len(result.CopilotOutputs) > 0 {
fmt.Fprintf(&sb, "\n### Copilot Execution Summary\n\n")
fmt.Fprintf(&sb, "%d chunk(s) processed in %s.\n",
len(result.CopilotOutputs), result.CopilotDuration.Round(time.Second))
}

if result.Summary != "" {
fmt.Fprintf(&sb, "\n### Summary\n\n")
fmt.Fprintf(&sb, "%s\n", result.Summary)
}

return sb.String()
}

// runOpenPR is a stub — to be fully implemented in Phase 2.
func runOpenPR(_ context.Context, _ *config.Config, _ orchestrator.Orchestrator) error {
return fmt.Errorf("--open-pr not yet implemented")
// countAllSuggestions returns the total number of suggestions across all location groups.
func countAllSuggestions(doc *gdocs.ProcessingResult) int {
total := 0
for _, loc := range doc.GroupedSuggestions {
total += len(loc.Suggestions)
}
return total
}
Loading