mirror of
https://github.com/SrIzan10/echospace.git
synced 2026-06-06 00:56:54 +00:00
fix: make github issue creation creator-restricted and easier for others
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Project" ADD COLUMN "githubInstallationId" TEXT,
|
||||||
|
ALTER COLUMN "inviteCode" SET DEFAULT floor(random() * 90000000 + 10000000)::text;
|
||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
})[];
|
})[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user