mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* feat(server): funnel/community/commercial business metrics + PostHog pairing (MUL-2949) PR3 of the Grafana board metrics split (parent MUL-2328). Adds 23 new Prometheus counter/histogram families to the PR2 BusinessMetrics collector covering the activation/community/commercial funnels, and binds every PostHog event emission to a matching metric increment so the two sides cannot drift. Funnel: signup, workspace_created, team_invite_sent/accepted, onboarding_*, cloud_waitlist_joined. Content: issue_created, chat_message_sent, agent_created, squad_created, autopilot_created, issue_executed. Runtime: runtime_registered/ready/failed/offline + ready_seconds histogram, daemon_ws_message_received_total. Autopilot: autopilot_run_started/terminal/skipped. Webhook/GitHub: webhook_delivery_total, github_event_received_total, github_pr_review_total, github_pr_merge_seconds histogram. CloudRuntime: cloudruntime_request_total + duration histogram, wired through a small RequestRecorder interface so the cloudruntime package stays decoupled from metrics. Commercial: feedback_submitted, contact_sales_submitted. The pairing helper metrics.RecordEvent(client, m, ev) emits the PostHog event AND increments the matching counter via IncForEvent dispatch, reading labels from the analytics event Properties. Every existing h.Analytics.Capture(analytics.X(...)) call site has been migrated to the helper across handler/, service/, and cmd/server/runtime_sweeper.go. Lint enforcement (server/internal/metrics/business_pairing_test.go): - TestEveryAnalyticsEventHasPrometheusCounter: every Event* constant in analytics/events.go either dispatches via IncForEvent or is in the taskMetricEvents allow-list (PR2 typed RecordTask* methods). - TestNoNakedAnalyticsCaptureInHandlersOrServices: AST-walks handler/ service/cmd-server for direct Analytics.Capture(...) calls — only service/task.go's captureTaskEvent helper is allow-listed. - TestEveryAnalyticsRecordEventTakesAnalyticsHelper: validates the third arg of every metrics.RecordEvent call is built from analytics.*. Cardinality protection: all new label values pass through fixed allow-lists in labels_pr3.go; unknown values collapse to 'other'/'unknown'/'error'. Refs: - Spec MUL-2328 / MUL-2949. - Builds on PR2 (MUL-2948) — collectors registered through the same BusinessMetrics struct, no separate Registry. - Uses PR1's taskfailure.Reason (MUL-2946) for runtime_failed's failure_reason label via NormalizeFailureReason. Out of scope: Sampler-class metrics (PR4 / MUL-2947), pr_review_total emission point (no review event handler exists yet — counter is defined, TODO to wire up when /api/webhooks/github grows pull_request_review handling). Co-authored-by: multica-agent <github@multica.ai> * fix(server): tighten PR3 review items — signup_source bucket, fill platform/kind/form_source enums, onboarding_started server emission, lint scope (MUL-2949) Addresses 张大彪's review on #3698: 1. signup_source: NormalizeSignupSource added to labels_pr3.go with a fixed allow-list bucket (direct/google/twitter/linkedin/.../other). Parses JSON cookie payload for utm_source/source/referrer fields, strips URL schemes, maps well-known hostnames to channel buckets. PostHog event still ships the raw cookie value for analytics; only the Prometheus label is bucketed. 2. Filled the unknown/other label gaps: - analytics.IssueCreated and analytics.ChatMessageSent now take a platform parameter sourced from middleware.ClientMetadataFromContext (X-Client-Platform header) at the handler. Autopilot-originated issues stamp PlatformServer. - analytics.FeedbackSubmitted now takes a kind parameter; CreateFeedback reads req.Kind (default "general") so the picker selection lights up the metric's kind label instead of long-term "other". - analytics.ContactSalesSubmitted now takes a formSource (page / onboarding / agents_page); CreateContactSales reads req.Source. The metric reads ev.Properties["form_source"] so the analytics CoreProperties.Source ("marketing_contact_sales") stays backward-compat for PostHog dashboards. 3. analytics.OnboardingStarted helper added; server-side emission lives in PatchOnboarding, fired exactly once per user on the first PATCH that carries a non-empty questionnaire payload (firstTouch logic compares prior bytes against {} / null). Frontend onboarding_started keeps firing on page open; the server emission is what guarantees the Prometheus counter exists so Grafana can be cross-checked against the PostHog funnel without depending on the SDK roundtrip. 4. business_pairing_test.go tightened: - TestNoNakedAnalyticsCaptureInHandlersOrServices now allow-lists at function granularity (just captureTaskEvent in service/task.go), not whole-file. Any future naked Capture in the same file fails CI. - TestEveryAnalyticsRecordEventTakesAnalyticsHelper now does def-use tracking inside the enclosing FuncDecl: when RecordEvent's third arg is an *ast.Ident, the test walks the function body for the assignment that defined it and confirms the RHS is an analytics.<Helper>(...) call. Bare local idents that didn't originate from analytics are now caught. 5. gofmt -w applied across the touched files; gofmt -l clean. Tests: go test ./internal/metrics/... ./internal/analytics/... pass. Pre-existing TestClaimTask_/TestWebhook_MergedPR/TestDeleteIssueByIdentifier failures on origin/main are DB-environment-dependent and not regressions from this change. Co-authored-by: multica-agent <github@multica.ai> * fix(server): normalise onboarding_started platform label + regression test (MUL-2949) Addresses 张大彪's last review nit: - IncForEvent's EventOnboardingStarted case now wraps the platform property with NormalizePlatform, matching every other platform-bearing metric. A misbehaving frontend can no longer leak a raw X-Client-Platform header value into the multica_onboarding_started_total{platform=...} series. - New labels_pr3_test.go covers every PR3 normalizer with both a happy-path value and an unknown value, asserting the unknown collapses to the documented fallback bucket. Includes a focused regression for onboarding_started: emits one event with an attacker-shaped platform string and asserts the metric only exposes web + unknown label values (no raw header bleed). - testutil.go gains a small GatherForTest helper so the regression test can pull the typed MetricFamily map without re-implementing the registry-walk dance. Co-authored-by: multica-agent <github@multica.ai> * fix(server): NormalizeTaskSource on workspace_created + document lint limitations (MUL-2949) Final review touch-ups before merge: - IncForEvent's EventWorkspaceCreated case wraps source through NormalizeTaskSource, matching the other source-bearing dispatches (issue_created, agent_created, issue_executed). Closes the last raw property leak in the dispatcher table. - business_pairing_test.go inline docstrings now spell out the two known limitations of the lint gate that 张大彪 / Eve flagged: analyticsBackedIdents matches by ident NAME (not SSA def-use, so a nested-scope shadow could pass) and isMetricsRecordEvent hard-codes the import alias set. PR description carries a Follow-ups section with the same two items so the work is visible after merge. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: 魏和尚 <agent+wei@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
1214 lines
45 KiB
Go
1214 lines
45 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/middleware"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// ── Response shapes ─────────────────────────────────────────────────────────
|
|
|
|
// GitHubInstallationResponse is the JSON shape returned by the installation
|
|
// list endpoint and broadcast on installation-related WS events.
|
|
//
|
|
// InstallationID is admin-only: the numeric GitHub installation_id is the
|
|
// management handle used by the Connect/Disconnect flows, so non-admin
|
|
// members receive responses with the field omitted. The list handler gates
|
|
// it by role; realtime broadcasts always omit it because the WS fanout has
|
|
// no per-recipient view (admins re-query the list endpoint on invalidation
|
|
// to recover the management handle).
|
|
type GitHubInstallationResponse struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
InstallationID *int64 `json:"installation_id,omitempty"`
|
|
AccountLogin string `json:"account_login"`
|
|
AccountType string `json:"account_type"`
|
|
AccountAvatarURL *string `json:"account_avatar_url"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
type GitHubPullRequestResponse struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
RepoOwner string `json:"repo_owner"`
|
|
RepoName string `json:"repo_name"`
|
|
Number int32 `json:"number"`
|
|
Title string `json:"title"`
|
|
State string `json:"state"`
|
|
HtmlURL string `json:"html_url"`
|
|
Branch *string `json:"branch"`
|
|
AuthorLogin *string `json:"author_login"`
|
|
AuthorAvatarURL *string `json:"author_avatar_url"`
|
|
MergedAt *string `json:"merged_at"`
|
|
ClosedAt *string `json:"closed_at"`
|
|
PRCreatedAt string `json:"pr_created_at"`
|
|
PRUpdatedAt string `json:"pr_updated_at"`
|
|
// Mergeable state mirrors GitHub's `mergeable_state` field. We only
|
|
// surface `clean`/`dirty` in the UI today; other values (`blocked`,
|
|
// `behind`, `unstable`, `unknown`) round-trip but render as unknown.
|
|
MergeableState *string `json:"mergeable_state"`
|
|
// ChecksConclusion is the aggregated state of the latest CI check
|
|
// suites for the PR's current head SHA. One of "passed", "failed",
|
|
// "pending", or nil when no completed suite has been observed.
|
|
ChecksConclusion *string `json:"checks_conclusion"`
|
|
// Per-suite counts that drive the card's segmented progress bar.
|
|
// Always present on list rows; bare upsert broadcasts default to 0
|
|
// and the frontend hides the bar when total == 0.
|
|
ChecksPassed int64 `json:"checks_passed"`
|
|
ChecksFailed int64 `json:"checks_failed"`
|
|
ChecksPending int64 `json:"checks_pending"`
|
|
// Diff stats (lines added/removed and file count) sourced from the
|
|
// `pull_request` webhook payload. Legacy rows that pre-date this
|
|
// field default to 0; the frontend treats total == 0 as "unknown"
|
|
// and hides the stats row.
|
|
Additions int32 `json:"additions"`
|
|
Deletions int32 `json:"deletions"`
|
|
ChangedFiles int32 `json:"changed_files"`
|
|
}
|
|
|
|
type GitHubConnectResponse struct {
|
|
URL string `json:"url"`
|
|
Configured bool `json:"configured"`
|
|
}
|
|
|
|
func githubInstallationToResponse(i db.GithubInstallation) GitHubInstallationResponse {
|
|
instID := i.InstallationID
|
|
return GitHubInstallationResponse{
|
|
ID: uuidToString(i.ID),
|
|
WorkspaceID: uuidToString(i.WorkspaceID),
|
|
InstallationID: &instID,
|
|
AccountLogin: i.AccountLogin,
|
|
AccountType: i.AccountType,
|
|
AccountAvatarURL: textToPtr(i.AccountAvatarUrl),
|
|
CreatedAt: timestampToString(i.CreatedAt),
|
|
}
|
|
}
|
|
|
|
// githubInstallationToBroadcast returns the same shape as the list endpoint's
|
|
// per-role response with the numeric `installation_id` stripped. Realtime
|
|
// events fan out to every WS client subscribed to the workspace, so the
|
|
// payload must match the weakest-role view — admin/owner clients re-query
|
|
// the list endpoint to recover the management handle. The frontend uses
|
|
// these events only to invalidate the installations query, so it does not
|
|
// read `installation_id` off the broadcast.
|
|
func githubInstallationToBroadcast(i db.GithubInstallation) GitHubInstallationResponse {
|
|
resp := githubInstallationToResponse(i)
|
|
resp.InstallationID = nil
|
|
return resp
|
|
}
|
|
|
|
func githubPullRequestToResponse(p db.GithubPullRequest) GitHubPullRequestResponse {
|
|
return GitHubPullRequestResponse{
|
|
ID: uuidToString(p.ID),
|
|
WorkspaceID: uuidToString(p.WorkspaceID),
|
|
RepoOwner: p.RepoOwner,
|
|
RepoName: p.RepoName,
|
|
Number: p.PrNumber,
|
|
Title: p.Title,
|
|
State: p.State,
|
|
HtmlURL: p.HtmlUrl,
|
|
Branch: textToPtr(p.Branch),
|
|
AuthorLogin: textToPtr(p.AuthorLogin),
|
|
AuthorAvatarURL: textToPtr(p.AuthorAvatarUrl),
|
|
MergedAt: timestampToPtr(p.MergedAt),
|
|
ClosedAt: timestampToPtr(p.ClosedAt),
|
|
PRCreatedAt: timestampToString(p.PrCreatedAt),
|
|
PRUpdatedAt: timestampToString(p.PrUpdatedAt),
|
|
MergeableState: textToPtr(p.MergeableState),
|
|
// A bare PR row has no aggregated check counts — webhook
|
|
// broadcasts of a single PR fall through here and the frontend
|
|
// re-queries the list for fresh counts.
|
|
ChecksConclusion: nil,
|
|
Additions: p.Additions,
|
|
Deletions: p.Deletions,
|
|
ChangedFiles: p.ChangedFiles,
|
|
}
|
|
}
|
|
|
|
func issuePullRequestRowToResponse(p db.ListPullRequestsByIssueRow) GitHubPullRequestResponse {
|
|
return GitHubPullRequestResponse{
|
|
ID: uuidToString(p.ID),
|
|
WorkspaceID: uuidToString(p.WorkspaceID),
|
|
RepoOwner: p.RepoOwner,
|
|
RepoName: p.RepoName,
|
|
Number: p.PrNumber,
|
|
Title: p.Title,
|
|
State: p.State,
|
|
HtmlURL: p.HtmlUrl,
|
|
Branch: textToPtr(p.Branch),
|
|
AuthorLogin: textToPtr(p.AuthorLogin),
|
|
AuthorAvatarURL: textToPtr(p.AuthorAvatarUrl),
|
|
MergedAt: timestampToPtr(p.MergedAt),
|
|
ClosedAt: timestampToPtr(p.ClosedAt),
|
|
PRCreatedAt: timestampToString(p.PrCreatedAt),
|
|
PRUpdatedAt: timestampToString(p.PrUpdatedAt),
|
|
MergeableState: textToPtr(p.MergeableState),
|
|
ChecksConclusion: aggregateChecksConclusion(p.ChecksFailed, p.ChecksPassed, p.ChecksPending, p.ChecksTotal),
|
|
ChecksPassed: p.ChecksPassed,
|
|
ChecksFailed: p.ChecksFailed,
|
|
ChecksPending: p.ChecksPending,
|
|
Additions: p.Additions,
|
|
Deletions: p.Deletions,
|
|
ChangedFiles: p.ChangedFiles,
|
|
}
|
|
}
|
|
|
|
// aggregateChecksConclusion collapses the per-PR check_suite counts into a
|
|
// single status surfaced to the UI:
|
|
// - any failed-class suite wins ("failed");
|
|
// - any not-yet-completed suite makes the PR "pending";
|
|
// - all completed and in the passed-class is "passed";
|
|
// - no observed suite at all is nil (rendered as "no checks" / hidden).
|
|
func aggregateChecksConclusion(failed, passed, pending, total int64) *string {
|
|
if total == 0 {
|
|
return nil
|
|
}
|
|
var v string
|
|
switch {
|
|
case failed > 0:
|
|
v = "failed"
|
|
case pending > 0:
|
|
v = "pending"
|
|
case passed > 0:
|
|
v = "passed"
|
|
default:
|
|
return nil
|
|
}
|
|
return &v
|
|
}
|
|
|
|
// ── Connect / state token ───────────────────────────────────────────────────
|
|
|
|
// githubAppSlug returns the GitHub App slug used to build the install URL.
|
|
// Empty when the integration is not configured for this deployment.
|
|
func githubAppSlug() string { return strings.TrimSpace(os.Getenv("GITHUB_APP_SLUG")) }
|
|
|
|
// githubWebhookSecret is shared by webhook verification and state-token signing.
|
|
// We reuse the webhook secret as the state HMAC key so operators only need to
|
|
// configure one value.
|
|
func githubWebhookSecret() string { return strings.TrimSpace(os.Getenv("GITHUB_WEBHOOK_SECRET")) }
|
|
|
|
// isGitHubConfigured returns true only when BOTH the install slug and the
|
|
// webhook secret are set. The Connect button uses this single flag, so the
|
|
// frontend never offers a flow that the backend would reject.
|
|
func isGitHubConfigured() bool { return githubAppSlug() != "" && githubWebhookSecret() != "" }
|
|
|
|
// signState produces an opaque token that binds a workspace ID to the
|
|
// install flow so the setup callback can recover the workspace without
|
|
// trusting query params alone. Format: "<workspaceID>.<nonce>.<sigHex>".
|
|
func signState(workspaceID string) (string, error) {
|
|
secret := githubWebhookSecret()
|
|
if secret == "" {
|
|
return "", errors.New("github integration is not configured")
|
|
}
|
|
nonceBytes := make([]byte, 12)
|
|
if _, err := rand.Read(nonceBytes); err != nil {
|
|
return "", err
|
|
}
|
|
nonce := hex.EncodeToString(nonceBytes)
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write([]byte(workspaceID))
|
|
mac.Write([]byte("."))
|
|
mac.Write([]byte(nonce))
|
|
sig := hex.EncodeToString(mac.Sum(nil))
|
|
return workspaceID + "." + nonce + "." + sig, nil
|
|
}
|
|
|
|
func verifyState(token string) (string, bool) {
|
|
secret := githubWebhookSecret()
|
|
if secret == "" {
|
|
return "", false
|
|
}
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
return "", false
|
|
}
|
|
workspaceID, nonce, sig := parts[0], parts[1], parts[2]
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write([]byte(workspaceID))
|
|
mac.Write([]byte("."))
|
|
mac.Write([]byte(nonce))
|
|
expected := hex.EncodeToString(mac.Sum(nil))
|
|
if !hmac.Equal([]byte(expected), []byte(sig)) {
|
|
return "", false
|
|
}
|
|
return workspaceID, true
|
|
}
|
|
|
|
// GitHubConnect (GET /api/workspaces/{id}/github/connect) returns the URL the
|
|
// browser should open to install the Multica GitHub App against the caller's
|
|
// repos. The state token binds the resulting setup callback to this workspace.
|
|
func (h *Handler) GitHubConnect(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := chi.URLParam(r, "id")
|
|
if _, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id"); !ok {
|
|
return
|
|
}
|
|
if !isGitHubConfigured() {
|
|
writeJSON(w, http.StatusOK, GitHubConnectResponse{Configured: false})
|
|
return
|
|
}
|
|
slug := githubAppSlug()
|
|
state, err := signState(workspaceID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to sign state")
|
|
return
|
|
}
|
|
installURL := fmt.Sprintf(
|
|
"https://github.com/apps/%s/installations/new?state=%s",
|
|
url.PathEscape(slug),
|
|
url.QueryEscape(state),
|
|
)
|
|
writeJSON(w, http.StatusOK, GitHubConnectResponse{URL: installURL, Configured: true})
|
|
}
|
|
|
|
// GitHubSetupCallback (GET /api/github/setup) handles the redirect GitHub
|
|
// sends after a user installs (or re-authorizes) the App. We expect
|
|
// ?installation_id=<id>&state=<signed token>. We persist the installation
|
|
// row (workspace ↔ installation_id mapping), then bounce the user back to
|
|
// the new Settings → GitHub tab in the web app (RFC MUL-2414 §4.1). The
|
|
// previous destination was the catch-all Settings page, which after the
|
|
// GitHub-tab split would land users on the default profile tab instead of
|
|
// the place that shows the connection they just completed.
|
|
func (h *Handler) GitHubSetupCallback(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
installationIDStr := q.Get("installation_id")
|
|
state := q.Get("state")
|
|
frontend := strings.TrimSpace(os.Getenv("FRONTEND_ORIGIN"))
|
|
if frontend == "" {
|
|
frontend = "http://localhost:3000"
|
|
}
|
|
settingsURL := strings.TrimRight(frontend, "/") + "/settings?tab=github"
|
|
|
|
if installationIDStr == "" || state == "" {
|
|
http.Redirect(w, r, settingsURL+"&github_error=missing_params", http.StatusFound)
|
|
return
|
|
}
|
|
workspaceID, ok := verifyState(state)
|
|
if !ok {
|
|
http.Redirect(w, r, settingsURL+"&github_error=invalid_state", http.StatusFound)
|
|
return
|
|
}
|
|
installationID, err := strconv.ParseInt(installationIDStr, 10, 64)
|
|
if err != nil {
|
|
http.Redirect(w, r, settingsURL+"&github_error=bad_installation_id", http.StatusFound)
|
|
return
|
|
}
|
|
wsUUID, err := parseStrictUUID(workspaceID)
|
|
if err != nil {
|
|
http.Redirect(w, r, settingsURL+"&github_error=bad_workspace", http.StatusFound)
|
|
return
|
|
}
|
|
// Resolve the installation against GitHub's API to capture display info.
|
|
// If the App auth is not configured we still create the row with the
|
|
// minimum we know; webhook events will refresh it as soon as one fires.
|
|
login, accountType, avatar := fetchInstallationAccount(r.Context(), installationID)
|
|
|
|
// Best-effort capture of the connecting user (may be nil if the public
|
|
// callback was hit without a session — e.g. user wasn't logged in to
|
|
// Multica when they finished the GitHub install). Either way we save
|
|
// the row so the workspace owner sees the connection on next reload.
|
|
connectedBy := pgtype.UUID{}
|
|
if userID := requestUserID(r); userID != "" {
|
|
if u, err := parseStrictUUID(userID); err == nil {
|
|
connectedBy = u
|
|
}
|
|
}
|
|
|
|
inst, err := h.Queries.CreateGitHubInstallation(r.Context(), db.CreateGitHubInstallationParams{
|
|
WorkspaceID: wsUUID,
|
|
InstallationID: installationID,
|
|
AccountLogin: login,
|
|
AccountType: accountType,
|
|
AccountAvatarUrl: ptrToText(avatar),
|
|
ConnectedByID: connectedBy,
|
|
})
|
|
if err != nil {
|
|
slog.Error("github: failed to persist installation", "err", err, "installation_id", installationID)
|
|
http.Redirect(w, r, settingsURL+"&github_error=persist_failed", http.StatusFound)
|
|
return
|
|
}
|
|
h.publish(protocol.EventGitHubInstallationCreated, workspaceID, "system", "", map[string]any{
|
|
"installation": githubInstallationToBroadcast(inst),
|
|
})
|
|
http.Redirect(w, r, settingsURL+"&github_connected=1", http.StatusFound)
|
|
}
|
|
|
|
// fetchInstallationAccount tries to enrich the installation row with the
|
|
// account name + avatar via GitHub's public API. We deliberately do NOT
|
|
// require GitHub App JWT auth here — the install endpoint is publicly
|
|
// readable for installations on public accounts, and on failure we fall
|
|
// back to placeholders that the next webhook will overwrite.
|
|
func fetchInstallationAccount(ctx context.Context, installationID int64) (login, accountType string, avatar *string) {
|
|
login = "unknown"
|
|
accountType = "User"
|
|
avatar = nil
|
|
url := fmt.Sprintf("https://api.github.com/app/installations/%d", installationID)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return
|
|
}
|
|
var body struct {
|
|
Account struct {
|
|
Login string `json:"login"`
|
|
Type string `json:"type"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
} `json:"account"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
return
|
|
}
|
|
if body.Account.Login != "" {
|
|
login = body.Account.Login
|
|
}
|
|
if body.Account.Type != "" {
|
|
accountType = body.Account.Type
|
|
}
|
|
if body.Account.AvatarURL != "" {
|
|
v := body.Account.AvatarURL
|
|
avatar = &v
|
|
}
|
|
return
|
|
}
|
|
|
|
// ── Listing / disconnect ────────────────────────────────────────────────────
|
|
|
|
// ListGitHubInstallations returns the workspace's connected GitHub
|
|
// installations to any workspace member. Connect/disconnect remain
|
|
// admin-only at the router level, so the response carries a `can_manage`
|
|
// hint and strips the numeric `installation_id` for non-admin callers —
|
|
// they get visibility into "is GitHub wired up, and by whom?" without the
|
|
// management handle.
|
|
func (h *Handler) ListGitHubInstallations(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := chi.URLParam(r, "id")
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
member, _ := middleware.MemberFromContext(r.Context())
|
|
canManage := roleAllowed(member.Role, "owner", "admin")
|
|
|
|
rows, err := h.Queries.ListGitHubInstallationsByWorkspace(r.Context(), wsUUID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list installations")
|
|
return
|
|
}
|
|
out := make([]GitHubInstallationResponse, 0, len(rows))
|
|
for _, row := range rows {
|
|
resp := githubInstallationToResponse(row)
|
|
if !canManage {
|
|
resp.InstallationID = nil
|
|
}
|
|
out = append(out, resp)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"installations": out,
|
|
"configured": isGitHubConfigured(),
|
|
"can_manage": canManage,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) DeleteGitHubInstallation(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := chi.URLParam(r, "id")
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
id := chi.URLParam(r, "installationId")
|
|
idUUID, ok := parseUUIDOrBadRequest(w, id, "installation id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.Queries.DeleteGitHubInstallation(r.Context(), db.DeleteGitHubInstallationParams{
|
|
ID: idUUID,
|
|
WorkspaceID: wsUUID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to remove installation")
|
|
return
|
|
}
|
|
h.publish(protocol.EventGitHubInstallationDeleted, workspaceID, "system", "", map[string]any{
|
|
"id": id,
|
|
})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ── List PRs for an issue ───────────────────────────────────────────────────
|
|
|
|
func (h *Handler) ListPullRequestsForIssue(w http.ResponseWriter, r *http.Request) {
|
|
issue, ok := h.loadIssueForUser(w, r, chi.URLParam(r, "id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
rows, err := h.Queries.ListPullRequestsByIssue(r.Context(), issue.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list pull requests")
|
|
return
|
|
}
|
|
out := make([]GitHubPullRequestResponse, 0, len(rows))
|
|
for _, row := range rows {
|
|
out = append(out, issuePullRequestRowToResponse(row))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"pull_requests": out})
|
|
}
|
|
|
|
// ── Webhook ─────────────────────────────────────────────────────────────────
|
|
|
|
// identifierRe extracts identifiers like "MUL-1510" from text. Case-insensitive
|
|
// because branch names are conventionally lowercase but issue prefixes are
|
|
// uppercase. Word boundary on the left prevents matching inside email-style
|
|
// strings (e.g. "abc@MUL-1") and the digit anchor on the right rules out
|
|
// version numbers like "v1.2-3".
|
|
var identifierRe = regexp.MustCompile(`(?i)\b([a-z][a-z0-9]{1,9})-(\d+)\b`)
|
|
|
|
// closingIdentifierRe extracts identifiers that appear immediately after a
|
|
// GitHub-style closing keyword ("close[sd]?", "fix(e[sd])?", "resolve[sd]?"),
|
|
// optionally separated by a colon and whitespace. Matching is intentionally
|
|
// strict on adjacency — "Fix MUL-1" closes MUL-1, but "Fix login MUL-1"
|
|
// does not. This mirrors GitHub's own closing-keyword grammar and is the
|
|
// gate the webhook uses to decide whether to auto-advance an issue to
|
|
// `done` after a PR merges. References like "Follow up in MUL-2" and bare
|
|
// title prefixes like "MUL-1: ..." link the PR (via identifierRe) but
|
|
// never auto-close.
|
|
var closingIdentifierRe = regexp.MustCompile(
|
|
`(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)[:\s]+([a-z][a-z0-9]{1,9})-(\d+)\b`,
|
|
)
|
|
|
|
// HandleGitHubWebhook (POST /api/webhooks/github) is GitHub's destination for
|
|
// every event from a connected installation. We verify HMAC signature, route
|
|
// on X-GitHub-Event, and either upsert PR rows + auto-link to issues or
|
|
// remove the installation on uninstall.
|
|
func (h *Handler) HandleGitHubWebhook(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20)) // 10 MiB cap
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "read body failed")
|
|
return
|
|
}
|
|
secret := githubWebhookSecret()
|
|
if secret == "" {
|
|
// Refusing to process webhooks at all is safer than treating an
|
|
// unconfigured deployment as "all signatures valid".
|
|
writeError(w, http.StatusServiceUnavailable, "github webhooks not configured")
|
|
return
|
|
}
|
|
sigHeader := r.Header.Get("X-Hub-Signature-256")
|
|
if !verifyWebhookSignature(secret, sigHeader, body) {
|
|
writeError(w, http.StatusUnauthorized, "invalid signature")
|
|
return
|
|
}
|
|
event := r.Header.Get("X-GitHub-Event")
|
|
action := extractGitHubAction(body)
|
|
h.Metrics.RecordGithubEventReceived(event, action)
|
|
ctx := r.Context()
|
|
switch event {
|
|
case "ping":
|
|
writeJSON(w, http.StatusOK, map[string]string{"ok": "pong"})
|
|
return
|
|
case "installation":
|
|
h.handleInstallationEvent(ctx, body)
|
|
case "pull_request":
|
|
h.handlePullRequestEvent(ctx, body)
|
|
case "check_suite":
|
|
h.handleCheckSuiteEvent(ctx, body)
|
|
default:
|
|
// Acknowledge every event so GitHub doesn't mark the endpoint failing,
|
|
// but ignore types we don't model.
|
|
}
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}
|
|
|
|
// extractGitHubAction sniffs the top-level "action" field from a webhook
|
|
// body without committing to a typed struct (each event has its own shape;
|
|
// we just need the verb for the metric label).
|
|
func extractGitHubAction(body []byte) string {
|
|
var probe struct {
|
|
Action string `json:"action"`
|
|
}
|
|
if err := json.Unmarshal(body, &probe); err != nil {
|
|
return ""
|
|
}
|
|
return probe.Action
|
|
}
|
|
|
|
func verifyWebhookSignature(secret, header string, body []byte) bool {
|
|
const prefix = "sha256="
|
|
if !strings.HasPrefix(header, prefix) {
|
|
return false
|
|
}
|
|
want, err := hex.DecodeString(strings.TrimPrefix(header, prefix))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write(body)
|
|
return hmac.Equal(mac.Sum(nil), want)
|
|
}
|
|
|
|
type ghInstallationPayload struct {
|
|
Action string `json:"action"`
|
|
Installation struct {
|
|
ID int64 `json:"id"`
|
|
Account struct {
|
|
Login string `json:"login"`
|
|
Type string `json:"type"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
} `json:"account"`
|
|
} `json:"installation"`
|
|
}
|
|
|
|
func (h *Handler) handleInstallationEvent(ctx context.Context, body []byte) {
|
|
var p ghInstallationPayload
|
|
if err := json.Unmarshal(body, &p); err != nil {
|
|
slog.Warn("github: bad installation payload", "err", err)
|
|
return
|
|
}
|
|
switch p.Action {
|
|
case "deleted", "suspend":
|
|
// User removed the App on GitHub — drop our row so the workspace
|
|
// stops trusting this installation_id. We DELETE … RETURNING so
|
|
// the broadcast can be scoped to the right workspace; events
|
|
// without WorkspaceID are dropped by the realtime listener and
|
|
// would leave already-open Settings tabs stale.
|
|
deleted, err := h.Queries.DeleteGitHubInstallationByInstallationID(ctx, p.Installation.ID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return // already gone — nothing to broadcast
|
|
}
|
|
slog.Warn("github: delete installation failed", "err", err, "installation_id", p.Installation.ID)
|
|
return
|
|
}
|
|
// Broadcast the internal row id only — the numeric installation_id is
|
|
// a management handle that non-admin members are not allowed to see.
|
|
// The frontend invalidates the installations query on this event and
|
|
// does not read the broadcast payload directly.
|
|
h.publish(protocol.EventGitHubInstallationDeleted, uuidToString(deleted.WorkspaceID), "system", "", map[string]any{
|
|
"id": uuidToString(deleted.ID),
|
|
})
|
|
case "created", "new_permissions_accepted", "unsuspend":
|
|
// We don't know which workspace this maps to from the webhook
|
|
// alone — the setup callback handler is what binds installation
|
|
// to workspace, so we just refresh metadata if we already have
|
|
// a row.
|
|
existing, err := h.Queries.GetGitHubInstallationByInstallationID(ctx, p.Installation.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
avatar := p.Installation.Account.AvatarURL
|
|
_, err = h.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
|
|
WorkspaceID: existing.WorkspaceID,
|
|
InstallationID: p.Installation.ID,
|
|
AccountLogin: p.Installation.Account.Login,
|
|
AccountType: coalesce(p.Installation.Account.Type, "User"),
|
|
AccountAvatarUrl: ptrToText(strPtrOrNil(avatar)),
|
|
ConnectedByID: existing.ConnectedByID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("github: refresh installation failed", "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
type ghPullRequestPayload struct {
|
|
Action string `json:"action"`
|
|
PullRequest struct {
|
|
Number int32 `json:"number"`
|
|
HTMLURL string `json:"html_url"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
State string `json:"state"`
|
|
Draft bool `json:"draft"`
|
|
Merged bool `json:"merged"`
|
|
MergedAt string `json:"merged_at"`
|
|
ClosedAt string `json:"closed_at"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
MergeableState string `json:"mergeable_state"`
|
|
Additions int32 `json:"additions"`
|
|
Deletions int32 `json:"deletions"`
|
|
ChangedFiles int32 `json:"changed_files"`
|
|
Head struct {
|
|
Ref string `json:"ref"`
|
|
SHA string `json:"sha"`
|
|
} `json:"head"`
|
|
User struct {
|
|
Login string `json:"login"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
} `json:"user"`
|
|
} `json:"pull_request"`
|
|
Changes *ghPRChanges `json:"changes"`
|
|
Repository struct {
|
|
Name string `json:"name"`
|
|
Owner struct {
|
|
Login string `json:"login"`
|
|
} `json:"owner"`
|
|
} `json:"repository"`
|
|
Installation struct {
|
|
ID int64 `json:"id"`
|
|
} `json:"installation"`
|
|
}
|
|
|
|
func (h *Handler) handlePullRequestEvent(ctx context.Context, body []byte) {
|
|
var p ghPullRequestPayload
|
|
if err := json.Unmarshal(body, &p); err != nil {
|
|
slog.Warn("github: bad pull_request payload", "err", err)
|
|
return
|
|
}
|
|
if p.Installation.ID == 0 {
|
|
return
|
|
}
|
|
inst, err := h.Queries.GetGitHubInstallationByInstallationID(ctx, p.Installation.ID)
|
|
if err != nil {
|
|
// Webhook from an installation we never wired up — nothing we
|
|
// can attribute to a workspace, so drop it silently.
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
slog.Warn("github: lookup installation failed", "err", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
state := derivePRState(p.PullRequest.State, p.PullRequest.Draft, p.PullRequest.Merged)
|
|
mergeable, clearMergeable := derivePRMergeableState(p.Action, p.PullRequest.MergeableState, baseRefChanged(p.Changes))
|
|
pr, err := h.Queries.UpsertGitHubPullRequest(ctx, db.UpsertGitHubPullRequestParams{
|
|
WorkspaceID: inst.WorkspaceID,
|
|
InstallationID: inst.InstallationID,
|
|
RepoOwner: p.Repository.Owner.Login,
|
|
RepoName: p.Repository.Name,
|
|
PrNumber: p.PullRequest.Number,
|
|
Title: p.PullRequest.Title,
|
|
State: state,
|
|
HtmlUrl: p.PullRequest.HTMLURL,
|
|
Branch: ptrToText(strPtrOrNil(p.PullRequest.Head.Ref)),
|
|
AuthorLogin: ptrToText(strPtrOrNil(p.PullRequest.User.Login)),
|
|
AuthorAvatarUrl: ptrToText(strPtrOrNil(p.PullRequest.User.AvatarURL)),
|
|
MergedAt: parseGHTime(p.PullRequest.MergedAt),
|
|
ClosedAt: parseGHTime(p.PullRequest.ClosedAt),
|
|
PrCreatedAt: parseGHTimeRequired(p.PullRequest.CreatedAt),
|
|
PrUpdatedAt: parseGHTimeRequired(p.PullRequest.UpdatedAt),
|
|
HeadSha: p.PullRequest.Head.SHA,
|
|
MergeableState: mergeable,
|
|
ClearMergeableState: pgtype.Bool{Bool: clearMergeable, Valid: true},
|
|
Additions: p.PullRequest.Additions,
|
|
Deletions: p.PullRequest.Deletions,
|
|
ChangedFiles: p.PullRequest.ChangedFiles,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("github: upsert pr failed", "err", err)
|
|
return
|
|
}
|
|
|
|
if state == "merged" {
|
|
if openSeconds := pullRequestMergeSeconds(p.PullRequest.CreatedAt, p.PullRequest.MergedAt); openSeconds > 0 {
|
|
h.Metrics.ObserveGithubPRMergeSeconds(openSeconds)
|
|
}
|
|
}
|
|
|
|
workspaceID := uuidToString(inst.WorkspaceID)
|
|
resp := githubPullRequestToResponse(pr)
|
|
|
|
// Auto-link: scan title/body/branch for issue identifiers, look them
|
|
// up in this workspace, attach the link rows. Idempotent (ON CONFLICT
|
|
// upserts the close_intent flag — see LinkIssueToPullRequest) so
|
|
// re-firing the webhook doesn't duplicate.
|
|
//
|
|
// RFC MUL-2414 §4.8: the PR mirror upsert above always runs (so re-enabling
|
|
// GitHub features restores history without backfill), but the link rows
|
|
// are a "new side-effect" and must be gated by the workspace's auto-link
|
|
// flag (which itself short-circuits when the master `github_enabled`
|
|
// switch is off).
|
|
linkedIssueIDs := make([]string, 0)
|
|
if h.workspaceAutoLinkPRsEnabled(ctx, inst.WorkspaceID) {
|
|
idents := extractIdentifiers(p.PullRequest.Title, p.PullRequest.Body, p.PullRequest.Head.Ref)
|
|
// closingIdents is the subset of identifiers that this PR explicitly
|
|
// declared via a closing keyword ("Closes/Fixes/Resolves MUL-X").
|
|
// Linking still happens for every mention (idents above), but the
|
|
// link row's close_intent column — and therefore whether the
|
|
// auto-advance gate eventually fires — is only set for keyword-
|
|
// declared identifiers. Bare title prefixes and branch-name
|
|
// references are link-only.
|
|
closingIdents := map[string]struct{}{}
|
|
for _, c := range extractClosingIdentifiers(p.PullRequest.Title, p.PullRequest.Body) {
|
|
closingIdents[c] = struct{}{}
|
|
}
|
|
// close_intent should follow the PR title/body while the PR is still
|
|
// editable before its terminal close event. Once GitHub has delivered
|
|
// a terminal event, later edit/synchronize webhooks must not rewrite
|
|
// the merge-time close decision.
|
|
preserveCloseIntent := p.Action != "closed" && (state == "merged" || state == "closed")
|
|
prefix := h.getIssuePrefix(ctx, inst.WorkspaceID)
|
|
// reevalIssues collects each issue whose link row we just touched so
|
|
// we can re-run the auto-advance gate against the persisted aggregate
|
|
// after every link upsert in this event. Driving the gate off
|
|
// persisted state (instead of "did *this* webhook declare closing
|
|
// intent?") is what fixes the multi-PR sibling case: a PR with
|
|
// `Closes MUL-1` merges first while a link-only sibling is still
|
|
// open, then the sibling closes later — its webhook has no closing
|
|
// keyword, but the earlier link row carries close_intent=true, so
|
|
// MUL-1 still advances.
|
|
reevalIssues := make([]db.Issue, 0, len(idents))
|
|
for _, id := range idents {
|
|
issue, ok := h.lookupIssueByIdentifier(ctx, inst.WorkspaceID, prefix, id)
|
|
if !ok {
|
|
continue
|
|
}
|
|
_, declared := closingIdents[id]
|
|
closeIntent := declared && !preserveCloseIntent
|
|
if err := h.Queries.LinkIssueToPullRequest(ctx, db.LinkIssueToPullRequestParams{
|
|
IssueID: issue.ID,
|
|
PullRequestID: pr.ID,
|
|
CloseIntent: closeIntent,
|
|
PreserveCloseIntent: preserveCloseIntent,
|
|
LinkedByType: strToText("system"),
|
|
LinkedByID: pgtype.UUID{},
|
|
}); err != nil {
|
|
slog.Warn("github: link failed", "err", err)
|
|
continue
|
|
}
|
|
linkedIssueIDs = append(linkedIssueIDs, uuidToString(issue.ID))
|
|
reevalIssues = append(reevalIssues, issue)
|
|
}
|
|
|
|
// A terminal PR event (`merged` or `closed`) may be the moment the
|
|
// last in-flight sibling resolves. We re-evaluate every issue we
|
|
// just linked once both the PR row and the link row are persisted,
|
|
// so the aggregate query sees the freshest state. We advance the
|
|
// issue to done when:
|
|
// 1. the issue isn't already terminal (`done` / `cancelled`);
|
|
// 2. no linked PR is still `open` / `draft`;
|
|
// 3. at least one merged linked PR declared close_intent (a
|
|
// "Closes/Fixes/Resolves" keyword on its link row).
|
|
// Rule (3) is what prevents "Follow up in MUL-2" / "Unblocks MUL-3"
|
|
// references from being treated the same as "Closes MUL-1", and
|
|
// also prevents an "all closed-without-merge" sequence from
|
|
// silently auto-closing the issue — if nothing carrying closing
|
|
// intent was ever delivered, the user should decide manually.
|
|
if state == "merged" || state == "closed" {
|
|
for _, issue := range reevalIssues {
|
|
if issue.Status == "done" || issue.Status == "cancelled" {
|
|
continue
|
|
}
|
|
counts, err := h.Queries.GetIssuePullRequestCloseAggregate(ctx, issue.ID)
|
|
if err != nil {
|
|
slog.Warn("github: count linked pr states failed", "err", err, "issue_id", uuidToString(issue.ID))
|
|
continue
|
|
}
|
|
if counts.OpenCount == 0 && counts.MergedWithCloseIntentCount > 0 {
|
|
h.advanceIssueToDone(ctx, issue, workspaceID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Broadcast PR change to the workspace so any open issue detail page
|
|
// re-queries its PR list.
|
|
h.publish(protocol.EventPullRequestUpdated, workspaceID, "system", "", map[string]any{
|
|
"pull_request": resp,
|
|
"linked_issue_ids": linkedIssueIDs,
|
|
})
|
|
}
|
|
|
|
// ── check_suite webhook ────────────────────────────────────────────────────
|
|
|
|
type ghCheckSuitePayload struct {
|
|
Action string `json:"action"`
|
|
CheckSuite struct {
|
|
ID int64 `json:"id"`
|
|
HeadSHA string `json:"head_sha"`
|
|
Status string `json:"status"`
|
|
Conclusion string `json:"conclusion"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
App struct {
|
|
ID int64 `json:"id"`
|
|
} `json:"app"`
|
|
PullRequests []struct {
|
|
Number int32 `json:"number"`
|
|
} `json:"pull_requests"`
|
|
} `json:"check_suite"`
|
|
Repository struct {
|
|
Name string `json:"name"`
|
|
Owner struct {
|
|
Login string `json:"login"`
|
|
} `json:"owner"`
|
|
} `json:"repository"`
|
|
Installation struct {
|
|
ID int64 `json:"id"`
|
|
} `json:"installation"`
|
|
}
|
|
|
|
// handleCheckSuiteEvent records the CI suite state for each PR the suite
|
|
// references. MVP only persists terminal events (`completed`); GitHub sends
|
|
// `requested`/`rerequested` for some apps but those carry no useful
|
|
// conclusion and the RFC restricts us to suite-level aggregation.
|
|
//
|
|
// The suite payload may reference multiple PRs (e.g. the same head SHA is
|
|
// open against several base branches), so we iterate. A reference whose PR
|
|
// hasn't been mirrored locally is logged and skipped — auto-backfill from
|
|
// GitHub's REST API is a v2 enhancement.
|
|
func (h *Handler) handleCheckSuiteEvent(ctx context.Context, body []byte) {
|
|
var p ghCheckSuitePayload
|
|
if err := json.Unmarshal(body, &p); err != nil {
|
|
slog.Warn("github: bad check_suite payload", "err", err)
|
|
return
|
|
}
|
|
if p.Action != "completed" {
|
|
// MVP scope: only completed suites carry a conclusion we can
|
|
// surface. queued / in_progress events would feed a future
|
|
// "real pending" display path.
|
|
return
|
|
}
|
|
if p.Installation.ID == 0 {
|
|
return
|
|
}
|
|
inst, err := h.Queries.GetGitHubInstallationByInstallationID(ctx, p.Installation.ID)
|
|
if err != nil {
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
slog.Warn("github: lookup installation failed", "err", err)
|
|
}
|
|
return
|
|
}
|
|
if len(p.CheckSuite.PullRequests) == 0 {
|
|
// Forks emit suites whose `pull_requests` array is empty for
|
|
// the upstream repo. We have no way to attribute the result
|
|
// without polling, so drop with a hint.
|
|
slog.Info("github: check_suite has no associated PRs", "suite_id", p.CheckSuite.ID)
|
|
return
|
|
}
|
|
updatedAt := parseGHTimeRequired(p.CheckSuite.UpdatedAt)
|
|
|
|
affectedWorkspaces := map[string]struct{}{}
|
|
affectedIssues := map[string]struct{}{}
|
|
for _, prRef := range p.CheckSuite.PullRequests {
|
|
// Scope the lookup to the installation's workspace. The
|
|
// (workspace_id, repo_owner, repo_name, pr_number) tuple is the
|
|
// real uniqueness key: if the same repo lived under a different
|
|
// workspace historically, a bare (owner, repo, number) lookup
|
|
// could return either row arbitrarily and land this suite on
|
|
// the wrong PR (or skip the right one because the installation
|
|
// ids no longer match).
|
|
pr, err := h.Queries.GetGitHubPullRequest(ctx, db.GetGitHubPullRequestParams{
|
|
WorkspaceID: inst.WorkspaceID,
|
|
RepoOwner: p.Repository.Owner.Login,
|
|
RepoName: p.Repository.Name,
|
|
PrNumber: prRef.Number,
|
|
})
|
|
if err != nil {
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
slog.Warn("github: lookup pr for check_suite failed", "err", err)
|
|
}
|
|
slog.Info("github: check_suite for unknown PR — skipping",
|
|
"repo", p.Repository.Owner.Login+"/"+p.Repository.Name,
|
|
"pr", prRef.Number,
|
|
"suite_id", p.CheckSuite.ID,
|
|
)
|
|
continue
|
|
}
|
|
if err := h.Queries.UpsertPullRequestCheckSuite(ctx, db.UpsertPullRequestCheckSuiteParams{
|
|
PrID: pr.ID,
|
|
SuiteID: p.CheckSuite.ID,
|
|
HeadSha: p.CheckSuite.HeadSHA,
|
|
AppID: p.CheckSuite.App.ID,
|
|
Conclusion: strToText(p.CheckSuite.Conclusion),
|
|
Status: p.CheckSuite.Status,
|
|
UpdatedAt: updatedAt,
|
|
}); err != nil {
|
|
slog.Warn("github: upsert check_suite failed", "err", err, "suite_id", p.CheckSuite.ID)
|
|
continue
|
|
}
|
|
affectedWorkspaces[uuidToString(pr.WorkspaceID)] = struct{}{}
|
|
issues, err := h.Queries.ListIssueIDsForPullRequest(ctx, pr.ID)
|
|
if err == nil {
|
|
for _, id := range issues {
|
|
affectedIssues[uuidToString(id)] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Broadcast on the existing event so the issue page just re-queries
|
|
// the PR list. We don't pass a single pull_request payload here
|
|
// because a suite can touch several and the listener already
|
|
// invalidates by issue.
|
|
for ws := range affectedWorkspaces {
|
|
linked := make([]string, 0, len(affectedIssues))
|
|
for id := range affectedIssues {
|
|
linked = append(linked, id)
|
|
}
|
|
h.publish(protocol.EventPullRequestUpdated, ws, "system", "", map[string]any{
|
|
"linked_issue_ids": linked,
|
|
})
|
|
}
|
|
}
|
|
|
|
// derivePRMergeableState resolves the upsert behaviour for the PR row's
|
|
// mergeable_state column on a `pull_request` webhook. It returns three
|
|
// states encoded as (value, clear):
|
|
//
|
|
// - clear=true → force the column to NULL. State-changing actions (`opened`,
|
|
// `synchronize`, `reopened`, or a base-branch swap) must blank the value
|
|
// because GitHub re-computes mergeability asynchronously; the payload may
|
|
// still carry the previous head's clean/dirty answer, and trusting it
|
|
// would surface a stale verdict against the new head.
|
|
// - clear=false, value valid → write the value. The event carried a
|
|
// concrete verdict we should persist.
|
|
// - clear=false, value invalid → preserve the existing column. Metadata
|
|
// events (labeled/assigned/edited-without-base-swap) ship pull_request
|
|
// payloads with mergeable_state empty even when the previous verdict is
|
|
// still accurate, and silently overwriting clean/dirty with NULL would
|
|
// drop information GitHub only refreshes lazily.
|
|
func derivePRMergeableState(action, payload string, baseRefChanged bool) (pgtype.Text, bool) {
|
|
if action == "opened" || action == "synchronize" || action == "reopened" {
|
|
return pgtype.Text{}, true
|
|
}
|
|
if action == "edited" && baseRefChanged {
|
|
return pgtype.Text{}, true
|
|
}
|
|
if payload == "" {
|
|
return pgtype.Text{}, false
|
|
}
|
|
return pgtype.Text{String: payload, Valid: true}, false
|
|
}
|
|
|
|
// ghPRChanges captures the only field of `pull_request.edited`'s `changes`
|
|
// payload we care about: a base-branch swap. Everything else (title, body)
|
|
// leaves mergeability intact.
|
|
type ghPRChanges struct {
|
|
Base *struct {
|
|
Ref *struct {
|
|
From string `json:"from"`
|
|
} `json:"ref"`
|
|
} `json:"base"`
|
|
}
|
|
|
|
// baseRefChanged returns true when a pull_request.edited event indicates the
|
|
// PR's base branch was swapped. Only this kind of edit invalidates the
|
|
// existing mergeable_state.
|
|
func baseRefChanged(c *ghPRChanges) bool {
|
|
return c != nil && c.Base != nil && c.Base.Ref != nil && c.Base.Ref.From != ""
|
|
}
|
|
|
|
func derivePRState(state string, draft, merged bool) string {
|
|
if merged {
|
|
return "merged"
|
|
}
|
|
if state == "closed" {
|
|
return "closed"
|
|
}
|
|
if draft {
|
|
return "draft"
|
|
}
|
|
return "open"
|
|
}
|
|
|
|
func parseGHTime(s string) pgtype.Timestamptz {
|
|
if s == "" {
|
|
return pgtype.Timestamptz{}
|
|
}
|
|
t, err := time.Parse(time.RFC3339, s)
|
|
if err != nil {
|
|
return pgtype.Timestamptz{}
|
|
}
|
|
return pgtype.Timestamptz{Time: t, Valid: true}
|
|
}
|
|
|
|
func parseGHTimeRequired(s string) pgtype.Timestamptz {
|
|
t := parseGHTime(s)
|
|
if !t.Valid {
|
|
return pgtype.Timestamptz{Time: time.Now().UTC(), Valid: true}
|
|
}
|
|
return t
|
|
}
|
|
|
|
// pullRequestMergeSeconds returns the open-to-merge latency in seconds, or
|
|
// 0 (caller skips) when either timestamp is missing or the merge happened
|
|
// before the create timestamp (clock skew safety).
|
|
func pullRequestMergeSeconds(createdAt, mergedAt string) float64 {
|
|
created, err := time.Parse(time.RFC3339, createdAt)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
merged, err := time.Parse(time.RFC3339, mergedAt)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
delta := merged.Sub(created).Seconds()
|
|
if delta <= 0 {
|
|
return 0
|
|
}
|
|
return delta
|
|
}
|
|
|
|
// extractIdentifiers pulls every "PREFIX-NUMBER" match across the supplied
|
|
// fields, deduplicating in input order.
|
|
func extractIdentifiers(parts ...string) []string {
|
|
seen := map[string]struct{}{}
|
|
out := []string{}
|
|
for _, src := range parts {
|
|
for _, m := range identifierRe.FindAllStringSubmatch(src, -1) {
|
|
ident := strings.ToUpper(m[1]) + "-" + m[2]
|
|
if _, dup := seen[ident]; dup {
|
|
continue
|
|
}
|
|
seen[ident] = struct{}{}
|
|
out = append(out, ident)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// extractClosingIdentifiers pulls every "PREFIX-NUMBER" identifier that
|
|
// appears immediately after a GitHub-style closing keyword in the supplied
|
|
// fields, deduplicating in input order. Identifiers in branch names are
|
|
// intentionally excluded — callers should pass only title and body — because
|
|
// branch names are not natural-language fields and treating "mul-1/fix-login"
|
|
// as a close declaration would silently re-open the bug this gate is meant
|
|
// to fix.
|
|
func extractClosingIdentifiers(parts ...string) []string {
|
|
seen := map[string]struct{}{}
|
|
out := []string{}
|
|
for _, src := range parts {
|
|
for _, m := range closingIdentifierRe.FindAllStringSubmatch(src, -1) {
|
|
ident := strings.ToUpper(m[1]) + "-" + m[2]
|
|
if _, dup := seen[ident]; dup {
|
|
continue
|
|
}
|
|
seen[ident] = struct{}{}
|
|
out = append(out, ident)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// lookupIssueByIdentifier looks up an issue in the given workspace by its
|
|
// "PREFIX-NUMBER" identifier. Returns the row + true if the prefix matches
|
|
// workspaceAutoLinkPRsEnabled reports whether the workspace allows the
|
|
// GitHub webhook to create issue ↔ PR link rows. Defaults to true so that
|
|
// workspaces predating RFC MUL-2414 keep the historical "auto-link on"
|
|
// behavior, and short-circuits to false whenever the master GitHub switch
|
|
// is explicitly off — mirroring the precedence used on the client side.
|
|
func (h *Handler) workspaceAutoLinkPRsEnabled(ctx context.Context, workspaceID pgtype.UUID) bool {
|
|
ws, err := h.Queries.GetWorkspace(ctx, workspaceID)
|
|
if err != nil || len(ws.Settings) == 0 {
|
|
return true
|
|
}
|
|
var s struct {
|
|
GitHubEnabled *bool `json:"github_enabled"`
|
|
GitHubAutoLinkPRsEnabled *bool `json:"github_auto_link_prs_enabled"`
|
|
}
|
|
if err := json.Unmarshal(ws.Settings, &s); err != nil {
|
|
return true
|
|
}
|
|
if s.GitHubEnabled != nil && !*s.GitHubEnabled {
|
|
return false
|
|
}
|
|
if s.GitHubAutoLinkPRsEnabled == nil {
|
|
return true
|
|
}
|
|
return *s.GitHubAutoLinkPRsEnabled
|
|
}
|
|
|
|
// the workspace's configured prefix and the number resolves to a real issue.
|
|
func (h *Handler) lookupIssueByIdentifier(ctx context.Context, workspaceID pgtype.UUID, prefix, identifier string) (db.Issue, bool) {
|
|
idx := strings.LastIndex(identifier, "-")
|
|
if idx < 0 {
|
|
return db.Issue{}, false
|
|
}
|
|
gotPrefix, numStr := identifier[:idx], identifier[idx+1:]
|
|
if !strings.EqualFold(gotPrefix, prefix) {
|
|
return db.Issue{}, false
|
|
}
|
|
n, err := strconv.Atoi(numStr)
|
|
if err != nil {
|
|
return db.Issue{}, false
|
|
}
|
|
issue, err := h.Queries.GetIssueByNumber(ctx, db.GetIssueByNumberParams{
|
|
WorkspaceID: workspaceID,
|
|
Number: int32(n),
|
|
})
|
|
if err != nil {
|
|
return db.Issue{}, false
|
|
}
|
|
return issue, true
|
|
}
|
|
|
|
func (h *Handler) advanceIssueToDone(ctx context.Context, issue db.Issue, workspaceID string) {
|
|
updated, err := h.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
|
|
ID: issue.ID,
|
|
Status: "done",
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("github: advance issue to done failed", "err", err)
|
|
return
|
|
}
|
|
|
|
// Fire the platform parent-notification path on the same transition the
|
|
// HTTP UpdateIssue / BatchUpdateIssues paths use. A merged PR is one of
|
|
// the most common ways a sub-issue actually reaches `done`, and skipping
|
|
// it here would leave the parent silent for the dominant completion path.
|
|
// notifyParentOfChildDone re-checks every guard (prev != done, parent
|
|
// exists, parent not terminal), so calling it unconditionally is safe.
|
|
h.notifyParentOfChildDone(ctx, issue, updated, "system", "")
|
|
|
|
prefix := h.getIssuePrefix(ctx, issue.WorkspaceID)
|
|
resp := issueToResponse(updated, prefix)
|
|
h.publish(protocol.EventIssueUpdated, workspaceID, "system", "", map[string]any{
|
|
"issue": resp,
|
|
"status_changed": true,
|
|
"prev_status": issue.Status,
|
|
"creator_type": issue.CreatorType,
|
|
"creator_id": uuidToString(issue.CreatorID),
|
|
"source": "github_pr_merged",
|
|
})
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
func parseStrictUUID(s string) (pgtype.UUID, error) {
|
|
var u pgtype.UUID
|
|
if err := u.Scan(s); err != nil {
|
|
return pgtype.UUID{}, err
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func coalesce(a, fallback string) string {
|
|
if strings.TrimSpace(a) == "" {
|
|
return fallback
|
|
}
|
|
return a
|
|
}
|
|
|
|
func strPtrOrNil(s string) *string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
v := s
|
|
return &v
|
|
}
|