blob: 7657043a6f49cd430d730134539c497a36237f37 [file] [log] [blame]
giod0026612025-05-08 13:00:36 +00001import axios from "axios";
2import { z } from "zod";
3
4export const GithubRepositorySchema = z.object({
5 id: z.number(),
6 name: z.string(),
7 full_name: z.string(),
8 html_url: z.string(),
9 ssh_url: z.string(),
10});
11
gio3ed59592025-05-14 16:51:09 +000012const DeployKeysSchema = z.array(
13 z.object({
14 id: z.number(),
15 key: z.string(),
16 }),
17);
18
gio76d8ae62025-05-19 15:21:54 +000019const WebhookSchema = z.object({
20 id: z.number(),
21 config: z.object({
22 url: z.string().optional(), // url might not always be present
23 content_type: z.string().optional(),
24 }),
25 events: z.array(z.string()),
26 active: z.boolean(),
27});
28
29const ListWebhooksResponseSchema = z.array(WebhookSchema);
30
giod0026612025-05-08 13:00:36 +000031export type GithubRepository = z.infer<typeof GithubRepositorySchema>;
32
33export class GithubClient {
34 private token: string;
35
36 constructor(token: string) {
37 this.token = token;
38 }
39
40 private getHeaders() {
41 return {
42 Authorization: `Bearer ${this.token}`,
43 Accept: "application/vnd.github.v3+json",
gio76d8ae62025-05-19 15:21:54 +000044 "X-GitHub-Api-Version": "2022-11-28",
giod0026612025-05-08 13:00:36 +000045 };
46 }
47
48 async getRepositories(): Promise<GithubRepository[]> {
49 const response = await axios.get("https://api.github.com/user/repos", {
50 headers: this.getHeaders(),
51 });
52 return z.array(GithubRepositorySchema).parse(response.data);
53 }
54
55 async addDeployKey(repoPath: string, key: string) {
56 const sshUrl = repoPath;
57 const repoOwnerAndName = sshUrl.replace("git@github.com:", "").replace(".git", "");
gioa71316d2025-05-24 09:41:36 +040058 let existingKeys: z.infer<typeof DeployKeysSchema> = [];
59 const response = await axios.get(`https://api.github.com/repos/${repoOwnerAndName}/keys`, {
60 headers: this.getHeaders(),
61 });
62 const parsedResult = DeployKeysSchema.safeParse(response.data);
63 if (parsedResult.success) {
64 existingKeys = parsedResult.data;
65 } else {
66 console.error("Failed to parse existing deploy keys:", parsedResult.error);
67 }
68 const keyToAddParts = key.trim().split(" ");
69 const mainKeyPartToAdd = keyToAddParts.length > 1 ? keyToAddParts.slice(0, 2).join(" ") : key.trim();
70 const keyAlreadyExists = existingKeys.some((existingKeyObj) => {
71 const existingKeyParts = existingKeyObj.key.trim().split(" ");
72 const mainExistingKeyPart =
73 existingKeyParts.length > 1 ? existingKeyParts.slice(0, 2).join(" ") : existingKeyObj.key.trim();
74 return mainExistingKeyPart === mainKeyPartToAdd;
75 });
76 if (keyAlreadyExists) {
77 return;
78 }
giod0026612025-05-08 13:00:36 +000079 await axios.post(
80 `https://api.github.com/repos/${repoOwnerAndName}/keys`,
81 {
82 title: "dodo",
83 key: key,
84 read_only: true,
85 },
86 {
87 headers: this.getHeaders(),
88 },
89 );
90 }
gio3ed59592025-05-14 16:51:09 +000091
92 async removeDeployKey(repoPath: string, key: string) {
93 const sshUrl = repoPath;
94 const repoOwnerAndName = sshUrl.replace("git@github.com:", "").replace(".git", "");
95 const response = await axios.get(`https://api.github.com/repos/${repoOwnerAndName}/keys`, {
96 headers: this.getHeaders(),
97 });
98 const result = DeployKeysSchema.safeParse(response.data);
99 if (!result.success) {
gioa71316d2025-05-24 09:41:36 +0400100 console.error("Failed to parse deploy keys response for removal:", result.error);
101 // Depending on desired robustness, you might throw an error or return early.
102 // For now, if parsing fails, we can't identify keys to remove.
103 throw new Error("Failed to parse deploy keys response during removal process");
gio3ed59592025-05-14 16:51:09 +0000104 }
gioa71316d2025-05-24 09:41:36 +0400105
106 // Extract the main part of the key we intend to remove
107 const keyToRemoveParts = key.trim().split(" ");
108 const mainKeyPartToRemove = keyToRemoveParts.length > 1 ? keyToRemoveParts.slice(0, 2).join(" ") : key.trim();
109
110 const deployKeysToDelete = result.data.filter((existingKeyObj) => {
111 const existingKeyParts = existingKeyObj.key.trim().split(" ");
112 const mainExistingKeyPart =
113 existingKeyParts.length > 1 ? existingKeyParts.slice(0, 2).join(" ") : existingKeyObj.key.trim();
114 return mainExistingKeyPart === mainKeyPartToRemove;
115 });
116
117 if (deployKeysToDelete.length === 0) {
118 console.log(
119 `No deploy key matching '${mainKeyPartToRemove.substring(0, 50)}...' found in repo ${repoOwnerAndName} for removal.`,
120 );
121 return;
122 }
123
gio3ed59592025-05-14 16:51:09 +0000124 await Promise.all(
gioa71316d2025-05-24 09:41:36 +0400125 deployKeysToDelete.map((deployKey) => {
126 console.log(
127 `Removing deploy key ID ${deployKey.id} ('${deployKey.key.substring(0, 50)}...') from repo ${repoOwnerAndName}`,
128 );
129 return axios.delete(`https://api.github.com/repos/${repoOwnerAndName}/keys/${deployKey.id}`, {
gio3ed59592025-05-14 16:51:09 +0000130 headers: this.getHeaders(),
gioa71316d2025-05-24 09:41:36 +0400131 });
132 }),
133 );
134 console.log(
135 `Successfully initiated removal of ${deployKeysToDelete.length} matching deploy key(s) from ${repoOwnerAndName}.`,
gio3ed59592025-05-14 16:51:09 +0000136 );
137 }
gio76d8ae62025-05-19 15:21:54 +0000138
139 async addPushWebhook(repoPath: string, callbackAddress: string): Promise<boolean> {
140 const repoOwnerAndName = repoPath.replace("git@github.com:", "").replace(".git", "");
141 const resp = await axios.post(
142 `https://api.github.com/repos/${repoOwnerAndName}/hooks`,
143 {
144 name: "web",
145 active: true,
146 events: ["push"],
147 config: {
148 url: callbackAddress,
149 content_type: "json",
150 },
151 },
152 {
153 headers: this.getHeaders(),
154 },
155 );
156 return resp.status === 201;
157 }
158
159 async removePushWebhook(repoPath: string, callbackAddress: string): Promise<boolean> {
160 const repoOwnerAndName = repoPath.replace("git@github.com:", "").replace(".git", "");
161 const listHooksUrl = `https://api.github.com/repos/${repoOwnerAndName}/hooks`;
162 try {
163 const response = await axios.get(listHooksUrl, {
164 headers: this.getHeaders(),
165 });
166 const parsedHooks = ListWebhooksResponseSchema.safeParse(response.data);
167 if (!parsedHooks.success) {
168 throw new Error(`Failed to parse webhooks list for ${repoOwnerAndName}`);
169 }
170 const results = await Promise.all(
171 parsedHooks.data
172 .filter((hook) => hook.config.url === callbackAddress && hook.events.includes("push"))
173 .map(async (hook) => {
174 const deleteHookUrl = `https://api.github.com/repos/${repoOwnerAndName}/hooks/${hook.id}`;
175 const resp = await axios.delete(deleteHookUrl, { headers: this.getHeaders() });
176 return resp.status === 204;
177 }),
178 );
179 return results.reduce((acc, curr) => acc && curr, true);
180 // eslint-disable-next-line @typescript-eslint/no-explicit-any
181 } catch (error: any) {
182 console.error(
183 `Failed to list or remove webhooks for ${repoOwnerAndName}:`,
184 error.response?.data || error.message,
185 );
186 throw error; // Re-throw to let the caller handle it
187 }
188 }
giod0026612025-05-08 13:00:36 +0000189}