-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathgithubContents.ts
More file actions
219 lines (206 loc) · 6.76 KB
/
Copy pathgithubContents.ts
File metadata and controls
219 lines (206 loc) · 6.76 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
export interface GithubFilePayload {
message: string
content: string
sha?: string
branch?: string
}
export interface GetGithubFileResult<T = unknown> {
parsed: T
sha: string
encoding: string
}
export interface GithubReadOptions {
/** Throw 413 when the file exceeds this size in bytes. */
maxBytes?: number
/** Emit a console.warn (once per path) when the file exceeds this size. */
warnAtBytes?: number
}
export interface GithubWriteOptions {
/** Throw 413 when the new file content exceeds this size in bytes. */
maxBytes?: number
/** Emit a console.warn (once per path) when the new content exceeds this size. */
warnAtBytes?: number
}
// Tracks paths that have already triggered a `warnAtBytes` warning, so the same
// soft-limit warning isn't logged on every request.
const sizeWarned = new Set<string>()
function authHeaders(token: string): HeadersInit {
return {
'Accept': 'application/vnd.github+json',
'Authorization': `Bearer ${token}`,
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'Awecode-Autoadmin',
}
}
function locator(owner: string, repo: string, path: string, ref?: string): string {
return `${owner}/${repo}:${path}${ref ? `@${ref}` : ''}`
}
function checkSize(
size: number,
owner: string,
repo: string,
path: string,
ref: string | undefined,
opts: GithubReadOptions | GithubWriteOptions | undefined,
): void {
const where = locator(owner, repo, path, ref)
if (opts?.maxBytes !== undefined && size > opts.maxBytes) {
throw createError({
statusCode: 413,
statusMessage: `File ${where} is ${size} bytes, exceeds configured maxBytes=${opts.maxBytes}.`,
})
}
if (opts?.warnAtBytes !== undefined && size > opts.warnAtBytes && !sizeWarned.has(where)) {
sizeWarned.add(where)
console.warn(
`[autoadmin] ${where} is ${size} bytes (warnAtBytes=${opts.warnAtBytes}). `
+ `GitHub Contents API inlines content only under 1 MB; files between 1 MB and 100 MB `
+ `take an extra Blobs API round-trip on every read. See docs/storage-limits.md.`,
)
}
}
export async function getGithubJsonFile<T = unknown>(
token: string,
owner: string,
repo: string,
path: string,
ref?: string,
opts?: GithubReadOptions,
): Promise<GetGithubFileResult<T>> {
const url = new URL(`https://api.github.com/repos/${owner}/${repo}/contents/${path.replace(/^\//, '')}`)
if (ref) {
url.searchParams.set('ref', ref)
}
const where = locator(owner, repo, path, ref)
const res = await fetch(url, { headers: authHeaders(token) })
const text = await res.text()
let body: any
try {
body = JSON.parse(text)
}
catch {
body = { message: text }
}
if (!res.ok) {
throw createError({
statusCode: res.status === 404 ? 404 : res.status >= 500 ? 502 : 400,
statusMessage: body?.message
? `${body.message} (${where})`
: `GitHub API error (${res.status}) for ${where}`,
})
}
if (body.type !== 'file' || !body.sha) {
throw createError({
statusCode: 500,
statusMessage: `GitHub response for ${where} is not a regular file (type=${body?.type ?? 'unknown'}).`,
})
}
if (typeof body.size === 'number') {
checkSize(body.size, owner, repo, path, ref, opts)
}
// The Contents API omits `content` for files >1 MB (returns encoding "none").
// Fall back to the Git Blobs API, which streams base64 content up to 100 MB.
let base64Content: string | undefined = typeof body.content === 'string' && body.content.length > 0 ? body.content : undefined
if (!base64Content || body.encoding === 'none') {
const blobUrl = `https://api.github.com/repos/${owner}/${repo}/git/blobs/${body.sha}`
const blobRes = await fetch(blobUrl, { headers: authHeaders(token) })
const blobText = await blobRes.text()
let blobBody: any
try {
blobBody = JSON.parse(blobText)
}
catch {
blobBody = { message: blobText }
}
if (!blobRes.ok) {
throw createError({
statusCode: blobRes.status >= 500 ? 502 : 400,
statusMessage: blobBody?.message
? `${blobBody.message} (${where}, blob ${body.sha.slice(0, 8)})`
: `GitHub Blobs API error (${blobRes.status}) for ${where}`,
})
}
if (blobBody.encoding !== 'base64' || typeof blobBody.content !== 'string') {
throw createError({
statusCode: 500,
statusMessage: `GitHub Blobs API response for ${where} is missing base64 content (encoding=${blobBody?.encoding ?? 'unknown'}).`,
})
}
base64Content = blobBody.content
}
// Explicit narrowing after the if-block: TypeScript can't follow the cross-branch
// dataflow proving `base64Content` is now a string.
if (typeof base64Content !== 'string') {
throw createError({
statusCode: 500,
statusMessage: `Internal error: no base64 content resolved for ${where}.`,
})
}
const decoded = Buffer.from(base64Content.replace(/\n/g, ''), 'base64').toString('utf8')
if (!decoded) {
throw createError({
statusCode: 422,
statusMessage: `File ${where} is empty.`,
})
}
let parsed: T
try {
parsed = JSON.parse(decoded) as T
}
catch (e: any) {
throw createError({
statusCode: 422,
statusMessage: `File ${where} is not valid JSON: ${e?.message ?? 'parse error'}.`,
})
}
return { parsed, sha: body.sha, encoding: body.encoding }
}
export async function putGithubJsonFile(
token: string,
owner: string,
repo: string,
path: string,
payload: GithubFilePayload,
opts?: GithubWriteOptions,
): Promise<{ commitSha?: string }> {
const where = locator(owner, repo, path, payload.branch)
if (opts?.maxBytes !== undefined || opts?.warnAtBytes !== undefined) {
// payload.content is base64; check the raw byte size that will land in the repo.
const rawSize = Buffer.byteLength(payload.content, 'base64')
checkSize(rawSize, owner, repo, path, payload.branch, opts)
}
const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path.replace(/^\//, '')}`
const res = await fetch(url, {
method: 'PUT',
headers: {
...authHeaders(token),
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
const text = await res.text()
let body: any
try {
body = JSON.parse(text)
}
catch {
body = { message: text }
}
if (res.status === 409) {
throw createError({
statusCode: 409,
statusMessage: body?.message
? `${body.message} (${where})`
: `GitHub file ${where} changed on the server (sha conflict). Refresh and try again.`,
})
}
if (!res.ok) {
throw createError({
statusCode: res.status >= 500 ? 502 : 400,
statusMessage: body?.message
? `${body.message} (${where})`
: `GitHub API error (${res.status}) for ${where}`,
})
}
return { commitSha: body?.commit?.sha }
}