@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.
@@ -0,0 +1,246 @@
1
+ // Composed reports: the answers a person actually asks for.
2
+ //
3
+ // Each function is a small composition over the plain reads, so the cache in the client
4
+ // makes a report cheap and the pieces stay testable. Nothing here is a verdict on quality:
5
+ // a health score is a transparent heuristic with its evidence attached, and the digest only
6
+ // reports what happened in a window.
7
+
8
+ import { getRepo, normalizeRepo } from './repos.js'
9
+ import { listReleases } from './releases.js'
10
+ import { listIssues } from './issues.js'
11
+ import { listRuns } from './actions.js'
12
+
13
+ function ensureRepo({ owner, repo }) {
14
+ if (!owner || !repo) throw new Error('owner and repo are required')
15
+ }
16
+
17
+ function normalizeCommit(data) {
18
+ return {
19
+ sha: data && data.sha,
20
+ shortSha: data && data.sha ? String(data.sha).slice(0, 7) : '',
21
+ message: data && data.commit && data.commit.message ? String(data.commit.message).split('\n')[0] : '',
22
+ author: (data && data.commit && data.commit.author && data.commit.author.name) || (data && data.author && data.author.login) || '',
23
+ date: (data && data.commit && data.commit.author && data.commit.author.date) || '',
24
+ }
25
+ }
26
+
27
+ /** Recent commits of a branch. */
28
+ export async function listCommits(client, { owner, repo, branch, limit = 10, since } = {}) {
29
+ ensureRepo({ owner, repo })
30
+ const capped = Math.max(1, Math.min(Number(limit) || 10, 100))
31
+ const query = { per_page: capped }
32
+ if (since) query.since = since
33
+ const { data } = await client.get(`/repos/${owner}/${repo}/commits`, {
34
+ query: branch ? { ...query, sha: branch } : query,
35
+ })
36
+ const commits = (Array.isArray(data) ? data : []).map(normalizeCommit).slice(0, capped)
37
+ return { commits, count: commits.length }
38
+ }
39
+
40
+ /** Top contributors by commit count. */
41
+ export async function listContributors(client, { owner, repo, limit = 10 } = {}) {
42
+ ensureRepo({ owner, repo })
43
+ const capped = Math.max(1, Math.min(Number(limit) || 10, 100))
44
+ const { data } = await client.get(`/repos/${owner}/${repo}/contributors`, { query: { per_page: capped } })
45
+ const contributors = (Array.isArray(data) ? data : []).slice(0, capped).map((c) => ({
46
+ login: c.login,
47
+ contributions: c.contributions,
48
+ url: c.html_url,
49
+ }))
50
+ return { contributors, count: contributors.length }
51
+ }
52
+
53
+ /**
54
+ * One call that answers "what is this repository": overview, latest release, open issues,
55
+ * recent commits and top contributors. Composed calls share the client cache, so the whole
56
+ * report costs a handful of requests.
57
+ */
58
+ export async function repoReport(client, { owner, repo, commits: commitLimit = 5, issues: issueLimit = 5, contributors: contributorLimit = 5 } = {}) {
59
+ ensureRepo({ owner, repo })
60
+ const overview = await getRepo(client, { owner, repo })
61
+ const [releases, openIssues, commitList, contributorList] = await Promise.all([
62
+ listReleases(client, { owner, repo, limit: 1 }).catch(() => ({ releases: [] })),
63
+ listIssues(client, { owner, repo, state: 'open', limit: issueLimit }).catch(() => ({ issues: [] })),
64
+ listCommits(client, { owner, repo, branch: overview.defaultBranch, limit: commitLimit }).catch(() => ({ commits: [] })),
65
+ listContributors(client, { owner, repo, limit: contributorLimit }).catch(() => ({ contributors: [] })),
66
+ ])
67
+ return {
68
+ repo: overview.fullName,
69
+ description: overview.description,
70
+ stars: overview.stars,
71
+ forks: overview.forks,
72
+ openIssues: overview.openIssues,
73
+ language: overview.language,
74
+ license: overview.license,
75
+ defaultBranch: overview.defaultBranch,
76
+ pushedAt: overview.pushedAt,
77
+ url: overview.url,
78
+ latestRelease: releases.releases[0] || null,
79
+ issues: openIssues.issues,
80
+ commits: commitList.commits,
81
+ contributors: contributorList.contributors,
82
+ }
83
+ }
84
+
85
+ /** What happened in a repository inside a look-back window. */
86
+ export async function weeklyDigest(client, { owner, repo, days = 7, limit = 10 } = {}) {
87
+ ensureRepo({ owner, repo })
88
+ const window = Math.max(1, Math.min(Number(days) || 7, 90))
89
+ const capped = Math.max(1, Math.min(Number(limit) || 10, 50))
90
+ const since = new Date(Date.now() - window * 24 * 60 * 60 * 1000).toISOString()
91
+
92
+ const [releases, issues, pulls, commits] = await Promise.all([
93
+ listReleases(client, { owner, repo, limit: capped }).catch(() => ({ releases: [] })),
94
+ // pull requests are excluded from "new issues": they are reported separately below
95
+ listIssues(client, { owner, repo, state: 'all', kind: 'issue', limit: capped }).catch(() => ({ issues: [] })),
96
+ listIssues(client, { owner, repo, state: 'all', kind: 'pr', limit: capped }).catch(() => ({ issues: [] })),
97
+ listCommits(client, { owner, repo, limit: capped, since }).catch(() => ({ commits: [] })),
98
+ ])
99
+ const after = (value) => Boolean(value) && value >= since
100
+ return {
101
+ windowDays: window,
102
+ since,
103
+ releases: releases.releases.filter((r) => after(r.publishedAt || r.createdAt)),
104
+ newIssues: issues.issues.filter((i) => after(i.createdAt)),
105
+ mergedPulls: pulls.issues.filter((i) => after(i.closedAt || i.updatedAt)),
106
+ commits: commits.commits,
107
+ }
108
+ }
109
+
110
+ /**
111
+ * The attention queue: mentions, review requests, assignments and threads the token watches.
112
+ * Needs access with notification read permission; a missing scope surfaces as an actionable
113
+ * failure rather than an empty list.
114
+ */
115
+ export async function listNotifications(client, { all = false, participating = false, limit = 20 } = {}) {
116
+ const capped = Math.max(1, Math.min(Number(limit) || 20, 100))
117
+ const query = { per_page: capped }
118
+ if (all) query.all = 'true'
119
+ if (participating) query.participating = 'true'
120
+ const { data } = await client.get('/notifications', { query })
121
+ const items = (Array.isArray(data) ? data : []).slice(0, capped).map((n) => ({
122
+ id: n.id,
123
+ reason: n.reason,
124
+ unread: Boolean(n.unread),
125
+ updatedAt: n.updated_at,
126
+ title: n.subject && n.subject.title,
127
+ type: n.subject && n.subject.type,
128
+ number: n.subject && n.subject.url ? Number(String(n.subject.url).split('/').pop()) : null,
129
+ repository: n.repository ? n.repository.full_name : '',
130
+ }))
131
+ const byReason = {}
132
+ for (const item of items) byReason[item.reason] = (byReason[item.reason] || 0) + 1
133
+ return { count: items.length, byReason, notifications: items }
134
+ }
135
+
136
+ /**
137
+ * A transparent maintenance heuristic. Every point of the score carries its evidence, and the
138
+ * result states plainly that it is not a security audit.
139
+ */
140
+ export function healthOf({ repo, releases = [], issues = [], pulls = [], commits = [] } = {}) {
141
+ const dimensions = []
142
+ const daysSince = (value) => (value ? Math.floor((Date.now() - Date.parse(value)) / (24 * 60 * 60 * 1000)) : null)
143
+
144
+ const pushed = daysSince(repo && repo.pushedAt)
145
+ const activity = pushed === null ? 0 : pushed <= 7 ? 25 : pushed <= 30 ? 18 : pushed <= 90 ? 10 : 3
146
+ dimensions.push({ name: 'activity', score: activity, max: 25, evidence: pushed === null ? 'no push date' : `last push ${pushed} day(s) ago` })
147
+
148
+ const releaseScore = releases.length ? 20 : 8
149
+ dimensions.push({ name: 'releases', score: releaseScore, max: 20, evidence: releases.length ? `latest ${releases[0].tag} (${releases[0].publishedAt || 'unpublished'})` : 'no releases published' })
150
+
151
+ const openCount = Array.isArray(issues) ? issues.length : 0
152
+ const issueScore = openCount === 0 ? 20 : openCount <= 3 ? 15 : openCount <= 10 ? 9 : 4
153
+ dimensions.push({ name: 'issue-load', score: issueScore, max: 20, evidence: `${openCount} open issue(s) in the last page` })
154
+
155
+ const prScore = Array.isArray(pulls) && pulls.length ? 15 : 8
156
+ dimensions.push({ name: 'review-load', score: prScore, max: 15, evidence: Array.isArray(pulls) && pulls.length ? `${pulls.length} recent pull request(s)` : 'no recent pull requests' })
157
+
158
+ const commitCount = Array.isArray(commits) ? commits.length : 0
159
+ const commitScore = commitCount >= 5 ? 20 : commitCount > 0 ? 12 : 4
160
+ dimensions.push({ name: 'commits', score: commitScore, max: 20, evidence: `${commitCount} commit(s) in the last page` })
161
+
162
+ const score = dimensions.reduce((sum, d) => sum + d.score, 0)
163
+ const risks = []
164
+ if (pushed !== null && pushed > 90) risks.push(`no push for ${pushed} days`)
165
+ if (!releases.length) risks.push('no published release')
166
+ if (!(repo && repo.license)) risks.push('no license declared')
167
+ if (!(repo && repo.description)) risks.push('no description')
168
+ if (openCount > 10) risks.push('large open issue backlog')
169
+
170
+ const nextActions = []
171
+ if (!releases.length) nextActions.push('publish the first release')
172
+ if (!(repo && repo.license)) nextActions.push('add a LICENSE')
173
+ if (!(repo && repo.description)) nextActions.push('write a repository description')
174
+ if (pushed !== null && pushed > 90) nextActions.push('push a change or archive the repository')
175
+
176
+ return {
177
+ repo: repo && repo.fullName,
178
+ score,
179
+ max: 100,
180
+ dimensions,
181
+ risks,
182
+ nextActions,
183
+ disclaimer: 'A maintenance heuristic over public signals, not a security audit.',
184
+ }
185
+ }
186
+
187
+ /** Repository health for one repository, gathering its own evidence. */
188
+ export async function repoHealth(client, { owner, repo } = {}) {
189
+ ensureRepo({ owner, repo })
190
+ const [repository, releases, issues, pulls, commits] = await Promise.all([
191
+ getRepo(client, { owner, repo }),
192
+ listReleases(client, { owner, repo, limit: 3 }).catch(() => ({ releases: [] })),
193
+ listIssues(client, { owner, repo, state: 'open', limit: 30 }).catch(() => ({ issues: [] })),
194
+ listIssues(client, { owner, repo, state: 'open', kind: 'pr', limit: 30 }).catch(() => ({ issues: [] })),
195
+ listCommits(client, { owner, repo, limit: 10 }).catch(() => ({ commits: [] })),
196
+ ])
197
+ return healthOf({ repo: repository, releases: releases.releases, issues: issues.issues, pulls: pulls.issues, commits: commits.commits })
198
+ }
199
+
200
+ /** Two repositories side by side, with numeric deltas. */
201
+ export async function compareRepos(client, { first, second } = {}) {
202
+ const a = typeof first === 'string' ? first : ''
203
+ const b = typeof second === 'string' ? second : ''
204
+ if (!a.includes('/') || !b.includes('/')) throw new Error('pass two repositories as "owner/repo"')
205
+ const [left, right] = await Promise.all([a, b].map(async (spec) => {
206
+ const [owner, repo] = spec.split('/')
207
+ return getRepo(client, { owner, repo })
208
+ }))
209
+ const delta = (x, y) => (typeof x === 'number' && typeof y === 'number' ? x - y : null)
210
+ return {
211
+ first: left,
212
+ second: right,
213
+ deltas: {
214
+ stars: delta(left.stars, right.stars),
215
+ forks: delta(left.forks, right.forks),
216
+ openIssues: delta(left.openIssues, right.openIssues),
217
+ },
218
+ }
219
+ }
220
+
221
+ /** Recently created repositories, by stars. Uses the search quota. */
222
+ export async function trendingRepos(client, { language, since, limit = 10 } = {}) {
223
+ const capped = Math.max(1, Math.min(Number(limit) || 10, 50))
224
+ const created = since || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
225
+ const parts = [`created:>${created}`, 'stars:>50']
226
+ if (language) parts.push(`language:${language}`)
227
+ const { data } = await client.get('/search/repositories', {
228
+ query: { q: parts.join(' '), sort: 'stars', order: 'desc', per_page: capped },
229
+ })
230
+ const repos = ((data && data.items) || []).slice(0, capped).map(normalizeRepo)
231
+ return { since: created, count: repos.length, repos }
232
+ }
233
+
234
+ /** A user's repositories by stars. */
235
+ export async function userRepos(client, { login, limit = 10 } = {}) {
236
+ if (!login) throw new Error('login is required')
237
+ const capped = Math.max(1, Math.min(Number(limit) || 10, 100))
238
+ const { data } = await client.get(`/users/${encodeURIComponent(login)}/repos`, {
239
+ query: { sort: 'stars', direction: 'desc', per_page: capped },
240
+ })
241
+ const repos = (Array.isArray(data) ? data : []).slice(0, capped).map(normalizeRepo)
242
+ return { login, count: repos.length, repos }
243
+ }
244
+
245
+ // Re-exported so a caller composing its own report needs one import.
246
+ export { listRuns }
@@ -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
+ }