Skip to content

Commit b953075

Browse files
authored
Allow syncing projects to milestones (calcom#144)
* Allow syncing projects to milestones
1 parent acc6d53 commit b953075

File tree

8 files changed

+773
-931
lines changed

8 files changed

+773
-931
lines changed

components/Dashboard.tsx

Lines changed: 89 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,36 @@
11
import { Cross1Icon, InfoCircledIcon, WidthIcon } from "@radix-ui/react-icons";
2-
import React, { useContext, useState } from "react";
2+
import React, { useContext, useEffect, useState } from "react";
33
import { LINEAR } from "../utils/constants";
44
import { updateGitHubWebhook } from "../utils/github";
5-
import { updateLinearWebhook } from "../utils/linear";
5+
import { getLinearWebhook, updateLinearWebhook } from "../utils/linear";
66
import { Context } from "./ContextProvider";
77
import Tooltip from "./Tooltip";
88

9+
const options = ["Cycle", "Project"] as const;
10+
type Option = (typeof options)[number];
11+
912
const Dashboard = () => {
1013
const { syncs, setSyncs, gitHubContext, linearContext } =
1114
useContext(Context);
1215

1316
const [loading, setLoading] = useState(false);
17+
const [milestoneAction, setMilestoneAction] = useState<Option | null>(null);
18+
19+
// Get initial webhook settings
20+
useEffect(() => {
21+
if (!syncs?.length) return;
22+
23+
getLinearWebhook(
24+
linearContext.apiKey,
25+
syncs[0].LinearTeam.teamName
26+
).then(res => {
27+
if (res.resourceTypes.includes("Cycle")) {
28+
setMilestoneAction("Cycle");
29+
} else if (res.resourceTypes.includes("Project")) {
30+
setMilestoneAction("Project");
31+
}
32+
});
33+
}, [syncs]);
1434

1535
const removeSync = async (syncId: string) => {
1636
if (!syncId || !gitHubContext.apiKey) return;
@@ -37,36 +57,37 @@ const Dashboard = () => {
3757
});
3858
};
3959

40-
const handleMilestoneSyncChange = async (
41-
e: React.ChangeEvent<HTMLInputElement>
42-
) => {
43-
setLoading(true);
60+
useEffect(() => {
61+
const handleMilestoneSyncChange = async () => {
62+
setLoading(true);
4463

45-
const checked = e.target.checked || false;
64+
for (const sync of syncs) {
65+
await updateGitHubWebhook(
66+
gitHubContext.apiKey,
67+
sync.GitHubRepo.repoName,
68+
{
69+
...(milestoneAction
70+
? { add_events: ["milestone"] }
71+
: { remove_events: ["milestone"] })
72+
}
73+
);
74+
await updateLinearWebhook(
75+
linearContext.apiKey,
76+
sync.LinearTeam.teamName,
77+
{
78+
resourceTypes: [
79+
...LINEAR.WEBHOOK_EVENTS,
80+
...(milestoneAction ? [milestoneAction] : [])
81+
]
82+
}
83+
);
84+
}
4685

47-
for (const sync of syncs) {
48-
await updateGitHubWebhook(
49-
gitHubContext.apiKey,
50-
sync.GitHubRepo.repoName,
51-
{
52-
...(checked && { add_events: ["milestone"] }),
53-
...(!checked && { remove_events: ["milestone"] })
54-
}
55-
);
56-
await updateLinearWebhook(
57-
linearContext.apiKey,
58-
sync.LinearTeam.teamName,
59-
{
60-
resourceTypes: [
61-
...LINEAR.WEBHOOK_EVENTS,
62-
...(checked ? ["Cycle"] : [])
63-
]
64-
}
65-
);
66-
}
86+
setLoading(false);
87+
};
6788

68-
setLoading(false);
69-
};
89+
handleMilestoneSyncChange();
90+
}, [milestoneAction]);
7091

7192
if (!syncs?.length) return <></>;
7293

@@ -104,19 +125,45 @@ const Dashboard = () => {
104125
</Tooltip>
105126
</div>
106127
))}
107-
<div className="flex items-center space-x-2 mb-4">
108-
<input
109-
disabled={!linearContext.apiKey}
110-
type="checkbox"
111-
id="syncsMilestones"
112-
onChange={handleMilestoneSyncChange}
113-
/>
114-
<label htmlFor="syncsMilestones" className="whitespace-nowrap">
115-
Sync milestones to cycles
116-
</label>
117-
<Tooltip content="Requires connecting to Linear first">
118-
<InfoCircledIcon className="w-6 h-6 text-gray-400 hover:font-secondary transition-colors duration-200" />
119-
</Tooltip>
128+
<div className="flex flex-col items-start">
129+
{options.map(option => (
130+
<div
131+
key={option}
132+
className="flex items-center space-x-2 mb-4"
133+
>
134+
<input
135+
id={option}
136+
disabled={!linearContext.apiKey}
137+
type="checkbox"
138+
checked={milestoneAction === option}
139+
onChange={e =>
140+
setMilestoneAction(
141+
e.target.checked
142+
? (e.target.id as Option)
143+
: null
144+
)
145+
}
146+
/>
147+
<label htmlFor={option} className="whitespace-nowrap">
148+
Sync {option}s to Milestones
149+
</label>
150+
<Tooltip
151+
content={
152+
!linearContext.apiKey
153+
? "Requires connecting to Linear first"
154+
: milestoneAction
155+
? `Will disable ${
156+
option == "Cycle"
157+
? "Project"
158+
: "Cycle"
159+
} sync`
160+
: ""
161+
}
162+
>
163+
<InfoCircledIcon className="w-6 h-6 text-gray-400 hover:font-secondary transition-colors duration-200" />
164+
</Tooltip>
165+
</div>
166+
))}
120167
</div>
121168
</div>
122169
);

components/GitHubAuthButton.tsx

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {
1212
getRepoWebhook,
1313
getGitHubAuthURL,
1414
saveGitHubContext,
15-
setGitHubWebook
15+
setGitHubWebook,
16+
getGitHubContext
1617
} from "../utils/github";
1718
import { Context } from "./ContextProvider";
1819
import Select from "./Select";
@@ -90,21 +91,28 @@ const GitHubAuthButton = ({
9091
const startingPage = 0;
9192

9293
const listReposRecursively = async (page: number): Promise<void> => {
93-
const res = await listReposForUser(gitHubToken, page);
94+
try {
95+
const res = await listReposForUser(gitHubToken, page);
9496

95-
if (!res || res.length < 1) {
97+
if (!res || res?.length < 1) {
98+
setReposLoading(false);
99+
return;
100+
}
101+
102+
setRepos((current: GitHubRepo[]) => [
103+
...current,
104+
...(res?.map?.(repo => {
105+
return { id: repo.id, name: repo.full_name };
106+
}) ?? [])
107+
]);
108+
109+
return await listReposRecursively(page + 1);
110+
} catch (err) {
111+
alert(`Error fetching repos: ${err}`);
96112
setReposLoading(false);
113+
97114
return;
98115
}
99-
100-
setRepos((current: GitHubRepo[]) => [
101-
...current,
102-
...(res?.map?.(repo => {
103-
return { id: repo.id, name: repo.full_name };
104-
}) ?? [])
105-
]);
106-
107-
return await listReposRecursively(page + 1);
108116
};
109117

110118
setReposLoading(true);
@@ -121,9 +129,14 @@ const GitHubAuthButton = ({
121129

122130
setLoading(true);
123131

124-
getRepoWebhook(chosenRepo.name, gitHubToken)
125-
.then(res => {
126-
if (res?.exists) {
132+
const checkRepo = async () => {
133+
try {
134+
const [webhook, repo] = await Promise.all([
135+
getRepoWebhook(chosenRepo.name, gitHubToken),
136+
getGitHubContext(chosenRepo.id, gitHubToken)
137+
]);
138+
139+
if (webhook?.exists && repo?.inDb) {
127140
setDeployed(true);
128141
onDeployWebhook({
129142
userId: gitHubUser.id,
@@ -133,12 +146,14 @@ const GitHubAuthButton = ({
133146
} else {
134147
setDeployed(false);
135148
}
136-
setLoading(false);
137-
})
138-
.catch(err => {
149+
} catch (err) {
139150
alert(`Error checking for existing repo: ${err}`);
140-
setLoading(false);
141-
});
151+
}
152+
153+
setLoading(false);
154+
};
155+
156+
checkRepo();
142157
}, [chosenRepo]);
143158

144159
const openAuthPage = () => {

pages/api/github/repo.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { NextApiRequest, NextApiResponse } from "next";
2+
import prisma from "../../../prisma";
3+
4+
// POST /api/github/repo
5+
export default async function handle(
6+
req: NextApiRequest,
7+
res: NextApiResponse
8+
) {
9+
if (req.method !== "POST") {
10+
return res.setHeader("Allow", "POST").status(405).send({
11+
error: "Only POST requests are accepted"
12+
});
13+
}
14+
15+
const { repoId } = JSON.parse(req.body);
16+
17+
if (!repoId || isNaN(repoId)) {
18+
return res.status(400).send({ error: "Request is missing repo ID" });
19+
}
20+
21+
try {
22+
const inDb = repoId
23+
? await prisma.gitHubRepo.findFirst({
24+
where: { repoId: Number(repoId) }
25+
})
26+
: false;
27+
28+
return res.status(200).json({ inDb });
29+
} catch (err) {
30+
console.error(err);
31+
return res.status(404).send({ error: err });
32+
}
33+
}
34+

0 commit comments

Comments
 (0)