@preventive/upstream 1.0.0-alpha.2 → 1.0.0-alpha.3

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/github.d.ts CHANGED
@@ -25,6 +25,16 @@ export interface ClientOptions {
25
25
 
26
26
  export type PullRequestStatus = 'open' | 'draft' | 'closed' | 'merged'
27
27
 
28
+ // An entry of a directory, as git has it, `path` its name: a file
29
+ // (`100644`, `100755` executable) or a symlink (`120000`) is a blob, a
30
+ // directory (`040000`) a tree, a submodule (`160000`) the commit it is at.
31
+ export interface RepoDirEntry {
32
+ path: string
33
+ mode: '100644' | '100755' | '120000' | '040000' | '160000'
34
+ type: 'blob' | 'tree' | 'commit'
35
+ sha: string
36
+ }
37
+
28
38
  // A failed request: `status` is the HTTP status GitHub answered with.
29
39
  export class HttpError extends Error {
30
40
  name: 'HttpError'
@@ -60,6 +70,11 @@ export interface Client {
60
70
  // on every call, cached or not. So its top directory is named for the
61
71
  // tree, and files marked `export-subst` are as committed, not rewritten.
62
72
  getRepoTarball(options: { repo: RepoName; sha: string }): Promise<Uint8Array>
73
+ // The id of the tree at `path`, a directory, in the full commit `sha`, or
74
+ // of its root without one: from the tree GitHub names for the commit,
75
+ // down GitHub's listings a directory at a time, each hashed back to its
76
+ // id. Refused where `path` is no directory.
77
+ getRepoTreeId(options: { repo: RepoName; sha: string; path?: string }): Promise<string>
63
78
  // The gzipped tarball of a tree, the repo's own or any subdirectory's, by
64
79
  // its id, whole, in memory: GitHub's, its files under one top directory.
65
80
  // Its files are hashed back into git's tree and the id must be `tree`,
@@ -78,6 +93,10 @@ export interface Client {
78
93
  // repository advisory objects. One page of 100: GitHub pages this list
79
94
  // by cursor, so a repository with a full page is refused.
80
95
  listRepoAdvisories(options: { repo: RepoName }): Promise<any[]>
96
+ // The entries of the directory at `path` in the full commit `sha`, or of
97
+ // its root, found as getRepoTreeId finds it: GitHub's listing, hashed
98
+ // back to the tree's id, so none is left out or changed.
99
+ listRepoDir(options: { repo: RepoName; sha: string; path?: string }): Promise<RepoDirEntry[]>
81
100
  // Every page of `GET /user/repos`, as GitHub's repository objects; more
82
101
  // than `maxPages` (100 by default) pages of 100 is an error.
83
102
  listUserRepos(options?: { maxPages?: number }): Promise<any[]>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/upstream",
3
- "version": "1.0.0-alpha.2",
3
+ "version": "1.0.0-alpha.3",
4
4
  "description": "Minimal npm, crates.io and Soldeer registry clients, a GitHub client, and a git checkout reader: the calls we use, nothing more",
5
5
  "type": "module",
6
6
  "exports": {
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto'
5
5
  import { assertArgs, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, assertTreeId, isSha, isSha1, optional, sameName, show } from '../args.js'
6
6
  import { verifiedDownload } from '../download.js'
7
7
  import { decode, encodeSegment } from '../http.js'
8
+ import { gitTreeOfListing } from '../tree.js'
8
9
  import { api, bindMethods, call, clientHeaders, isGone, repoApi } from './client.js'
9
10
 
10
11
  const PER_PAGE = 100
@@ -83,15 +84,20 @@ async function treeTarball(method, headers, repo, tree) {
83
84
  return await verifiedDownload({ method, dir: 'github/trees', what: tree, ext: 'tgz', algorithm: 'tree', expected: tree, locate, options: { headers, redirect: 'follow' }, list })
84
85
  }
85
86
 
87
+ async function commitTree(method, headers, repo, sha) {
88
+ const commit = await call(headers, repoApi(repo, ['git', 'commits', sha]))
89
+ assert.ok(commit?.sha === sha && isSha1(commit.tree?.sha), `${method}: GitHub names no tree for ${repo}@${sha}`)
90
+ return commit.tree.sha
91
+ }
92
+
86
93
  // A full sha only, so the bytes are that commit's, not wherever a ref points
87
94
  // now. Its tree's tarball, not its own, in which `git archive` rewrites the
88
95
  // files marked `export-subst`.
89
96
  async function getRepoTarball(headers, options) {
90
97
  assertArgs('getRepoTarball', options, { repo: assertRepo, sha: assertSha })
91
98
  const { repo, sha } = options
92
- const commit = await call(headers, repoApi(repo, ['git', 'commits', sha]))
93
- assert.ok(commit?.sha === sha && isSha1(commit.tree?.sha), `getRepoTarball: GitHub names no tree for ${repo}@${sha}`)
94
- return await treeTarball('getRepoTarball', headers, repo, commit.tree.sha)
99
+ const tree = await commitTree('getRepoTarball', headers, repo, sha)
100
+ return await treeTarball('getRepoTarball', headers, repo, tree)
95
101
  }
96
102
 
97
103
  async function getRepoTreeTarball(headers, options) {
@@ -99,6 +105,32 @@ async function getRepoTreeTarball(headers, options) {
99
105
  return await treeTarball('getRepoTreeTarball', headers, options.repo, options.tree)
100
106
  }
101
107
 
108
+ // Not recursive: GitHub cuts short a recursive listing of a large tree.
109
+ async function listTree(method, headers, repo, tree) {
110
+ const entries = (await call(headers, repoApi(repo, ['git', 'trees', tree])))?.tree
111
+ assert.ok(Array.isArray(entries) && gitTreeOfListing(entries) === tree, `${method}: GitHub's listing of tree ${tree} in ${repo} is not that tree`)
112
+ return entries.map(({ path, mode, type, sha }) => ({ path, mode, type, sha }))
113
+ }
114
+
115
+ async function treeAt(method, headers, options) {
116
+ assertArgs(method, options, { repo: assertRepo, sha: assertSha, path: optional(assertPath) })
117
+ const { repo, sha, path } = options
118
+ let tree = await commitTree(method, headers, repo, sha)
119
+ for (const name of path?.split('/') ?? []) {
120
+ const entry = (await listTree(method, headers, repo, tree)).find((candidate) => candidate.path === name)
121
+ assert.ok(entry?.type === 'tree', `${method}: ${repo}@${sha} has no directory at ${show(path)}`)
122
+ tree = entry.sha
123
+ }
124
+ return tree
125
+ }
126
+
127
+ const getRepoTreeId = (headers, options) => treeAt('getRepoTreeId', headers, options)
128
+
129
+ async function listRepoDir(headers, options) {
130
+ const tree = await treeAt('listRepoDir', headers, options)
131
+ return await listTree('listRepoDir', headers, options.repo, tree)
132
+ }
133
+
102
134
  async function getPullRequest(headers, options) {
103
135
  assertArgs('getPullRequest', options, { repo: assertRepo, number: assertNumber })
104
136
  const { repo, number } = options
@@ -146,5 +178,5 @@ async function listRepoAdvisories(headers, options) {
146
178
  return list
147
179
  }
148
180
 
149
- export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoFile, getRepoTarball, getRepoTreeTarball, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
181
+ export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoFile, getRepoTarball, getRepoTreeTarball, getRepoTreeId, listRepoDir, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
150
182
  export const createClient = (options) => bindMethods(clientHeaders('createClient', options, true), readMethods)
package/src/tree.js CHANGED
@@ -71,6 +71,8 @@ function treeId(dir) {
71
71
 
72
72
  const EMPTY_TREE = objectId('tree', Buffer.alloc(0)).toString('hex')
73
73
  const isName = matches(/^[^\0/]+$/u)
74
+ const isEntry = (entry) => isName(entry?.path) && isSha1(entry.sha)
75
+ const nameOf = (path) => Buffer.from(path).toString('latin1')
74
76
  const subtree = (sha) => ({ mode: '40000', id: Buffer.from(sha, 'hex') })
75
77
 
76
78
  // A subtree with nothing in it but subtrees like it, down to the empty
@@ -163,8 +165,8 @@ export async function gitTreeOfTarball(gzipped, { expected, list } = {}) {
163
165
  }
164
166
  if (top === undefined) return 'no tree: an empty tarball'
165
167
  const listed = async (sha) => (list && isSha1(sha) ? await list(sha) : [])
166
- .filter((entry) => isName(entry?.path) && isSha1(entry.sha))
167
- .map((entry) => ({ ...entry, name: Buffer.from(entry.path).toString('latin1') }))
168
+ .filter(isEntry)
169
+ .map((entry) => ({ ...entry, name: nameOf(entry.path) }))
168
170
  for (const [dir, name, path] of emptyDirs(root)) {
169
171
  let sha = expected
170
172
  const parts = path.split('/')
@@ -178,3 +180,15 @@ export async function gitTreeOfTarball(gzipped, { expected, list } = {}) {
178
180
  await putBackEmptyTrees(root, expected, listed)
179
181
  return treeId(root).toString('hex')
180
182
  }
183
+
184
+ // GitHub lists a subtree's mode as `040000`, which git writes `40000`.
185
+ const LISTED = new Set(['100644 blob', '100755 blob', '120000 blob', '040000 tree', '160000 commit'])
186
+
187
+ // The id of the tree a listing such as GitHub's names, its entries
188
+ // { path, mode, type, sha }, or null where one is not an entry a tree can
189
+ // hold, or a name is there twice.
190
+ export function gitTreeOfListing(entries) {
191
+ if (!entries.every((entry) => isEntry(entry) && LISTED.has(`${entry.mode} ${entry.type}`))) return null
192
+ const dir = new Map(entries.map(({ path, mode, sha }) => [nameOf(path), { mode: mode.replace(/^0/u, ''), id: Buffer.from(sha, 'hex') }]))
193
+ return dir.size === entries.length ? treeId(dir).toString('hex') : null
194
+ }