Compare commits

...

4 Commits

Author SHA1 Message Date
Jiang Bohan
245e2d5582 fix(github): i18n the new GitHub UI strings to satisfy lint
CI flagged every literal string in the Integrations tab, the Pull requests
sidebar section, and the per-PR row label. Move them through useT() and
add the matching `integrations.*` block to settings.json (en / zh-Hans)
plus `detail.section_pull_requests` / `detail.pull_request_state_*` /
loading + empty copy under `issues.json`.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 16:45:35 +08:00
Jiang Bohan
329eed7a22 fix(github): address review — null actor, role gating, configured guard, scoped uninstall broadcast
- listeners: use optionalUUID(e.ActorID) so the system actor on the github-driven issue:updated event no longer panics activity / notification listeners; merged-PR → issue done now produces a status_changed activity and inbox entry.
- IntegrationsTab: gate the admin-only installations query on canManage so members no longer hit /github/installations 403; the configured/not-configured copy is also scoped to admins.
- backend: introduce isGitHubConfigured() requiring both GITHUB_APP_SLUG and GITHUB_WEBHOOK_SECRET, and surface that single flag from list-installations + connect endpoints so the frontend Connect button stays disabled until both are set.
- DeleteGitHubInstallationByInstallationID now RETURNs workspace_id; webhook handler publishes github_installation:deleted scoped to the right workspace so already-open Settings tabs invalidate in real time. ErrNoRows on a re-fired delete short-circuits cleanly.
- tests: focused webhook integration coverage (auto-link + merge → done, cancelled preservation, uninstall returns workspace).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-08 18:20:02 +08:00
Jiang Bohan
4fae72ddba feat(github): UI for connecting GitHub and viewing linked PRs
- Settings → Integrations: new tab with Connect GitHub / installations list / disconnect, gated on the deployment having the App configured.
- Issue detail sidebar: Pull requests section showing linked PR title, repo, state (open/draft/merged/closed), and author, with deep link to GitHub.
- Real-time refresh: github_installation:* and pull_request:* events invalidate the matching TanStack Query caches.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-08 18:19:39 +08:00
Jiang Bohan
2f61cdaac4 feat(github): GitHub App backend for PR ↔ issue linking
- New tables: github_installation (workspace ↔ App install), github_pull_request (mirrored PR state), issue_pull_request (M:N link).
- Webhook handler verifies HMAC-SHA256, upserts PR rows, parses issue identifiers from PR title/body/branch and auto-links them. Merging a linked PR moves the issue to done.
- Connect/setup endpoints power the zero-config "Connect GitHub" install flow; state token is HMAC-signed so the setup callback can recover the workspace.
- Workspace-scoped admin routes for listing/disconnecting installations, plus a per-issue `pull-requests` list endpoint.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-08 18:18:13 +08:00
27 changed files with 2200 additions and 12 deletions

View File

@@ -82,6 +82,9 @@ import type {
ListAutopilotRunsResponse,
NotificationPreferenceResponse,
NotificationPreferences,
GitHubPullRequest,
ListGitHubInstallationsResponse,
GitHubConnectResponse,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
@@ -1266,4 +1269,23 @@ export class ApiClient {
async deleteAutopilotTrigger(autopilotId: string, triggerId: string): Promise<void> {
await this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, { method: "DELETE" });
}
// GitHub integration
async getGitHubConnectURL(workspaceId: string): Promise<GitHubConnectResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/github/connect`);
}
async listGitHubInstallations(workspaceId: string): Promise<ListGitHubInstallationsResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/github/installations`);
}
async deleteGitHubInstallation(workspaceId: string, installationId: string): Promise<void> {
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`);
}
}

View File

@@ -0,0 +1 @@
export * from "./queries";

View File

@@ -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,
});

View File

@@ -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",

View File

@@ -20,6 +20,7 @@ import {
agentRunCountsKeys,
agentTasksKeys,
} from "../agents/queries";
import { githubKeys } from "../github/queries";
import {
onIssueCreated,
onIssueUpdated,
@@ -155,6 +156,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

View File

@@ -65,7 +65,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<T = unknown> {
type: WSEventType;

View File

@@ -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;
}

View File

@@ -69,6 +69,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,

View File

@@ -48,6 +48,7 @@ import { CommentCard } from "./comment-card";
import { CommentInput } from "./comment-input";
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";
@@ -228,6 +229,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);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [highlightedId, setHighlightedId] = useState<string | null>(null);
@@ -553,6 +555,18 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
</div>
)}
{/* Pull requests */}
<div>
<button
className={`flex w-full items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors mb-2 hover:bg-accent/70 ${pullRequestsOpen ? "" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => setPullRequestsOpen(!pullRequestsOpen)}
>
{t(($) => $.detail.section_pull_requests)}
<ChevronRight className={`!size-3 shrink-0 stroke-[2.5] text-muted-foreground transition-transform ${pullRequestsOpen ? "rotate-90" : ""}`} />
</button>
{pullRequestsOpen && <div className="pl-2"><PullRequestList issueId={id} /></div>}
</div>
{/* Details */}
<div>
<button

View File

@@ -0,0 +1,82 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
GitPullRequest,
GitPullRequestArrow,
GitPullRequestClosed,
GitMerge,
GitPullRequestDraft,
} from "lucide-react";
import { issuePullRequestsOptions } from "@multica/core/github/queries";
import type { GitHubPullRequest, GitHubPullRequestState } from "@multica/core/types";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
const STATE_ICON: Record<
GitHubPullRequestState,
{ icon: React.ComponentType<{ className?: string }>; className: string }
> = {
open: { icon: GitPullRequestArrow, className: "text-emerald-600 dark:text-emerald-400" },
draft: { icon: GitPullRequestDraft, className: "text-muted-foreground" },
merged: { icon: GitMerge, className: "text-violet-600 dark:text-violet-400" },
closed: { icon: GitPullRequestClosed, className: "text-rose-600 dark:text-rose-400" },
};
export function PullRequestList({ issueId }: { issueId: string }) {
const { t } = useT("issues");
const { data, isLoading } = useQuery(issuePullRequestsOptions(issueId));
const prs = data?.pull_requests ?? [];
if (isLoading) {
return <p className="text-xs text-muted-foreground px-2">{t(($) => $.detail.pull_requests_loading)}</p>;
}
if (prs.length === 0) {
return (
<p className="text-xs text-muted-foreground px-2">
{t(($) => $.detail.pull_requests_empty)}
</p>
);
}
return (
<div className="space-y-1">
{prs.map((pr) => (
<PullRequestRow key={pr.id} pr={pr} />
))}
</div>
);
}
function PullRequestRow({ pr }: { pr: GitHubPullRequest }) {
const { t } = useT("issues");
const cfg = STATE_ICON[pr.state] ?? { icon: GitPullRequest, className: "" };
const Icon = cfg.icon;
const label =
pr.state === "open"
? t(($) => $.detail.pull_request_state_open)
: pr.state === "draft"
? t(($) => $.detail.pull_request_state_draft)
: pr.state === "merged"
? t(($) => $.detail.pull_request_state_merged)
: pr.state === "closed"
? t(($) => $.detail.pull_request_state_closed)
: pr.state;
return (
<a
href={pr.html_url}
target="_blank"
rel="noreferrer noopener"
className="flex items-start gap-2 rounded-md px-2 py-1.5 -mx-2 hover:bg-accent/50 transition-colors group"
>
<Icon className={cn("h-3.5 w-3.5 mt-0.5 shrink-0", cfg.className)} />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium truncate group-hover:text-foreground">{pr.title}</p>
<p className="text-[11px] text-muted-foreground truncate">
{pr.repo_owner}/{pr.repo_name}#{pr.number} · {label}
{pr.author_login ? ` · @${pr.author_login}` : null}
</p>
</div>
</a>
);
}

View File

@@ -98,8 +98,15 @@
"add_sub_issue_aria": "Add sub-issue",
"section_properties": "Properties",
"section_parent_issue": "Parent issue",
"section_pull_requests": "Pull requests",
"section_details": "Details",
"section_token_usage": "Token usage",
"pull_requests_loading": "Loading…",
"pull_requests_empty": "No linked pull requests yet. Reference this issue's identifier in a PR's branch name, title, or body to auto-link it.",
"pull_request_state_open": "Open",
"pull_request_state_draft": "Draft",
"pull_request_state_merged": "Merged",
"pull_request_state_closed": "Closed",
"prop_status": "Status",
"prop_priority": "Priority",
"prop_assignee": "Assignee",

View File

@@ -24,6 +24,7 @@
"tokens": "API Tokens",
"general": "General",
"repositories": "Repositories",
"integrations": "Integrations",
"labs": "Labs",
"members": "Members"
}
@@ -154,6 +155,27 @@
"co_authored_by_description_suffix": "to commits made by agents.",
"toast_failed": "Failed to update setting"
},
"integrations": {
"section_title": "Integrations",
"github_title": "GitHub",
"github_description_prefix": "Link issues to pull requests automatically. When a PR with",
"github_description_suffix": "in its branch, title, or body is merged, the corresponding issue moves to",
"github_description_done": "Done",
"github_identifier_example": "MUL-123",
"connect_github": "Connect GitHub",
"connect_opening": "Opening…",
"connect_disabled_tooltip": "GitHub App is not configured on this server",
"not_configured": "GitHub integration is not configured for this deployment. Operators must set",
"not_configured_and": "and",
"loading": "Loading…",
"empty": "No GitHub accounts connected yet.",
"connected_at": "{{type}} · connected {{date}}",
"manage_hint": "Only admins and owners can manage integrations.",
"toast_not_configured": "GitHub integration is not configured for this deployment",
"toast_open_failed": "Failed to open GitHub install",
"toast_disconnected": "Disconnected GitHub account",
"toast_disconnect_failed": "Failed to disconnect"
},
"repositories": {
"section_title": "Repositories",
"description": "Git repositories associated with this workspace. Agents use these to clone and work on code.",

View File

@@ -97,8 +97,15 @@
"add_sub_issue_aria": "添加子 issue",
"section_properties": "属性",
"section_parent_issue": "父 issue",
"section_pull_requests": "Pull Request",
"section_details": "详情",
"section_token_usage": "Token 用量",
"pull_requests_loading": "加载中…",
"pull_requests_empty": "还没有关联的 PR。在 PR 的分支名、标题或正文里引用本 issue 的 identifier 即可自动关联。",
"pull_request_state_open": "Open",
"pull_request_state_draft": "Draft",
"pull_request_state_merged": "Merged",
"pull_request_state_closed": "Closed",
"prop_status": "状态",
"prop_priority": "优先级",
"prop_assignee": "负责人",

View File

@@ -24,6 +24,7 @@
"tokens": "API Token",
"general": "通用",
"repositories": "代码仓库",
"integrations": "集成",
"labs": "实验室",
"members": "成员"
}
@@ -154,6 +155,27 @@
"co_authored_by_description_suffix": "。",
"toast_failed": "更新设置失败"
},
"integrations": {
"section_title": "集成",
"github_title": "GitHub",
"github_description_prefix": "自动把 issue 关联到 Pull Request。当 PR 的分支、标题或正文中包含",
"github_description_suffix": "并被合并时,对应的 issue 会自动转为",
"github_description_done": "Done",
"github_identifier_example": "MUL-123",
"connect_github": "连接 GitHub",
"connect_opening": "正在打开…",
"connect_disabled_tooltip": "服务端未配置 GitHub App",
"not_configured": "当前部署没有配置 GitHub 集成。运维需要设置",
"not_configured_and": "和",
"loading": "加载中…",
"empty": "还没有连接 GitHub 账户。",
"connected_at": "{{type}} · {{date}} 连接",
"manage_hint": "只有管理员和所有者可以管理集成。",
"toast_not_configured": "当前部署未配置 GitHub 集成",
"toast_open_failed": "打开 GitHub 安装页失败",
"toast_disconnected": "已断开 GitHub 账户",
"toast_disconnect_failed": "断开连接失败"
},
"repositories": {
"section_title": "代码仓库",
"description": "与该工作区关联的 Git 仓库。智能体会从这里 clone 代码并完成工作。",

View File

@@ -0,0 +1,177 @@
"use client";
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions } from "@multica/core/workspace/queries";
import { githubInstallationsOptions, githubKeys } from "@multica/core/github/queries";
import { api } from "@multica/core/api";
import { useT } from "../../i18n";
// lucide-react v1.x dropped brand marks (including Github). Render an inline
// SVG of the official GitHub octocat mark so the card is still recognizable.
function GitHubMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className={className} fill="currentColor">
<path d="M12 .5C5.6.5.5 5.6.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2.2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.8-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.3-1.5 3.3-1.2 3.3-1.2.6 1.6.2 2.8.1 3.1.7.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.6 18.4.5 12 .5z" />
</svg>
);
}
export function IntegrationsTab() {
const { t } = useT("settings");
const wsId = useWorkspaceId();
const user = useAuthStore((s) => s.user);
const qc = useQueryClient();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const [connecting, setConnecting] = useState(false);
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
const canManage = currentMember?.role === "owner" || currentMember?.role === "admin";
// The list endpoint is admin-only; non-admins would see a 403 toast and
// an empty configured state. Gate the query on canManage so members get
// a clean read-only view.
const { data, isLoading } = useQuery({
...githubInstallationsOptions(wsId),
enabled: !!wsId && canManage,
});
const installations = data?.installations ?? [];
const configured = data?.configured ?? false;
async function handleConnect() {
setConnecting(true);
try {
const resp = await api.getGitHubConnectURL(wsId);
if (!resp.configured || !resp.url) {
toast.error(t(($) => $.integrations.toast_not_configured));
return;
}
window.open(resp.url, "_blank", "noopener");
} catch (e) {
toast.error(e instanceof Error ? e.message : t(($) => $.integrations.toast_open_failed));
} finally {
setConnecting(false);
}
}
async function handleDisconnect(installationDbId: string) {
try {
await api.deleteGitHubInstallation(wsId, installationDbId);
qc.invalidateQueries({ queryKey: githubKeys.installations(wsId) });
toast.success(t(($) => $.integrations.toast_disconnected));
} catch (e) {
toast.error(e instanceof Error ? e.message : t(($) => $.integrations.toast_disconnect_failed));
}
}
return (
<div className="space-y-8">
<section className="space-y-4">
<h2 className="text-sm font-semibold">{t(($) => $.integrations.section_title)}</h2>
<Card>
<CardContent className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<GitHubMark className="h-6 w-6 mt-0.5 shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium">{t(($) => $.integrations.github_title)}</p>
<p className="text-xs text-muted-foreground">
{t(($) => $.integrations.github_description_prefix)}{" "}
<code className="rounded bg-muted px-1 py-0.5 text-[10px]">
{t(($) => $.integrations.github_identifier_example)}
</code>{" "}
{t(($) => $.integrations.github_description_suffix)}{" "}
<strong>{t(($) => $.integrations.github_description_done)}</strong>.
</p>
</div>
</div>
{canManage && (
<Button
size="sm"
onClick={handleConnect}
disabled={connecting || !configured}
title={!configured ? t(($) => $.integrations.connect_disabled_tooltip) : undefined}
>
{connecting
? t(($) => $.integrations.connect_opening)
: t(($) => $.integrations.connect_github)}
</Button>
)}
</div>
{canManage && !configured && (
<p className="text-xs text-muted-foreground">
{t(($) => $.integrations.not_configured)}{" "}
<code className="rounded bg-muted px-1 py-0.5 text-[10px]">GITHUB_APP_SLUG</code>{" "}
{t(($) => $.integrations.not_configured_and)}{" "}
<code className="rounded bg-muted px-1 py-0.5 text-[10px]">GITHUB_WEBHOOK_SECRET</code>.
</p>
)}
{canManage && configured && (
<div className="space-y-2">
{isLoading && <p className="text-xs text-muted-foreground">{t(($) => $.integrations.loading)}</p>}
{!isLoading && installations.length === 0 && (
<p className="text-xs text-muted-foreground">
{t(($) => $.integrations.empty)}
</p>
)}
{installations.map((inst) => (
<div
key={inst.id}
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
>
<div className="flex items-center gap-2 min-w-0">
{inst.account_avatar_url && (
<img
src={inst.account_avatar_url}
alt=""
className="h-6 w-6 rounded-full shrink-0"
/>
)}
<div className="min-w-0">
<p className="text-sm font-medium truncate">{inst.account_login}</p>
<p className="text-xs text-muted-foreground">
{t(($) => $.integrations.connected_at, {
type: inst.account_type,
date: new Date(inst.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
}),
})}
</p>
</div>
</div>
{canManage && (
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive shrink-0"
onClick={() => handleDisconnect(inst.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
</div>
)}
{!canManage && (
<p className="text-xs text-muted-foreground">
{t(($) => $.integrations.manage_hint)}
</p>
)}
</CardContent>
</Card>
</section>
</div>
);
}

View File

@@ -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 = {}) {
<TabsContent value="tokens"><TokensTab /></TabsContent>
<TabsContent value="workspace"><WorkspaceTab /></TabsContent>
<TabsContent value="repositories"><RepositoriesTab /></TabsContent>
<TabsContent value="integrations"><IntegrationsTab /></TabsContent>
<TabsContent value="labs"><LabsTab /></TabsContent>
<TabsContent value="members"><MembersTab /></TabsContent>
{extraAccountTabs?.map((tab) => (

View File

@@ -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("{}"),
})

View File

@@ -269,7 +269,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 {
@@ -334,7 +334,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 {
@@ -421,7 +421,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 {

View File

@@ -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)
})
})
@@ -579,6 +594,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

View File

@@ -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: "<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 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
}

View File

@@ -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")
}
}

View File

@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS issue_pull_request;
DROP TABLE IF EXISTS github_pull_request;
DROP TABLE IF EXISTS github_installation;

View File

@@ -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);

View File

@@ -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
}

View File

@@ -232,6 +232,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"`
@@ -291,6 +324,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"`

View File

@@ -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;

View File

@@ -105,4 +105,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"
)