]> Git Repo - pico-vscode.git/blob - src/utils/githubREST.mts
b428ff97c83e19ebefda0fd9037a00d342ca0d73
[pico-vscode.git] / src / utils / githubREST.mts
1 import Settings, { SettingsKey } from "../settings.mjs";
2 import Logger from "../logger.mjs";
3 import GithubApiCache, {
4   GithubApiCacheEntryDataType,
5 } from "./githubApiCache.mjs";
6 import { type RequestOptions, request } from "https";
7
8 const HTTP_STATUS_OK = 200;
9 const HTTP_STATUS_NOT_MODIFIED = 304;
10 export const EXT_USER_AGENT = "Raspberry-Pi Pico VS Code Extension";
11 export const GITHUB_API_BASE_URL = "https://api.github.com";
12
13 export enum GithubRepository {
14   picoSDK = 0,
15   cmake = 1,
16   ninja = 2,
17   tools = 3,
18   openocd = 4,
19 }
20
21 export type GithubReleaseResponse = {
22   assetsUrl: string;
23   assets: GithubReleaseAssetData[];
24 };
25
26 export type GithubReleaseAssetData = {
27   name: string;
28   // eslint-disable-next-line @typescript-eslint/naming-convention
29   browser_download_url: string;
30   id: number;
31 };
32
33 // NOTE: The primary rate limit for unauthenticated requests is 60 requests per hour.
34 export const SDK_REPOSITORY_URL = "https://github.com/raspberrypi/pico-sdk.git";
35 export const NINJA_REPOSITORY_URL = "https://github.com/ninja-build/ninja.git";
36 export const CMAKE_REPOSITORY_URL = "https://github.com/Kitware/CMake.git";
37 export const PYENV_REPOSITORY_URL = "https://github.com/pyenv/pyenv.git";
38
39 export function ownerOfRepository(repository: GithubRepository): string {
40   switch (repository) {
41     case GithubRepository.picoSDK:
42       return "raspberrypi";
43     case GithubRepository.cmake:
44       return "Kitware";
45     case GithubRepository.ninja:
46       return "ninja-build";
47     case GithubRepository.tools:
48       return "will-v-pi";
49     case GithubRepository.openocd:
50       return "xpack-dev-tools";
51   }
52 }
53
54 export function repoNameOfRepository(repository: GithubRepository): string {
55   switch (repository) {
56     case GithubRepository.picoSDK:
57       return "pico-sdk";
58     case GithubRepository.cmake:
59       return "CMake";
60     case GithubRepository.ninja:
61       return "ninja";
62     case GithubRepository.tools:
63       return "pico-sdk-tools";
64     case GithubRepository.openocd:
65       return "openocd-xpack";
66   }
67 }
68
69 interface AuthorizationHeaders {
70   // eslint-disable-next-line @typescript-eslint/naming-convention
71   Authorization?: string;
72 }
73
74 export function getAuthorizationHeaders(): AuthorizationHeaders {
75   const headers: AuthorizationHeaders = {};
76   // takes some time to execute (noticable in UI)
77   const githubPAT = Settings.getInstance()?.getString(SettingsKey.githubToken);
78   if (githubPAT && githubPAT.length > 0) {
79     Logger.log("Using GitHub Personal Access Token for authentication");
80     headers.Authorization = `Bearer ${githubPAT}`;
81   }
82
83   return headers;
84 }
85
86 async function makeAsyncGetRequest<T>(
87   url: string,
88   headers: { [key: string]: string }
89 ): Promise<{
90   status: number;
91   data: T | null;
92   headers: { etag?: string };
93 }> {
94   return new Promise((resolve, reject) => {
95     const urlObj = new URL(url);
96     const options: RequestOptions = {
97       method: "GET",
98       headers: {
99         ...getAuthorizationHeaders(),
100         ...headers,
101         // eslint-disable-next-line @typescript-eslint/naming-convention
102         "User-Agent": EXT_USER_AGENT,
103       },
104       hostname: urlObj.hostname,
105       path: urlObj.pathname,
106       minVersion: "TLSv1.2",
107       protocol: urlObj.protocol,
108     };
109
110     const req = request(options, res => {
111       const chunks: Buffer[] = [];
112
113       res.on("data", (chunk: Buffer) => {
114         chunks.push(chunk);
115       });
116
117       res.on("end", () => {
118         const responseBody = Buffer.concat(chunks).toString();
119         try {
120           const jsonData =
121             res.statusCode && res.statusCode === HTTP_STATUS_OK
122               ? (JSON.parse(responseBody) as T)
123               : null;
124           const response = {
125             status: res.statusCode ?? -1,
126             data: jsonData,
127             headers: { etag: res.headers.etag },
128           };
129           resolve(response);
130         } catch (error) {
131           console.error("Error parsing JSON:", error);
132           reject(error);
133         }
134       });
135     });
136
137     req.on("error", error => {
138       console.error("Error making GET request:", error.message);
139       reject(error);
140     });
141
142     req.end();
143   });
144 }
145
146 async function getReleases(repository: GithubRepository): Promise<string[]> {
147   try {
148     const owner = ownerOfRepository(repository);
149     const repo = repoNameOfRepository(repository);
150     const lastEtag = await GithubApiCache.getInstance().getLastEtag(
151       repository,
152       GithubApiCacheEntryDataType.releases
153     );
154     const headers: { [key: string]: string } = {
155       // eslint-disable-next-line @typescript-eslint/naming-convention
156       "X-GitHub-Api-Version": "2022-11-28",
157     };
158     if (lastEtag) {
159       headers["if-none-match"] = lastEtag;
160     }
161
162     // eslint-disable-next-line @typescript-eslint/naming-convention
163     const response = await makeAsyncGetRequest<Array<{ tag_name: string }>>(
164       `${GITHUB_API_BASE_URL}/repos/${owner}/${repo}/releases`,
165       headers
166     );
167
168     if (response.status === HTTP_STATUS_NOT_MODIFIED) {
169       Logger.log("Using cached response for", repo, "releases");
170
171       const cachedResponse = await GithubApiCache.getInstance().getResponse(
172         repository,
173         GithubApiCacheEntryDataType.releases
174       );
175       if (cachedResponse) {
176         return cachedResponse.data as string[];
177       }
178     } else if (response.status !== 200) {
179       throw new Error("Error http status code: " + response.status);
180     }
181
182     if (response.data !== null) {
183       const responseData = response.data
184         //.slice(0, 10)
185         .flatMap(release => release.tag_name)
186         .filter(release => release !== null);
187
188       // store the response in the cache
189       await GithubApiCache.getInstance().saveResponse(
190         repository,
191         GithubApiCacheEntryDataType.releases,
192         responseData,
193         undefined,
194         response.headers.etag
195       );
196
197       return responseData;
198     } else {
199       throw new Error("response.data is null");
200     }
201   } catch (error) {
202     Logger.log("Error fetching", repoNameOfRepository(repository), "releases");
203
204     return (await GithubApiCache.getInstance().getDefaultResponse(
205       repository,
206       GithubApiCacheEntryDataType.releases
207     ))?.data as string[];
208   }
209 }
210
211 export async function getSDKReleases(): Promise<string[]> {
212   return getReleases(GithubRepository.picoSDK);
213 }
214
215 export async function getNinjaReleases(): Promise<string[]> {
216   return getReleases(GithubRepository.ninja);
217 }
218
219 export async function getCmakeReleases(): Promise<string[]> {
220   return getReleases(GithubRepository.cmake);
221 }
222
223 export async function getGithubReleaseByTag(
224   repository: GithubRepository,
225   tag: string
226 ): Promise<GithubReleaseResponse | undefined> {
227   try {
228     const owner = ownerOfRepository(repository);
229     const repo = repoNameOfRepository(repository);
230     const lastEtag = await GithubApiCache.getInstance().getLastEtag(
231       repository,
232       GithubApiCacheEntryDataType.tag,
233       tag
234     );
235     const headers: { [key: string]: string } = {
236       // eslint-disable-next-line @typescript-eslint/naming-convention
237       "X-GitHub-Api-Version": "2022-11-28",
238     };
239     if (lastEtag) {
240       headers["if-none-match"] = lastEtag;
241     }
242
243     const response = await makeAsyncGetRequest<{
244       assets: GithubReleaseAssetData[];
245       // eslint-disable-next-line @typescript-eslint/naming-convention
246       assets_url: string;
247     }>(
248       `${GITHUB_API_BASE_URL}/repos/${owner}/${repo}/releases/tags/${tag}`,
249       headers
250     );
251
252     if (response.status === HTTP_STATUS_NOT_MODIFIED) {
253       Logger.log("Using cached response for", repo, "release by tag", tag);
254
255       const cachedResponse = await GithubApiCache.getInstance().getResponse(
256         repository,
257         GithubApiCacheEntryDataType.tag,
258         tag
259       );
260       if (cachedResponse) {
261         return cachedResponse.data as GithubReleaseResponse;
262       }
263     } else if (response.status !== 200) {
264       throw new Error("Error http status code: " + response.status);
265     }
266
267     if (response.data !== null) {
268       const responseData = {
269         assets: response.data.assets,
270         assetsUrl: response.data.assets_url,
271       };
272
273       // store the response in the cache
274       await GithubApiCache.getInstance().saveResponse(
275         repository,
276         GithubApiCacheEntryDataType.tag,
277         responseData,
278         tag,
279         response.headers.etag
280       );
281
282       return responseData;
283     } else {
284       throw new Error("response.data is null");
285     }
286   } catch (error) {
287     Logger.log("Error fetching", repoNameOfRepository(repository), "releases");
288
289     return (await GithubApiCache.getInstance().getDefaultResponse(
290       repository,
291       GithubApiCacheEntryDataType.tag,
292       tag
293     ))?.data as GithubReleaseResponse;
294   }
295 }
This page took 0.030997 seconds and 2 git commands to generate.