@goodandready/dsh-github-ops 0.1.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.
@@ -0,0 +1,207 @@
1
+ import { analyzePull, areasFor, verdictFor } from '../review-rules.js'
2
+
3
+ export { areasFor }
4
+
5
+ // Pull request operations: create, update, merge, read a review pack
6
+ // (metadata + capped diff + comments + CI + rule-based findings), publish a review,
7
+ // and read the check rollup. The findings are deterministic rules, not an LLM: they
8
+ // point the reader at what needs human attention and never pretend to be a verdict
9
+ // on correctness.
10
+
11
+ function ensureRepo({ owner, repo }) {
12
+ if (!owner || !repo) {
13
+ throw new Error('owner and repo are required (pass "repository": "owner/repo" or set defaults in settings)')
14
+ }
15
+ }
16
+
17
+ export function normalizePull(data) {
18
+ if (!data || typeof data !== 'object') return data
19
+ return {
20
+ number: data.number,
21
+ title: data.title,
22
+ state: data.state,
23
+ draft: Boolean(data.draft),
24
+ merged: Boolean(data.merged),
25
+ mergeable: data.mergeable,
26
+ mergeableState: data.mergeable_state,
27
+ author: data.user && data.user.login,
28
+ head: data.head && data.head.ref,
29
+ base: data.base && data.base.ref,
30
+ additions: data.additions,
31
+ deletions: data.deletions,
32
+ changedFiles: data.changed_files,
33
+ createdAt: data.created_at,
34
+ updatedAt: data.updated_at,
35
+ url: data.html_url,
36
+ body: typeof data.body === 'string' ? data.body : '',
37
+ labels: Array.isArray(data.labels) ? data.labels.map((l) => (typeof l === 'string' ? l : l.name)) : [],
38
+ requestedReviewers: Array.isArray(data.requested_reviewers)
39
+ ? data.requested_reviewers.map((r) => r.login)
40
+ : [],
41
+ }
42
+ }
43
+
44
+ export async function createPull(client, { owner, repo, title, head, base, body, draft = false }) {
45
+ ensureRepo({ owner, repo })
46
+ if (!title) throw new Error('title is required')
47
+ if (!head) throw new Error('head branch is required')
48
+ const payload = { title, head, base: base || 'main', body: body || '', draft: Boolean(draft) }
49
+ const { data } = await client.post(`/repos/${owner}/${repo}/pulls`, payload)
50
+ return normalizePull(data)
51
+ }
52
+
53
+ export async function updatePull(client, { owner, repo, number, title, body, state, base }) {
54
+ ensureRepo({ owner, repo })
55
+ if (!number) throw new Error('number is required')
56
+ const payload = {}
57
+ if (title !== undefined) payload.title = title
58
+ if (body !== undefined) payload.body = body
59
+ if (state !== undefined) payload.state = state
60
+ if (base !== undefined) payload.base = base
61
+ if (!Object.keys(payload).length) throw new Error('nothing to update: pass title, body, state or base')
62
+ const { data } = await client.patch(`/repos/${owner}/${repo}/pulls/${number}`, payload)
63
+ return normalizePull(data)
64
+ }
65
+
66
+ const MERGE_METHODS = { merge: 'merge', squash: 'squash', rebase: 'rebase' }
67
+
68
+ export async function mergePull(client, { owner, repo, number, method = 'merge', commitTitle, commitMessage, deleteBranch = false }) {
69
+ ensureRepo({ owner, repo })
70
+ if (!number) throw new Error('number is required')
71
+ const mergeMethod = MERGE_METHODS[String(method).toLowerCase()]
72
+ if (!mergeMethod) throw new Error(`method must be one of: ${Object.keys(MERGE_METHODS).join(', ')}`)
73
+ const payload = { merge_method: mergeMethod }
74
+ if (commitTitle) payload.commit_title = commitTitle
75
+ if (commitMessage) payload.commit_message = commitMessage
76
+ const { data } = await client.put(`/repos/${owner}/${repo}/pulls/${number}/merge`, payload)
77
+ const result = { merged: Boolean(data && data.merged), message: data && data.message, sha: data && data.sha }
78
+ if (result.merged && deleteBranch) {
79
+ try {
80
+ const pull = await client.get(`/repos/${owner}/${repo}/pulls/${number}`)
81
+ const branch = pull.data && pull.data.head && pull.data.head.ref
82
+ if (branch) {
83
+ await client.del(`/repos/${owner}/${repo}/git/refs/heads/${encodeURIComponent(branch)}`)
84
+ result.branchDeleted = branch
85
+ }
86
+ } catch (err) {
87
+ result.branchDeleteError = String(err && err.message || err)
88
+ }
89
+ }
90
+ return result
91
+ }
92
+
93
+ export async function reviewPull(client, { owner, repo, number, maxDiffChars = 60000, rules }) {
94
+ ensureRepo({ owner, repo })
95
+ if (!number) throw new Error('number is required')
96
+ const [pull, filesRes, commentsRes] = await Promise.all([
97
+ client.get(`/repos/${owner}/${repo}/pulls/${number}`),
98
+ client.get(`/repos/${owner}/${repo}/pulls/${number}/files`, { query: { per_page: 100 } }),
99
+ client.get(`/repos/${owner}/${repo}/issues/${number}/comments`, { query: { per_page: 50 } }),
100
+ ])
101
+ const pullData = normalizePull(pull.data)
102
+ const files = Array.isArray(filesRes.data)
103
+ ? filesRes.data.map((f) => ({
104
+ filename: f.filename,
105
+ status: f.status,
106
+ additions: f.additions,
107
+ deletions: f.deletions,
108
+ patch: typeof f.patch === 'string' ? f.patch : '',
109
+ }))
110
+ : []
111
+
112
+ let diff = files.map((f) => `--- ${f.filename} (${f.status})\n${f.patch}`).join('\n')
113
+ const diffTruncated = diff.length > maxDiffChars
114
+ if (diffTruncated) diff = diff.slice(0, maxDiffChars)
115
+
116
+ let checks = null
117
+ try {
118
+ checks = await getChecks(client, { owner, repo, ref: pullData.head })
119
+ } catch (err) {
120
+ checks = { error: String(err && err.message || err) }
121
+ }
122
+
123
+ return {
124
+ pull: pullData,
125
+ areas: areasFor(files),
126
+ files: files.map(({ patch, ...rest }) => ({ ...rest, patchChars: patch.length })),
127
+ findings: analyzePull({ files, rules }),
128
+ comments: Array.isArray(commentsRes.data)
129
+ ? commentsRes.data.map((c) => ({ id: c.id, author: c.user && c.user.login, body: c.body }))
130
+ : [],
131
+ checks,
132
+ diff,
133
+ diffTruncated,
134
+ }
135
+ }
136
+
137
+ export async function postReview(client, { owner, repo, number, body, mode = 'summary', inline = [] }) {
138
+ ensureRepo({ owner, repo })
139
+ if (!number) throw new Error('number is required')
140
+ if (!body) throw new Error('body is required')
141
+ if (mode === 'inline' && inline.length) {
142
+ const payload = {
143
+ body,
144
+ event: 'COMMENT',
145
+ comments: inline.map((c) => ({ path: c.path, line: c.line, body: c.body })),
146
+ }
147
+ const { data } = await client.post(`/repos/${owner}/${repo}/pulls/${number}/reviews`, payload)
148
+ return { mode: 'inline', id: data && data.id, url: data && data.html_url }
149
+ }
150
+ const { data } = await client.post(`/repos/${owner}/${repo}/issues/${number}/comments`, { body })
151
+ return { mode: 'summary', id: data && data.id, url: data && data.html_url }
152
+ }
153
+
154
+ /** Check rollup: check runs plus legacy commit statuses, normalized to one verdict. */
155
+ export async function getChecks(client, { owner, repo, ref }) {
156
+ ensureRepo({ owner, repo })
157
+ if (!ref) throw new Error('ref is required')
158
+ const [runs, statuses] = await Promise.all([
159
+ client.get(`/repos/${owner}/${repo}/commits/${ref}/check-runs`, { query: { per_page: 100 } }),
160
+ client.get(`/repos/${owner}/${repo}/commits/${ref}/status`),
161
+ ])
162
+ const checkRuns = ((runs.data && runs.data.check_runs) || []).map((r) => ({
163
+ name: r.name,
164
+ status: r.status,
165
+ conclusion: r.conclusion,
166
+ url: r.html_url,
167
+ }))
168
+ const state = (statuses.data && statuses.data.state) || 'unknown'
169
+ const failed = checkRuns.filter((r) => r.conclusion && !['success', 'neutral', 'skipped'].includes(r.conclusion))
170
+ const pending = checkRuns.filter((r) => r.status !== 'completed')
171
+ const rollup = failed.length || ['failure', 'error'].includes(state)
172
+ ? 'failure'
173
+ : pending.length || state === 'pending'
174
+ ? 'pending'
175
+ : checkRuns.length || state === 'success'
176
+ ? 'success'
177
+ : 'unknown'
178
+ return { ref, rollup, checkRuns, statuses: (statuses.data && statuses.data.statuses) || [] }
179
+ }
180
+
181
+ /**
182
+ * One-shot CI review run: the review pack plus a deterministic verdict. `success`
183
+ * means no rule found something needing attention, not that the change is correct.
184
+ */
185
+ export async function ciRun(client, { owner, repo, number, maxDiffChars, rules }) {
186
+ const pack = await reviewPull(client, { owner, repo, number, maxDiffChars, rules })
187
+ const blocking = pack.findings.filter((f) => f.level === 'critical')
188
+ const attention = pack.findings.filter((f) => f.level === 'attention')
189
+ const verdict = verdictFor({ findings: pack.findings, checkRollup: pack.checks ? pack.checks.rollup : 'unknown' })
190
+ return {
191
+ verdict,
192
+ number: pack.pull.number,
193
+ title: pack.pull.title,
194
+ url: pack.pull.url,
195
+ areas: pack.areas,
196
+ findings: pack.findings,
197
+ checks: pack.checks ? pack.checks.rollup : 'unknown',
198
+ summary: [
199
+ `PR #${pack.pull.number}: ${pack.pull.title}`,
200
+ `areas: ${pack.areas.join(', ') || 'none'}`,
201
+ `checks: ${pack.checks ? pack.checks.rollup : 'unknown'}`,
202
+ blocking.length ? `critical: ${blocking.map((f) => f.detail).join('; ')}` : '',
203
+ attention.length ? `attention: ${attention.map((f) => f.detail).join('; ')}` : '',
204
+ `verdict: ${verdict}`,
205
+ ].filter(Boolean).join('\n'),
206
+ }
207
+ }
@@ -0,0 +1,233 @@
1
+ // Collaboration tool registrations: issues, pull requests, reviews and checks.
2
+ //
3
+ // Reads are free; every write goes through the caller's confirmation contour in the
4
+ // description, and destructive variants demand an explicit flag.
5
+
6
+ import {
7
+ listIssues,
8
+ getIssue,
9
+ createIssue,
10
+ commentIssue,
11
+ closeIssue,
12
+ searchIssues,
13
+ } from './issues.js'
14
+ import {
15
+ createPull,
16
+ updatePull,
17
+ mergePull,
18
+ reviewPull,
19
+ postReview,
20
+ getChecks,
21
+ ciRun,
22
+ } from './pulls.js'
23
+
24
+ const REPOSITORY = {
25
+ repository: { type: 'string', description: 'Repository as "owner/repo". Omit to use the default from settings.' },
26
+ }
27
+
28
+ export function registerCollabTools({ tool, asRepo, client, liveConfig, reviewRules }) {
29
+ const repoParams = (extra = {}) => ({ ...REPOSITORY, ...extra })
30
+ const rules = () => (typeof reviewRules === 'function' ? reviewRules() : undefined)
31
+
32
+ // ---------------------------------------------------------------------- issues
33
+
34
+ tool(
35
+ 'gh_issue',
36
+ 'List or read GitHub issues. Pull requests are returned with kind: "pr". Use action "list" (default), "get" for one issue, or "comments" for one issue\'s comments. Read-only.',
37
+ repoParams({
38
+ action: { type: 'string', description: 'list (default), get, or comments.' },
39
+ number: { type: 'number', description: 'Issue number; required for get and comments.' },
40
+ state: { type: 'string', description: 'open (default), closed or all.' },
41
+ labels: { type: 'array', items: { type: 'string' }, description: 'Filter by labels.' },
42
+ kind: { type: 'string', description: 'all (default), issue or pr.' },
43
+ limit: { type: 'number', description: 'How many issues to list (max 100, default 30).' },
44
+ }),
45
+ async (args) => {
46
+ const { owner, repo } = await asRepo(args, liveConfig())
47
+ const action = args.action || 'list'
48
+ if (action === 'get' || action === 'comments') {
49
+ const issue = await getIssue(await client(), {
50
+ owner, repo, number: args.number, includeComments: action === 'comments',
51
+ })
52
+ return action === 'comments' ? { number: issue.number, comments: issue.commentList || [] } : issue
53
+ }
54
+ const result = await listIssues(await client(), {
55
+ owner, repo, state: args.state, labels: args.labels, limit: args.limit, kind: args.kind,
56
+ })
57
+ return { issues: result.issues, count: result.count }
58
+ },
59
+ )
60
+
61
+ tool(
62
+ 'issue_open',
63
+ 'Create a GitHub issue with a title, body, labels and assignees.',
64
+ repoParams({
65
+ title: { type: 'string', description: 'Issue title.' },
66
+ body: { type: 'string', description: 'Issue body in markdown.' },
67
+ labels: { type: 'array', items: { type: 'string' }, description: 'Labels to apply.' },
68
+ assignees: { type: 'array', items: { type: 'string' }, description: 'Logins to assign.' },
69
+ }),
70
+ async (args) => {
71
+ const { owner, repo } = await asRepo(args, liveConfig())
72
+ return createIssue(await client(), {
73
+ owner, repo, title: args.title, body: args.body, labels: args.labels, assignees: args.assignees,
74
+ })
75
+ },
76
+ )
77
+
78
+ tool(
79
+ 'issue_comment',
80
+ 'Comment on a GitHub issue or pull request.',
81
+ repoParams({
82
+ number: { type: 'number', description: 'Issue or pull request number.' },
83
+ body: { type: 'string', description: 'Comment body in markdown.' },
84
+ }),
85
+ async (args) => {
86
+ const { owner, repo } = await asRepo(args, liveConfig())
87
+ return commentIssue(await client(), { owner, repo, number: args.number, body: args.body })
88
+ },
89
+ )
90
+
91
+ tool(
92
+ 'issue_close',
93
+ 'Close a GitHub issue, optionally recording a state reason (completed or not_planned).',
94
+ repoParams({
95
+ number: { type: 'number', description: 'Issue number.' },
96
+ stateReason: { type: 'string', description: 'completed or not_planned.' },
97
+ }),
98
+ async (args) => {
99
+ const { owner, repo } = await asRepo(args, liveConfig())
100
+ return closeIssue(await client(), { owner, repo, number: args.number, stateReason: args.stateReason })
101
+ },
102
+ )
103
+
104
+ tool(
105
+ 'gh_search',
106
+ 'Search GitHub issues and pull requests with GitHub search syntax (e.g. "repo:owner/name is:open label:bug"). Uses the separate search quota. Read-only.',
107
+ {
108
+ query: { type: 'string', description: 'Search query in GitHub search syntax.' },
109
+ limit: { type: 'number', description: 'How many results to return (max 100, default 20).' },
110
+ },
111
+ async (args) => searchIssues(await client(), { query: args.query, limit: args.limit }),
112
+ )
113
+
114
+ // ----------------------------------------------------------------- pull requests
115
+
116
+ tool(
117
+ 'pr_create',
118
+ 'Create a GitHub pull request from a head branch into a base branch.',
119
+ repoParams({
120
+ title: { type: 'string', description: 'Pull request title.' },
121
+ head: { type: 'string', description: 'Source branch.' },
122
+ base: { type: 'string', description: 'Target branch (default main).' },
123
+ body: { type: 'string', description: 'Description in markdown.' },
124
+ draft: { type: 'boolean', description: 'Create as a draft.' },
125
+ }),
126
+ async (args) => {
127
+ const { owner, repo } = await asRepo(args, liveConfig())
128
+ return createPull(await client(), {
129
+ owner, repo, title: args.title, head: args.head, base: args.base, body: args.body, draft: args.draft,
130
+ })
131
+ },
132
+ )
133
+
134
+ tool(
135
+ 'pr_update',
136
+ 'Update a GitHub pull request: title, body, state (open/closed) or base branch.',
137
+ repoParams({
138
+ number: { type: 'number', description: 'Pull request number.' },
139
+ title: { type: 'string', description: 'New title.' },
140
+ body: { type: 'string', description: 'New body.' },
141
+ state: { type: 'string', description: 'open or closed.' },
142
+ base: { type: 'string', description: 'New target branch.' },
143
+ }),
144
+ async (args) => {
145
+ const { owner, repo } = await asRepo(args, liveConfig())
146
+ return updatePull(await client(), {
147
+ owner, repo, number: args.number, title: args.title, body: args.body, state: args.state, base: args.base,
148
+ })
149
+ },
150
+ )
151
+
152
+ tool(
153
+ 'pr_merge',
154
+ 'Merge a GitHub pull request. method is merge (default), squash or rebase; deleteBranch removes the head branch afterwards. This is the irreversible step of a review cycle — confirm the review finished first.',
155
+ repoParams({
156
+ number: { type: 'number', description: 'Pull request number.' },
157
+ method: { type: 'string', description: 'merge (default), squash or rebase.' },
158
+ commitTitle: { type: 'string', description: 'Merge/squash commit title.' },
159
+ commitMessage: { type: 'string', description: 'Merge/squash commit message.' },
160
+ deleteBranch: { type: 'boolean', description: 'Delete the head branch after merging.' },
161
+ }),
162
+ async (args) => {
163
+ const { owner, repo } = await asRepo(args, liveConfig())
164
+ return mergePull(await client(), {
165
+ owner, repo,
166
+ number: args.number,
167
+ method: args.method,
168
+ commitTitle: args.commitTitle,
169
+ commitMessage: args.commitMessage,
170
+ deleteBranch: args.deleteBranch,
171
+ })
172
+ },
173
+ )
174
+
175
+ tool(
176
+ 'gh_review',
177
+ 'Read a pull request as one review pack: metadata, changed files with areas, a capped unified diff, comments, the CI rollup and deterministic findings (secrets, migrations, CI config, missing tests, large diff). Read-only.',
178
+ repoParams({
179
+ number: { type: 'number', description: 'Pull request number.' },
180
+ maxDiffChars: { type: 'number', description: 'Cap for the returned diff text (default 60000).' },
181
+ }),
182
+ async (args) => {
183
+ const { owner, repo } = await asRepo(args, liveConfig())
184
+ return reviewPull(await client(), { owner, repo, number: args.number, maxDiffChars: args.maxDiffChars, rules: rules() })
185
+ },
186
+ )
187
+
188
+ tool(
189
+ 'review_post',
190
+ 'Publish a review on a pull request: mode "summary" posts one issue-level comment (default), mode "inline" posts a review with line-anchored comments.',
191
+ repoParams({
192
+ number: { type: 'number', description: 'Pull request number.' },
193
+ body: { type: 'string', description: 'Review body in markdown.' },
194
+ mode: { type: 'string', description: 'summary (default) or inline.' },
195
+ inline: {
196
+ type: 'array',
197
+ description: 'Inline comments for mode inline: [{ path, line, body }].',
198
+ items: { type: 'object', additionalProperties: true },
199
+ },
200
+ }),
201
+ async (args) => {
202
+ const { owner, repo } = await asRepo(args, liveConfig())
203
+ return postReview(await client(), {
204
+ owner, repo, number: args.number, body: args.body, mode: args.mode, inline: args.inline,
205
+ })
206
+ },
207
+ )
208
+
209
+ tool(
210
+ 'gh_checks',
211
+ 'Read the CI state of a commit: check runs, legacy commit statuses and one rollup verdict (success, failure, pending or unknown). Read-only.',
212
+ repoParams({
213
+ ref: { type: 'string', description: 'Commit SHA, branch or tag.' },
214
+ }),
215
+ async (args) => {
216
+ const { owner, repo } = await asRepo(args, liveConfig())
217
+ return getChecks(await client(), { owner, repo, ref: args.ref })
218
+ },
219
+ )
220
+
221
+ tool(
222
+ 'ci_run',
223
+ 'Run a one-shot CI review of a pull request: the review pack plus a deterministic verdict (success or needs-changes). success means no rule found something needing attention — it is not a statement that the change is correct.',
224
+ repoParams({
225
+ number: { type: 'number', description: 'Pull request number.' },
226
+ maxDiffChars: { type: 'number', description: 'Cap for the diff text (default 60000).' },
227
+ }),
228
+ async (args) => {
229
+ const { owner, repo } = await asRepo(args, liveConfig())
230
+ return ciRun(await client(), { owner, repo, number: args.number, maxDiffChars: args.maxDiffChars, rules: rules() })
231
+ },
232
+ )
233
+ }