Skip to content

feat(api): issues endpoint, readiness probe, Jira webhook (Phase 4 — T4.1–T4.3)#45

Open
canonical-muhammadbassiony wants to merge 3 commits into
feat/phase-3-api-foundationfrom
feat/phase-4-api-endpoints
Open

feat(api): issues endpoint, readiness probe, Jira webhook (Phase 4 — T4.1–T4.3)#45
canonical-muhammadbassiony wants to merge 3 commits into
feat/phase-3-api-foundationfrom
feat/phase-4-api-endpoints

Conversation

@canonical-muhammadbassiony

Copy link
Copy Markdown
Collaborator

Summary

Adds three new API endpoints completing Phase 4.

Tasks Implemented

  • T4.1 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 }.
  • T4.2 GET /api/v1/health/ready: K8s readiness probe (public mux, no auth). Checks credentials file readable + GH token set + gh in PATH. Returns 503 with failure map if any check fails.
  • T4.3 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

  • Jira webhook secret validated with crypto/hmac.Equal (constant-time)
  • Readiness probe on public mux without auth (K8s probes cannot send tokens)
  • No secrets in request bodies

Files Changed

  • cmd/app/v1/issues.goIssuesHandler, formatIssueBody
  • cmd/app/v1/api.goReadinessHandler
  • cmd/app/v1/jira.goJiraWebhookHandler
  • cmd/app/v1/helpers.go — shared helpers
  • internal/github/issue.goCreateIssue returns (string, int, error)
  • internal/jira/payload.goWebhookPayload, ExtractDocID
  • cmd/app/main.go — route registration

Part of the Bauer v2 stacked PR series (Branch 10 of 12).

…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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/issues to run orchestrator in dry-run and open a GitHub issue with the plan.
  • Added GET /api/v1/health/ready readiness probe to verify runtime prerequisites.
  • Added POST /api/v1/webhooks/jira to 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.

Comment thread cmd/app/v1/jira.go
Comment on lines +25 to +30
// 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
Comment thread cmd/app/v1/jira.go Outdated

sources := source.NewManager(cfg.CredentialsPath)
arts := artifacts.NewManager(firstNonEmpty(os.Getenv("BAUER_ARTIFACTS_DIR"), "./bauer-artifacts"))
agent, _ := copilotcli.NewClient(os.TempDir())
Comment thread cmd/app/v1/jira.go Outdated
Comment on lines +55 to +67
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())
Comment thread cmd/app/v1/jira.go
Comment on lines +34 to +38
// 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
Comment thread cmd/app/v1/issues.go
Comment on lines +58 to +66
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"),
Comment thread cmd/app/v1/issues.go Outdated
Comment on lines +88 to +94
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]

Comment thread cmd/app/v1/api.go
Comment on lines +112 to +114
} 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)"

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 thread internal/jira/payload.go
Comment on lines +18 to +25
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
Comment thread .env Outdated
BAUER_BRANCH_PREFIX=bauer

# Jira webhook integration
BAUER_JIRA_WEBHOOK_SECRET= # Shared secret for Jira webhook validation

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 8 comments.

Comment thread cmd/app/v1/api.go Outdated
Comment on lines +102 to +136
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"})
Comment thread cmd/app/v1/api.go
Comment on lines +112 to +114
} 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)"

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.

Comment thread cmd/app/v1/issues.go
Comment on lines +52 to +54
)
if credsPath == "" {
httpError(w, http.StatusInternalServerError, "credentials not configured (set BAUER_CREDENTIALS_PATH)")
Comment thread cmd/app/v1/issues.go
Comment on lines +61 to +63
Model: firstNonEmpty(req.Model, apiCfg.Model, "gpt-5-mini-high"),
ChunkSize: firstNonZero(req.ChunkSize, 1),
DryRun: config.BoolPtr(true),
Comment thread cmd/app/v1/jira.go
Comment on lines +25 to +31
// 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
}
Comment thread cmd/app/v1/jira.go
Comment on lines +53 to +70
// 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",
Comment thread internal/jira/payload.go
Comment on lines +19 to +25
raw, ok := payload.Issue.Fields[fieldKey]
if !ok {
return ""
}
var docID string
_ = json.Unmarshal(raw, &docID)
return docID
Comment thread internal/jira/payload.go
Comment on lines +5 to +25
// 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

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.

Agreed this would be valuable, but unit test coverage expansion is out of scope for this PR. Tracked as a follow-up.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 10 comments.

Comment thread cmd/app/v1/issues.go
Comment on lines +33 to +36
var req IssueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpError(w, http.StatusBadRequest, "invalid request body")
return
Comment thread cmd/app/v1/issues.go
DocID: req.DocID,
CredentialsPath: credsPath,
Model: firstNonEmpty(req.Model, apiCfg.Model, "gpt-5-mini-high"),
ChunkSize: firstNonZero(req.ChunkSize, 1),
Comment thread cmd/app/v1/issues.go
if err != nil {
httpError(w, http.StatusInternalServerError, "failed to create temp directory")
return
}
Comment thread cmd/app/v1/issues.go
cfg.ApplyDefaults()

sources := source.NewManager(cfg.CredentialsPath)
arts := artifacts.NewManager(firstNonEmpty(os.Getenv("BAUER_ARTIFACTS_DIR"), "./bauer-artifacts"))
Comment thread cmd/app/v1/jira.go
if err != nil {
slog.Error("failed to create temp dir", slog.String("error", err.Error()))
return
}
Comment thread cmd/app/v1/jira.go

cfg := &config.Config{
DocID: docID,
CredentialsPath: firstNonEmpty(os.Getenv("BAUER_CREDENTIALS_PATH"), os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")),
Comment thread cmd/app/v1/jira.go
cfg.ApplyDefaults()

sources := source.NewManager(cfg.CredentialsPath)
arts := artifacts.NewManager(firstNonEmpty(os.Getenv("BAUER_ARTIFACTS_DIR"), "./bauer-artifacts"))
Comment thread cmd/app/v1/api.go
Comment on lines +119 to +122
// Check GitHub token
if _, err := github.GetGitHubToken(); err != nil {
failures["github_token"] = "not configured (set BAUER_GITHUB_TOKEN or run 'gh auth login')"
}
Comment thread .env

# Jira webhook integration
BAUER_JIRA_WEBHOOK_SECRET=
BAUER_JIRA_DOC_FIELD=customfield_10100 # Jira custom field key that holds the Google Doc ID
Comment thread cmd/app/v1/issues.go
}

if req.ChunkSize < 0 {
httpError(w, http.StatusBadRequest, "chunk_size must be a positive integer")
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