feat: initial github integration with importing projects (#421)

* feat: initial github integration with importing projects

* fix: remove unused args

* chore: remove duplicate col

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
This commit is contained in:
Morfixx
2026-03-02 01:39:25 +03:00
committed by GitHub
parent eeae23a24c
commit 280d8f66dd
11 changed files with 3961 additions and 36 deletions

View File

@@ -4,7 +4,7 @@ import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { Fragment, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { FaTrello } from "react-icons/fa";
import { FaGithub, FaTrello } from "react-icons/fa";
import {
HiChevronUpDown,
HiMiniArrowTopRightOnSquare,
@@ -27,12 +27,23 @@ const integrationProviders: Record<
name: "Trello",
icon: <FaTrello />,
},
github: {
name: "GitHub",
icon: <FaGithub />,
},
};
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
const SelectSource = ({
handleNextStep,
}: {
handleNextStep: (provider: string) => void;
}) => {
const { data: integrations, refetch: refetchIntegrations } =
api.integration.providers.useQuery();
const { control, handleSubmit } = useForm({
const { data: githubStatus, refetch: refetchGithubStatus } =
api.integration.getGitHubStatus.useQuery();
const { control, handleSubmit, watch } = useForm({
defaultValues: {
source: integrations?.[0]?.provider ?? "trello",
},
@@ -47,23 +58,36 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
},
);
const hasIntegrations = integrations && integrations.length > 0;
const availableIntegrations = [
...(integrations ?? []),
...(githubStatus?.connected ? [{ provider: "github" }] : []),
];
const hasIntegrations = availableIntegrations.length > 0;
useEffect(() => {
const handleFocus = () => {
refetchIntegrations();
void refetchIntegrations();
void refetchGithubStatus();
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [refetchIntegrations]);
}, [refetchIntegrations, refetchGithubStatus]);
const onSubmit = () => {
if (!hasIntegrations && trelloUrl) {
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
const selected = watch("source");
if (
selected === "trello" &&
!integrations?.some((i) => i.provider === "trello")
) {
if (trelloUrl)
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
} else if (selected === "github" && !githubStatus?.connected) {
window.open("/settings/integrations", "_blank");
} else {
handleNextStep();
handleNextStep(selected);
}
};
@@ -102,7 +126,7 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
>
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-light-50 py-1 text-base text-neutral-900 shadow-lg ring-1 ring-light-600 ring-opacity-5 focus:outline-none dark:bg-dark-300 dark:text-dark-1000 sm:text-sm">
{hasIntegrations ? (
integrations.map((integration, index) => (
availableIntegrations.map((integration, index) => (
<Listbox.Option
key={`source_${index}`}
className="relative cursor-default select-none px-1"
@@ -123,18 +147,32 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
</Listbox.Option>
))
) : (
<Listbox.Option
key="trello_placeholder"
className="font-sm relative cursor-default select-none px-1"
value="trello"
>
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
{integrationProviders.trello?.icon}
<span className="ml-2 block truncate text-sm">
{integrationProviders.trello?.name}
</span>
</div>
</Listbox.Option>
<>
<Listbox.Option
key="trello_placeholder"
className="font-sm relative cursor-default select-none px-1"
value="trello"
>
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
{integrationProviders.trello?.icon}
<span className="ml-2 block truncate text-sm">
{integrationProviders.trello?.name}
</span>
</div>
</Listbox.Option>
<Listbox.Option
key="github_placeholder"
className="font-sm relative cursor-default select-none px-1"
value="github"
>
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
{integrationProviders.github?.icon}
<span className="ml-2 block truncate text-sm">
{integrationProviders.github?.name}
</span>
</div>
</Listbox.Option>
</>
)}
</Listbox.Options>
</Transition>
@@ -154,7 +192,159 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
!hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined
}
>
{hasIntegrations ? t`Select source` : t`Connect Trello`}
{hasIntegrations ? t`Select source` : t`Connect`}
</Button>
</div>
</div>
</form>
);
};
const ImportGithub: React.FC = () => {
const utils = api.useUtils();
const { closeModal } = useModal();
const { workspace } = useWorkspace();
const { showPopup } = usePopup();
const [isSelectAllEnabled, setIsSelectAllEnabled] = useState(false);
const refetchBoards = () => utils.board.all.refetch();
const { data: projects, isLoading: projectsLoading } =
api.import.github.getProjects.useQuery();
const {
register: registerProjects,
handleSubmit: handleSubmitProjects,
setValue,
watch,
} = useForm({
defaultValues: Object.fromEntries(
projects?.map((project) => [project.id, true]) ?? [],
),
});
const importProjects = api.import.github.importProjects.useMutation({
onSuccess: async () => {
showPopup({
header: t`Import complete`,
message: t`Your projects have been imported.`,
icon: "success",
});
try {
await refetchBoards();
closeModal();
} catch (e) {
console.log(e);
}
},
onError: () => {
showPopup({
header: t`Import failed`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const projectWatchers = projects?.map((project) => ({
id: project.id,
value: watch(project.id),
}));
const projectCount =
projectWatchers?.filter((w) => w.value === true).length ?? 0;
const onSubmitProjects = (values: Record<string, boolean>) => {
const projectIds = Object.keys(values).filter(
(key) => values[key] === true,
);
importProjects.mutate({
projectIds,
workspacePublicId: workspace.publicId,
});
};
const renderContent = () => {
if (projectsLoading) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1">
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
</div>
);
}
if (!projects?.length) {
return (
<div className="flex h-full w-full items-center justify-center">
<p className="text-sm text-neutral-500 dark:text-dark-900">
{t`No projects found`}
</p>
</div>
);
}
return projects.map((project) => (
<div key={project.id}>
<label
className="flex cursor-pointer items-center rounded-[5px] p-2 hover:bg-light-100 dark:hover:bg-dark-300"
htmlFor={project.id}
>
<input
id={project.id}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0"
{...registerProjects(project.id)}
/>
<span className="ml-3 text-sm text-neutral-900 dark:text-dark-1000">
{project.name}
</span>
</label>
</div>
));
};
return (
<form onSubmit={handleSubmitProjects(onSubmitProjects)}>
<div className="h-[105px] overflow-auto px-5">{renderContent()}</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Select all`}
isChecked={!!isSelectAllEnabled}
onChange={() => {
const newState = !isSelectAllEnabled;
setIsSelectAllEnabled(newState);
for (const project of projects ?? []) {
setValue(project.id, newState);
}
}}
/>
<div className="space-x-2">
<Button
type="submit"
isLoading={importProjects.isPending}
disabled={
importProjects.isPending ||
projectsLoading ||
!projects?.length ||
!projects.some(
(project) =>
projectWatchers?.find((w) => w.id === project.id)?.value ===
true,
)
}
>
<Trans>
<Plural
value={projectCount}
one={`Import project (1)`}
other={`Import projects (${projectCount})`}
/>
</Trans>
</Button>
</div>
</div>
@@ -213,7 +403,7 @@ const ImportTrello: React.FC = () => {
value: watch(board.id),
}));
const boardCount = boardWatchers?.filter((w) => w.value === true).length || 0;
const boardCount = boardWatchers?.filter((w) => w.value === true).length ?? 0;
const onSubmitBoards = (values: Record<string, boolean>) => {
const boardIds = Object.keys(values).filter((key) => values[key] === true);
@@ -267,7 +457,7 @@ const ImportTrello: React.FC = () => {
return (
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
<div className="h-[105px] overflow-scroll px-5">{renderContent()}</div>
<div className="h-[105px] overflow-auto px-5">{renderContent()}</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
@@ -277,7 +467,7 @@ const ImportTrello: React.FC = () => {
const newState = !isSelectAllEnabled;
setIsSelectAllEnabled(newState);
for (const board of boards || []) {
for (const board of boards ?? []) {
setValue(board.id, newState);
}
}}
@@ -313,6 +503,7 @@ const ImportTrello: React.FC = () => {
export function ImportBoardsForm() {
const { closeModal } = useModal();
const [step, setStep] = useState(1);
const [provider, setProvider] = useState<string | null>(null);
return (
<div>
@@ -339,8 +530,16 @@ export function ImportBoardsForm() {
</button>
</div>
{step === 1 && <SelectSource handleNextStep={() => setStep(step + 1)} />}
{step === 2 && <ImportTrello />}
{step === 1 && (
<SelectSource
handleNextStep={(p) => {
setProvider(p);
setStep(step + 1);
}}
/>
)}
{step === 2 && provider === "trello" && <ImportTrello />}
{step === 2 && provider === "github" && <ImportGithub />}
</div>
);
}

View File

@@ -1,9 +1,13 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import { z } from "zod";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import Input from "~/components/Input";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
@@ -11,10 +15,28 @@ import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
const githubTokenSchema = z.object({
token: z.string().min(1, { message: t`Token is required` }),
});
type GitHubTokenFormValues = z.infer<typeof githubTokenSchema>;
export default function IntegrationsSettings() {
const { modalContentType, isOpen } = useModal();
const { showPopup } = usePopup();
const {
register,
handleSubmit,
formState: { isDirty, errors },
reset,
} = useForm<GitHubTokenFormValues>({
resolver: zodResolver(githubTokenSchema),
defaultValues: {
token: "",
},
});
const {
data: integrations,
refetch: refetchIntegrations,
@@ -34,21 +56,25 @@ export default function IntegrationsSettings() {
},
);
const { data: githubStatus, refetch: refetchGithubStatus } =
api.integration.getGitHubStatus.useQuery();
useEffect(() => {
const handleFocus = () => {
refetchIntegrations();
void refetchIntegrations();
void refetchGithubStatus();
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [refetchIntegrations]);
}, [refetchIntegrations, refetchGithubStatus]);
const { mutateAsync: disconnectTrello } =
api.integration.disconnect.useMutation({
onSuccess: () => {
refetchIntegrations();
refetchTrelloUrl();
void refetchIntegrations();
void refetchTrelloUrl();
showPopup({
header: t`Trello disconnected`,
message: t`Your Trello account has been disconnected.`,
@@ -64,6 +90,49 @@ export default function IntegrationsSettings() {
},
});
const { mutateAsync: saveGithubToken, isPending: isSavingGithubToken } =
api.integration.saveGitHubToken.useMutation({
onSuccess: () => {
void refetchGithubStatus();
reset();
showPopup({
header: t`GitHub connected`,
message: t`Your GitHub account has been connected.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Error connecting GitHub`,
message: t`An error occurred while connecting your GitHub account.`,
icon: "error",
});
},
});
const onSubmitGithubToken = (data: GitHubTokenFormValues) => {
void saveGithubToken({ token: data.token });
};
const { mutateAsync: disconnectGithub } =
api.integration.disconnectGitHub.useMutation({
onSuccess: () => {
void refetchGithubStatus();
showPopup({
header: t`GitHub disconnected`,
message: t`Your GitHub account has been disconnected.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Error disconnecting GitHub`,
message: t`An error occurred while disconnecting your GitHub account.`,
icon: "error",
});
},
});
return (
<>
<PageHead title={t`Settings | Integrations`} />
@@ -112,6 +181,51 @@ export default function IntegrationsSettings() {
)}
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`GitHub`}
</h2>
{!githubStatus?.connected ? (
<>
<p className="mb-4 text-sm text-neutral-500 dark:text-dark-900">
{t`Connect your GitHub account to import projects.`}
</p>
<form
onSubmit={handleSubmit(onSubmitGithubToken)}
className="flex gap-2"
>
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
<Input
type="password"
placeholder="Personal Access Token"
{...register("token")}
errorMessage={errors.token?.message}
/>
</div>
<div>
<Button
variant="secondary"
type="submit"
disabled={!isDirty || isSavingGithubToken}
isLoading={isSavingGithubToken}
>
{t`Connect GitHub`}
</Button>
</div>
</form>
</>
) : (
<>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Your GitHub account is connected.`}
</p>
<Button variant="secondary" onClick={() => disconnectGithub()}>
{t`Disconnect GitHub`}
</Button>
</>
)}
</div>
{/* Global modals */}
<Modal
modalSize="md"

View File

@@ -11,10 +11,12 @@ import * as labelRepo from "@kan/db/repository/label.repo";
import * as listRepo from "@kan/db/repository/list.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { colours } from "@kan/shared/constants";
import { generateUID } from "@kan/shared/utils";
import { generateSlug, generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions";
import { assertUserInWorkspace } from "../utils/auth";
import { decryptToken } from "../utils/encryption";
import { apiKeys, urls } from "./integration";
export interface TrelloBoard {
@@ -50,6 +52,51 @@ interface TrelloCheckItem {
pos: number;
}
interface GitHubProjectsResponse {
data: {
viewer: {
projectsV2: {
nodes?: { id: string; title: string }[];
};
organizations: {
nodes: {
projectsV2: {
nodes?: { id: string; title: string }[];
};
}[];
};
};
};
errors?: unknown[];
}
interface GitHubGraphQLResponse {
data?: {
node?: GitHubProjectV2Node;
};
errors?: unknown[];
}
interface GitHubProjectV2Node {
title: string;
field?: {
options?: { id: string; name: string }[];
};
areaField?: {
options?: { id: string; name: string; color: string }[];
};
items?: {
nodes: {
fieldValueByName?: { name: string };
areaValue?: { name: string };
content?: {
title?: string;
body?: string;
};
}[];
};
}
interface TrelloCard {
id: string;
name: string | null;
@@ -464,4 +511,436 @@ export const importRouter = createTRPCRouter({
return { boardsCreated };
}),
}),
github: createTRPCRouter({
getProjects: protectedProcedure
.meta({
openapi: {
summary: "Get projects from GitHub",
method: "GET",
path: "/integrations/github/projects",
description: "Retrieves all projects from GitHub",
tags: ["Integrations"],
protect: true,
},
})
.input(z.void())
.output(z.array(z.object({ id: z.string(), name: z.string() })))
.query(async ({ ctx }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const integration = await integrationsRepo.getProviderForUser(
ctx.db,
user.id,
"github",
);
if (!integration)
throw new TRPCError({
message: "GitHub token not found",
code: "UNAUTHORIZED",
});
const token = decryptToken(integration.accessToken);
// GraphQL query to fetch Projects V2 for the user and their organizations
const query = `
query {
viewer {
projectsV2(first: 20) {
nodes {
id
title
}
}
organizations(first: 10) {
nodes {
projectsV2(first: 10) {
nodes {
id
title
}
}
}
}
}
}
`;
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `token ${token}`,
"Content-Type": "application/json",
"User-Agent": "Kan-App",
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
const errorText = await response.text();
console.error(
`GitHub API Error: ${response.status} ${response.statusText}`,
);
console.error(`GitHub API Response: ${errorText}`);
throw new TRPCError({
message: `Failed to fetch GitHub projects: ${response.status} ${response.statusText}`,
code: "INTERNAL_SERVER_ERROR",
});
}
const result = (await response.json()) as GitHubProjectsResponse;
if (result.errors) {
console.error("GitHub GraphQL Errors:", result.errors);
throw new TRPCError({
message: "Failed to fetch GitHub projects (GraphQL Error)",
code: "INTERNAL_SERVER_ERROR",
});
}
const userProjects = result.data.viewer.projectsV2.nodes ?? [];
const orgProjects = result.data.viewer.organizations.nodes.flatMap(
(org) => org.projectsV2.nodes ?? [],
);
const allProjects = [...userProjects, ...orgProjects];
return allProjects.map((project) => ({
id: project.id,
name: project.title,
}));
}),
importProjects: protectedProcedure
.meta({
openapi: {
summary: "Import projects from GitHub",
method: "POST",
path: "/imports/github/projects",
description: "Imports projects from GitHub",
tags: ["Imports"],
protect: true,
},
})
.input(
z.object({
projectIds: z.array(z.string()),
workspacePublicId: z.string().min(12),
}),
)
.output(z.object({ projectsImported: z.number() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId) throw new TRPCError({ code: "UNAUTHORIZED" });
const integration = await integrationsRepo.getProviderForUser(
ctx.db,
userId,
"github",
);
if (!integration)
throw new TRPCError({
code: "UNAUTHORIZED",
message: "GitHub token not found",
});
const token = decryptToken(integration.accessToken);
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
code: "NOT_FOUND",
message: "Workspace not found",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id);
const newImport = await importRepo.create(ctx.db, {
source: "github",
createdBy: userId,
});
if (!newImport) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create import record",
});
}
const newImportId = newImport.id;
let projectsImported = 0;
for (const projectId of input.projectIds) {
// GraphQL query to fetch Project V2 details, status options, area options, and items
const query = `
query($id: ID!) {
node(id: $id) {
... on ProjectV2 {
title
field(name: "Status") {
... on ProjectV2SingleSelectField {
options {
id
name
}
}
}
areaField: field(name: "Area") {
... on ProjectV2SingleSelectField {
options {
id
name
color
}
}
}
items(first: 100) {
nodes {
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue {
name
}
}
areaValue: fieldValueByName(name: "Area") {
... on ProjectV2ItemFieldSingleSelectValue {
name
}
}
content {
... on Issue {
title
body
}
... on PullRequest {
title
body
}
... on DraftIssue {
title
body
}
}
}
}
}
}
}
`;
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `token ${token}`,
"Content-Type": "application/json",
"User-Agent": "Kan-App",
},
body: JSON.stringify({ query, variables: { id: projectId } }),
});
const result = (await response.json()) as GitHubGraphQLResponse;
if (result.errors || !result.data?.node) continue;
const projectData = result.data.node;
const statusOptions = projectData.field?.options ?? [];
const areaOptions = projectData.areaField?.options ?? [];
const items = projectData.items?.nodes ?? [];
const boardPublicId = generateUID();
const board = await boardRepo.create(ctx.db, {
publicId: boardPublicId,
name: projectData.title,
workspaceId: workspace.id,
slug: generateSlug(projectData.title),
createdBy: userId,
importId: newImportId,
});
if (!board) continue;
// Prepare Labels
const labelsInsert = areaOptions.map((option) => {
let colourCode = "#0d9488"; // Default Teal
const ghColor = option.color;
// Map GitHub colors to Kan colors
if (ghColor === "BLUE") colourCode = "#0284c7";
else if (ghColor === "GREEN") colourCode = "#65a30d";
else if (ghColor === "YELLOW") colourCode = "#ca8a04";
else if (ghColor === "ORANGE") colourCode = "#ea580c";
else if (ghColor === "RED") colourCode = "#dc2626";
else if (ghColor === "PINK") colourCode = "#db2777";
else if (ghColor === "PURPLE") colourCode = "#4f46e5";
else if (ghColor === "GRAY") colourCode = "#0d9488";
return {
publicId: generateUID(),
name: option.name,
colourCode,
createdBy: userId,
boardId: board.id,
importId: newImportId,
};
});
const createdLabels = await labelRepo.bulkCreate(
ctx.db,
labelsInsert,
);
const labelMap = new Map<string, number>();
createdLabels.forEach((label, index) => {
const originalName = areaOptions[index]?.name;
if (originalName) {
labelMap.set(originalName, label.id);
}
});
// Prepare Lists
const listsInsert: {
publicId: string;
name: string;
createdBy: string;
boardId: number;
index: number;
importId: number;
}[] = [];
if (statusOptions.length === 0) {
listsInsert.push({
publicId: generateUID(),
name: "To Do",
createdBy: userId,
boardId: board.id,
index: 0,
importId: newImportId,
});
} else {
statusOptions.forEach((option, index) => {
listsInsert.push({
publicId: generateUID(),
name: option.name,
createdBy: userId,
boardId: board.id,
index: index,
importId: newImportId,
});
});
}
const createdLists = await listRepo.bulkCreate(ctx.db, listsInsert);
const listIdMap = new Map<string, number>();
createdLists.forEach((list, index) => {
const originalName = listsInsert[index]?.name;
if (originalName) {
listIdMap.set(originalName, list.id);
}
});
// Prepare Cards
const itemsToInsert: {
item: NonNullable<
NonNullable<
NonNullable<GitHubProjectV2Node["items"]>["nodes"]
>[number]
>;
listId: number;
title: string;
description: string;
}[] = [];
for (const item of items) {
const statusName = item.fieldValueByName?.name;
const content = item.content ?? {};
const title = content.title ?? "Untitled Card";
const description = content.body ?? "";
let listId = statusName ? listIdMap.get(statusName) : undefined;
// Fallback to first list
if (!listId && createdLists.length > 0) {
listId = createdLists[0]?.id;
}
if (listId) {
itemsToInsert.push({
item,
listId,
title,
description,
});
}
}
const cardsInput = itemsToInsert.map((data, index) => ({
publicId: generateUID(),
title: data.title,
description: data.description,
createdBy: userId,
listId: data.listId,
index: index,
importId: newImportId,
}));
const createdCards = await cardRepo.bulkCreate(ctx.db, cardsInput);
// Create Activities
const activities = createdCards.map((card) => ({
type: "card.created" as const,
cardId: card.id,
createdBy: userId,
}));
if (activities.length > 0) {
await cardActivityRepo.bulkCreate(ctx.db, activities);
}
// Link Labels
const cardLabelRelations: { cardId: number; labelId: number }[] = [];
createdCards.forEach((card, index) => {
const originalItem = itemsToInsert[index]?.item;
const areaName = originalItem?.areaValue?.name;
if (areaName) {
const labelId = labelMap.get(areaName);
if (labelId) {
cardLabelRelations.push({
cardId: card.id,
labelId: labelId,
});
}
}
});
if (cardLabelRelations.length > 0) {
await cardRepo.bulkCreateCardLabelRelationships(
ctx.db,
cardLabelRelations,
);
}
projectsImported++;
}
if (projectsImported > 0 && newImportId) {
await importRepo.update(
ctx.db,
{ status: "success" },
{ importId: newImportId },
);
}
return { projectsImported };
}),
}),
});

View File

@@ -14,7 +14,65 @@ export const apiKeys = {
trello: process.env.TRELLO_APP_API_KEY,
};
import { encryptToken } from "../utils/encryption";
export const integrationRouter = createTRPCRouter({
saveGitHubToken: protectedProcedure
.input(z.object({ token: z.string() }))
.mutation(async ({ ctx, input }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const encryptedToken = encryptToken(input.token);
const expiresAt = new Date();
expiresAt.setFullYear(expiresAt.getFullYear() + 1);
await integrationsRepo.createOrUpdateProvider(ctx.db, {
provider: "github",
userId: user.id,
accessToken: encryptedToken,
expiresAt,
});
return { success: true };
}),
disconnectGitHub: protectedProcedure.mutation(async ({ ctx }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
await integrationsRepo.deleteProviderForUser(ctx.db, user.id, "github");
return { success: true };
}),
getGitHubStatus: protectedProcedure.query(async ({ ctx }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const connected = await integrationsRepo.isProviderAvailableForUser(
ctx.db,
user.id,
"github",
);
return { connected };
}),
providers: protectedProcedure
.meta({
openapi: {
@@ -67,7 +125,7 @@ export const integrationRouter = createTRPCRouter({
protect: true,
},
})
.input(z.object({ provider: z.enum(["trello"]) }))
.input(z.object({ provider: z.enum(["trello", "github"]) }))
.output(z.object({}))
.mutation(async ({ ctx, input }) => {
const user = ctx.user;

View File

@@ -0,0 +1,53 @@
import crypto from "crypto";
const ALGORITHM = "aes-256-gcm";
const SECRET_KEY = process.env.BETTER_AUTH_SECRET;
if (!SECRET_KEY) {
throw new Error("Encryption key is missing. Set BETTER_AUTH_SECRET.");
}
// Ensure the key is exactly 32 bytes
const key = crypto.createHash("sha256").update(String(SECRET_KEY)).digest();
export const encryptToken = (text: string) => {
const iv = crypto.randomBytes(12); // 12 bytes is the recommended IV size for GCM
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
// buffer concat is faster/cleaner for raw binary manipulation
const encrypted = Buffer.concat([
cipher.update(text, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
// Combine IV + AuthTag + EncryptedData into one buffer
// This saves space compared to storing them as separate hex strings
const combined = Buffer.concat([iv, authTag, encrypted]);
// Return as URL-safe Base64 (ideal for cookies)
return combined.toString("base64url");
};
export const decryptToken = (text: string) => {
// Convert URL-safe Base64 back to a Buffer
const combined = Buffer.from(text, "base64url");
// Extract the parts based on fixed lengths
// IV is 12 bytes (standard for GCM)
// AuthTag is 16 bytes (standard for GCM)
const iv = combined.subarray(0, 12);
const authTag = combined.subarray(12, 28);
const encryptedText = combined.subarray(28);
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
// If the cookie was tampered with, this will throw an error
const decrypted = Buffer.concat([
decipher.update(encryptedText),
decipher.final(),
]);
return decrypted.toString("utf8");
};

View File

@@ -0,0 +1,2 @@
ALTER TYPE "public"."source" ADD VALUE 'github';--> statement-breakpoint
ALTER TABLE "integration" ALTER COLUMN "accessToken" SET DATA TYPE text;--> statement-breakpoint

File diff suppressed because it is too large Load Diff

View File

@@ -197,6 +197,13 @@
"when": 1772049587901,
"tag": "20260225195947_AddIsArchivedToBoard",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1771930355536,
"tag": "20260224105235_AddGitHubIntegrationSupport",
"breakpoints": true
}
]
}

View File

@@ -46,6 +46,35 @@ export const getProvidersForUser = async (db: dbClient, userId: string) => {
return integration;
};
export const createOrUpdateProvider = async (
db: dbClient,
data: {
userId: string;
provider: string;
accessToken: string;
refreshToken?: string | null;
expiresAt: Date;
},
) => {
await db
.insert(integrations)
.values({
provider: data.provider,
userId: data.userId,
accessToken: data.accessToken,
refreshToken: data.refreshToken ?? null,
expiresAt: data.expiresAt,
})
.onConflictDoUpdate({
target: [integrations.userId, integrations.provider],
set: {
accessToken: data.accessToken,
refreshToken: data.refreshToken ?? null,
expiresAt: data.expiresAt,
},
});
};
export const deleteProviderForUser = async (
db: dbClient,
userId: string,

View File

@@ -14,7 +14,7 @@ import { labels } from "./labels";
import { lists } from "./lists";
import { users } from "./users";
export const importSourceEnum = pgEnum("source", ["trello"]);
export const importSourceEnum = pgEnum("source", ["trello", "github"]);
export const importStatusEnum = pgEnum("status", [
"started",
"success",

View File

@@ -2,6 +2,7 @@ import { relations } from "drizzle-orm";
import {
pgTable,
primaryKey,
text,
timestamp,
uuid,
varchar,
@@ -16,7 +17,7 @@ export const integrations = pgTable(
userId: uuid("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
accessToken: varchar("accessToken", { length: 255 }).notNull(),
accessToken: text("accessToken").notNull(),
refreshToken: varchar("refreshToken", { length: 255 }),
expiresAt: timestamp("expiresAt").notNull(),
createdAt: timestamp("createdAt")