fix: make github issue creation creator-restricted and easier for others

This commit is contained in:
2024-12-23 23:28:47 +01:00
parent 22d3437694
commit 5b24b0078d
8 changed files with 163 additions and 141 deletions

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "githubInstallationId" TEXT,
ALTER COLUMN "inviteCode" SET DEFAULT floor(random() * 90000000 + 10000000)::text;

View File

@@ -33,15 +33,16 @@ model Session {
} }
model Project { model Project {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
description String description String
github String? github String?
customData String[] githubInstallationId String?
rateLimitReq Int @default(5) customData String[]
rateLimitTime Int @default(60) rateLimitReq Int @default(5)
rateLimitTime Int @default(60)
// 8 digit random number // 8 digit random number
inviteCode String @unique @default(dbgenerated("floor(random() * 90000000 + 10000000)::text")) inviteCode String @unique @default(dbgenerated("floor(random() * 90000000 + 10000000)::text"))
users User[] @relation("UserProjects") users User[] @relation("UserProjects")
feedback Feedback[] feedback Feedback[]

View File

@@ -20,7 +20,7 @@ import { getRepos } from './getRepos';
export default function GithubRepoChooser(props: Props) { export default function GithubRepoChooser(props: Props) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState(''); const [value, setValue] = React.useState('');
const [repos, setRepos] = React.useState<string[]>([]); const [repos, setRepos] = React.useState<{ name: string, installationId: string }[]>([]);
const [isLoading, setIsLoading] = React.useState(true); const [isLoading, setIsLoading] = React.useState(true);
const [displayText, setDisplayText] = React.useState('Select a repository'); const [displayText, setDisplayText] = React.useState('Select a repository');
@@ -30,16 +30,22 @@ export default function GithubRepoChooser(props: Props) {
if (response.success) { if (response.success) {
setRepos(response.repos!); setRepos(response.repos!);
setIsLoading(false); setIsLoading(false);
if (props.selected) {
setValue(props.selected);
}
} }
}); });
setValue(props.selected ?? '');
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
props.onSelect(value); if (isLoading || !value) return;
}, [value]); const repo = repos.find((repo) => repo.name === value);
if (repo) {
props.onSelect(value, repo.installationId);
}
}, [value, repos, isLoading, props.onSelect]);
React.useEffect(() => { React.useEffect(() => {
if (value.length > 0) { if (value.length > 0 && !isLoading) {
setDisplayText(repos.find((repo) => repo === value)!); setDisplayText(repos.find((repo) => repo.name === value)!.name);
} else if (repos.length === 0) { } else if (repos.length === 0) {
setDisplayText('No repositories found'); setDisplayText('No repositories found');
} else { } else {
@@ -69,16 +75,15 @@ export default function GithubRepoChooser(props: Props) {
<CommandGroup> <CommandGroup>
{repos.map((repo) => ( {repos.map((repo) => (
<CommandItem <CommandItem
key={repo} key={repo.name}
value={repo} value={repo.name}
onSelect={(currentValue) => { onSelect={(currentValue) => {
console.log(currentValue, value); setValue(currentValue);
setValue(currentValue === value ? '' : currentValue);
setOpen(false); setOpen(false);
}} }}
> >
{repo} {repo.name}
<Check className={cn('ml-auto', value === repo ? 'opacity-100' : 'opacity-0')} /> <Check className={cn('ml-auto', value === repo.name ? 'opacity-100' : 'opacity-0')} />
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
@@ -90,6 +95,6 @@ export default function GithubRepoChooser(props: Props) {
} }
interface Props { interface Props {
onSelect: (repo: string) => void; onSelect: (repo: string, installationId: string) => void;
selected?: string; selected?: string;
} }

View File

@@ -9,7 +9,7 @@ export async function getRepos() {
return { success: false, error: 'You must be logged in' }; return { success: false, error: 'You must be logged in' };
} }
const repoList: Array<{ name: string; pushed_at: string }> = []; const repoList: Array<{ name: string; pushed_at: string; installationId: string }> = [];
for (const installation of user.installations) { for (const installation of user.installations) {
const octokit = await octokitApp.getInstallationOctokit(Number(installation)); const octokit = await octokitApp.getInstallationOctokit(Number(installation));
@@ -28,6 +28,7 @@ export async function getRepos() {
const repoData = repos.repositories.map((repo) => ({ const repoData = repos.repositories.map((repo) => ({
name: repo.full_name, name: repo.full_name,
pushed_at: repo.pushed_at ?? '1970-01-01T00:00:00Z', pushed_at: repo.pushed_at ?? '1970-01-01T00:00:00Z',
installationId: installation,
})); }));
repoList.push(...repoData); repoList.push(...repoData);
@@ -35,7 +36,9 @@ export async function getRepos() {
const sortedRepos = repoList const sortedRepos = repoList
.sort((a, b) => new Date(b.pushed_at).getTime() - new Date(a.pushed_at).getTime()) .sort((a, b) => new Date(b.pushed_at).getTime() - new Date(a.pushed_at).getTime())
.map((repo) => repo.name); .map((repo) => {
return { name: repo.name, installationId: repo.installationId };
});
return { success: true, repos: sortedRepos }; return { success: true, repos: sortedRepos };
} }

View File

@@ -25,9 +25,12 @@ import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import InviteCodeViewer from '../InviteCodeViewer/InviteCodeViewer'; import InviteCodeViewer from '../InviteCodeViewer/InviteCodeViewer';
import ProjectTeamUsers from '../ProjectTeamUsers/ProjectTeamUsers'; import ProjectTeamUsers from '../ProjectTeamUsers/ProjectTeamUsers';
import { useSession } from '@/lib/providers/SessionProvider';
export default function ProjectSettings(project: ProjectWithUsers) { export default function ProjectSettings(project: ProjectWithUsers) {
const { user } = useSession();
const [ghRepo, setGhRepo] = React.useState(''); const [ghRepo, setGhRepo] = React.useState('');
const [ghInstallationId, setGhInstallationId] = React.useState('');
const [hasSubmitted, setHasSubmitted] = React.useState(false); const [hasSubmitted, setHasSubmitted] = React.useState(false);
const apiUrl = `https://${window.location.hostname}/api/feedback/${project.id}`; const apiUrl = `https://${window.location.hostname}/api/feedback/${project.id}`;
React.useEffect(() => { React.useEffect(() => {
@@ -59,7 +62,9 @@ export default function ProjectSettings(project: ProjectWithUsers) {
<Tabs defaultValue="project" className="w-full"> <Tabs defaultValue="project" className="w-full">
<TabsList className="mb-4"> <TabsList className="mb-4">
<TabsTrigger value="project">Project</TabsTrigger> <TabsTrigger value="project">Project</TabsTrigger>
<TabsTrigger value="github">Github</TabsTrigger> {user?.id === project.UserProject.find((u) => u.isOwner)?.userId && (
<TabsTrigger value="github">Github</TabsTrigger>
)}
<TabsTrigger value="api">API</TabsTrigger> <TabsTrigger value="api">API</TabsTrigger>
</TabsList> </TabsList>
@@ -161,61 +166,46 @@ export default function ProjectSettings(project: ProjectWithUsers) {
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="github" className="grid gap-4"> {user?.id === project.UserProject.find((u) => u.isOwner)?.userId && (
<Card> <TabsContent value="github" className="grid gap-4">
<CardHeader> <Card>
<CardTitle>Github Integration</CardTitle> <CardHeader>
<CardDescription>Connect your project to Github</CardDescription> <CardTitle>Github Integration</CardTitle>
</CardHeader> <CardDescription>Connect your project to Github</CardDescription>
<CardContent> </CardHeader>
<GithubRepoChooser <CardContent>
onSelect={(repo) => { <GithubRepoChooser
setGhRepo(`https://github.com/${repo}`); onSelect={(repo, id) => {
}} setGhRepo(`https://github.com/${repo}`);
selected={project.github ? project.github.replace('https://github.com/', '') : ''} setGhInstallationId(id);
/> }}
<p className="text-muted-foreground text-xs mt-2"> selected={project.github ? project.github.replace('https://github.com/', '') : ''}
Not the results you were expecting? You may have not allowed your user in the{' '} />
<Link <p className="text-muted-foreground text-xs mt-2">
href="https://github.com/apps/echospacedev/installations/new" Not the results you were expecting? You may have not allowed your user in the{' '}
target="_blank" <Link
className="text-primary" href="https://github.com/apps/echospacedev/installations/new"
> target="_blank"
installation settings className="text-primary"
</Link> >
. installation settings
</p> </Link>
<UniversalForm .
fields={[ </p>
{
name: 'github',
label: 'GitHub Repository',
type: 'hidden',
value: ghRepo,
},
{
name: 'id',
label: 'ID',
type: 'hidden',
value: project.id,
},
]}
key={ghRepo}
schemaName={'githubSettings'}
action={githubSettings}
onActionComplete={() => setHasSubmitted(true)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Issue submission testing</CardTitle>
<CardDescription>Make sure your setup works!</CardDescription>
</CardHeader>
<CardContent>
{hasSubmitted ? (
<UniversalForm <UniversalForm
fields={[ fields={[
{
name: 'github',
label: 'GitHub Repository',
type: 'hidden',
value: ghRepo,
},
{
name: 'installationId',
label: 'Installation ID',
type: 'hidden',
value: ghInstallationId,
},
{ {
name: 'id', name: 'id',
label: 'ID', label: 'ID',
@@ -223,20 +213,44 @@ export default function ProjectSettings(project: ProjectWithUsers) {
value: project.id, value: project.id,
}, },
]} ]}
schemaName={'githubTestIssue'} key={ghRepo}
action={githubTestIssue} schemaName={'githubSettings'}
submitText="Create test issue" action={githubSettings}
submitClassname="!mt-0" onActionComplete={() => setHasSubmitted(true)}
/> />
) : ( </CardContent>
<p className="text-muted-foreground text-sm"> </Card>
You need to connect your project to a GitHub repository before you can test issue <Card>
submission. <CardHeader>
</p> <CardTitle>Issue submission testing</CardTitle>
)} <CardDescription>Make sure your setup works!</CardDescription>
</CardContent> </CardHeader>
</Card> <CardContent>
</TabsContent> {hasSubmitted ? (
<UniversalForm
fields={[
{
name: 'id',
label: 'ID',
type: 'hidden',
value: project.id,
},
]}
schemaName={'githubTestIssue'}
action={githubTestIssue}
submitText="Create test issue"
submitClassname="!mt-0"
/>
) : (
<p className="text-muted-foreground text-sm">
You need to connect your project to a GitHub repository before you can test
issue submission.
</p>
)}
</CardContent>
</Card>
</TabsContent>
)}
<TabsContent value="api" className="space-y-5"> <TabsContent value="api" className="space-y-5">
<Card> <Card>
@@ -339,4 +353,4 @@ interface ProjectWithUsers extends Project {
UserProject: (UserProject & { UserProject: (UserProject & {
user: User; user: User;
})[]; })[];
} }

View File

@@ -31,9 +31,10 @@ export default function ProjectTeamUsers(userProject: ProjectTeamUsersProps) {
}); });
}; };
// toReversed shows the owner at the top, then at join order
return ( return (
<ul className="space-y-2 pt-5"> <ul className="space-y-2 pt-5">
{users.map((user) => ( {users.toReversed().map((user) => (
<li <li
key={user.userId} key={user.userId}
className="flex items-center justify-between p-3 rounded-lg shadow bg-accent" className="flex items-center justify-between p-3 rounded-lg shadow bg-accent"
@@ -46,7 +47,7 @@ export default function ProjectTeamUsers(userProject: ProjectTeamUsersProps) {
/> />
<AvatarFallback>{user.user.username.toUpperCase()}</AvatarFallback> <AvatarFallback>{user.user.username.toUpperCase()}</AvatarFallback>
</Avatar> </Avatar>
<span className="font-medium">{user.user.username}</span> <span className="font-medium">{user.user.username}{user.isOwner && ' (owner)'}</span>
</div> </div>
<Button <Button
variant="destructive" variant="destructive"

View File

@@ -32,6 +32,10 @@ export const githubSettingsSchema = z.object({
'Github URL must be "https://github.com/user/repo"' 'Github URL must be "https://github.com/user/repo"'
) )
.nonempty(), .nonempty(),
installationId: z
.string()
.nonempty()
.transform((val) => parseInt(val, 10)),
}); });
export const githubTestIssueSchema = z.object({ export const githubTestIssueSchema = z.object({

View File

@@ -107,6 +107,7 @@ export async function customData(prev: any, formData: FormData) {
} }
export async function githubSettings(prev: any, formData: FormData) { export async function githubSettings(prev: any, formData: FormData) {
// @ts-ignore transforming string to number makes the types go crazy
const zod = await zodVerify(githubSettingsSchema, formData); const zod = await zodVerify(githubSettingsSchema, formData);
const { user, session } = await validateRequest(); const { user, session } = await validateRequest();
if (!user) { if (!user) {
@@ -122,6 +123,7 @@ export async function githubSettings(prev: any, formData: FormData) {
}, },
data: { data: {
github: zod.data.github, github: zod.data.github,
githubInstallationId: zod.data.installationId.toString(),
}, },
}); });
return { success: true, id: dbUpdate.id }; return { success: true, id: dbUpdate.id };
@@ -149,40 +151,34 @@ export async function githubTestIssue(prev: any, formData: FormData) {
include: { include: {
user: true, user: true,
}, },
} },
}, },
}); });
if (!project) { if (!project) {
return { success: false, error: 'Project not found' }; return { success: false, error: 'Project not found' };
} }
if (!project.github || !project.githubInstallationId) {
return { success: false, error: 'Github settings not found' };
}
try { try {
const [owner, repo] = project.github!.split('/').slice(-2); const [owner, repo] = project.github!.split('/').slice(-2);
let issueCreated = false; const installation = await octokitApp.getInstallationOctokit(
parseInt(project.githubInstallationId!)
for (const installationId of project.UserProject[0].user.installations) { );
if (issueCreated) break; const getRepo = await installation
.request('GET /repos/{owner}/{repo}', {
const installation = await octokitApp.getInstallationOctokit(Number(installationId)); owner,
const getRepo = await installation repo,
.request('GET /repos/{owner}/{repo}', { })
owner, .catch(() => ({ status: 404 }));
repo, if (getRepo.status === 200) {
}) await installation.request('POST /repos/{owner}/{repo}/issues', {
.catch(() => ({ status: 404 })); owner,
if (getRepo.status === 200) { repo,
const createIssue = await installation.request('POST /repos/{owner}/{repo}/issues', { title: 'Test issue',
owner, body: "### You are all set! 🎉\n\nIf you're reading this, the test issue has been created successfully!",
repo, });
title: 'Test issue',
body: "### You are all set! 🎉\n\nIf you're reading this, the test issue has been created successfully!",
});
if (createIssue.status === 201) {
issueCreated = true;
break;
}
}
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -213,7 +209,7 @@ export async function githubCreateIssue(prev: any, formData: FormData) {
}, },
include: { include: {
user: true, user: true,
} },
}, },
}, },
}); });
@@ -223,27 +219,22 @@ export async function githubCreateIssue(prev: any, formData: FormData) {
try { try {
const [owner, repo] = project.github!.split('/').slice(-2); const [owner, repo] = project.github!.split('/').slice(-2);
const installation = await octokitApp.getInstallationOctokit(
for (const installationId of project.UserProject[0].user.installations) { Number(project.githubInstallationId)
const installation = await octokitApp.getInstallationOctokit(Number(installationId)); );
const getRepo = await installation const getRepo = await installation
.request('GET /repos/{owner}/{repo}', { .request('GET /repos/{owner}/{repo}', {
owner, owner,
repo, repo,
}) })
.catch(() => ({ status: 404 })); .catch(() => ({ status: 404 }));
if (getRepo.status === 200) { if (getRepo.status === 200) {
const createIssue = await installation.request('POST /repos/{owner}/{repo}/issues', { await installation.request('POST /repos/{owner}/{repo}/issues', {
owner, owner,
repo, repo,
title: zod.data.title, title: zod.data.title,
body: `### Feedback\n\nFeedback ID: ${zod.data.feedback}\n\n### Message\n\n${zod.data.message}`, body: `### Feedback\n\nFeedback ID: ${zod.data.feedback}\n\n### Message\n\n${zod.data.message}`,
}); });
if (createIssue.status === 201) {
break;
}
}
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);