Skip to content

Commit

Permalink
feat(web): add auto flag removal
Browse files Browse the repository at this point in the history
  • Loading branch information
cstrnt committed Aug 25, 2024
1 parent 50ea045 commit ad15862
Show file tree
Hide file tree
Showing 20 changed files with 1,174 additions and 355 deletions.
10 changes: 6 additions & 4 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"@monaco-editor/react": "^4.5.1",
"@next-auth/prisma-adapter": "1.0.5",
"@next/mdx": "14.0.4",
"@prisma/client": "5.6.0",
"@prisma/client": "5.18.0",
"@radix-ui/react-avatar": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
Expand Down Expand Up @@ -91,6 +91,7 @@
"lodash-es": "^4.17.21",
"logsnag": "^0.1.6",
"lucide-react": "0.320.0",
"memoize": "^10.0.0",
"micro": "^10.0.1",
"ms": "^2.1.3",
"next": "14.1.1",
Expand All @@ -102,7 +103,9 @@
"nextjs-cors": "^2.1.2",
"nodemailer": "^6.9.1",
"nuqs": "^1.17.8",
"octokit": "^2.0.18",
"octokit": "^4.0.2",
"openai": "^4.56.0",
"prettier": "^2.8.7",
"rate-limiter-flexible": "^5.0.3",
"react": "18.2.0",
"react-dom": "18.2.0",
Expand Down Expand Up @@ -138,9 +141,8 @@
"autoprefixer": "^10.4.14",
"jsdom": "^20.0.3",
"postcss": "^8.4.21",
"prettier": "^2.8.7",
"prettier-plugin-tailwindcss": "^0.1.13",
"prisma": "5.6.0",
"prisma": "5.18.0",
"tailwindcss": "^3.3.1",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
Expand Down
21 changes: 20 additions & 1 deletion apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ model Project {
stripePriceId String?
currentPeriodEnd DateTime @default(dbgenerated("(CURRENT_TIMESTAMP(3) + INTERVAL 30 DAY)"))
apiRequests ApiRequest[]
apiRequests ApiRequest[]
integrations Integration[]
}

model ProjectUser {
Expand Down Expand Up @@ -310,3 +311,21 @@ model ApiRequest {
@@index([createdAt])
@@index([type])
}

enum IntegrationType {
GITHUB
}

model Integration {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
type IntegrationType
settings Json
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String
@@unique([projectId, type])
@@index([projectId])
}
33 changes: 33 additions & 0 deletions apps/web/src/api/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { Context, MiddlewareHandler } from "hono";
import { getCookie } from "hono/cookie";
import type { GetServerSidePropsContext } from "next";
import type { DefaultSession } from "next-auth";
import { getServerAuthSession } from "server/common/get-server-auth-session";
import type { UserSession } from "types/next-auth";

export async function getHonoSession(c: Context) {
return await getServerAuthSession({
req: {
...c.req.raw.clone(),
cookies: getCookie(c),
} as unknown as GetServerSidePropsContext["req"],
res: {
...c.res,
getHeader: (h: string) => c.req.header(h),
setHeader: (h: string, v: string) => c.header(h, v),
} as unknown as GetServerSidePropsContext["res"],
});
}

export const authMiddleware: MiddlewareHandler<{
Variables: {
user: UserSession & DefaultSession["user"];
};
}> = async (c, next) => {
const session = await getHonoSession(c);
if (!session || !session.user) {
return c.json({ error: "Unauthorized" }, { status: 401 });
}
c.set("user", session.user);
return next();
};
4 changes: 3 additions & 1 deletion apps/web/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { logger } from "hono/logger";
import { makeHealthRoute } from "./routes/health";
import { makeLegacyProjectDataRoute } from "./routes/legacy_project_data";
import { makeEventRoute } from "./routes/v1_event";
import { makeIntegrationsRoute } from "./routes/integrations";

export const app = new Hono()
.basePath("/api")
Expand All @@ -19,4 +20,5 @@ export const app = new Hono()
// v1 routes
.route("/v1/config", makeConfigRoute())
.route("/v1/data", makeProjectDataRoute())
.route("/v1/track", makeEventRoute());
.route("/v1/track", makeEventRoute())
.route("/integrations", makeIntegrationsRoute());
94 changes: 94 additions & 0 deletions apps/web/src/api/routes/integrations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { zValidator } from "@hono/zod-validator";
import { authMiddleware } from "api/helpers";
import { Hono } from "hono";
import { prisma } from "server/db/client";
import { z } from "zod";
import { githubApp } from "server/common/github-app";
import type { GithubIntegrationSettings } from "server/common/integrations";

export function makeIntegrationsRoute() {
return new Hono()
.get(
"/github",
zValidator(
"query",
z.object({
projectId: z.string(),
})
),
authMiddleware,
async (c) => {
const user = c.get("user");
const { projectId } = c.req.valid("query");
const project = await prisma.project.findFirst({
where: { id: projectId, users: { some: { userId: user.id } } },
include: { integrations: true },
});
const referrer = new URL(c.req.header("Referer") ?? "/");

if (!project) {
referrer.searchParams.set("error", "Unauthorized");
return c.redirect(referrer.toString());
}
if (project.integrations.some((i) => i.type === "GITHUB")) {
referrer.searchParams.set("error", "Integration already exists");
return c.redirect(referrer.toString());
}

const searchParams = new URLSearchParams();
searchParams.set("projectId", projectId);

return c.redirect(
await githubApp.getInstallationUrl({ state: searchParams.toString() })
);
}
)
.get(
"/github/setup",
zValidator(
"query",
z.object({
installation_id: z.string().transform(Number),
setup_action: z.enum(["install", "update"]),
state: z.string().transform((s) => {
const url = new URLSearchParams(s);
const projectId = url.get("projectId");
if (!projectId) {
throw new Error("projectId not found in state");
}
return { projectId };
}),
})
),
async (c) => {
const { installation_id, setup_action, state } = c.req.valid("query");
if (setup_action === "update") {
return c.json({ message: "Update not implemented" }, { status: 501 });
}

const project = await prisma.project.findFirst({
where: { id: state.projectId },
include: { integrations: true },
});
if (!project) {
return c.json(
{ message: "Project not found" },
{
status: 404,
}
);
}
await prisma.integration.create({
data: {
type: "GITHUB",
projectId: project.id,
settings: {
installationId: installation_id,
repositoryIds: [],
} satisfies GithubIntegrationSettings,
},
});
return c.redirect(`/projects/${project.id}/settings`);
}
);
}
4 changes: 3 additions & 1 deletion apps/web/src/components/AddFeatureFlagModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,9 @@ export const AddFeatureFlagModal = ({

const { mutateAsync } = trpc.flags.addFlag.useMutation({
onSuccess() {
ctx.flags.getFlags.invalidate({ projectId });
ctx.flags.getFlags.invalidate({
projectId,
});
},
});

Expand Down
42 changes: 36 additions & 6 deletions apps/web/src/components/FlagPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { FeatureFlagType } from "@prisma/client";
import type { InferQueryResult } from "@trpc/react-query/dist/utils/inferReactQueryProcedure";
import type { inferRouterOutputs } from "@trpc/server";
import { AddFeatureFlagModal } from "components/AddFeatureFlagModal";
import { CreateEnvironmentModal } from "components/CreateEnvironmentModal";
import {
Expand All @@ -15,8 +15,14 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "components/Tooltip";
import { Input } from "components/ui/input";
import Fuse from "fuse.js";
import { useProjectId } from "lib/hooks/useProjectId";
import { EditIcon, FileEditIcon, Search, TrashIcon } from "lucide-react";
import { useMemo, useState } from "react";
import {
EditIcon,
FileEditIcon,
Search,
Sparkle,
TrashIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { AiOutlinePlus } from "react-icons/ai";
import { BiInfoCircle } from "react-icons/bi";
Expand Down Expand Up @@ -155,9 +161,7 @@ export const FeatureFlagPageContent = ({
data,
type,
}: {
data: NonNullable<
InferQueryResult<(typeof appRouter)["flags"]["getFlags"]>["data"]
>;
data: NonNullable<inferRouterOutputs<typeof appRouter>["flags"]["getFlags"]>;
type: "Flags" | "Remote Config";
}) => {
const [isCreateFlagModalOpen, setIsCreateFlagModalOpen] = useState(false);
Expand All @@ -170,6 +174,8 @@ export const FeatureFlagPageContent = ({

const [isCreateEnvironmentModalOpen, setIsCreateEnvironmentModalOpen] =
useState(false);
const createFlagRemovalPRMutation =
trpc.flags.createFlagRemovalPR.useMutation();

const projectId = useProjectId();

Expand All @@ -178,6 +184,10 @@ export const FeatureFlagPageContent = ({
[data.flags]
);

useEffect(() => {
setFlags(data.flags);
}, [data.flags]);

const onSearch = (query: string) => {
if (!query) {
setFlags(data.flags);
Expand Down Expand Up @@ -326,6 +336,26 @@ export const FeatureFlagPageContent = ({
<FileEditIcon className="mr-4 h-4 w-4" />
Edit Description
</DropdownMenuItem>
<DropdownMenuItem
disabled={!data.hasGithubIntegration}
className="cursor-pointer bg-gradient-to-r from-blue-800 via-purple-600 to-pink-500 hover:from-purple-700 hover:via-pink-500 hover:to-red-400"
onClick={async () => {
const url = await toast.promise(
createFlagRemovalPRMutation.mutateAsync({
flagId: currentFlag.id,
}),
{
loading: "Creating removal PR...",
success: "Successfully created removal PR",
error: "Failed to create removal PR",
}
);
window.open(url, "_blank");
}}
>
<Sparkle className="mr-4 h-4 w-4" />
Create Removal PR
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer focus:!bg-red-700 focus:!text-white"
onClick={() => {
Expand Down
Loading

0 comments on commit ad15862

Please sign in to comment.