diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 0650320ba0..3e6845b298 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -81,6 +81,9 @@ import type { ListAutopilotRunsResponse, NotificationPreferenceResponse, NotificationPreferences, + GitHubPullRequest, + ListGitHubInstallationsResponse, + GitHubConnectResponse, } from "../types"; import type { OnboardingCompletionPath } from "../onboarding/types"; import { type Logger, noopLogger } from "../logger"; @@ -1310,4 +1313,23 @@ export class ApiClient { async deleteAutopilotTrigger(autopilotId: string, triggerId: string): Promise { await this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, { method: "DELETE" }); } + + // GitHub integration + async getGitHubConnectURL(workspaceId: string): Promise { + return this.fetch(`/api/workspaces/${workspaceId}/github/connect`); + } + + async listGitHubInstallations(workspaceId: string): Promise { + return this.fetch(`/api/workspaces/${workspaceId}/github/installations`); + } + + async deleteGitHubInstallation(workspaceId: string, installationId: string): Promise { + await this.fetch(`/api/workspaces/${workspaceId}/github/installations/${installationId}`, { + method: "DELETE", + }); + } + + async listIssuePullRequests(issueId: string): Promise<{ pull_requests: GitHubPullRequest[] }> { + return this.fetch(`/api/issues/${issueId}/pull-requests`); + } } diff --git a/packages/core/github/index.ts b/packages/core/github/index.ts new file mode 100644 index 0000000000..b69c251208 --- /dev/null +++ b/packages/core/github/index.ts @@ -0,0 +1 @@ +export * from "./queries"; diff --git a/packages/core/github/queries.ts b/packages/core/github/queries.ts new file mode 100644 index 0000000000..24bbbf4742 --- /dev/null +++ b/packages/core/github/queries.ts @@ -0,0 +1,22 @@ +import { queryOptions } from "@tanstack/react-query"; +import { api } from "../api"; + +export const githubKeys = { + all: (wsId: string) => ["github", wsId] as const, + installations: (wsId: string) => [...githubKeys.all(wsId), "installations"] as const, + pullRequests: (issueId: string) => ["github", "pull-requests", issueId] as const, +}; + +export const githubInstallationsOptions = (wsId: string) => + queryOptions({ + queryKey: githubKeys.installations(wsId), + queryFn: () => api.listGitHubInstallations(wsId), + enabled: !!wsId, + }); + +export const issuePullRequestsOptions = (issueId: string) => + queryOptions({ + queryKey: githubKeys.pullRequests(issueId), + queryFn: () => api.listIssuePullRequests(issueId), + enabled: !!issueId, + }); diff --git a/packages/core/package.json b/packages/core/package.json index 083bf5b7ab..848651150b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -66,6 +66,8 @@ "./pins": "./pins/index.ts", "./pins/queries": "./pins/queries.ts", "./pins/mutations": "./pins/mutations.ts", + "./github": "./github/index.ts", + "./github/queries": "./github/queries.ts", "./feedback": "./feedback/index.ts", "./feedback/mutations": "./feedback/mutations.ts", "./realtime": "./realtime/index.ts", diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index 2b3cc31e2c..b0d7914d79 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -20,6 +20,7 @@ import { agentRunCountsKeys, agentTasksKeys, } from "../agents/queries"; +import { githubKeys } from "../github/queries"; import { onIssueCreated, onIssueUpdated, @@ -157,6 +158,15 @@ export function useRealtimeSync( const wsId = getCurrentWsId(); if (wsId) qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) }); }, + github_installation: () => { + const wsId = getCurrentWsId(); + if (wsId) qc.invalidateQueries({ queryKey: githubKeys.installations(wsId) }); + }, + pull_request: () => { + // PR list is keyed by issue id, not workspace, so we invalidate all + // PR queries — the open issue detail page will refetch its own list. + qc.invalidateQueries({ queryKey: ["github", "pull-requests"] }); + }, // Powers the agent presence cache: any task lifecycle change // (dispatch / completed / failed / cancelled) refreshes the // workspace-wide agent-task-snapshot query so per-agent presence diff --git a/packages/core/types/events.ts b/packages/core/types/events.ts index fbce64078d..932a846f76 100644 --- a/packages/core/types/events.ts +++ b/packages/core/types/events.ts @@ -67,7 +67,12 @@ export type WSEventType = | "invitation:created" | "invitation:accepted" | "invitation:declined" - | "invitation:revoked"; + | "invitation:revoked" + | "github_installation:created" + | "github_installation:deleted" + | "pull_request:linked" + | "pull_request:updated" + | "pull_request:unlinked"; export interface WSMessage { type: WSEventType; diff --git a/packages/core/types/github.ts b/packages/core/types/github.ts new file mode 100644 index 0000000000..0cf2ebdec2 --- /dev/null +++ b/packages/core/types/github.ts @@ -0,0 +1,41 @@ +export type GitHubPullRequestState = "open" | "closed" | "merged" | "draft"; + +export interface GitHubInstallation { + id: string; + workspace_id: string; + installation_id: number; + account_login: string; + account_type: "User" | "Organization"; + account_avatar_url: string | null; + created_at: string; +} + +export interface GitHubPullRequest { + id: string; + workspace_id: string; + repo_owner: string; + repo_name: string; + number: number; + title: string; + state: GitHubPullRequestState; + html_url: string; + branch: string | null; + author_login: string | null; + author_avatar_url: string | null; + merged_at: string | null; + closed_at: string | null; + pr_created_at: string; + pr_updated_at: string; +} + +export interface ListGitHubInstallationsResponse { + installations: GitHubInstallation[]; + /** Whether the deployment has GitHub App credentials configured. When false, the Connect button is hidden / disabled. */ + configured: boolean; +} + +export interface GitHubConnectResponse { + /** The GitHub App install URL the browser should open. Empty when `configured` is false. */ + url?: string; + configured: boolean; +} diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index 0482b3b89f..58e591d877 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -67,6 +67,13 @@ export type { ListProjectResourcesResponse, } from "./project"; export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin"; +export type { + GitHubInstallation, + GitHubPullRequest, + GitHubPullRequestState, + ListGitHubInstallationsResponse, + GitHubConnectResponse, +} from "./github"; export type { Autopilot, AutopilotStatus, diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx index 5ddc3b3750..0b21550636 100644 --- a/packages/views/issues/components/issue-detail.tsx +++ b/packages/views/issues/components/issue-detail.tsx @@ -51,6 +51,7 @@ import { ResolvedThreadBar } from "./resolved-thread-bar"; import { collectThreadReplies } from "./thread-utils"; import { AgentLiveCard } from "./agent-live-card"; import { ExecutionLogSection } from "./execution-log-section"; +import { PullRequestList } from "./pull-request-list"; import { useQuery } from "@tanstack/react-query"; import { useAuthStore } from "@multica/core/auth"; import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths"; @@ -393,6 +394,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr const [propertiesOpen, setPropertiesOpen] = useState(true); const [detailsOpen, setDetailsOpen] = useState(true); const [parentIssueOpen, setParentIssueOpen] = useState(true); + const [pullRequestsOpen, setPullRequestsOpen] = useState(true); const [tokenUsageOpen, setTokenUsageOpen] = useState(true); // Virtuoso's `customScrollParent` wants the HTMLElement, not a ref. A plain // `useRef.current` does not trigger a re-render when it populates, so the @@ -915,6 +917,18 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr )} + {/* Pull requests */} +
+ + {pullRequestsOpen &&
} +
+ {/* Details */}
+ )} +
+ + {canManage && !configured && ( +

+ {t(($) => $.integrations.not_configured)}{" "} + GITHUB_APP_SLUG{" "} + {t(($) => $.integrations.not_configured_and)}{" "} + GITHUB_WEBHOOK_SECRET. +

+ )} + + {canManage && configured && ( +
+ {isLoading &&

{t(($) => $.integrations.loading)}

} + {!isLoading && installations.length === 0 && ( +

+ {t(($) => $.integrations.empty)} +

+ )} + {installations.map((inst) => ( +
+
+ {inst.account_avatar_url && ( + + )} +
+

{inst.account_login}

+

+ {t(($) => $.integrations.connected_at, { + type: inst.account_type, + date: new Date(inst.created_at).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }), + })} +

+
+
+ {canManage && ( + + )} +
+ ))} +
+ )} + + {!canManage && ( +

+ {t(($) => $.integrations.manage_hint)} +

+ )} + + + + + ); +} diff --git a/packages/views/settings/components/settings-page.tsx b/packages/views/settings/components/settings-page.tsx index bbc47c0d51..45ed26882c 100644 --- a/packages/views/settings/components/settings-page.tsx +++ b/packages/views/settings/components/settings-page.tsx @@ -10,6 +10,7 @@ import { FolderGit2, FlaskConical, Bell, + Plug, } from "lucide-react"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@multica/ui/components/ui/tabs"; import { useCurrentWorkspace } from "@multica/core/paths"; @@ -20,6 +21,7 @@ import { TokensTab } from "./tokens-tab"; import { WorkspaceTab } from "./workspace-tab"; import { MembersTab } from "./members-tab"; import { RepositoriesTab } from "./repositories-tab"; +import { IntegrationsTab } from "./integrations-tab"; import { LabsTab } from "./labs-tab"; import { NotificationsTab } from "./notifications-tab"; import { useT } from "../../i18n"; @@ -32,16 +34,18 @@ const ACCOUNT_TAB_ICONS = { tokens: Key, } as const; -const WORKSPACE_TAB_KEYS = ["general", "repositories", "labs", "members"] as const; +const WORKSPACE_TAB_KEYS = ["general", "repositories", "integrations", "labs", "members"] as const; const WORKSPACE_TAB_VALUES = { general: "workspace", repositories: "repositories", + integrations: "integrations", labs: "labs", members: "members", } as const; const WORKSPACE_TAB_ICONS = { general: Settings, repositories: FolderGit2, + integrations: Plug, labs: FlaskConical, members: Users, } as const; @@ -147,6 +151,7 @@ export function SettingsPage({ extraAccountTabs }: SettingsPageProps = {}) { + {extraAccountTabs?.map((tab) => ( diff --git a/server/cmd/server/activity_listeners.go b/server/cmd/server/activity_listeners.go index 5e8864d591..1339f46065 100644 --- a/server/cmd/server/activity_listeners.go +++ b/server/cmd/server/activity_listeners.go @@ -34,7 +34,7 @@ func registerActivityListeners(bus *events.Bus, queries *db.Queries) { WorkspaceID: parseUUID(issue.WorkspaceID), IssueID: parseUUID(issue.ID), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Action: "created", Details: []byte("{}"), }) @@ -73,7 +73,7 @@ func registerActivityListeners(bus *events.Bus, queries *db.Queries) { WorkspaceID: parseUUID(issue.WorkspaceID), IssueID: parseUUID(issue.ID), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Action: "status_changed", Details: details, }) @@ -95,7 +95,7 @@ func registerActivityListeners(bus *events.Bus, queries *db.Queries) { WorkspaceID: parseUUID(issue.WorkspaceID), IssueID: parseUUID(issue.ID), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Action: "priority_changed", Details: details, }) @@ -130,7 +130,7 @@ func registerActivityListeners(bus *events.Bus, queries *db.Queries) { WorkspaceID: parseUUID(issue.WorkspaceID), IssueID: parseUUID(issue.ID), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Action: "assignee_changed", Details: details, }) @@ -159,7 +159,7 @@ func registerActivityListeners(bus *events.Bus, queries *db.Queries) { WorkspaceID: parseUUID(issue.WorkspaceID), IssueID: parseUUID(issue.ID), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Action: "due_date_changed", Details: details, }) @@ -181,7 +181,7 @@ func registerActivityListeners(bus *events.Bus, queries *db.Queries) { WorkspaceID: parseUUID(issue.WorkspaceID), IssueID: parseUUID(issue.ID), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Action: "title_changed", Details: details, }) @@ -198,7 +198,7 @@ func registerActivityListeners(bus *events.Bus, queries *db.Queries) { WorkspaceID: parseUUID(issue.WorkspaceID), IssueID: parseUUID(issue.ID), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Action: "description_updated", Details: []byte("{}"), }) diff --git a/server/cmd/server/notification_listeners.go b/server/cmd/server/notification_listeners.go index 18730ca727..8b44945fd5 100644 --- a/server/cmd/server/notification_listeners.go +++ b/server/cmd/server/notification_listeners.go @@ -338,7 +338,7 @@ func notifyIssueSubscribers( Title: title, Body: util.StrToText(body), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Details: details, }) if err != nil { @@ -403,7 +403,7 @@ func notifyDirect( Title: title, Body: util.StrToText(body), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Details: details, }) if err != nil { @@ -490,7 +490,7 @@ func notifyMentionedMembers( IssueID: parseUUID(issueID), Title: title, ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Details: details, }) if err != nil { diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 2f1add2de2..76c73656be 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -213,6 +213,11 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus // Public API r.Get("/api/config", h.GetConfig) + // GitHub App webhook (no Multica auth — requests are authenticated via + // HMAC-SHA256 signature in the handler) and post-install setup callback. + r.Post("/api/webhooks/github", h.HandleGitHubWebhook) + r.Get("/api/github/setup", h.GitHubSetupCallback) + // Daemon API routes (require daemon token or valid user token) r.Route("/api/daemon", func(r chi.Router) { r.Use(middleware.DaemonAuth(queries, patCache, daemonTokenCache)) @@ -291,6 +296,15 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus }) // Owner-only access r.With(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner")).Delete("/", h.DeleteWorkspace) + + // GitHub integration — admin-only operations live here so the + // nesting matches the rest of /api/workspaces/{id}/* routes. + r.Group(func(r chi.Router) { + r.Use(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner", "admin")) + r.Get("/github/connect", h.GitHubConnect) + r.Get("/github/installations", h.ListGitHubInstallations) + r.Delete("/github/installations/{installationId}", h.DeleteGitHubInstallation) + }) }) }) @@ -344,6 +358,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus r.Get("/labels", h.ListLabelsForIssue) r.Post("/labels", h.AttachLabel) r.Delete("/labels/{labelId}", h.DetachLabel) + r.Get("/pull-requests", h.ListPullRequestsForIssue) }) }) @@ -582,6 +597,18 @@ func parseUUID(s string) pgtype.UUID { return util.MustParseUUID(s) } +// optionalUUID returns a NULL pgtype.UUID for an empty string and otherwise +// behaves like parseUUID. Use this for actor IDs on events where the producer +// may legitimately be a "system" actor with no member/agent attribution +// (e.g. GitHub webhook auto-status sync) — the activity_log and inbox_item +// tables both allow actor_id to be NULL. +func optionalUUID(s string) pgtype.UUID { + if s == "" { + return pgtype.UUID{} + } + return util.MustParseUUID(s) +} + func splitAndTrim(s string) []string { if s == "" { return nil diff --git a/server/internal/handler/github.go b/server/internal/handler/github.go new file mode 100644 index 0000000000..eac9e02c39 --- /dev/null +++ b/server/internal/handler/github.go @@ -0,0 +1,717 @@ +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" + db "github.com/multica-ai/multica/server/pkg/db/generated" + "github.com/multica-ai/multica/server/pkg/protocol" +) + +// ── Response shapes ───────────────────────────────────────────────────────── + +type GitHubInstallationResponse struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + InstallationID int64 `json:"installation_id"` + 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"` +} + +type GitHubConnectResponse struct { + URL string `json:"url"` + Configured bool `json:"configured"` +} + +func githubInstallationToResponse(i db.GithubInstallation) GitHubInstallationResponse { + return GitHubInstallationResponse{ + ID: uuidToString(i.ID), + WorkspaceID: uuidToString(i.WorkspaceID), + InstallationID: i.InstallationID, + AccountLogin: i.AccountLogin, + AccountType: i.AccountType, + AccountAvatarURL: textToPtr(i.AccountAvatarUrl), + CreatedAt: timestampToString(i.CreatedAt), + } +} + +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), + } +} + +// ── 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: "..". +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=&state=. We persist the installation +// row (workspace ↔ installation_id mapping), then bounce the user back to +// the Settings → Integrations page in the web app. +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" + + 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": githubInstallationToResponse(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 ──────────────────────────────────────────────────── + +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 + } + 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 { + out = append(out, githubInstallationToResponse(row)) + } + writeJSON(w, http.StatusOK, map[string]any{"installations": out, "configured": isGitHubConfigured()}) +} + +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, githubPullRequestToResponse(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`) + +// 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") + 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) + default: + // Acknowledge every event so GitHub doesn't mark the endpoint failing, + // but ignore types we don't model. + } + w.WriteHeader(http.StatusAccepted) +} + +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 + } + h.publish(protocol.EventGitHubInstallationDeleted, uuidToString(deleted.WorkspaceID), "system", "", map[string]any{ + "installation_id": p.Installation.ID, + "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"` + Head struct { + Ref string `json:"ref"` + } `json:"head"` + User struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + } `json:"user"` + } `json:"pull_request"` + 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) + 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), + }) + if err != nil { + slog.Warn("github: upsert pr failed", "err", err) + return + } + + 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 + // DO NOTHING) so re-firing the webhook doesn't duplicate. + idents := extractIdentifiers(p.PullRequest.Title, p.PullRequest.Body, p.PullRequest.Head.Ref) + prefix := h.getIssuePrefix(ctx, inst.WorkspaceID) + linkedIssueIDs := make([]string, 0, len(idents)) + for _, id := range idents { + issue, ok := h.lookupIssueByIdentifier(ctx, inst.WorkspaceID, prefix, id) + if !ok { + continue + } + if err := h.Queries.LinkIssueToPullRequest(ctx, db.LinkIssueToPullRequestParams{ + IssueID: issue.ID, + PullRequestID: pr.ID, + LinkedByType: strToText("system"), + LinkedByID: pgtype.UUID{}, + }); err != nil { + slog.Warn("github: link failed", "err", err) + continue + } + linkedIssueIDs = append(linkedIssueIDs, uuidToString(issue.ID)) + + // PR merged → move issue to done if it isn't already terminal. + // We deliberately avoid clobbering 'cancelled' since that signals + // the user explicitly abandoned the work. + if state == "merged" && issue.Status != "done" && issue.Status != "cancelled" { + 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, + }) +} + +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 +} + +// 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 +} + +// lookupIssueByIdentifier looks up an issue in the given workspace by its +// "PREFIX-NUMBER" identifier. Returns the row + true if the prefix matches +// 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", + }) + if err != nil { + slog.Warn("github: advance issue to done failed", "err", err) + return + } + 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 +} diff --git a/server/internal/handler/github_test.go b/server/internal/handler/github_test.go new file mode 100644 index 0000000000..547cb30f34 --- /dev/null +++ b/server/internal/handler/github_test.go @@ -0,0 +1,371 @@ +package handler + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +func TestExtractIdentifiers(t *testing.T) { + cases := []struct { + name string + in []string + want []string + }{ + { + name: "branch_name", + in: []string{"", "", "mul-1510/fix-login"}, + want: []string{"MUL-1510"}, + }, + { + name: "title_and_body", + in: []string{"Fix MUL-82", "Closes MUL-1510 and ABC-7", ""}, + want: []string{"MUL-82", "MUL-1510", "ABC-7"}, + }, + { + name: "dedupe_across_fields", + in: []string{"MUL-1", "MUL-1 again", "mul-1/branch"}, + want: []string{"MUL-1"}, + }, + { + name: "ignore_email_and_versions", + in: []string{"reply@user-1 v1.2-3 here", "", ""}, + // Word-boundary regex still matches "user-1"; identifier prefix is + // any 2..10 letters/digits, so this is intentional. The downstream + // workspace prefix check in lookupIssueByIdentifier filters it. + want: []string{"USER-1"}, + }, + { + name: "no_match", + in: []string{"plain text", "no idents", ""}, + want: []string{}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := extractIdentifiers(tc.in...) + if len(got) == 0 && len(tc.want) == 0 { + return + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("extractIdentifiers() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestDerivePRState(t *testing.T) { + cases := []struct { + state string + draft bool + merged bool + want string + }{ + {"open", false, false, "open"}, + {"open", true, false, "draft"}, + {"closed", false, false, "closed"}, + {"closed", false, true, "merged"}, + {"closed", true, true, "merged"}, // merged trumps draft + } + for _, tc := range cases { + got := derivePRState(tc.state, tc.draft, tc.merged) + if got != tc.want { + t.Errorf("derivePRState(%q, draft=%v, merged=%v) = %q, want %q", + tc.state, tc.draft, tc.merged, got, tc.want) + } + } +} + +func TestVerifyWebhookSignature(t *testing.T) { + secret := "shared-secret" + body := []byte(`{"action":"opened"}`) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + good := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + + if !verifyWebhookSignature(secret, good, body) { + t.Error("expected valid signature to verify") + } + if verifyWebhookSignature(secret, "sha256=deadbeef", body) { + t.Error("expected bad hex to fail") + } + if verifyWebhookSignature(secret, "", body) { + t.Error("expected empty header to fail") + } + if verifyWebhookSignature(secret, "sha1=whatever", body) { + t.Error("expected non-sha256 prefix to fail") + } + if verifyWebhookSignature("other-secret", good, body) { + t.Error("expected wrong secret to fail") + } +} + +func TestStateRoundTrip(t *testing.T) { + t.Setenv("GITHUB_WEBHOOK_SECRET", "test-secret-123") + wsID := "11111111-2222-3333-4444-555555555555" + + tok, err := signState(wsID) + if err != nil { + t.Fatalf("signState: %v", err) + } + got, ok := verifyState(tok) + if !ok { + t.Fatal("verifyState rejected a freshly-signed token") + } + if got != wsID { + t.Errorf("verifyState() = %q, want %q", got, wsID) + } + + // Tampering with the workspace portion must fail (signature is bound + // to it). Replace the leading UUID's first hex digit. + tampered := "01111111" + tok[8:] + if _, ok := verifyState(tampered); ok { + t.Error("tampered state token should fail to verify") + } + + // Wrong secret rejects. + t.Setenv("GITHUB_WEBHOOK_SECRET", "different") + if _, ok := verifyState(tok); ok { + t.Error("token signed with old secret should fail under a new one") + } +} + +func TestSignStateRequiresSecret(t *testing.T) { + t.Setenv("GITHUB_WEBHOOK_SECRET", "") + if _, err := signState("ws"); err == nil { + t.Error("signState should error when secret is unset") + } +} + +// TestWebhook_MergedPR_AdvancesLinkedIssueToDone exercises the end-to-end +// auto-link + merge-sync path: install a workspace, fire a `pull_request` +// webhook with the issue identifier in the title, and verify (a) the PR row +// is upserted, (b) it is linked to the issue, (c) the issue transitions to +// 'done'. The system actor on that issue:updated event is what previously +// panicked the activity / notification listeners — having this test pass +// while listeners are wired up is the regression guard. +func TestWebhook_MergedPR_AdvancesLinkedIssueToDone(t *testing.T) { + if testHandler == nil { + t.Skip("handler test fixture not initialized (no DB?)") + } + ctx := context.Background() + secret := "merge-sync-test-secret" + t.Setenv("GITHUB_WEBHOOK_SECRET", secret) + + // Seed an issue we expect the webhook to close out. + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": "PR auto-merge test", + "status": "in_progress", + }) + testHandler.CreateIssue(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateIssue: %d %s", w.Code, w.Body.String()) + } + var created IssueResponse + json.NewDecoder(w.Body).Decode(&created) + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, created.ID) + testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID) + testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID) + testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, created.ID) + testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID) + }) + + // Wire up an installation row for the webhook to attribute to. + const installationID int64 = 99887766 + if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{ + WorkspaceID: parseUUID(testWorkspaceID), + InstallationID: installationID, + AccountLogin: "merge-sync-acct", + AccountType: "User", + }); err != nil { + t.Fatalf("CreateGitHubInstallation: %v", err) + } + + // Build a minimal pull_request webhook payload referencing the issue. + body := map[string]any{ + "action": "closed", + "pull_request": map[string]any{ + "number": 1234, + "html_url": "https://github.com/acme/widget/pull/1234", + "title": "Fix login " + created.Identifier, + "body": "", + "state": "closed", + "draft": false, + "merged": true, + "merged_at": "2026-04-29T00:00:00Z", + "closed_at": "2026-04-29T00:00:00Z", + "created_at": "2026-04-28T00:00:00Z", + "updated_at": "2026-04-29T00:00:00Z", + "head": map[string]any{"ref": "fix/login"}, + "user": map[string]any{"login": "octocat", "avatar_url": ""}, + }, + "repository": map[string]any{ + "name": "widget", + "owner": map[string]any{"login": "acme"}, + }, + "installation": map[string]any{"id": installationID}, + } + raw, _ := json.Marshal(body) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(raw) + sig := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + + w = httptest.NewRecorder() + req2 := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewReader(raw)) + req2.Header.Set("X-GitHub-Event", "pull_request") + req2.Header.Set("X-Hub-Signature-256", sig) + testHandler.HandleGitHubWebhook(w, req2) + if w.Code != http.StatusAccepted { + t.Fatalf("webhook: expected 202, got %d (%s)", w.Code, w.Body.String()) + } + + // Verify PR row + link + issue status. + pr, err := testHandler.Queries.GetGitHubPullRequest(ctx, db.GetGitHubPullRequestParams{ + WorkspaceID: parseUUID(testWorkspaceID), + RepoOwner: "acme", + RepoName: "widget", + PrNumber: 1234, + }) + if err != nil { + t.Fatalf("GetGitHubPullRequest: %v", err) + } + if pr.State != "merged" { + t.Errorf("expected pr state merged, got %q", pr.State) + } + + linked, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID)) + if err != nil { + t.Fatalf("ListPullRequestsByIssue: %v", err) + } + if len(linked) != 1 { + t.Fatalf("expected 1 linked PR, got %d", len(linked)) + } + + updated, err := testHandler.Queries.GetIssue(ctx, parseUUID(created.ID)) + if err != nil { + t.Fatalf("GetIssue: %v", err) + } + if updated.Status != "done" { + t.Errorf("expected issue status 'done', got %q", updated.Status) + } +} + +// TestWebhook_MergedPR_PreservesCancelled guards the "do not stomp cancelled" +// rule: cancelling an issue then merging a linked PR must leave the issue +// cancelled. +func TestWebhook_MergedPR_PreservesCancelled(t *testing.T) { + if testHandler == nil { + t.Skip("handler test fixture not initialized (no DB?)") + } + ctx := context.Background() + secret := "cancelled-secret" + t.Setenv("GITHUB_WEBHOOK_SECRET", secret) + + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": "Already cancelled", + "status": "cancelled", + }) + testHandler.CreateIssue(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateIssue: %d %s", w.Code, w.Body.String()) + } + var created IssueResponse + json.NewDecoder(w.Body).Decode(&created) + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, created.ID) + testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID) + testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID) + testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID) + }) + + const installationID int64 = 11223344 + if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{ + WorkspaceID: parseUUID(testWorkspaceID), + InstallationID: installationID, + AccountLogin: "cancelled-acct", + AccountType: "User", + }); err != nil { + t.Fatalf("CreateGitHubInstallation: %v", err) + } + + body, _ := json.Marshal(map[string]any{ + "action": "closed", + "pull_request": map[string]any{ + "number": 7, "html_url": "https://x", "title": "Closes " + created.Identifier, + "state": "closed", "merged": true, "draft": false, + "merged_at": "2026-04-29T00:00:00Z", "closed_at": "2026-04-29T00:00:00Z", + "created_at": "2026-04-28T00:00:00Z", "updated_at": "2026-04-29T00:00:00Z", + "head": map[string]any{"ref": "x"}, "user": map[string]any{"login": "u"}, + }, + "repository": map[string]any{"name": "r", "owner": map[string]any{"login": "o"}}, + "installation": map[string]any{"id": installationID}, + }) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + sig := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + + w = httptest.NewRecorder() + req2 := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewReader(body)) + req2.Header.Set("X-GitHub-Event", "pull_request") + req2.Header.Set("X-Hub-Signature-256", sig) + testHandler.HandleGitHubWebhook(w, req2) + + updated, err := testHandler.Queries.GetIssue(ctx, parseUUID(created.ID)) + if err != nil { + t.Fatalf("GetIssue: %v", err) + } + if updated.Status != "cancelled" { + t.Errorf("expected status to remain 'cancelled', got %q", updated.Status) + } +} + +// TestWebhook_UninstallReturnsWorkspaceForBroadcast guards #4: the uninstall +// path must look up the workspace_id BEFORE deleting the row so the +// resulting `github_installation:deleted` event is broadcast scoped to that +// workspace (the realtime listener drops events with empty workspace_id). +func TestWebhook_UninstallReturnsWorkspaceForBroadcast(t *testing.T) { + if testHandler == nil { + t.Skip("handler test fixture not initialized (no DB?)") + } + ctx := context.Background() + const installationID int64 = 55443322 + + if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{ + WorkspaceID: parseUUID(testWorkspaceID), + InstallationID: installationID, + AccountLogin: "uninstall-test", + AccountType: "User", + }); err != nil { + t.Fatalf("CreateGitHubInstallation: %v", err) + } + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID) + }) + + deleted, err := testHandler.Queries.DeleteGitHubInstallationByInstallationID(ctx, installationID) + if err != nil { + t.Fatalf("DeleteGitHubInstallationByInstallationID: %v", err) + } + if uuidToString(deleted.WorkspaceID) != testWorkspaceID { + t.Errorf("expected returned workspace_id %s, got %s", testWorkspaceID, uuidToString(deleted.WorkspaceID)) + } + // Re-deleting must surface ErrNoRows so the handler can short-circuit + // the broadcast (and not panic). + if _, err := testHandler.Queries.DeleteGitHubInstallationByInstallationID(ctx, installationID); err == nil { + t.Error("expected ErrNoRows on second delete, got nil") + } +} diff --git a/server/migrations/079_github_integration.down.sql b/server/migrations/079_github_integration.down.sql new file mode 100644 index 0000000000..ce67d6f8ce --- /dev/null +++ b/server/migrations/079_github_integration.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS issue_pull_request; +DROP TABLE IF EXISTS github_pull_request; +DROP TABLE IF EXISTS github_installation; diff --git a/server/migrations/079_github_integration.up.sql b/server/migrations/079_github_integration.up.sql new file mode 100644 index 0000000000..c1035002f4 --- /dev/null +++ b/server/migrations/079_github_integration.up.sql @@ -0,0 +1,54 @@ +-- GitHub App integration: connected installations, mirrored pull request state, +-- and the link table joining issues ↔ PRs. + +CREATE TABLE github_installation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + installation_id BIGINT NOT NULL, + account_login TEXT NOT NULL, + account_type TEXT NOT NULL DEFAULT 'User' + CHECK (account_type IN ('User', 'Organization')), + account_avatar_url TEXT, + connected_by_id UUID REFERENCES "user"(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (installation_id) +); + +CREATE INDEX idx_github_installation_workspace ON github_installation(workspace_id); + +CREATE TABLE github_pull_request ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + installation_id BIGINT NOT NULL, + repo_owner TEXT NOT NULL, + repo_name TEXT NOT NULL, + pr_number INTEGER NOT NULL, + title TEXT NOT NULL, + state TEXT NOT NULL + CHECK (state IN ('open', 'closed', 'merged', 'draft')), + html_url TEXT NOT NULL, + branch TEXT, + author_login TEXT, + author_avatar_url TEXT, + merged_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + pr_created_at TIMESTAMPTZ NOT NULL, + pr_updated_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (workspace_id, repo_owner, repo_name, pr_number) +); + +CREATE INDEX idx_github_pull_request_workspace ON github_pull_request(workspace_id); + +CREATE TABLE issue_pull_request ( + issue_id UUID NOT NULL REFERENCES issue(id) ON DELETE CASCADE, + pull_request_id UUID NOT NULL REFERENCES github_pull_request(id) ON DELETE CASCADE, + linked_by_type TEXT, + linked_by_id UUID, + linked_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (issue_id, pull_request_id) +); + +CREATE INDEX idx_issue_pull_request_pr ON issue_pull_request(pull_request_id); diff --git a/server/pkg/db/generated/github.sql.go b/server/pkg/db/generated/github.sql.go new file mode 100644 index 0000000000..2a61e47757 --- /dev/null +++ b/server/pkg/db/generated/github.sql.go @@ -0,0 +1,425 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: github.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createGitHubInstallation = `-- name: CreateGitHubInstallation :one +INSERT INTO github_installation ( + workspace_id, installation_id, account_login, account_type, account_avatar_url, connected_by_id +) VALUES ( + $1, $2, $3, $4, $5, $6 +) +ON CONFLICT (installation_id) DO UPDATE SET + workspace_id = EXCLUDED.workspace_id, + account_login = EXCLUDED.account_login, + account_type = EXCLUDED.account_type, + account_avatar_url = EXCLUDED.account_avatar_url, + connected_by_id = EXCLUDED.connected_by_id, + updated_at = now() +RETURNING id, workspace_id, installation_id, account_login, account_type, account_avatar_url, connected_by_id, created_at, updated_at +` + +type CreateGitHubInstallationParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + InstallationID int64 `json:"installation_id"` + AccountLogin string `json:"account_login"` + AccountType string `json:"account_type"` + AccountAvatarUrl pgtype.Text `json:"account_avatar_url"` + ConnectedByID pgtype.UUID `json:"connected_by_id"` +} + +func (q *Queries) CreateGitHubInstallation(ctx context.Context, arg CreateGitHubInstallationParams) (GithubInstallation, error) { + row := q.db.QueryRow(ctx, createGitHubInstallation, + arg.WorkspaceID, + arg.InstallationID, + arg.AccountLogin, + arg.AccountType, + arg.AccountAvatarUrl, + arg.ConnectedByID, + ) + var i GithubInstallation + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.InstallationID, + &i.AccountLogin, + &i.AccountType, + &i.AccountAvatarUrl, + &i.ConnectedByID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteGitHubInstallation = `-- name: DeleteGitHubInstallation :exec +DELETE FROM github_installation WHERE id = $1 AND workspace_id = $2 +` + +type DeleteGitHubInstallationParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) DeleteGitHubInstallation(ctx context.Context, arg DeleteGitHubInstallationParams) error { + _, err := q.db.Exec(ctx, deleteGitHubInstallation, arg.ID, arg.WorkspaceID) + return err +} + +const deleteGitHubInstallationByInstallationID = `-- name: DeleteGitHubInstallationByInstallationID :one +DELETE FROM github_installation WHERE installation_id = $1 +RETURNING id, workspace_id +` + +type DeleteGitHubInstallationByInstallationIDRow struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) DeleteGitHubInstallationByInstallationID(ctx context.Context, installationID int64) (DeleteGitHubInstallationByInstallationIDRow, error) { + row := q.db.QueryRow(ctx, deleteGitHubInstallationByInstallationID, installationID) + var i DeleteGitHubInstallationByInstallationIDRow + err := row.Scan(&i.ID, &i.WorkspaceID) + return i, err +} + +const getGitHubInstallationByID = `-- name: GetGitHubInstallationByID :one +SELECT id, workspace_id, installation_id, account_login, account_type, account_avatar_url, connected_by_id, created_at, updated_at FROM github_installation +WHERE id = $1 +` + +func (q *Queries) GetGitHubInstallationByID(ctx context.Context, id pgtype.UUID) (GithubInstallation, error) { + row := q.db.QueryRow(ctx, getGitHubInstallationByID, id) + var i GithubInstallation + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.InstallationID, + &i.AccountLogin, + &i.AccountType, + &i.AccountAvatarUrl, + &i.ConnectedByID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getGitHubInstallationByInstallationID = `-- name: GetGitHubInstallationByInstallationID :one +SELECT id, workspace_id, installation_id, account_login, account_type, account_avatar_url, connected_by_id, created_at, updated_at FROM github_installation +WHERE installation_id = $1 +` + +func (q *Queries) GetGitHubInstallationByInstallationID(ctx context.Context, installationID int64) (GithubInstallation, error) { + row := q.db.QueryRow(ctx, getGitHubInstallationByInstallationID, installationID) + var i GithubInstallation + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.InstallationID, + &i.AccountLogin, + &i.AccountType, + &i.AccountAvatarUrl, + &i.ConnectedByID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getGitHubPullRequest = `-- name: GetGitHubPullRequest :one +SELECT id, workspace_id, installation_id, repo_owner, repo_name, pr_number, title, state, html_url, branch, author_login, author_avatar_url, merged_at, closed_at, pr_created_at, pr_updated_at, created_at, updated_at FROM github_pull_request +WHERE workspace_id = $1 AND repo_owner = $2 AND repo_name = $3 AND pr_number = $4 +` + +type GetGitHubPullRequestParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + RepoOwner string `json:"repo_owner"` + RepoName string `json:"repo_name"` + PrNumber int32 `json:"pr_number"` +} + +func (q *Queries) GetGitHubPullRequest(ctx context.Context, arg GetGitHubPullRequestParams) (GithubPullRequest, error) { + row := q.db.QueryRow(ctx, getGitHubPullRequest, + arg.WorkspaceID, + arg.RepoOwner, + arg.RepoName, + arg.PrNumber, + ) + var i GithubPullRequest + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.InstallationID, + &i.RepoOwner, + &i.RepoName, + &i.PrNumber, + &i.Title, + &i.State, + &i.HtmlUrl, + &i.Branch, + &i.AuthorLogin, + &i.AuthorAvatarUrl, + &i.MergedAt, + &i.ClosedAt, + &i.PrCreatedAt, + &i.PrUpdatedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const linkIssueToPullRequest = `-- name: LinkIssueToPullRequest :exec + +INSERT INTO issue_pull_request ( + issue_id, pull_request_id, linked_by_type, linked_by_id +) VALUES ( + $1, $2, $3, $4 +) +ON CONFLICT (issue_id, pull_request_id) DO NOTHING +` + +type LinkIssueToPullRequestParams struct { + IssueID pgtype.UUID `json:"issue_id"` + PullRequestID pgtype.UUID `json:"pull_request_id"` + LinkedByType pgtype.Text `json:"linked_by_type"` + LinkedByID pgtype.UUID `json:"linked_by_id"` +} + +// ===================== +// Issue ↔ Pull Request link +// ===================== +func (q *Queries) LinkIssueToPullRequest(ctx context.Context, arg LinkIssueToPullRequestParams) error { + _, err := q.db.Exec(ctx, linkIssueToPullRequest, + arg.IssueID, + arg.PullRequestID, + arg.LinkedByType, + arg.LinkedByID, + ) + return err +} + +const listGitHubInstallationsByWorkspace = `-- name: ListGitHubInstallationsByWorkspace :many + +SELECT id, workspace_id, installation_id, account_login, account_type, account_avatar_url, connected_by_id, created_at, updated_at FROM github_installation +WHERE workspace_id = $1 +ORDER BY created_at ASC +` + +// ===================== +// GitHub Installation +// ===================== +func (q *Queries) ListGitHubInstallationsByWorkspace(ctx context.Context, workspaceID pgtype.UUID) ([]GithubInstallation, error) { + rows, err := q.db.Query(ctx, listGitHubInstallationsByWorkspace, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GithubInstallation{} + for rows.Next() { + var i GithubInstallation + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.InstallationID, + &i.AccountLogin, + &i.AccountType, + &i.AccountAvatarUrl, + &i.ConnectedByID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listIssueIDsForPullRequest = `-- name: ListIssueIDsForPullRequest :many +SELECT issue_id FROM issue_pull_request +WHERE pull_request_id = $1 +` + +func (q *Queries) ListIssueIDsForPullRequest(ctx context.Context, pullRequestID pgtype.UUID) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, listIssueIDsForPullRequest, pullRequestID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []pgtype.UUID{} + for rows.Next() { + var issue_id pgtype.UUID + if err := rows.Scan(&issue_id); err != nil { + return nil, err + } + items = append(items, issue_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPullRequestsByIssue = `-- name: ListPullRequestsByIssue :many +SELECT pr.id, pr.workspace_id, pr.installation_id, pr.repo_owner, pr.repo_name, pr.pr_number, pr.title, pr.state, pr.html_url, pr.branch, pr.author_login, pr.author_avatar_url, pr.merged_at, pr.closed_at, pr.pr_created_at, pr.pr_updated_at, pr.created_at, pr.updated_at +FROM github_pull_request pr +JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id +WHERE ipr.issue_id = $1 +ORDER BY pr.pr_created_at DESC +` + +func (q *Queries) ListPullRequestsByIssue(ctx context.Context, issueID pgtype.UUID) ([]GithubPullRequest, error) { + rows, err := q.db.Query(ctx, listPullRequestsByIssue, issueID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GithubPullRequest{} + for rows.Next() { + var i GithubPullRequest + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.InstallationID, + &i.RepoOwner, + &i.RepoName, + &i.PrNumber, + &i.Title, + &i.State, + &i.HtmlUrl, + &i.Branch, + &i.AuthorLogin, + &i.AuthorAvatarUrl, + &i.MergedAt, + &i.ClosedAt, + &i.PrCreatedAt, + &i.PrUpdatedAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const unlinkIssueFromPullRequest = `-- name: UnlinkIssueFromPullRequest :exec +DELETE FROM issue_pull_request +WHERE issue_id = $1 AND pull_request_id = $2 +` + +type UnlinkIssueFromPullRequestParams struct { + IssueID pgtype.UUID `json:"issue_id"` + PullRequestID pgtype.UUID `json:"pull_request_id"` +} + +func (q *Queries) UnlinkIssueFromPullRequest(ctx context.Context, arg UnlinkIssueFromPullRequestParams) error { + _, err := q.db.Exec(ctx, unlinkIssueFromPullRequest, arg.IssueID, arg.PullRequestID) + return err +} + +const upsertGitHubPullRequest = `-- name: UpsertGitHubPullRequest :one + +INSERT INTO github_pull_request ( + workspace_id, installation_id, repo_owner, repo_name, pr_number, + title, state, html_url, branch, author_login, author_avatar_url, + merged_at, closed_at, pr_created_at, pr_updated_at +) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $11, $12, $13, + $14, $15, $9, $10 +) +ON CONFLICT (workspace_id, repo_owner, repo_name, pr_number) DO UPDATE SET + installation_id = EXCLUDED.installation_id, + title = EXCLUDED.title, + state = EXCLUDED.state, + html_url = EXCLUDED.html_url, + branch = EXCLUDED.branch, + author_login = EXCLUDED.author_login, + author_avatar_url = EXCLUDED.author_avatar_url, + merged_at = EXCLUDED.merged_at, + closed_at = EXCLUDED.closed_at, + pr_updated_at = EXCLUDED.pr_updated_at, + updated_at = now() +RETURNING id, workspace_id, installation_id, repo_owner, repo_name, pr_number, title, state, html_url, branch, author_login, author_avatar_url, merged_at, closed_at, pr_created_at, pr_updated_at, created_at, updated_at +` + +type UpsertGitHubPullRequestParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + InstallationID int64 `json:"installation_id"` + RepoOwner string `json:"repo_owner"` + RepoName string `json:"repo_name"` + PrNumber int32 `json:"pr_number"` + Title string `json:"title"` + State string `json:"state"` + HtmlUrl string `json:"html_url"` + PrCreatedAt pgtype.Timestamptz `json:"pr_created_at"` + PrUpdatedAt pgtype.Timestamptz `json:"pr_updated_at"` + Branch pgtype.Text `json:"branch"` + AuthorLogin pgtype.Text `json:"author_login"` + AuthorAvatarUrl pgtype.Text `json:"author_avatar_url"` + MergedAt pgtype.Timestamptz `json:"merged_at"` + ClosedAt pgtype.Timestamptz `json:"closed_at"` +} + +// ===================== +// GitHub Pull Request +// ===================== +func (q *Queries) UpsertGitHubPullRequest(ctx context.Context, arg UpsertGitHubPullRequestParams) (GithubPullRequest, error) { + row := q.db.QueryRow(ctx, upsertGitHubPullRequest, + arg.WorkspaceID, + arg.InstallationID, + arg.RepoOwner, + arg.RepoName, + arg.PrNumber, + arg.Title, + arg.State, + arg.HtmlUrl, + arg.PrCreatedAt, + arg.PrUpdatedAt, + arg.Branch, + arg.AuthorLogin, + arg.AuthorAvatarUrl, + arg.MergedAt, + arg.ClosedAt, + ) + var i GithubPullRequest + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.InstallationID, + &i.RepoOwner, + &i.RepoName, + &i.PrNumber, + &i.Title, + &i.State, + &i.HtmlUrl, + &i.Branch, + &i.AuthorLogin, + &i.AuthorAvatarUrl, + &i.MergedAt, + &i.ClosedAt, + &i.PrCreatedAt, + &i.PrUpdatedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 4bb1dca4e2..7de4d47097 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -240,6 +240,39 @@ type Feedback struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type GithubInstallation struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + InstallationID int64 `json:"installation_id"` + AccountLogin string `json:"account_login"` + AccountType string `json:"account_type"` + AccountAvatarUrl pgtype.Text `json:"account_avatar_url"` + ConnectedByID pgtype.UUID `json:"connected_by_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type GithubPullRequest struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + InstallationID int64 `json:"installation_id"` + RepoOwner string `json:"repo_owner"` + RepoName string `json:"repo_name"` + PrNumber int32 `json:"pr_number"` + Title string `json:"title"` + State string `json:"state"` + HtmlUrl string `json:"html_url"` + Branch pgtype.Text `json:"branch"` + AuthorLogin pgtype.Text `json:"author_login"` + AuthorAvatarUrl pgtype.Text `json:"author_avatar_url"` + MergedAt pgtype.Timestamptz `json:"merged_at"` + ClosedAt pgtype.Timestamptz `json:"closed_at"` + PrCreatedAt pgtype.Timestamptz `json:"pr_created_at"` + PrUpdatedAt pgtype.Timestamptz `json:"pr_updated_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type InboxItem struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -299,6 +332,14 @@ type IssueLabel struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type IssuePullRequest struct { + IssueID pgtype.UUID `json:"issue_id"` + PullRequestID pgtype.UUID `json:"pull_request_id"` + LinkedByType pgtype.Text `json:"linked_by_type"` + LinkedByID pgtype.UUID `json:"linked_by_id"` + LinkedAt pgtype.Timestamptz `json:"linked_at"` +} + type IssueReaction struct { ID pgtype.UUID `json:"id"` IssueID pgtype.UUID `json:"issue_id"` diff --git a/server/pkg/db/queries/github.sql b/server/pkg/db/queries/github.sql new file mode 100644 index 0000000000..cb9edf3e5a --- /dev/null +++ b/server/pkg/db/queries/github.sql @@ -0,0 +1,97 @@ +-- ===================== +-- GitHub Installation +-- ===================== + +-- name: ListGitHubInstallationsByWorkspace :many +SELECT * FROM github_installation +WHERE workspace_id = $1 +ORDER BY created_at ASC; + +-- name: GetGitHubInstallationByInstallationID :one +SELECT * FROM github_installation +WHERE installation_id = $1; + +-- name: GetGitHubInstallationByID :one +SELECT * FROM github_installation +WHERE id = $1; + +-- name: CreateGitHubInstallation :one +INSERT INTO github_installation ( + workspace_id, installation_id, account_login, account_type, account_avatar_url, connected_by_id +) VALUES ( + $1, $2, $3, $4, sqlc.narg('account_avatar_url'), sqlc.narg('connected_by_id') +) +ON CONFLICT (installation_id) DO UPDATE SET + workspace_id = EXCLUDED.workspace_id, + account_login = EXCLUDED.account_login, + account_type = EXCLUDED.account_type, + account_avatar_url = EXCLUDED.account_avatar_url, + connected_by_id = EXCLUDED.connected_by_id, + updated_at = now() +RETURNING *; + +-- name: DeleteGitHubInstallation :exec +DELETE FROM github_installation WHERE id = $1 AND workspace_id = $2; + +-- name: DeleteGitHubInstallationByInstallationID :one +DELETE FROM github_installation WHERE installation_id = $1 +RETURNING id, workspace_id; + +-- ===================== +-- GitHub Pull Request +-- ===================== + +-- name: UpsertGitHubPullRequest :one +INSERT INTO github_pull_request ( + workspace_id, installation_id, repo_owner, repo_name, pr_number, + title, state, html_url, branch, author_login, author_avatar_url, + merged_at, closed_at, pr_created_at, pr_updated_at +) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, sqlc.narg('branch'), sqlc.narg('author_login'), sqlc.narg('author_avatar_url'), + sqlc.narg('merged_at'), sqlc.narg('closed_at'), $9, $10 +) +ON CONFLICT (workspace_id, repo_owner, repo_name, pr_number) DO UPDATE SET + installation_id = EXCLUDED.installation_id, + title = EXCLUDED.title, + state = EXCLUDED.state, + html_url = EXCLUDED.html_url, + branch = EXCLUDED.branch, + author_login = EXCLUDED.author_login, + author_avatar_url = EXCLUDED.author_avatar_url, + merged_at = EXCLUDED.merged_at, + closed_at = EXCLUDED.closed_at, + pr_updated_at = EXCLUDED.pr_updated_at, + updated_at = now() +RETURNING *; + +-- name: GetGitHubPullRequest :one +SELECT * FROM github_pull_request +WHERE workspace_id = $1 AND repo_owner = $2 AND repo_name = $3 AND pr_number = $4; + +-- name: ListPullRequestsByIssue :many +SELECT pr.* +FROM github_pull_request pr +JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id +WHERE ipr.issue_id = $1 +ORDER BY pr.pr_created_at DESC; + +-- name: ListIssueIDsForPullRequest :many +SELECT issue_id FROM issue_pull_request +WHERE pull_request_id = $1; + +-- ===================== +-- Issue ↔ Pull Request link +-- ===================== + +-- name: LinkIssueToPullRequest :exec +INSERT INTO issue_pull_request ( + issue_id, pull_request_id, linked_by_type, linked_by_id +) VALUES ( + $1, $2, sqlc.narg('linked_by_type'), sqlc.narg('linked_by_id') +) +ON CONFLICT (issue_id, pull_request_id) DO NOTHING; + +-- name: UnlinkIssueFromPullRequest :exec +DELETE FROM issue_pull_request +WHERE issue_id = $1 AND pull_request_id = $2; diff --git a/server/pkg/protocol/events.go b/server/pkg/protocol/events.go index 4f5e99786c..3c64a9a018 100644 --- a/server/pkg/protocol/events.go +++ b/server/pkg/protocol/events.go @@ -107,4 +107,11 @@ const ( EventDaemonHeartbeatAck = "daemon:heartbeat_ack" EventDaemonRegister = "daemon:register" EventDaemonTaskAvailable = "daemon:task_available" + + // GitHub integration events + EventGitHubInstallationCreated = "github_installation:created" + EventGitHubInstallationDeleted = "github_installation:deleted" + EventPullRequestLinked = "pull_request:linked" + EventPullRequestUpdated = "pull_request:updated" + EventPullRequestUnlinked = "pull_request:unlinked" )