diff --git a/.env b/.env index 54efc52..ffa0151 100644 --- a/.env +++ b/.env @@ -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 diff --git a/cmd/app/main.go b/cmd/app/main.go index 516c082..23be96f 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -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)) diff --git a/cmd/app/v1/api.go b/cmd/app/v1/api.go index 6f1f725..9662b6d 100644 --- a/cmd/app/v1/api.go +++ b/cmd/app/v1/api.go @@ -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) { @@ -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)" + } + + // Check GitHub token + if _, err := github.GetGitHubToken(); err != nil { + failures["github_token"] = "not configured (set BAUER_GITHUB_TOKEN or run 'gh auth login')" + } + + // 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"}) + } } \ No newline at end of file diff --git a/cmd/app/v1/helpers.go b/cmd/app/v1/helpers.go new file mode 100644 index 0000000..fc86a4e --- /dev/null +++ b/cmd/app/v1/helpers.go @@ -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 +} diff --git a/cmd/app/v1/issues.go b/cmd/app/v1/issues.go new file mode 100644 index 0000000..ef5e4c6 --- /dev/null +++ b/cmd/app/v1/issues.go @@ -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 + } + 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)") + 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), + OutputDir: tmpDir, + FigmaURL: req.FigmaURL, + FigmaToken: os.Getenv("BAUER_FIGMA_TOKEN"), + } + 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() +} diff --git a/cmd/app/v1/jira.go b/cmd/app/v1/jira.go new file mode 100644 index 0000000..3f6c79e --- /dev/null +++ b/cmd/app/v1/jira.go @@ -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 + } + } + + // 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 + } + + // 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", + slog.String("issue_key", payload.Issue.Key), + slog.String("error", err.Error()), + ) + } + }() + + w.WriteHeader(http.StatusAccepted) + } +} diff --git a/cmd/bauer/main.go b/cmd/bauer/main.go index 58fac22..5c94913 100644 --- a/cmd/bauer/main.go +++ b/cmd/bauer/main.go @@ -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) } diff --git a/docs/implementation-log.md b/docs/implementation-log.md index d6fb525..1df1631 100644 --- a/docs/implementation-log.md +++ b/docs/implementation-log.md @@ -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 --- diff --git a/internal/github/issue.go b/internal/github/issue.go index bdad80b..02650e0 100644 --- a/internal/github/issue.go +++ b/internal/github/issue.go @@ -9,18 +9,18 @@ import ( "net/http" ) -// CreateIssue creates a GitHub issue via the REST API and returns the HTML URL. +// CreateIssue creates a GitHub issue via the REST API and returns the HTML URL and issue number. // repo must be in "owner/name" format. // token must be a valid GitHub personal access token or fine-grained token. -func CreateIssue(ctx context.Context, token, repo, title, body string) (string, error) { +func CreateIssue(ctx context.Context, token, repo, title, body string) (string, int, error) { if token == "" { - return "", fmt.Errorf("GitHub token is required") + return "", 0, fmt.Errorf("GitHub token is required") } if repo == "" { - return "", fmt.Errorf("repo is required (owner/name format)") + return "", 0, fmt.Errorf("repo is required (owner/name format)") } if title == "" { - return "", fmt.Errorf("issue title is required") + return "", 0, fmt.Errorf("issue title is required") } type issueRequest struct { @@ -30,13 +30,13 @@ func CreateIssue(ctx context.Context, token, repo, title, body string) (string, payload, err := json.Marshal(issueRequest{Title: title, Body: body}) if err != nil { - return "", fmt.Errorf("failed to marshal issue request: %w", err) + return "", 0, fmt.Errorf("failed to marshal issue request: %w", err) } url := fmt.Sprintf("https://api.github.com/repos/%s/issues", repo) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { - return "", fmt.Errorf("failed to create HTTP request: %w", err) + return "", 0, fmt.Errorf("failed to create HTTP request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) @@ -46,25 +46,26 @@ func CreateIssue(ctx context.Context, token, repo, title, body string) (string, resp, err := http.DefaultClient.Do(req) if err != nil { - return "", fmt.Errorf("failed to send request to GitHub API: %w", err) + return "", 0, fmt.Errorf("failed to send request to GitHub API: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { - return "", fmt.Errorf("failed to read GitHub API response: %w", err) + return "", 0, fmt.Errorf("failed to read GitHub API response: %w", err) } if resp.StatusCode != http.StatusCreated { - return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, respBody) + return "", 0, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, respBody) } var result struct { HTMLURL string `json:"html_url"` + Number int `json:"number"` } if err := json.Unmarshal(respBody, &result); err != nil { - return "", fmt.Errorf("failed to parse GitHub API response: %w", err) + return "", 0, fmt.Errorf("failed to parse GitHub API response: %w", err) } - return result.HTMLURL, nil + return result.HTMLURL, result.Number, nil } diff --git a/internal/jira/payload.go b/internal/jira/payload.go new file mode 100644 index 0000000..8b91396 --- /dev/null +++ b/internal/jira/payload.go @@ -0,0 +1,28 @@ +package jira + +import "encoding/json" + +// WebhookPayload is the Jira issue webhook payload shape. +type WebhookPayload struct { + Timestamp int64 `json:"timestamp"` + WebhookEvent string `json:"webhookEvent"` + Issue struct { + ID string `json:"id"` + Key string `json:"key"` + Fields map[string]json.RawMessage `json:"fields"` + } `json:"issue"` +} + +// ExtractDocID reads the Google Doc ID from the payload using the configured field key. +// fieldKey is typically something like "customfield_10100" — the exact key depends on the Jira config. +func ExtractDocID(payload *WebhookPayload, fieldKey string) string { + raw, ok := payload.Issue.Fields[fieldKey] + if !ok { + return "" + } + var docID string + if err := json.Unmarshal(raw, &docID); err != nil { + return "" + } + return docID +}