feat(api): issues endpoint, readiness probe, Jira webhook (Phase 4 — T4.1–T4.3)#45
Conversation
…ebhook T4.1: POST /api/v1/issues runs orchestrator dry-run and creates GitHub issue T4.1: IssueRequest accepts doc_id, github_repo, chunk_size, page_refresh, model, figma_url T4.1: github.CreateIssue updated to also return issue number (string, int, error) T4.2: GET /api/v1/health/ready checks credentials file, GitHub token, gh CLI T4.2: returns 503 with missing map if anything is not ready T4.3: POST /api/v1/webhooks/jira validates secret, extracts doc_id, fires workflow async T4.3: internal/jira/payload.go WebhookPayload + ExtractDocID T4.3: BAUER_JIRA_WEBHOOK_SECRET and BAUER_JIRA_DOC_FIELD env vars documented
There was a problem hiding this comment.
Pull request overview
Adds Phase 4 API surface to the Bauer app server: an issues-creation endpoint, a Kubernetes readiness probe, and a Jira webhook trigger that kicks off the BAU workflow asynchronously.
Changes:
- Added
POST /api/v1/issuesto run orchestrator in dry-run and open a GitHub issue with the plan. - Added
GET /api/v1/health/readyreadiness probe to verify runtime prerequisites. - Added
POST /api/v1/webhooks/jirato accept Jira issue webhooks, extract doc ID, and run the workflow async.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
cmd/app/v1/issues.go |
New handler for creating a GitHub issue from a dry-run orchestration result. |
cmd/app/v1/api.go |
Adds readiness probe handler and supporting imports. |
cmd/app/v1/jira.go |
New Jira webhook handler with shared-secret validation and async orchestration execution. |
cmd/app/v1/helpers.go |
Adds small HTTP/error and defaulting helpers reused by v1 handlers. |
cmd/app/main.go |
Registers the new routes on the server mux. |
internal/github/issue.go |
Extends CreateIssue to also return the GitHub issue number. |
internal/jira/payload.go |
Adds Jira webhook payload model and doc ID extraction helper. |
cmd/bauer/main.go |
Updates CLI call site for new CreateIssue return signature. |
docs/implementation-log.md |
Documents Phase 4 endpoints and file changes. |
.env |
Documents Jira webhook-related environment variables. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 1. Validate shared secret (constant-time comparison prevents timing attacks). | ||
| expectedSecret := os.Getenv("BAUER_JIRA_WEBHOOK_SECRET") | ||
| if expectedSecret != "" { | ||
| if !hmac.Equal([]byte(r.URL.Query().Get("secret")), []byte(expectedSecret)) { | ||
| http.Error(w, "unauthorized", http.StatusUnauthorized) | ||
| return |
|
|
||
| sources := source.NewManager(cfg.CredentialsPath) | ||
| arts := artifacts.NewManager(firstNonEmpty(os.Getenv("BAUER_ARTIFACTS_DIR"), "./bauer-artifacts")) | ||
| agent, _ := copilotcli.NewClient(os.TempDir()) |
| 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: os.TempDir(), | ||
| } | ||
| cfg.ApplyDefaults() | ||
|
|
||
| sources := source.NewManager(cfg.CredentialsPath) | ||
| arts := artifacts.NewManager(firstNonEmpty(os.Getenv("BAUER_ARTIFACTS_DIR"), "./bauer-artifacts")) | ||
| agent, _ := copilotcli.NewClient(os.TempDir()) |
| // 2. Parse payload. | ||
| var payload jira.WebhookPayload | ||
| if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { | ||
| http.Error(w, "bad request", http.StatusBadRequest) | ||
| 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: os.TempDir(), | ||
| FigmaURL: req.FigmaURL, | ||
| FigmaToken: os.Getenv("BAUER_FIGMA_TOKEN"), |
| parts := strings.SplitN(req.GitHubRepo, "/", 2) | ||
| if len(parts) != 2 { | ||
| httpError(w, http.StatusBadRequest, "github_repo must be in owner/repo format") | ||
| return | ||
| } | ||
| repoFull := parts[0] + "/" + parts[1] | ||
|
|
| } 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)" |
There was a problem hiding this comment.
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.
| func ExtractDocID(payload *WebhookPayload, fieldKey string) string { | ||
| raw, ok := payload.Issue.Fields[fieldKey] | ||
| if !ok { | ||
| return "" | ||
| } | ||
| var docID string | ||
| _ = json.Unmarshal(raw, &docID) | ||
| return docID |
| BAUER_BRANCH_PREFIX=bauer | ||
|
|
||
| # Jira webhook integration | ||
| BAUER_JIRA_WEBHOOK_SECRET= # Shared secret for Jira webhook validation |
| func ReadinessHandler(w http.ResponseWriter, r *http.Request) { | ||
| failures := map[string]string{} | ||
|
|
||
| // Check credentials | ||
| credsPath := firstNonEmpty( | ||
| 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"}) |
| } 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)" |
There was a problem hiding this comment.
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.
| ) | ||
| if credsPath == "" { | ||
| httpError(w, http.StatusInternalServerError, "credentials not configured (set BAUER_CREDENTIALS_PATH)") |
| Model: firstNonEmpty(req.Model, apiCfg.Model, "gpt-5-mini-high"), | ||
| ChunkSize: firstNonZero(req.ChunkSize, 1), | ||
| DryRun: config.BoolPtr(true), |
| // 1. Validate shared secret (constant-time comparison prevents timing attacks). | ||
| expectedSecret := os.Getenv("BAUER_JIRA_WEBHOOK_SECRET") | ||
| if expectedSecret != "" { | ||
| if !hmac.Equal([]byte(r.URL.Query().Get("secret")), []byte(expectedSecret)) { | ||
| http.Error(w, "unauthorized", http.StatusUnauthorized) | ||
| return | ||
| } |
| // 4. Fire workflow in background so we respond fast. | ||
| go func() { | ||
| 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: os.TempDir(), | ||
| } | ||
| cfg.ApplyDefaults() | ||
|
|
||
| sources := source.NewManager(cfg.CredentialsPath) | ||
| arts := artifacts.NewManager(firstNonEmpty(os.Getenv("BAUER_ARTIFACTS_DIR"), "./bauer-artifacts")) | ||
| agent, _ := copilotcli.NewClient(os.TempDir()) | ||
| orch := orchestrator.New(agent, sources, arts) | ||
| if _, err := orch.Execute(context.Background(), cfg); err != nil { | ||
| slog.Error("Jira webhook workflow failed", |
| raw, ok := payload.Issue.Fields[fieldKey] | ||
| if !ok { | ||
| return "" | ||
| } | ||
| var docID string | ||
| _ = json.Unmarshal(raw, &docID) | ||
| return docID |
| // 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 | ||
| _ = json.Unmarshal(raw, &docID) | ||
| return docID |
There was a problem hiding this comment.
Agreed this would be valuable, but unit test coverage expansion is out of scope for this PR. Tracked as a follow-up.
| var req IssueRequest | ||
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||
| httpError(w, http.StatusBadRequest, "invalid request body") | ||
| return |
| DocID: req.DocID, | ||
| CredentialsPath: credsPath, | ||
| Model: firstNonEmpty(req.Model, apiCfg.Model, "gpt-5-mini-high"), | ||
| ChunkSize: firstNonZero(req.ChunkSize, 1), |
| if err != nil { | ||
| httpError(w, http.StatusInternalServerError, "failed to create temp directory") | ||
| return | ||
| } |
| cfg.ApplyDefaults() | ||
|
|
||
| sources := source.NewManager(cfg.CredentialsPath) | ||
| arts := artifacts.NewManager(firstNonEmpty(os.Getenv("BAUER_ARTIFACTS_DIR"), "./bauer-artifacts")) |
| 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")), |
| cfg.ApplyDefaults() | ||
|
|
||
| sources := source.NewManager(cfg.CredentialsPath) | ||
| arts := artifacts.NewManager(firstNonEmpty(os.Getenv("BAUER_ARTIFACTS_DIR"), "./bauer-artifacts")) |
| // Check GitHub token | ||
| if _, err := github.GetGitHubToken(); err != nil { | ||
| failures["github_token"] = "not configured (set BAUER_GITHUB_TOKEN or run 'gh auth login')" | ||
| } |
|
|
||
| # Jira webhook integration | ||
| BAUER_JIRA_WEBHOOK_SECRET= | ||
| BAUER_JIRA_DOC_FIELD=customfield_10100 # Jira custom field key that holds the Google Doc ID |
| } | ||
|
|
||
| if req.ChunkSize < 0 { | ||
| httpError(w, http.StatusBadRequest, "chunk_size must be a positive integer") |
Summary
Adds three new API endpoints completing Phase 4.
Tasks Implemented
POST /api/v1/issues: Runs orchestrator in dry-run mode, formats result as markdown issue body, creates GitHub issue via REST API. Returns{ issue_url, issue_number }.GET /api/v1/health/ready: K8s readiness probe (public mux, no auth). Checks credentials file readable + GH token set +ghin PATH. Returns 503 with failure map if any check fails.POST /api/v1/webhooks/jira: Validates shared secret with constant-time comparison, parses Jira payload, extracts doc ID from configurable custom field, fires workflow async, responds 202.Security
crypto/hmac.Equal(constant-time)Files Changed
cmd/app/v1/issues.go—IssuesHandler,formatIssueBodycmd/app/v1/api.go—ReadinessHandlercmd/app/v1/jira.go—JiraWebhookHandlercmd/app/v1/helpers.go— shared helpersinternal/github/issue.go—CreateIssuereturns(string, int, error)internal/jira/payload.go—WebhookPayload,ExtractDocIDcmd/app/main.go— route registrationPart of the Bauer v2 stacked PR series (Branch 10 of 12).