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
4 changes: 4 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ BAUER_CHUNK_SIZE=1
BAUER_PAGE_REFRESH=false
BAUER_OUTPUT_DIR=bauer-output
BAUER_BRANCH_PREFIX=bauer

# Jira webhook integration
BAUER_JIRA_WEBHOOK_SECRET=
BAUER_JIRA_DOC_FIELD=customfield_10100 # Jira custom field key that holds the Google Doc ID
3 changes: 3 additions & 0 deletions cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ func run() error {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/job", v1.JobPost(rc))
mux.HandleFunc("/api/v1/health", v1.GetHealth)
mux.HandleFunc("GET /api/v1/health/ready", v1.ReadinessHandler(cfg))
mux.HandleFunc("POST /api/v1/workflows", workflow.ExecuteWorkflowHandler(orch))
mux.HandleFunc("POST /api/v1/issues", v1.IssuesHandler(cfg))
mux.HandleFunc("POST /api/v1/webhooks/jira", v1.JiraWebhookHandler(cfg))
slog.Info("starting server", "address", ":8090")
err = http.ListenAndServe(":8090", middleware.RequestTrace(mux))

Expand Down
46 changes: 46 additions & 0 deletions cmd/app/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import (
"bauer/cmd/app/models/v1"
"bauer/cmd/app/types"
"bauer/internal/config"
"bauer/internal/github"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/exec"
)

func JobPost(rc types.RouteConfig) func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -91,4 +94,47 @@ func GetHealth(w http.ResponseWriter, r *http.Request) {
if err != nil {
slog.Error("error writing response", "error", err.Error())
}
}

// ReadinessHandler checks that all required runtime dependencies are present.
// It must be registered on the public (unauthenticated) mux so that K8s readiness
// probes — which cannot send bearer tokens — can call it.
func ReadinessHandler(apiCfg *types.APIConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
failures := map[string]string{}

// Check credentials — prefer config flag, fall back to env vars.
credsPath := firstNonEmpty(
apiCfg.CredentialsPath,
os.Getenv("BAUER_CREDENTIALS_PATH"),
os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"),
)
if credsPath == "" {
failures["credentials"] = "not configured (set BAUER_CREDENTIALS_PATH)"
} else if _, err := os.Stat(credsPath); err != nil {
slog.Warn("credentials file not readable", slog.String("error", err.Error()))
failures["credentials"] = "credentials file is not readable (check server logs for details)"
Comment on lines +114 to +116

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.

os.Stat is intentional here. For a readiness probe the goal is to verify the credentials file exists on the expected path; actually opening/reading it on every probe adds I/O for negligible benefit. If the file becomes unreadable at runtime the downstream Google API calls will surface the real error.

Comment on lines +114 to +116

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.

Discussed in round 1. os.Stat failing covers the most common failure mode (file missing/permissions). A full os.Open adds complexity for a readiness probe that only needs a fast sanity check — the real read happens in the request path where errors surface properly.

}

// Check GitHub token
if _, err := github.GetGitHubToken(); err != nil {
failures["github_token"] = "not configured (set BAUER_GITHUB_TOKEN or run 'gh auth login')"
}
Comment on lines +119 to +122

// Check gh CLI
if _, err := exec.LookPath("gh"); err != nil {
failures["gh_cli"] = "not found in PATH"
}

w.Header().Set("Content-Type", "application/json")
if len(failures) > 0 {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]any{
"status": "not ready",
"missing": failures,
})
return
}
json.NewEncoder(w).Encode(map[string]any{"status": "ready"})
}
}
30 changes: 30 additions & 0 deletions cmd/app/v1/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package v1

import (
"encoding/json"
"net/http"
)

func httpError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}

func firstNonZero(vals ...int) int {
for _, v := range vals {
if v != 0 {
return v
}
}
return 0
}
138 changes: 138 additions & 0 deletions cmd/app/v1/issues.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package v1

import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"

"bauer/cmd/app/types"
"bauer/internal/artifacts"
"bauer/internal/config"
"bauer/internal/copilotcli"
"bauer/internal/github"
"bauer/internal/orchestrator"
"bauer/internal/source"
)

// IssueRequest is the request body for POST /api/v1/issues.
type IssueRequest struct {
DocID string `json:"doc_id"`
GitHubRepo string `json:"github_repo"`
ChunkSize int `json:"chunk_size,omitempty"`
PageRefresh bool `json:"page_refresh,omitempty"`
Model string `json:"model,omitempty"`
FigmaURL string `json:"figma_url,omitempty"`
}

// IssuesHandler runs the orchestrator in dry-run mode, formats a GitHub issue body,
// creates the issue via the GitHub API, and returns the issue URL and number.
func IssuesHandler(apiCfg *types.APIConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req IssueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpError(w, http.StatusBadRequest, "invalid request body")
return
Comment on lines +33 to +36
}
if req.DocID == "" || req.GitHubRepo == "" {
httpError(w, http.StatusBadRequest, "doc_id and github_repo are required")
return
}

token, err := github.GetGitHubToken()
if err != nil {
httpError(w, http.StatusInternalServerError, "GitHub token not configured")
return
}

credsPath := firstNonEmpty(
apiCfg.CredentialsPath,
os.Getenv("BAUER_CREDENTIALS_PATH"),
os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"),
)
if credsPath == "" {
httpError(w, http.StatusInternalServerError, "credentials not configured (set BAUER_CREDENTIALS_PATH)")
Comment on lines +53 to +55
return
}

if req.ChunkSize < 0 {
httpError(w, http.StatusBadRequest, "chunk_size must be a positive integer")
return
}

tmpDir, err := os.MkdirTemp("", "bauer-issues-*")
if err != nil {
httpError(w, http.StatusInternalServerError, "failed to create temp directory")
return
}

cfg := &config.Config{
DocID: req.DocID,
CredentialsPath: credsPath,
Model: firstNonEmpty(req.Model, apiCfg.Model, "gpt-5-mini-high"),
ChunkSize: firstNonZero(req.ChunkSize, 1),
DryRun: config.BoolPtr(true),
Comment on lines +73 to +75
OutputDir: tmpDir,
FigmaURL: req.FigmaURL,
FigmaToken: os.Getenv("BAUER_FIGMA_TOKEN"),
Comment on lines +70 to +78
}
if req.PageRefresh {
cfg.PageRefresh = config.BoolPtr(true)
}
cfg.ApplyDefaults()

sources := source.NewManager(cfg.CredentialsPath)
arts := artifacts.NewManager(firstNonEmpty(os.Getenv("BAUER_ARTIFACTS_DIR"), "./bauer-artifacts"))
copilotAgent, err := copilotcli.NewClient(tmpDir)
if err != nil {
httpError(w, http.StatusInternalServerError, "failed to create copilot client")
return
}
orch := orchestrator.New(copilotAgent, sources, arts)

result, err := orch.Execute(r.Context(), cfg)
if err != nil {
httpError(w, http.StatusInternalServerError, fmt.Sprintf("orchestration failed: %s", err))
return
}

parts := strings.Split(req.GitHubRepo, "/")
if len(parts) != 2 {
httpError(w, http.StatusBadRequest, "github_repo must be in owner/repo format")
return
}
repoFull := parts[0] + "/" + parts[1]

title := fmt.Sprintf("BAU: Apply suggestions from doc %s", req.DocID)
body := formatIssueBody(result, req.DocID)

issueURL, issueNum, err := github.CreateIssue(r.Context(), token, repoFull, title, body)
if err != nil {
httpError(w, http.StatusInternalServerError, fmt.Sprintf("creating GitHub issue: %s", err))
return
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "success",
"issue_url": issueURL,
"issue_number": issueNum,
})
}
}

func formatIssueBody(result *orchestrator.OrchestrationResult, docID string) string {
var sb strings.Builder
sb.WriteString("## BAU Documentation Improvement Plan\n\n")
sb.WriteString(fmt.Sprintf("**Doc ID**: `%s`\n\n", docID))
if result.ExtractionBundle != nil && result.ExtractionBundle.Document != nil {
sb.WriteString(fmt.Sprintf("**Document**: %s\n\n", result.ExtractionBundle.Document.DocumentTitle))
}
sb.WriteString(fmt.Sprintf("**Chunks generated**: %d\n\n", len(result.Chunks)))
sb.WriteString("### Prompt files\n\n")
for _, c := range result.Chunks {
sb.WriteString(fmt.Sprintf("- Chunk %d: %d location(s)\n", c.ChunkNumber, c.LocationCount))
}
return sb.String()
}
91 changes: 91 additions & 0 deletions cmd/app/v1/jira.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package v1

import (
"context"
"crypto/hmac"
"encoding/json"
"log/slog"
"net/http"
"os"

"bauer/cmd/app/types"
"bauer/internal/artifacts"
"bauer/internal/config"
"bauer/internal/copilotcli"
"bauer/internal/jira"
"bauer/internal/orchestrator"
"bauer/internal/source"
)

// JiraWebhookHandler handles POST /api/v1/webhooks/jira.
// It validates a shared secret, extracts the Google Doc ID from the Jira issue payload,
// and runs the orchestrator (extraction + generation) asynchronously so the response
// is immediate. It does not perform Git operations (branch/PR creation).
func JiraWebhookHandler(apiCfg *types.APIConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 1. Validate shared secret (constant-time comparison prevents timing attacks).
expectedSecret := os.Getenv("BAUER_JIRA_WEBHOOK_SECRET")
if expectedSecret != "" {
if !hmac.Equal([]byte(r.Header.Get("X-Webhook-Secret")), []byte(expectedSecret)) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
Comment on lines +26 to +31
}
Comment on lines +26 to +32
}

// 2. Parse payload.
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB limit
var payload jira.WebhookPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
Comment on lines +35 to +40
}

// 3. Extract doc ID from configured custom field.
fieldKey := firstNonEmpty(os.Getenv("BAUER_JIRA_DOC_FIELD"), "customfield_10100")
docID := jira.ExtractDocID(&payload, fieldKey)
if docID == "" {
slog.Warn("Jira webhook received but no doc ID found",
slog.String("issue_key", payload.Issue.Key),
slog.String("field_key", fieldKey),
)
w.WriteHeader(http.StatusNoContent)
return
}

// 4. Fire workflow in background so we respond fast.
go func() {
tmpDir, err := os.MkdirTemp("", "bauer-jira-*")
if err != nil {
slog.Error("failed to create temp dir", slog.String("error", err.Error()))
return
}

cfg := &config.Config{
DocID: docID,
CredentialsPath: firstNonEmpty(os.Getenv("BAUER_CREDENTIALS_PATH"), os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")),
Model: firstNonEmpty(os.Getenv("BAUER_MODEL"), apiCfg.Model, "gpt-5-mini-high"),
ChunkSize: firstNonZero(1),
DryRun: config.BoolPtr(false),
OutputDir: tmpDir,
}
cfg.ApplyDefaults()

sources := source.NewManager(cfg.CredentialsPath)
arts := artifacts.NewManager(firstNonEmpty(os.Getenv("BAUER_ARTIFACTS_DIR"), "./bauer-artifacts"))
agent, err := copilotcli.NewClient(tmpDir)
if err != nil {
slog.Error("failed to create copilot client", slog.String("error", err.Error()))
return
}
orch := orchestrator.New(agent, sources, arts)
if _, err := orch.Execute(context.Background(), cfg); err != nil {
slog.Error("Jira webhook workflow failed",
Comment on lines +55 to +82
slog.String("issue_key", payload.Issue.Key),
slog.String("error", err.Error()),
)
}
}()

w.WriteHeader(http.StatusAccepted)
}
}
2 changes: 1 addition & 1 deletion cmd/bauer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ func runOpenIssue(ctx context.Context, cfg *config.Config, orch orchestrator.Orc
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)
issueURL, _, err := github.CreateIssue(ctx, token, cfg.GitHubRepo, title, body)
if err != nil {
return fmt.Errorf("failed to create GitHub issue: %w", err)
}
Expand Down
14 changes: 12 additions & 2 deletions docs/implementation-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,9 +287,19 @@ _Parent: `feat/phase-3-api-foundation`_

**Tasks:** T4.1, T4.2, T4.3

**Summary:** _(to be filled by agent)_
**Summary:** Added three new API endpoints to the Bauer API server. `POST /api/v1/issues` (T4.1) runs the orchestrator in dry-run mode (extraction + prompt generation only, no Copilot), builds a markdown issue body summarising the plan, and creates a GitHub issue via the REST API, returning the issue URL and number. `GET /api/v1/health/ready` (T4.2) is a K8s-compatible readiness probe registered on the public mux without authentication; it checks for a readable credentials file, a GitHub token, and the `gh` CLI in PATH, returning 503 with a `missing` map if any check fails. `POST /api/v1/webhooks/jira` (T4.3) validates an optional shared secret with a constant-time comparison, extracts a Google Doc ID from a configurable Jira custom field, and fires the full BAU workflow asynchronously, responding immediately with 202 Accepted. Updated `github.CreateIssue` to return the issue number in addition to the URL.

**Files changed:** _(to be filled by agent)_
**Files changed:**

- `internal/github/issue.go` — updated `CreateIssue` signature from `(string, error)` to `(string, int, error)` to also return the issue number parsed from the GitHub API JSON response
- `cmd/bauer/main.go` — updated `runOpenIssue` to use the new three-return-value `github.CreateIssue` (ignoring the number with `_`)
- `cmd/app/v1/helpers.go` — new: `httpError`, `firstNonEmpty`, and `firstNonZero` helpers shared across v1 handlers
- `cmd/app/v1/issues.go` — new: `IssuesHandler` and `formatIssueBody` implementing `POST /api/v1/issues` (T4.1)
- `cmd/app/v1/api.go` — added `ReadinessHandler` for `GET /api/v1/health/ready` (T4.2); added imports for `os`, `os/exec`, and `bauer/internal/github`
- `internal/jira/payload.go` — new package: `WebhookPayload` struct and `ExtractDocID` helper (T4.3)
- `cmd/app/v1/jira.go` — new: `JiraWebhookHandler` implementing `POST /api/v1/webhooks/jira` with constant-time secret validation and async workflow execution (T4.3)
- `cmd/app/main.go` — registered `GET /api/v1/health/ready`, `POST /api/v1/issues`, and `POST /api/v1/webhooks/jira` routes
- `.env` — added `BAUER_JIRA_WEBHOOK_SECRET` and `BAUER_JIRA_DOC_FIELD` env var documentation

---

Expand Down
Loading