mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 02:19:19 +02:00
Compare commits
4 Commits
agent/lamb
...
agent/j/7c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
245e2d5582 | ||
|
|
329eed7a22 | ||
|
|
4fae72ddba | ||
|
|
2f61cdaac4 |
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
1
packages/core/github/index.ts
Normal file
1
packages/core/github/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./queries";
|
||||
22
packages/core/github/queries.ts
Normal file
22
packages/core/github/queries.ts
Normal 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,
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
41
packages/core/types/github.ts
Normal file
41
packages/core/types/github.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
82
packages/views/issues/components/pull-request-list.tsx
Normal file
82
packages/views/issues/components/pull-request-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "负责人",
|
||||
|
||||
@@ -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 代码并完成工作。",
|
||||
|
||||
177
packages/views/settings/components/integrations-tab.tsx
Normal file
177
packages/views/settings/components/integrations-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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("{}"),
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
717
server/internal/handler/github.go
Normal file
717
server/internal/handler/github.go
Normal 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
|
||||
}
|
||||
371
server/internal/handler/github_test.go
Normal file
371
server/internal/handler/github_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
3
server/migrations/079_github_integration.down.sql
Normal file
3
server/migrations/079_github_integration.down.sql
Normal 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;
|
||||
54
server/migrations/079_github_integration.up.sql
Normal file
54
server/migrations/079_github_integration.up.sql
Normal 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);
|
||||
425
server/pkg/db/generated/github.sql.go
Normal file
425
server/pkg/db/generated/github.sql.go
Normal 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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
97
server/pkg/db/queries/github.sql
Normal file
97
server/pkg/db/queries/github.sql
Normal 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;
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user