blob: e201c498ba80bd1e8b89725deda204bbf517fa36 [file] [log] [blame]
giod0026612025-05-08 13:00:36 +00001import axios from "axios";
gio007c8572025-07-08 04:27:35 +00002import { GithubRepository, GithubRepositoriesSchema, DeployKeysSchema, ListWebhooksResponseSchema } from "config";
giod0026612025-05-08 13:00:36 +00003
4export class GithubClient {
5 private token: string;
6
7 constructor(token: string) {
8 this.token = token;
9 }
10
11 private getHeaders() {
12 return {
13 Authorization: `Bearer ${this.token}`,
14 Accept: "application/vnd.github.v3+json",
gio76d8ae62025-05-19 15:21:54 +000015 "X-GitHub-Api-Version": "2022-11-28",
giod0026612025-05-08 13:00:36 +000016 };
17 }
18
19 async getRepositories(): Promise<GithubRepository[]> {
gio007c8572025-07-08 04:27:35 +000020 const allRepos: GithubRepository[] = [];
21 let url: string | null = "https://api.github.com/user/repos?per_page=100";
22
23 while (url) {
24 const response = await axios.get(url, {
25 headers: this.getHeaders(),
26 });
27
28 const reposOnPage = GithubRepositoriesSchema.parse(response.data);
29 allRepos.push(...reposOnPage);
30
31 const linkHeader = response.headers.link as string | undefined;
32 if (linkHeader) {
33 const nextLink = linkHeader.split(",").find((s) => s.includes('rel="next"'));
34 if (nextLink) {
35 const match = nextLink.match(/<(.*)>/);
36 url = match ? match[1] : null;
37 } else {
38 url = null;
39 }
40 } else {
41 url = null;
42 }
43 }
44 return allRepos;
giod0026612025-05-08 13:00:36 +000045 }
46
gio007c8572025-07-08 04:27:35 +000047 // TODO(giolekva): generate unique deploy key per repo and add it to the repo
giod0026612025-05-08 13:00:36 +000048 async addDeployKey(repoPath: string, key: string) {
gio007c8572025-07-08 04:27:35 +000049 await this.addSSHKey(key);
50 // const sshUrl = repoPath;
51 // const repoOwnerAndName = sshUrl.replace("git@github.com:", "").replace(".git", "");
52 // let existingKeys: DeployKeys = [];
53 // const response = await axios.get(`https://api.github.com/repos/${repoOwnerAndName}/keys`, {
54 // headers: this.getHeaders(),
55 // });
56 // const parsedResult = DeployKeysSchema.safeParse(response.data);
57 // if (parsedResult.success) {
58 // existingKeys = parsedResult.data;
59 // } else {
60 // console.error("Failed to parse existing deploy keys:", parsedResult.error);
61 // }
62 // const keyToAddParts = key.trim().split(" ");
63 // const mainKeyPartToAdd = keyToAddParts.length > 1 ? keyToAddParts.slice(0, 2).join(" ") : key.trim();
64 // const keyAlreadyExists = existingKeys.some((existingKeyObj) => {
65 // const existingKeyParts = existingKeyObj.key.trim().split(" ");
66 // const mainExistingKeyPart =
67 // existingKeyParts.length > 1 ? existingKeyParts.slice(0, 2).join(" ") : existingKeyObj.key.trim();
68 // return mainExistingKeyPart === mainKeyPartToAdd;
69 // });
70 // if (keyAlreadyExists) {
71 // return;
72 // }
73 // await axios.post(
74 // `https://api.github.com/repos/${repoOwnerAndName}/keys`,
75 // {
76 // title: "dodo",
77 // key: key,
78 // read_only: true,
79 // },
80 // {
81 // headers: this.getHeaders(),
82 // },
83 // );
giod0026612025-05-08 13:00:36 +000084 }
gio3ed59592025-05-14 16:51:09 +000085
86 async removeDeployKey(repoPath: string, key: string) {
gio007c8572025-07-08 04:27:35 +000087 this.removeSSHKey(key);
88 // const sshUrl = repoPath;
89 // const repoOwnerAndName = sshUrl.replace("git@github.com:", "").replace(".git", "");
90 // const response = await axios.get(`https://api.github.com/repos/${repoOwnerAndName}/keys`, {
91 // headers: this.getHeaders(),
92 // });
93 // const result = DeployKeysSchema.safeParse(response.data);
94 // if (!result.success) {
95 // console.error("Failed to parse deploy keys response for removal:", result.error);
96 // // Depending on desired robustness, you might throw an error or return early.
97 // // For now, if parsing fails, we can't identify keys to remove.
98 // throw new Error("Failed to parse deploy keys response during removal process");
99 // }
gioa71316d2025-05-24 09:41:36 +0400100
gio007c8572025-07-08 04:27:35 +0000101 // // Extract the main part of the key we intend to remove
102 // const keyToRemoveParts = key.trim().split(" ");
103 // const mainKeyPartToRemove = keyToRemoveParts.length > 1 ? keyToRemoveParts.slice(0, 2).join(" ") : key.trim();
gioa71316d2025-05-24 09:41:36 +0400104
gio007c8572025-07-08 04:27:35 +0000105 // const deployKeysToDelete = result.data.filter((existingKeyObj) => {
106 // const existingKeyParts = existingKeyObj.key.trim().split(" ");
107 // const mainExistingKeyPart =
108 // existingKeyParts.length > 1 ? existingKeyParts.slice(0, 2).join(" ") : existingKeyObj.key.trim();
109 // return mainExistingKeyPart === mainKeyPartToRemove;
110 // });
gioa71316d2025-05-24 09:41:36 +0400111
gio007c8572025-07-08 04:27:35 +0000112 // if (deployKeysToDelete.length === 0) {
113 // console.log(
114 // `No deploy key matching '${mainKeyPartToRemove.substring(0, 50)}...' found in repo ${repoOwnerAndName} for removal.`,
115 // );
116 // return;
117 // }
gioa71316d2025-05-24 09:41:36 +0400118
gio007c8572025-07-08 04:27:35 +0000119 // await Promise.all(
120 // deployKeysToDelete.map((deployKey) => {
121 // console.log(
122 // `Removing deploy key ID ${deployKey.id} ('${deployKey.key.substring(0, 50)}...') from repo ${repoOwnerAndName}`,
123 // );
124 // return axios.delete(`https://api.github.com/repos/${repoOwnerAndName}/keys/${deployKey.id}`, {
125 // headers: this.getHeaders(),
126 // });
127 // }),
128 // );
129 // console.log(
130 // `Successfully initiated removal of ${deployKeysToDelete.length} matching deploy key(s) from ${repoOwnerAndName}.`,
131 // );
gio3ed59592025-05-14 16:51:09 +0000132 }
gio76d8ae62025-05-19 15:21:54 +0000133
134 async addPushWebhook(repoPath: string, callbackAddress: string): Promise<boolean> {
135 const repoOwnerAndName = repoPath.replace("git@github.com:", "").replace(".git", "");
136 const resp = await axios.post(
137 `https://api.github.com/repos/${repoOwnerAndName}/hooks`,
138 {
139 name: "web",
140 active: true,
141 events: ["push"],
142 config: {
143 url: callbackAddress,
144 content_type: "json",
145 },
146 },
147 {
148 headers: this.getHeaders(),
149 },
150 );
151 return resp.status === 201;
152 }
153
154 async removePushWebhook(repoPath: string, callbackAddress: string): Promise<boolean> {
155 const repoOwnerAndName = repoPath.replace("git@github.com:", "").replace(".git", "");
156 const listHooksUrl = `https://api.github.com/repos/${repoOwnerAndName}/hooks`;
157 try {
158 const response = await axios.get(listHooksUrl, {
159 headers: this.getHeaders(),
160 });
161 const parsedHooks = ListWebhooksResponseSchema.safeParse(response.data);
162 if (!parsedHooks.success) {
163 throw new Error(`Failed to parse webhooks list for ${repoOwnerAndName}`);
164 }
165 const results = await Promise.all(
166 parsedHooks.data
167 .filter((hook) => hook.config.url === callbackAddress && hook.events.includes("push"))
168 .map(async (hook) => {
169 const deleteHookUrl = `https://api.github.com/repos/${repoOwnerAndName}/hooks/${hook.id}`;
170 const resp = await axios.delete(deleteHookUrl, { headers: this.getHeaders() });
171 return resp.status === 204;
172 }),
173 );
174 return results.reduce((acc, curr) => acc && curr, true);
175 // eslint-disable-next-line @typescript-eslint/no-explicit-any
176 } catch (error: any) {
177 console.error(
178 `Failed to list or remove webhooks for ${repoOwnerAndName}:`,
179 error.response?.data || error.message,
180 );
181 throw error; // Re-throw to let the caller handle it
182 }
183 }
gio007c8572025-07-08 04:27:35 +0000184
185 async addSSHKey(key: string) {
186 let existingKeys: { key: string }[] = [];
187 const response = await axios.get(`https://api.github.com/user/keys`, {
188 headers: this.getHeaders(),
189 });
190 if (Array.isArray(response.data)) {
191 existingKeys = response.data;
192 }
193 const keyToAddParts = key.trim().split(" ");
194 const mainKeyPartToAdd = keyToAddParts.length > 1 ? keyToAddParts.slice(0, 2).join(" ") : key.trim();
195 const keyAlreadyExists = existingKeys.some((existingKeyObj) => {
196 const existingKeyParts = existingKeyObj.key.trim().split(" ");
197 const mainExistingKeyPart =
198 existingKeyParts.length > 1 ? existingKeyParts.slice(0, 2).join(" ") : existingKeyObj.key.trim();
199 return mainExistingKeyPart === mainKeyPartToAdd;
200 });
201
202 if (keyAlreadyExists) {
203 return;
204 }
205 await axios.post(
206 `https://api.github.com/user/keys`,
207 {
208 title: "dodo",
209 key: key,
210 },
211 {
212 headers: this.getHeaders(),
213 },
214 );
215 }
216
217 async removeSSHKey(key: string) {
218 const response = await axios.get(`https://api.github.com/user/keys`, {
219 headers: this.getHeaders(),
220 });
221
222 const result = DeployKeysSchema.safeParse(response.data);
223 if (!result.success) {
224 throw new Error("Failed to parse deploy keys response during removal process");
225 }
226
227 // Extract the main part of the key we intend to remove
228 const keyToRemoveParts = key.trim().split(" ");
229 const mainKeyPartToRemove = keyToRemoveParts.length > 1 ? keyToRemoveParts.slice(0, 2).join(" ") : key.trim();
230
231 const deployKeysToDelete = result.data.filter((existingKeyObj) => {
232 const existingKeyParts = existingKeyObj.key.trim().split(" ");
233 const mainExistingKeyPart =
234 existingKeyParts.length > 1 ? existingKeyParts.slice(0, 2).join(" ") : existingKeyObj.key.trim();
235 return mainExistingKeyPart === mainKeyPartToRemove;
236 });
237
238 if (deployKeysToDelete.length === 0) {
239 return;
240 }
241
242 await Promise.all(
243 deployKeysToDelete.map((deployKey) => {
244 return axios.delete(`https://api.github.com/user/keys/${deployKey.id}`, {
245 headers: this.getHeaders(),
246 });
247 }),
248 );
249 }
giod0026612025-05-08 13:00:36 +0000250}