@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.
- package/CHANGELOG.md +68 -0
- package/README.md +91 -0
- package/README.ru.md +81 -0
- package/README.zh.md +63 -0
- package/lib/auth-device.js +150 -0
- package/lib/auth.js +17 -5
- package/lib/client.js +802 -0
- package/lib/github.js +105 -9
- package/lib/guidance.js +26 -0
- package/lib/index.js +185 -11
- package/lib/lossless.js +90 -0
- package/lib/routes.js +309 -0
- package/lib/scm-write.js +144 -0
- package/lib/scm.js +115 -0
- package/lib/status.js +66 -0
- package/lib/tools/actions.js +1 -1
- package/lib/tools/insights.js +246 -0
- package/lib/tools/issues.js +2 -1
- package/lib/tools/panel-data.js +45 -0
- package/lib/tools/register-insights.js +126 -0
- package/lib/tools/register-write.js +245 -0
- package/lib/tools/releases.js +3 -1
- package/lib/tools/repos.js +1 -1
- package/lib/tools/write-files.js +207 -0
- package/media/visual-verification.png +0 -0
- package/package.json +3 -2
package/lib/tools/releases.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/lib/tools/repos.js
CHANGED
|
@@ -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
|
|
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
|
|
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": {
|