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