@preventive/triage 1.0.0-alpha.21 → 1.0.0-alpha.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/common/bundle-source-links.d.ts +5 -0
- package/common/bundle-source-links.js +82 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/managed/routes.js +3 -0
- package/common/server-info.ts +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +74 -58
- package/out/client-sync.js +3 -3
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +6 -6
- package/out/prism.js +2 -2
- package/out/view.css +1 -1
- package/out/view.js +186 -159
- package/package.json +11 -1
- package/server-e2e/app.ts +2 -1
- package/server-e2e/config.ts +2 -0
- package/server-managed/config.ts +9 -16
- package/server-managed/db-methods.ts +48 -18
- package/server-managed/db-neon.ts +6 -1
- package/server-managed/db-schema.ts +1 -0
- package/server-managed/db.ts +2 -1
- package/server-managed/github-app.ts +106 -36
- package/server-managed/github-issue-oauth.ts +48 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-oauth.ts +7 -23
- package/server-managed/http.ts +178 -9
- package/server-managed/managed-issues.ts +47 -0
- package/server-managed/report-sources.ts +4 -2
- package/server-managed/repository-browser.ts +111 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ManagedConfig } from './config.ts'
|
|
2
|
+
import type { SelectedRepo } from './db.ts'
|
|
3
|
+
import { GithubApiError, githubJson, githubRepoReadPermission, githubUserIdentity, repoAccessToken } from './github-app.ts'
|
|
4
|
+
|
|
5
|
+
export interface RepositoryEntry { name: string; path: string; type: 'dir' | 'file' | 'symlink' | 'submodule' }
|
|
6
|
+
|
|
7
|
+
// Restricted managers can walk the ancestors of their grants, without seeing
|
|
8
|
+
// sibling names or files outside those grants. null means the entire repo.
|
|
9
|
+
export function scopedDirectory(path: string, scopes: (string | null)[]): RepositoryEntry[] | null {
|
|
10
|
+
if (scopes.some(scope => !scope || path === scope || path.startsWith(scope + '/'))) return null
|
|
11
|
+
const prefix = path ? path + '/' : ''
|
|
12
|
+
const names = new Set(scopes.filter((scope): scope is string => !!scope && scope.startsWith(prefix)).map(scope => scope.slice(prefix.length).split('/')[0]!))
|
|
13
|
+
return [...names].toSorted().map(name => ({ name, path: prefix + name, type: 'dir' }))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RepositoryBrowserUser { githubUserId: number | null; token: string | null }
|
|
17
|
+
type GithubIdentity = Awaited<ReturnType<typeof githubUserIdentity>>
|
|
18
|
+
|
|
19
|
+
// Create once per HTTP request. Coalesce concurrent credential lookups without
|
|
20
|
+
// caching repository visibility or permissions, or sharing credentials across users.
|
|
21
|
+
export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryBrowserUser, fetchImpl: typeof fetch = globalThis.fetch) {
|
|
22
|
+
const tokens = new Map<number, Promise<string | null>>()
|
|
23
|
+
let identity: Promise<GithubIdentity> | undefined
|
|
24
|
+
const getIdentity = (fresh = false): Promise<GithubIdentity> => {
|
|
25
|
+
if (!user.token || user.githubUserId == null) return Promise.reject(new GithubApiError(404, 'no-repository'))
|
|
26
|
+
if (!identity || fresh) {
|
|
27
|
+
identity = githubUserIdentity(user.token, fetchImpl).then(value => {
|
|
28
|
+
if (value.id !== user.githubUserId) throw new GithubApiError(404, 'no-repository')
|
|
29
|
+
return value
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
return identity
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
async reader(repo: SelectedRepo) {
|
|
36
|
+
let token = Promise.resolve<string | null>(null)
|
|
37
|
+
if (repo.installationId != null) {
|
|
38
|
+
const cached = tokens.get(repo.installationId)
|
|
39
|
+
token = cached ?? repoAccessToken(config, repo.installationId, fetchImpl).catch((err: unknown) => {
|
|
40
|
+
if (!(err instanceof GithubApiError)) throw err
|
|
41
|
+
// Stale installations cannot prevent verified anonymous public access.
|
|
42
|
+
return null
|
|
43
|
+
})
|
|
44
|
+
if (!cached) tokens.set(repo.installationId, token)
|
|
45
|
+
}
|
|
46
|
+
return repositoryReader(repo, await token, getIdentity, fetchImpl)
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function repositoryReader(repo: SelectedRepo, token: string | null, getIdentity: (fresh?: boolean) => Promise<GithubIdentity>, fetchImpl: typeof fetch) {
|
|
52
|
+
const base = `https://api.github.com/repos/${repo.fullName.split('/').map(encodeURIComponent).join('/')}`
|
|
53
|
+
const read = (suffix: string) => githubJson(base + suffix, token, fetchImpl)
|
|
54
|
+
const metadata = async () => {
|
|
55
|
+
try { return await read('') }
|
|
56
|
+
catch (err) {
|
|
57
|
+
if (!token || !(err instanceof GithubApiError)) throw err
|
|
58
|
+
// A valid installation token may have lost this particular repository.
|
|
59
|
+
// After falling back, all reads and rechecks stay anonymous.
|
|
60
|
+
token = null
|
|
61
|
+
return read('')
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const checkAccess = async (freshIdentity = false) => {
|
|
65
|
+
// Stored visibility can be stale (or internal with private=false). Require
|
|
66
|
+
// current, explicit public visibility or the caller's effective permission.
|
|
67
|
+
const data = await metadata() as { id?: unknown; full_name?: unknown; private?: unknown; visibility?: unknown; default_branch?: unknown }
|
|
68
|
+
if (data?.id !== repo.repoId || typeof data.full_name !== 'string' || data.full_name.toLowerCase() !== repo.fullName.toLowerCase()) {
|
|
69
|
+
throw new GithubApiError(404, 'no-repository')
|
|
70
|
+
}
|
|
71
|
+
if (data.private === false && data.visibility === 'public') return data
|
|
72
|
+
if (!token) throw new GithubApiError(404, 'no-repository')
|
|
73
|
+
if (!(await githubRepoReadPermission(repo.fullName, token, await getIdentity(freshIdentity), fetchImpl))) {
|
|
74
|
+
throw new GithubApiError(404, 'no-repository')
|
|
75
|
+
}
|
|
76
|
+
return data
|
|
77
|
+
}
|
|
78
|
+
const current = await checkAccess()
|
|
79
|
+
const defaultBranch = typeof current.default_branch === 'string' ? current.default_branch : ''
|
|
80
|
+
return {
|
|
81
|
+
recheckAccess: () => checkAccess(true),
|
|
82
|
+
async refs() {
|
|
83
|
+
// Suggestions are bounded; the input also accepts any branch or tag name.
|
|
84
|
+
const results = await Promise.all(['branches', 'tags'].map(async kind => {
|
|
85
|
+
const data = await read(`/${kind}?per_page=100`)
|
|
86
|
+
if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
|
|
87
|
+
return data.flatMap(item => typeof item?.name === 'string' ? [item.name] : [])
|
|
88
|
+
}))
|
|
89
|
+
return { defaultBranch, branches: results[0], tags: results[1] }
|
|
90
|
+
},
|
|
91
|
+
async commit(ref: string) {
|
|
92
|
+
if (/^[a-f\d]{40}$/iu.test(ref)) return ref
|
|
93
|
+
const data = await read(`/commits/${encodeURIComponent(ref || (defaultBranch ? `heads/${defaultBranch}` : 'HEAD'))}`) as { sha?: unknown }
|
|
94
|
+
if (typeof data?.sha !== 'string' || !/^[a-f\d]{40}$/iu.test(data.sha)) throw new GithubApiError(502, 'github-malformed')
|
|
95
|
+
return data.sha
|
|
96
|
+
},
|
|
97
|
+
async directory(path: string, commit: string) {
|
|
98
|
+
const data = await read(`/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(commit)}`)
|
|
99
|
+
if (!Array.isArray(data)) throw new GithubApiError(400, 'not-a-directory')
|
|
100
|
+
const entries: RepositoryEntry[] = []
|
|
101
|
+
for (const item of data) {
|
|
102
|
+
if (typeof item?.name !== 'string' || item.name.includes('/') || item.name === '.' || item.name === '..') continue
|
|
103
|
+
const expectedPath = path ? `${path}/${item.name}` : item.name
|
|
104
|
+
if (item.path !== expectedPath) continue
|
|
105
|
+
const type = item.submodule_git_url ? 'submodule' : item.type
|
|
106
|
+
if (['dir', 'file', 'symlink', 'submodule'].includes(type)) entries.push({ name: item.name, path: expectedPath, type })
|
|
107
|
+
}
|
|
108
|
+
return { entries, limited: data.length >= 1000 }
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|