@goodandready/dsh-github-ops 0.1.2 → 0.2.1

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.
@@ -36,7 +36,9 @@ export async function listReleases(client, { owner, repo, limit = RELEASE_LIST_L
36
36
  const { data, rateLimit } = await client.get(`/repos/${owner}/${repo}/releases`, {
37
37
  query: { per_page: capped },
38
38
  })
39
- const releases = Array.isArray(data) ? data.map(normalizeRelease) : []
39
+ // GitHub honours per_page for this endpoint, but a proxy or a cached page may not:
40
+ // the caller asked for `capped` items, so the list is cut here as well.
41
+ const releases = (Array.isArray(data) ? data.map(normalizeRelease) : []).slice(0, capped)
40
42
  return { releases, count: releases.length, rateLimit }
41
43
  }
42
44
 
@@ -67,7 +67,7 @@ export async function searchRepos(client, { query, limit = 20 }) {
67
67
  const { data } = await client.get('/search/repositories', {
68
68
  query: { q: query, per_page: capped },
69
69
  })
70
- const items = (data && data.items) || []
70
+ const items = ((data && data.items) || []).slice(0, capped)
71
71
  return {
72
72
  total: (data && data.total_count) || items.length,
73
73
  repos: items.map(normalizeRepo),
@@ -0,0 +1,207 @@
1
+ // Writing files without a local checkout.
2
+ //
3
+ // A commit is built the way git builds it: blobs, a tree on top of the branch's tree, a
4
+ // commit, then the ref moves. Nothing here needs a working copy, so the agent can create a
5
+ // repository, publish files and open a pull request from a conversation alone.
6
+ //
7
+ // `force` is never implied: moving a ref backwards is an explicit request, and the tool layer
8
+ // asks for `confirm: true` on top of it.
9
+
10
+ function ensureRepo({ owner, repo }) {
11
+ if (!owner || !repo) throw new Error('owner and repo are required (pass "owner/repo")')
12
+ }
13
+
14
+ /** Files of a tree, one level or recursive, with the truncation flag GitHub returns. */
15
+ export async function repoTree(client, { owner, repo, ref, path, recursive = true, limit = 500 } = {}) {
16
+ ensureRepo({ owner, repo })
17
+ const capped = Math.max(1, Math.min(Number(limit) || 500, 2000))
18
+ const target = ref || 'HEAD'
19
+ const suffix = path ? `/${String(path).replace(/^\/+|\/+$/g, '')}` : ''
20
+ const { data } = await client.get(`/repos/${owner}/${repo}/git/trees/${encodeURIComponent(target)}${suffix}`, {
21
+ query: recursive ? { recursive: '1' } : undefined,
22
+ })
23
+ const entries = (data && Array.isArray(data.tree) ? data.tree : []).slice(0, capped).map((entry) => {
24
+ // a directory has no size in the API: say nothing rather than claiming undefined
25
+ const row = { path: entry.path, type: entry.type, sha: entry.sha }
26
+ if (typeof entry.size === 'number') row.size = entry.size
27
+ return row
28
+ })
29
+ return {
30
+ ref: target,
31
+ path: path || '',
32
+ truncated: Boolean(data && data.truncated),
33
+ count: entries.length,
34
+ entries,
35
+ }
36
+ }
37
+
38
+ async function refSha(client, owner, repo, branch) {
39
+ try {
40
+ const { data } = await client.get(`/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(branch)}`)
41
+ return data && data.object && data.object.sha ? data.object.sha : ''
42
+ } catch (err) {
43
+ if (err && err.status === 404) return ''
44
+ throw err
45
+ }
46
+ }
47
+
48
+ /** Encode one file the way the API wants it. */
49
+ function fileBlob(file) {
50
+ const path = String(file.path || '').replace(/^\/+/, '')
51
+ if (!path) throw new Error('every file needs a path')
52
+ const base64 = file.base64 === true || file.encoding === 'base64'
53
+ return { path, content: String(file.content ?? ''), encoding: base64 ? 'base64' : 'utf-8' }
54
+ }
55
+
56
+ /**
57
+ * Commit one or more files to a branch, creating the branch when it does not exist.
58
+ *
59
+ * @returns {{commit: string, branch: string, files: number, created: boolean}}
60
+ */
61
+ export async function pushFiles(client, { owner, repo, branch, message, files = [], base, force = false } = {}) {
62
+ ensureRepo({ owner, repo })
63
+ if (!branch) throw new Error('branch is required')
64
+ if (!message) throw new Error('message is required')
65
+ if (!Array.isArray(files) || files.length === 0) throw new Error('files must be a non-empty array')
66
+
67
+ const head = await refSha(client, owner, repo, branch)
68
+ let parent = head
69
+ let parentTree = ''
70
+ const created = !head
71
+ if (!head) {
72
+ const from = base || (await repoDefaultBranch(client, { owner, repo }))
73
+ parent = await refSha(client, owner, repo, from)
74
+ if (!parent) throw new Error(`cannot start branch "${branch}": "${from}" has no commit yet`)
75
+ }
76
+ const { data: commitData } = await client.get(`/repos/${owner}/${repo}/git/commits/${parent}`)
77
+ parentTree = commitData && commitData.tree ? commitData.tree.sha : ''
78
+
79
+ const tree = []
80
+ for (const file of files) {
81
+ const { path, content, encoding } = fileBlob(file)
82
+ const { data: blob } = await client.post(`/repos/${owner}/${repo}/git/blobs`, { content, encoding })
83
+ if (!blob || !blob.sha) throw new Error(`cannot create a blob for ${path}`)
84
+ tree.push({ path, mode: '100644', type: 'blob', sha: blob.sha })
85
+ }
86
+
87
+ const treePayload = { tree }
88
+ if (parentTree) treePayload.base_tree = parentTree
89
+ const { data: newTree } = await client.post(`/repos/${owner}/${repo}/git/trees`, treePayload)
90
+ if (!newTree || !newTree.sha) throw new Error('cannot create the tree')
91
+
92
+ const { data: newCommit } = await client.post(`/repos/${owner}/${repo}/git/commits`, {
93
+ message,
94
+ tree: newTree.sha,
95
+ parents: [parent],
96
+ })
97
+ if (!newCommit || !newCommit.sha) throw new Error('cannot create the commit')
98
+
99
+ if (head) {
100
+ await client.patch(`/repos/${owner}/${repo}/git/refs/heads/${encodeURIComponent(branch)}`, {
101
+ sha: newCommit.sha,
102
+ force: Boolean(force),
103
+ })
104
+ } else {
105
+ await client.post(`/repos/${owner}/${repo}/git/refs`, {
106
+ ref: `refs/heads/${branch}`,
107
+ sha: newCommit.sha,
108
+ })
109
+ }
110
+ return { commit: newCommit.sha, branch, files: tree.length, created }
111
+ }
112
+
113
+ /** Default branch of a repository (a fresh repository may not answer immediately). */
114
+ export async function repoDefaultBranch(client, { owner, repo, attempts = 5, sleep = defaultSleep } = {}) {
115
+ ensureRepo({ owner, repo })
116
+ let lastError = null
117
+ for (let attempt = 1; attempt <= Math.max(1, attempts); attempt += 1) {
118
+ try {
119
+ const { data } = await client.get(`/repos/${owner}/${repo}`)
120
+ if (data && data.default_branch) return data.default_branch
121
+ } catch (err) {
122
+ lastError = err
123
+ }
124
+ if (attempt < attempts) await sleep(1000)
125
+ }
126
+ if (lastError) throw lastError
127
+ throw new Error(`cannot read the default branch of ${owner}/${repo} yet`)
128
+ }
129
+
130
+ function defaultSleep(ms) {
131
+ return new Promise((resolve) => setTimeout(resolve, ms))
132
+ }
133
+
134
+ /**
135
+ * Create a repository, publish files into it and optionally open a pull request — the
136
+ * "upload this project" flow in one call.
137
+ */
138
+ export async function uploadProject(client, {
139
+ owner, repo, description, private: isPrivate = true, files = [], branch, base, title, body,
140
+ createPullRequest = false, org, attempts = 5, sleep = defaultSleep,
141
+ } = {}) {
142
+ if (!repo) throw new Error('repository name is required')
143
+ if (!Array.isArray(files) || files.length === 0) throw new Error('files must be a non-empty array')
144
+
145
+ let repository = null
146
+ try {
147
+ repository = await client.post(org ? `/orgs/${org}/repos` : '/user/repos', {
148
+ name: repo,
149
+ description: description || '',
150
+ private: Boolean(isPrivate),
151
+ auto_init: true,
152
+ })
153
+ } catch (err) {
154
+ if (!err || err.status !== 422) throw err
155
+ // already exists: continue with it, the caller asked to publish into it
156
+ const full = org ? `${org}/${repo}` : `${owner}/${repo}`
157
+ const [foundOwner, foundRepo] = full.split('/')
158
+ const { data } = await client.get(`/repos/${foundOwner}/${foundRepo}`)
159
+ repository = data
160
+ }
161
+ const fullName = (repository && repository.full_name) || `${org || owner}/${repo}`
162
+ const [repoOwner, repoName] = fullName.split('/')
163
+ const target = branch || (await repoDefaultBranch(client, { owner: repoOwner, repo: repoName, attempts, sleep }))
164
+
165
+ const pushed = await pushFiles(client, {
166
+ owner: repoOwner, repo: repoName, branch: target, message: title || 'Initial commit', files, base,
167
+ })
168
+
169
+ let pullRequest = null
170
+ if (createPullRequest) {
171
+ const baseBranch = base || (await repoDefaultBranch(client, { owner: repoOwner, repo: repoName, attempts, sleep }))
172
+ if (baseBranch !== target) {
173
+ const { data } = await client.post(`/repos/${repoOwner}/${repoName}/pulls`, {
174
+ title: title || 'Initial commit',
175
+ head: target,
176
+ base: baseBranch,
177
+ body: body || '',
178
+ })
179
+ pullRequest = data ? { number: data.number, url: data.html_url } : null
180
+ }
181
+ }
182
+ return { repository: fullName, branch: target, commit: pushed.commit, files: pushed.files, pullRequest }
183
+ }
184
+
185
+ /** Delete one file, resolving its blob sha first (the contents API requires it). */
186
+ export async function deleteFile(client, { owner, repo, path, message, branch } = {}) {
187
+ ensureRepo({ owner, repo })
188
+ if (!path) throw new Error('path is required')
189
+ if (!message) throw new Error('message is required')
190
+ const { data: current } = await client.get(`/repos/${owner}/${repo}/contents/${String(path).replace(/^\/+/, '')}`, {
191
+ query: branch ? { ref: branch } : undefined,
192
+ })
193
+ const sha = current && current.sha
194
+ if (!sha) throw new Error(`cannot resolve ${path}`)
195
+ const { data } = await client.del(`/repos/${owner}/${repo}/contents/${String(path).replace(/^\/+/, '')}`, {
196
+ body: { message, sha, ...(branch ? { branch } : {}) },
197
+ })
198
+ return { deleted: true, path, commit: data && data.commit ? data.commit.sha : null }
199
+ }
200
+
201
+ /** Delete a branch. */
202
+ export async function deleteBranch(client, { owner, repo, branch } = {}) {
203
+ ensureRepo({ owner, repo })
204
+ if (!branch) throw new Error('branch is required')
205
+ await client.del(`/repos/${owner}/${repo}/git/refs/heads/${encodeURIComponent(branch)}`)
206
+ return { deleted: true, branch }
207
+ }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-github-ops",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "GitHub operations for DeepSeek Harness: releases, tags, a guarded generic API pass-through, workflow runs, secrets and sanitized mirror publication.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "files": [
13
13
  "lib",
14
+ "media/visual-verification.png",
14
15
  "cordis.patch.yml",
15
16
  "LICENSE",
16
17
  "CHANGELOG.md",
@@ -31,7 +32,7 @@
31
32
  }
32
33
  },
33
34
  "scripts": {
34
- "pretest": "for f in lib/index.js lib/github.js lib/tools/*.js; do node --check \"$f\" || exit 1; done",
35
+ "pretest": "for f in lib/*.js lib/tools/*.js; do node --check \"$f\" || exit 1; done",
35
36
  "test": "node --test test/*.test.mjs"
36
37
  },
37
38
  "peerDependencies": {