Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,4 @@ To set up Discord notifications:
1. Provide a `DISCORD_WEBHOOK_URL` that contains a valid Discord webhook endpoint. See [Discord's Webhook Guide](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) on how to create this endpoint in your server.
2. Set `WEBHOOK_URL` to `https://<your-todolist-domain>/webhook/discord`

Voila, your discord server will start receiving events.


Voila, your discord server will start receiving events.
83 changes: 83 additions & 0 deletions app/components/github-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import clsx from "clsx";
import { Link, useLoaderData } from "react-router";
import type { loader } from "~/routes/$project";
import { Button } from "./button";
import { usePopoverContext } from "./popover";

interface GitHubCardProps {
onBack: () => void;
}

function GitHubCard({ onBack }: GitHubCardProps) {
const { installation } = useLoaderData<typeof loader>();
const popover = usePopoverContext();

function to() {
if (installation) {
return `https://github.com/settings/installations/${installation.githubInstallationId}`;
}

return "https://github.com/apps/gr-s-todo-list/installations/new";
}

return (
<>
<div className="flex gap-2 justify-end items-center">
<button
type="button"
className="bg-transparent cursor-pointer"
onClick={onBack}
>
<div className="i-lucide-x size-5 text-secondary" />
</button>
</div>

<div className="flex justify-center mb-2 items-center text-secondary relative">
<div className="i-solar-archive-minimalistic-bold-duotone size-8 text-rose-500" />
<div className="i-mdi-github size-8 -ml-2.5 text-neutral-800 dark:text-white" />
</div>

<h2 className="mb-2">Github Integration</h2>

{installation ? (
<p className="text-.8rem font-mono leading-tight text-secondary mb-4 overflow-hidden !w-16rem">
GitHub integration is{" "}
<span
className={clsx(
"font-medium",
installation.active
? "text-green-600 dark:text-green-500"
: "text-amber-500",
)}
>
{installation.active ? "active" : "paused"}
</span>
. Tasks will {installation.active ? "" : "not "}automatically update
when you open or merge pull requests.
</p>
) : (
<p className="text-.8rem font-mono leading-tight text-secondary mb-4 overflow-hidden !w-16rem">
Connect Todo List with your GitHub account to automatically update the
status of tasks.
</p>
)}

<Link
target="_blank"
rel="noreferrer noopener"
to={to()}
className="w-full"
>
<Button
type="button"
onClick={() => popover.setOpen(false)}
className="w-full text-sm font-medium flex items-center justify-center bg-neutral-800 text-white dark:bg-white dark:text-neutral-900 px-3 !py-1.5 gap-1"
>
{installation ? "Manage Integration" : "Connect with GitHub"}
</Button>
</Link>
</>
);
}

export { GitHubCard };
1 change: 1 addition & 0 deletions app/components/status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface StatusProps {
const StatusIcons: Record<Task["status"], string> = {
pending: "i-lucide-circle text-secondary",
inProgress: "i-lucide-loader-circle text-amber-500",
inReview: "i-solar-document-add-linear text-green-500",
done: "i-solar-check-circle-linear text-stone-400 dark:text-neutral-700",
};

Expand Down
70 changes: 62 additions & 8 deletions app/components/user-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,35 @@ import clsx from "clsx";
import React from "react";
import { Link, useLoaderData, useNavigate } from "react-router";
import type { loader } from "~/routes/$project";
import { GitHubCard } from "./github-card";
import { InviteCard } from "./invite-card";

type View = "default" | "github";

function UserMenu() {
const { user } = useLoaderData<typeof loader>();
const { user, installation } = useLoaderData<typeof loader>();
const navigate = useNavigate();
const [showInvite, setShowInvite] = React.useState(false);
const [view, setView] = React.useState<View>("default");

const handleLogout = () => {
function handleLogout() {
const confirmed = window.confirm("Are you sure you want to logout?");
if (confirmed) {
navigate("/logout");
}
};
}

if (view === "github") {
return (
<Container className="!w-17rem text-center p-2">
<GitHubCard onBack={() => setView("default")} />
</Container>
);
}

return (
<div
className={clsx(
"transition-width duration-300 ease-in-out will-change-width bg-stone-100 dark:bg-neutral-900 rounded-lg border border-neutral-300 dark:border-neutral-800 overflow-hidden shadow-lg mt-1.5 animate-fade-in animate-duration-200",
{ "w-14rem": !showInvite, "w-17.8rem": showInvite },
)}
<Container
className={clsx({ "!w-14rem": !showInvite, "!w-17rem": showInvite })}
>
{!showInvite ? (
<>
Expand All @@ -37,6 +46,7 @@ function UserMenu() {
<hr className="dark:border-neutral-800" />
</div>
)}

<ul className="font-medium p-1">
{user.superUser && (
<li>
Expand All @@ -50,6 +60,33 @@ function UserMenu() {
</button>
</li>
)}

{user.superUser && (
<li>
<button
type="button"
onClick={() => setView("github")}
className={clsx(
"bg-transparent w-full flex gap-2 items-center py-1.5 px-2 hover:bg-neutral-200 dark:hover:bg-neutral-800 rounded-lg",
installation && "opacity-70",
)}
>
<div className="i-mdi-github opacity-50" />
GitHub Integration{" "}
{installation && (
<div
className={clsx(
"size-2 rounded-full",
installation.active
? "bg-green-600 dark:bg-green-500"
: "bg-amber-500",
)}
/>
)}
</button>
</li>
)}

<li>
<Link
to="/change-password"
Expand All @@ -59,6 +96,7 @@ function UserMenu() {
Change Password
</Link>
</li>

<li>
<button
type="button"
Expand All @@ -74,6 +112,22 @@ function UserMenu() {
) : (
<InviteCard onClose={() => setShowInvite(false)} />
)}
</Container>
);
}

function Container({
children,
className,
}: { children: React.ReactNode; className?: string }) {
return (
<div
className={clsx(
"transition-width duration-300 ease-in-out will-change-width bg-stone-100 dark:bg-neutral-900 rounded-lg border border-neutral-300 dark:border-neutral-800 overflow-hidden shadow-lg mt-1.5 animate-fade-in animate-duration-200",
className,
)}
>
{children}
</div>
);
}
Expand Down
155 changes: 155 additions & 0 deletions app/lib/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import type { Task } from "@prisma/client";
import { TASK_ID_REGEX } from "./constants";
import { prisma } from "./prisma.server";
import { sendWebhook } from "./webhook";

export interface InstallationEvent {
installation: {
id: number;
};
action: "deleted" | "suspend" | "unsuspend";
}

export interface PREvent {
pull_request: {
head: {
ref: string;
};
html_url: string;
number: number;
merged: boolean;
};
action: "opened" | "reopened" | "closed";
}

export type EventTypeMap = {
installation: InstallationEvent;
pull_request: PREvent;
};

export type GitHubEventType = keyof EventTypeMap;
async function handlePullRequestEvent(event: PREvent) {
switch (event.action) {
case "opened":
case "reopened":
await handlePROpened(event);
break;
case "closed":
if (event.pull_request.merged) {
await handlePRMerged(event);
}
break;
}
}

async function handleInstallationEvent(event: InstallationEvent) {
const installationId = event.installation.id;

switch (event.action) {
case "deleted":
await prisma.installation.delete({
where: { githubInstallationId: installationId },
});
break;

case "suspend":
await prisma.installation.update({
where: { githubInstallationId: installationId },
data: { active: false },
});
break;

case "unsuspend":
await prisma.installation.update({
where: { githubInstallationId: installationId },
data: { active: true },
});
break;
}
}

async function handlePROpened(event: PREvent) {
const branchName = event.pull_request.head.ref;
const taskId = extractTaskId(branchName);

if (!taskId) return;

const task = await prisma.task.findUnique({
where: { id: taskId },
});

if (task) {
await updateTask({
taskId: task.id,
updates: {
status: "inReview",
githubPrUrl: event.pull_request.html_url,
githubPrNumber: event.pull_request.number,
},
});

sendWebhook("task.pr_opened", {
task,
prUrl: event.pull_request.html_url,
prNumber: event.pull_request.number,
branchName,
projectId: task.projectId,
});
}
}

async function handlePRMerged(event: PREvent) {
const branchName = event.pull_request.head.ref;
const taskId = extractTaskId(branchName);

if (!taskId) return;

const task = await prisma.task.findUnique({
where: { id: taskId },
});

if (task) {
await updateTask({
taskId: task.id,
updates: {
status: "done",
},
});

sendWebhook("task.pr_merged", {
task,
prUrl: event.pull_request.html_url,
prNumber: event.pull_request.number,
branchName,
projectId: task.projectId,
});
}
}

function extractTaskId(branchName: string): number | null {
const parts = branchName.split(/[\/\-_]/);

for (const part of parts) {
const match = part.match(TASK_ID_REGEX);
if (match) {
return Number.parseInt(match[1]);
}
}

return null;
}

async function updateTask({
taskId,
updates,
}: {
taskId: number;
updates: Partial<Task>;
}): Promise<Task> {
return await prisma.task.update({
where: { id: taskId },
data: updates,
});
}

export { handleInstallationEvent, handlePullRequestEvent };
Loading