@goodandready/dsh-github-ops 0.1.2 → 0.2.0

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.
@@ -41,6 +41,7 @@ export async function listIssues(client, { owner, repo, state = 'open', labels,
41
41
  let issues = Array.isArray(data) ? data.map((i) => normalizeIssue(i)) : []
42
42
  if (kind === 'pr') issues = issues.filter((i) => i.kind === 'pr')
43
43
  if (kind === 'issue') issues = issues.filter((i) => i.kind === 'issue')
44
+ issues = issues.slice(0, capped)
44
45
  return { issues, count: issues.length }
45
46
  }
46
47
 
@@ -90,7 +91,7 @@ export async function searchIssues(client, { query, limit = 20 }) {
90
91
  if (!query) throw new Error('query is required')
91
92
  const capped = Math.max(1, Math.min(Number(limit) || 20, 100))
92
93
  const { data } = await client.get('/search/issues', { query: { q: query, per_page: capped } })
93
- const items = (data && data.items) || []
94
+ const items = ((data && data.items) || []).slice(0, capped)
94
95
  return {
95
96
  total: (data && data.total_count) || items.length,
96
97
  issues: items.map((i) => ({
@@ -0,0 +1,45 @@
1
+ // Data shapes the sidebar panel needs.
2
+ //
3
+ // The panel shows a repository the way a person browses it: a tree, a file, the issue and pull
4
+ // request lists, the recent runs and the attention inbox. These are thin compositions over the
5
+ // existing readers, kept here so the route stays a switch instead of a second implementation.
6
+
7
+ export { listNotifications } from './insights.js'
8
+
9
+ function ensureRepo({ owner, repo }) {
10
+ if (!owner || !repo) throw new Error('owner and repo are required')
11
+ }
12
+
13
+ /**
14
+ * One level of a repository tree, ready for a file browser: directories first, then files,
15
+ * each with the path the panel asks for next.
16
+ */
17
+ export async function repoTreeView(client, { owner, repo, ref, path = '', limit = 200 } = {}) {
18
+ ensureRepo({ owner, repo })
19
+ const capped = Math.max(1, Math.min(Number(limit) || 200, 1000))
20
+ const target = ref || 'HEAD'
21
+ const suffix = path ? `/${String(path).replace(/^\/+|\/+$/g, '')}` : ''
22
+ const { data } = await client.get(`/repos/${owner}/${repo}/git/trees/${encodeURIComponent(target)}${suffix}`, {
23
+ query: { per_page: capped },
24
+ })
25
+ const entries = (data && Array.isArray(data.tree) ? data.tree : [])
26
+ .slice(0, capped)
27
+ .map((entry) => ({
28
+ name: String(entry.path || '').split('/').pop(),
29
+ path: entry.path,
30
+ type: entry.type,
31
+ size: entry.size,
32
+ sha: entry.sha,
33
+ }))
34
+ .sort((a, b) => {
35
+ if (a.type !== b.type) return a.type === 'tree' ? -1 : 1
36
+ return String(a.name).localeCompare(String(b.name))
37
+ })
38
+ return {
39
+ ref: target,
40
+ path: path || '',
41
+ truncated: Boolean(data && data.truncated),
42
+ count: entries.length,
43
+ entries,
44
+ }
45
+ }
@@ -0,0 +1,126 @@
1
+ // Registrations for the composed reports: the answers a person asks for in one call.
2
+
3
+ import {
4
+ repoReport,
5
+ weeklyDigest,
6
+ listNotifications,
7
+ repoHealth,
8
+ compareRepos,
9
+ trendingRepos,
10
+ userRepos,
11
+ listContributors,
12
+ listCommits,
13
+ } from './insights.js'
14
+
15
+ const REPOSITORY = {
16
+ repository: { type: 'string', description: 'Repository as "owner/repo". Omit to use the default from settings or the checkout.' },
17
+ }
18
+
19
+ export function registerInsightTools({ tool, asRepo, client, liveConfig }) {
20
+ const repoParams = (extra = {}) => ({ ...REPOSITORY, ...extra })
21
+
22
+ tool(
23
+ 'gh_repo_report',
24
+ 'One call that says what a repository is: overview, latest release, open issues, recent commits and top contributors. Read-only; the parts share the client cache, so it stays cheap.',
25
+ repoParams({
26
+ commits: { type: 'number', description: 'How many recent commits to include (default 5).' },
27
+ issues: { type: 'number', description: 'How many open issues to include (default 5).' },
28
+ contributors: { type: 'number', description: 'How many contributors to include (default 5).' },
29
+ }),
30
+ async (args) => {
31
+ const { owner, repo } = await asRepo(args, liveConfig())
32
+ return repoReport(await client(), {
33
+ owner, repo, commits: args.commits, issues: args.issues, contributors: args.contributors,
34
+ })
35
+ },
36
+ )
37
+
38
+ tool(
39
+ 'gh_weekly_digest',
40
+ 'What happened in a repository inside a look-back window: releases, new issues, merged or closed pull requests and commits. Read-only.',
41
+ repoParams({
42
+ days: { type: 'number', description: 'Look-back window in days (default 7).' },
43
+ limit: { type: 'number', description: 'How many items per list (default 10).' },
44
+ }),
45
+ async (args) => {
46
+ const { owner, repo } = await asRepo(args, liveConfig())
47
+ return weeklyDigest(await client(), { owner, repo, days: args.days, limit: args.limit })
48
+ },
49
+ )
50
+
51
+ tool(
52
+ 'gh_notifications',
53
+ 'The attention queue for the authenticated account: mentions, review requests, assignments and watched threads, grouped by reason. Needs access with notification read permission. Read-only.',
54
+ {
55
+ all: { type: 'boolean', description: 'Include already-read notifications.' },
56
+ participating: { type: 'boolean', description: 'Only threads the account participates in.' },
57
+ limit: { type: 'number', description: 'How many notifications to return (default 20).' },
58
+ },
59
+ async (args) => listNotifications(await client(), { all: args.all, participating: args.participating, limit: args.limit }),
60
+ )
61
+
62
+ tool(
63
+ 'gh_repo_health',
64
+ 'A transparent maintenance score for a repository: five weighted dimensions with the evidence for each, the risks found and concrete next actions. It is a heuristic over public signals, not a security audit. Read-only.',
65
+ repoParams(),
66
+ async (args) => {
67
+ const { owner, repo } = await asRepo(args, liveConfig())
68
+ return repoHealth(await client(), { owner, repo })
69
+ },
70
+ )
71
+
72
+ tool(
73
+ 'gh_compare',
74
+ 'Two repositories side by side with numeric deltas for stars, forks and open issues. Read-only.',
75
+ {
76
+ first: { type: 'string', description: 'First repository as "owner/repo".' },
77
+ second: { type: 'string', description: 'Second repository as "owner/repo".' },
78
+ },
79
+ async (args) => compareRepos(await client(), { first: args.first, second: args.second }),
80
+ )
81
+
82
+ tool(
83
+ 'gh_trending',
84
+ 'Recently created repositories sorted by stars, optionally filtered by language. Uses the search quota. Read-only.',
85
+ {
86
+ language: { type: 'string', description: 'Language filter, e.g. "TypeScript".' },
87
+ since: { type: 'string', description: 'Created after this date (YYYY-MM-DD); defaults to 30 days ago.' },
88
+ limit: { type: 'number', description: 'How many repositories (default 10).' },
89
+ },
90
+ async (args) => trendingRepos(await client(), { language: args.language, since: args.since, limit: args.limit }),
91
+ )
92
+
93
+ tool(
94
+ 'gh_contributors',
95
+ 'Top contributors of a repository by commit count. Read-only.',
96
+ repoParams({ limit: { type: 'number', description: 'How many contributors (default 10).' } }),
97
+ async (args) => {
98
+ const { owner, repo } = await asRepo(args, liveConfig())
99
+ return listContributors(await client(), { owner, repo, limit: args.limit })
100
+ },
101
+ )
102
+
103
+ tool(
104
+ 'gh_user_repos',
105
+ "A user's or organization's repositories sorted by stars. Read-only.",
106
+ {
107
+ login: { type: 'string', description: 'GitHub login of the user or organization.' },
108
+ limit: { type: 'number', description: 'How many repositories (default 10).' },
109
+ },
110
+ async (args) => userRepos(await client(), { login: args.login, limit: args.limit }),
111
+ )
112
+
113
+ tool(
114
+ 'gh_commits',
115
+ 'Recent commits of a repository, optionally since a date and on a specific branch. Read-only.',
116
+ repoParams({
117
+ branch: { type: 'string', description: 'Branch or ref; defaults to the default branch.' },
118
+ since: { type: 'string', description: 'Only commits after this ISO timestamp.' },
119
+ limit: { type: 'number', description: 'How many commits (default 10).' },
120
+ }),
121
+ async (args) => {
122
+ const { owner, repo } = await asRepo(args, liveConfig())
123
+ return listCommits(await client(), { owner, repo, branch: args.branch, since: args.since, limit: args.limit })
124
+ },
125
+ )
126
+ }
@@ -0,0 +1,245 @@
1
+ // Content and account tools: writing files without a checkout, and signing in without a
2
+ // terminal.
3
+ //
4
+ // Nothing here stores a value in a tool result: the sign-in writes its file and reports only
5
+ // the account and the scope. Destructive calls (deleting a file or a branch, signing out,
6
+ // forcing a ref) need `confirm: true`.
7
+
8
+ import {
9
+ repoTree,
10
+ pushFiles,
11
+ uploadProject,
12
+ deleteFile,
13
+ deleteBranch,
14
+ } from './write-files.js'
15
+ import {
16
+ startDeviceFlow,
17
+ pollDeviceFlow,
18
+ writeAccessFile,
19
+ readAccessFile,
20
+ clearAccessFile,
21
+ accessFilePath,
22
+ DEFAULT_CLIENT_ID,
23
+ DEFAULT_SCOPE,
24
+ } from '../auth-device.js'
25
+
26
+ const REPOSITORY = {
27
+ repository: { type: 'string', description: 'Repository as "owner/repo". Omit to use the default from settings or the checkout.' },
28
+ }
29
+
30
+ const FILES_PARAM = {
31
+ type: 'array',
32
+ description: 'Files to publish: [{ path, content, base64? }]. `path` is relative to the repository root.',
33
+ items: { type: 'object', additionalProperties: true },
34
+ }
35
+
36
+ export function registerWriteTools({ tool, asRepo, client, liveConfig, auth }) {
37
+ const repoParams = (extra = {}) => ({ ...REPOSITORY, ...extra })
38
+ const authConfig = () => auth.config()
39
+
40
+ tool(
41
+ 'gh_repo_tree',
42
+ 'List the file tree of a repository at a ref, recursively by default. Read-only, and much cheaper than walking directories one by one.',
43
+ repoParams({
44
+ ref: { type: 'string', description: 'Branch, tag or commit; defaults to HEAD.' },
45
+ path: { type: 'string', description: 'Only the subtree under this path.' },
46
+ recursive: { type: 'boolean', description: 'Walk the whole tree (default true).' },
47
+ limit: { type: 'number', description: 'How many entries (default 500).' },
48
+ }),
49
+ async (args) => {
50
+ const { owner, repo } = await asRepo(args, liveConfig())
51
+ return repoTree(await client(), {
52
+ owner, repo, ref: args.ref, path: args.path, recursive: args.recursive !== false, limit: args.limit,
53
+ })
54
+ },
55
+ )
56
+
57
+ tool(
58
+ 'gh_push_files',
59
+ 'Commit one or more files to a branch without a local checkout: blobs, a tree on top of the branch tree, a commit, then the ref moves. Creates the branch when it does not exist. Read the branch first if you need to preserve its current content.',
60
+ repoParams({
61
+ branch: { type: 'string', description: 'Branch to write to.' },
62
+ message: { type: 'string', description: 'Commit message.' },
63
+ files: FILES_PARAM,
64
+ base: { type: 'string', description: 'Branch to start from when the target branch does not exist.' },
65
+ force: { type: 'boolean', description: 'Move the ref backwards. Requires confirm: true and is never implied.' },
66
+ confirm: { type: 'boolean', description: 'Required when force is true.' },
67
+ }),
68
+ async (args) => {
69
+ if (args.force === true && args.confirm !== true) {
70
+ throw new Error('refusing to force a ref without confirm: true')
71
+ }
72
+ const { owner, repo } = await asRepo(args, liveConfig())
73
+ return pushFiles(await client(), {
74
+ owner, repo, branch: args.branch, message: args.message, files: args.files, base: args.base, force: args.force === true,
75
+ })
76
+ },
77
+ )
78
+
79
+ tool(
80
+ 'gh_upload_project',
81
+ 'Create a repository, publish files into it and optionally open a pull request — the whole "upload this project" flow in one call. An existing repository is used as is instead of failing.',
82
+ {
83
+ repository: { type: 'string', description: 'Target repository as "owner/repo"; the owner is used for the default account.' },
84
+ org: { type: 'string', description: 'Organization to create the repository in.' },
85
+ description: { type: 'string', description: 'Repository description.' },
86
+ private: { type: 'boolean', description: 'Private repository (default true).' },
87
+ files: FILES_PARAM,
88
+ branch: { type: 'string', description: 'Branch to publish into; defaults to the default branch.' },
89
+ base: { type: 'string', description: 'Base branch for the pull request.' },
90
+ title: { type: 'string', description: 'Commit message and pull request title.' },
91
+ body: { type: 'string', description: 'Pull request body.' },
92
+ createPullRequest: { type: 'boolean', description: 'Open a pull request after publishing.' },
93
+ },
94
+ async (args) => {
95
+ const cfg = liveConfig()
96
+ let owner = args.org || ''
97
+ let repo = args.repository || ''
98
+ if (repo.includes('/')) {
99
+ const [ownerPart, repoPart] = repo.split('/')
100
+ owner = args.org || ownerPart
101
+ repo = repoPart
102
+ } else if (!owner) {
103
+ const resolved = await asRepo({ repository: cfg.defaultRepository }, cfg).catch(() => null)
104
+ owner = resolved ? resolved.owner : ''
105
+ }
106
+ return uploadProject(await client(), {
107
+ owner,
108
+ repo,
109
+ org: args.org,
110
+ description: args.description,
111
+ private: args.private !== false,
112
+ files: args.files,
113
+ branch: args.branch,
114
+ base: args.base,
115
+ title: args.title,
116
+ body: args.body,
117
+ createPullRequest: args.createPullRequest === true,
118
+ })
119
+ },
120
+ )
121
+
122
+ tool(
123
+ 'gh_delete_file',
124
+ 'Delete one file from a branch. Requires confirm: true.',
125
+ repoParams({
126
+ path: { type: 'string', description: 'File path inside the repository.' },
127
+ message: { type: 'string', description: 'Commit message.' },
128
+ branch: { type: 'string', description: 'Branch; defaults to the default branch.' },
129
+ confirm: { type: 'boolean', description: 'Must be true to delete.' },
130
+ }),
131
+ async (args) => {
132
+ if (args.confirm !== true) throw new Error('refusing to delete a file without confirm: true')
133
+ const { owner, repo } = await asRepo(args, liveConfig())
134
+ return deleteFile(await client(), { owner, repo, path: args.path, message: args.message, branch: args.branch })
135
+ },
136
+ )
137
+
138
+ tool(
139
+ 'gh_delete_branch',
140
+ 'Delete a branch. Requires confirm: true.',
141
+ repoParams({
142
+ branch: { type: 'string', description: 'Branch to delete.' },
143
+ confirm: { type: 'boolean', description: 'Must be true to delete.' },
144
+ }),
145
+ async (args) => {
146
+ if (args.confirm !== true) throw new Error('refusing to delete a branch without confirm: true')
147
+ const { owner, repo } = await asRepo(args, liveConfig())
148
+ return deleteBranch(await client(), { owner, repo, branch: args.branch })
149
+ },
150
+ )
151
+
152
+ // ------------------------------------------------------------------ account
153
+
154
+ tool(
155
+ 'gh_auth_login',
156
+ 'Start a GitHub sign-in without a terminal: returns a short code and a URL for the human. Approve it in the browser, then call gh_auth_finish to collect the access. Stored in this plugin\'s own file with mode 0600; the value is never returned.',
157
+ {
158
+ scope: { type: 'string', description: `OAuth scope to request (default "${DEFAULT_SCOPE}").` },
159
+ clientId: { type: 'string', description: `OAuth app id (default: the public gh CLI application ${DEFAULT_CLIENT_ID}).` },
160
+ },
161
+ async (args) => {
162
+ const cfg = authConfig()
163
+ const flow = await startDeviceFlow({
164
+ clientId: args.clientId || cfg.clientId || DEFAULT_CLIENT_ID,
165
+ scope: args.scope || cfg.scope || DEFAULT_SCOPE,
166
+ fetchImpl: globalThis.fetch,
167
+ })
168
+ auth.remember(flow)
169
+ return {
170
+ userCode: flow.userCode,
171
+ verificationUri: flow.verificationUri,
172
+ expiresInSeconds: flow.expiresInSeconds,
173
+ pollIntervalSeconds: flow.intervalSeconds,
174
+ next: 'ask the human to approve, then call gh_auth_finish',
175
+ }
176
+ },
177
+ )
178
+
179
+ tool(
180
+ 'gh_auth_finish',
181
+ 'Collect a started sign-in. Polls while the human is approving, then stores the access in this plugin\'s file and reports the sign-in name only.',
182
+ {
183
+ maxWaitMs: { type: 'number', description: 'How long to keep polling (default 120000).' },
184
+ },
185
+ async (args) => {
186
+ const pending = auth.pending()
187
+ if (!pending) throw new Error('no sign-in is in progress: call gh_auth_login first')
188
+ const cfg = authConfig()
189
+ const outcome = await pollDeviceFlow({
190
+ clientId: cfg.clientId || DEFAULT_CLIENT_ID,
191
+ clientSecret: auth.secret(),
192
+ deviceCode: pending.deviceCode,
193
+ intervalSeconds: pending.intervalSeconds,
194
+ maxWaitMs: args.maxWaitMs,
195
+ fetchImpl: globalThis.fetch,
196
+ })
197
+ if (outcome.status !== 'authorized') return { status: outcome.status, message: outcome.message }
198
+ const saved = await writeAccessFile(auth.path(), {
199
+ token: outcome.token,
200
+ scope: outcome.scope || pending.scope,
201
+ source: 'device-flow',
202
+ })
203
+ auth.forget()
204
+ return { status: 'authorized', stored: saved.path, login: saved.login || '', scope: saved.scope }
205
+ },
206
+ )
207
+
208
+ tool(
209
+ 'gh_auth_status',
210
+ 'Report where the current access comes from and whether it works: the source (credential, environment, this plugin\'s sign-in, gh CLI), the account, and the remaining API rate limit. The value itself is never shown. Read-only.',
211
+ {},
212
+ async () => {
213
+ const cfg = liveConfig()
214
+ const access = await auth.resolve()
215
+ if (!access.value) return { configured: false, guidance: access.guidance }
216
+ const github = await client()
217
+ const { data, rateLimit } = await github.get('/user')
218
+ return {
219
+ configured: true,
220
+ source: access.source,
221
+ login: data && data.login,
222
+ name: data && data.name,
223
+ scopes: github.lastScopes ? github.lastScopes() : undefined,
224
+ rateLimit: rateLimit || undefined,
225
+ accessFile: auth.path(),
226
+ }
227
+ },
228
+ )
229
+
230
+ tool(
231
+ 'gh_auth_logout',
232
+ 'Forget the sign-in stored by this plugin (gh_auth_login). Other sources (a DSH credential, the environment, the gh CLI) are untouched. Requires confirm: true.',
233
+ { confirm: { type: 'boolean', description: 'Must be true to sign out.' } },
234
+ async (args) => {
235
+ if (args.confirm !== true) throw new Error('refusing to sign out without confirm: true')
236
+ const file = auth.path()
237
+ const stored = await readAccessFile(file)
238
+ const result = await clearAccessFile(file)
239
+ return { ...result, had: Boolean(stored), note: result.removed ? 'the stored sign-in is gone' : 'nothing was stored' }
240
+ },
241
+ )
242
+
243
+ // keep the imports honest for callers that compose their own flows
244
+ return { accessFilePath, readAccessFile }
245
+ }
@@ -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
+ path: entry.path,
25
+ type: entry.type,
26
+ size: entry.size,
27
+ sha: entry.sha,
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