@goodandready/dsh-github-ops 0.2.2 → 0.2.4
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 +14 -0
- package/README.md +9 -0
- package/README.ru.md +9 -0
- package/README.zh.md +9 -0
- package/lib/auth.js +64 -0
- package/lib/github.js +24 -9
- package/lib/index.js +12 -31
- package/lib/tools/actions.js +78 -0
- package/lib/tools/pulls.js +129 -0
- package/lib/tools/register-collab.js +62 -0
- package/lib/tools/register-core.js +77 -1
- package/lib/tools/register-ops.js +32 -0
- package/lib/tools/releases.js +87 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to `@goodandready/dsh-github-ops`.
|
|
4
4
|
|
|
5
|
+
## 0.2.4
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- Fixed GitHub client cache holding onto stale credentials when rotated with a token of the exact same length: cache key now incorporates a non-reversible sha256 token fingerprint (#38).
|
|
9
|
+
|
|
10
|
+
## 0.2.3
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Release assets management: `gh_release_asset_list`, `gh_release_asset_upload`, `gh_release_asset_delete` (#41).
|
|
14
|
+
- Workflow dispatch: `gh_run_dispatch` triggering Actions workflow_dispatch events with inputs and confirm gate (#42).
|
|
15
|
+
- CI log summary: `gh_run_log_summary` extracting compact failure context, stripping ANSI and redacting secrets (#43).
|
|
16
|
+
- Release notes generation: `gh_release_notes_generate` generating changelogs from PRs and commits (#44).
|
|
17
|
+
- PR review discussion threads: `pr_review_threads`, `pr_thread_reply`, `pr_thread_resolve` (#45).
|
|
18
|
+
|
|
5
19
|
## 0.2.2
|
|
6
20
|
|
|
7
21
|
### Fixed
|
package/README.md
CHANGED
|
@@ -87,6 +87,10 @@ Requires a GitHub token in the DSH credential service. Store the token under a n
|
|
|
87
87
|
| `gh_release_create` | Create a release for a tag, optionally on a specific commit. |
|
|
88
88
|
| `gh_release_edit` | Edit title, notes, draft/prerelease, and **which release is latest**. |
|
|
89
89
|
| `gh_release_delete` | Delete a release (requires `confirm: true`). |
|
|
90
|
+
| `gh_release_asset_list` | List release assets with sizes and download counts (read-only). |
|
|
91
|
+
| `gh_release_asset_upload` | Upload a file asset (.tgz, binary) to a release (requires `confirm: true`). |
|
|
92
|
+
| `gh_release_asset_delete` | Delete a release asset by ID (requires `confirm: true`). |
|
|
93
|
+
| `gh_release_notes_generate` | Generate release notes for a tag from merged PRs and commits (read-only). |
|
|
90
94
|
|
|
91
95
|
`gh_release_edit` with `makeLatest: true` is the step that keeps the repository badge on
|
|
92
96
|
the current version: creating older releases afterwards otherwise moves “Latest” back.
|
|
@@ -119,6 +123,9 @@ a single confirmation cannot make those safe.
|
|
|
119
123
|
| `review_post` | Publish a review as one summary comment, or as line-anchored inline comments. |
|
|
120
124
|
| `gh_checks` | Check runs, legacy statuses and one rollup verdict for a commit. |
|
|
121
125
|
| `ci_run` | One-shot review of a PR with a rule-based verdict. |
|
|
126
|
+
| `pr_review_threads` | List review discussion threads with comments and resolution status (read-only). |
|
|
127
|
+
| `pr_thread_reply` | Reply to a review discussion thread by threadId or commentId (requires `confirm: true`). |
|
|
128
|
+
| `pr_thread_resolve` | Mark a review discussion thread resolved or unresolved (requires `confirm: true`). |
|
|
122
129
|
|
|
123
130
|
### Mirror publication
|
|
124
131
|
|
|
@@ -140,6 +147,8 @@ published because it is part of the package.
|
|
|
140
147
|
| `gh_run_jobs` | Jobs with their steps, and the steps that failed — usually enough to diagnose a failure. |
|
|
141
148
|
| `gh_run_rerun`, `gh_run_cancel` | Re-run all or only failed jobs; cancel an in-progress run (needs `confirm: true`). |
|
|
142
149
|
| `gh_run_logs` | Returns the logs archive URL: GitHub answers with a redirect to a zip, which is not pulled into the conversation. |
|
|
150
|
+
| `gh_run_dispatch` | Trigger a workflow_dispatch event with inputs (requires `confirm: true`). |
|
|
151
|
+
| `gh_run_log_summary` | Compact failed step log summary with stripped ANSI codes and masked secrets (read-only). |
|
|
143
152
|
|
|
144
153
|
### Repository settings
|
|
145
154
|
|
package/README.ru.md
CHANGED
|
@@ -87,6 +87,10 @@ dsh plugin --profile web add @goodandready/dsh-github-ops
|
|
|
87
87
|
| `gh_release_create` | Создание релиза для тега, при необходимости на конкретном коммите. |
|
|
88
88
|
| `gh_release_edit` | Правка заголовка, заметок, draft/prerelease и **того, какой релиз считается latest**. |
|
|
89
89
|
| `gh_release_delete` | Удаление релиза (нужно `confirm: true`). |
|
|
90
|
+
| `gh_release_asset_list` | Список прикрепленных ассетов релиза с размерами и счетчиками скачиваний (только чтение). |
|
|
91
|
+
| `gh_release_asset_upload` | Загрузка файла (.tgz, бинарника) в релиз (нужно `confirm: true`). |
|
|
92
|
+
| `gh_release_asset_delete` | Удаление ассета по его ID (нужно `confirm: true`). |
|
|
93
|
+
| `gh_release_notes_generate` | Генерация заметок к релизу по PR и коммитам (только чтение). |
|
|
90
94
|
|
|
91
95
|
`gh_release_edit` с `makeLatest: true` — тот шаг, который держит бейдж репозитория на
|
|
92
96
|
актуальной версии: если после этого создать релиз старой версии, «Latest» уедет назад.
|
|
@@ -119,6 +123,9 @@ dsh plugin --profile web add @goodandready/dsh-github-ops
|
|
|
119
123
|
| `review_post` | Публикация обзора: сводный комментарий или построчные inline-замечания. |
|
|
120
124
|
| `gh_checks` | Check-runs, статусы коммита и единый вердикт. |
|
|
121
125
|
| `ci_run` | Одноразовый обзор PR с вердиктом по правилам. |
|
|
126
|
+
| `pr_review_threads` | Список тредов обсуждений ревью со статусом разрешения (только чтение). |
|
|
127
|
+
| `pr_thread_reply` | Ответ в тред обсуждения по threadId или commentId (нужно `confirm: true`). |
|
|
128
|
+
| `pr_thread_resolve` | Пометка треда ревью разрешенным или повторное открытие (нужно `confirm: true`). |
|
|
122
129
|
|
|
123
130
|
### Публикация зеркала
|
|
124
131
|
|
|
@@ -140,6 +147,8 @@ Allowlist — состав `package.json → files` плюс `.gitignore`, `LICE
|
|
|
140
147
|
| `gh_run_jobs` | Джобы с шагами и отдельно — упавшие шаги; обычно этого достаточно для диагноза. |
|
|
141
148
|
| `gh_run_rerun`, `gh_run_cancel` | Перезапуск всех или только упавших джоб; отмена идущего запуска (нужно `confirm: true`). |
|
|
142
149
|
| `gh_run_logs` | Возвращает адрес архива логов: GitHub отвечает редиректом на zip, который не тянется в разговор. |
|
|
150
|
+
| `gh_run_dispatch` | Запуск workflow_dispatch с пользовательскими параметрами inputs (нужно `confirm: true`). |
|
|
151
|
+
| `gh_run_log_summary` | Компактная выжимка логов упавшего шага с очисткой ANSI и маскированием токенов (только чтение). |
|
|
143
152
|
|
|
144
153
|
### Настройки репозитория
|
|
145
154
|
|
package/README.zh.md
CHANGED
|
@@ -84,6 +84,10 @@ dsh plugin --profile web add @goodandready/dsh-github-ops
|
|
|
84
84
|
| `gh_release_create` | 为 tag 创建 release,可指定 commit。 |
|
|
85
85
|
| `gh_release_edit` | 修改标题、说明、draft/prerelease,以及**哪个 release 是 latest**。 |
|
|
86
86
|
| `gh_release_delete` | 删除 release(需要 `confirm: true`)。 |
|
|
87
|
+
| `gh_release_asset_list` | 列出 release 关联的资源附件(大小与下载量)(只读)。 |
|
|
88
|
+
| `gh_release_asset_upload` | 上传文件附件(.tgz、二进制等)至已有 release(需要 `confirm: true`)。 |
|
|
89
|
+
| `gh_release_asset_delete` | 按资源 ID 删除 release 附件(需要 `confirm: true`)。 |
|
|
90
|
+
| `gh_release_notes_generate` | 根据 PR 与提交自动生成 release notes(只读)。 |
|
|
87
91
|
|
|
88
92
|
`gh_release_edit` 配合 `makeLatest: true` 是保持仓库徽章指向当前版本的关键一步:之后再创建
|
|
89
93
|
旧版本的 release 会把 “Latest” 移回旧版本。
|
|
@@ -115,6 +119,9 @@ dsh plugin --profile web add @goodandready/dsh-github-ops
|
|
|
115
119
|
| `review_post` | 发布评审:一条汇总评论,或按行内联评论。 |
|
|
116
120
|
| `gh_checks` | 某个提交的 check runs、旧式状态与统一结论。 |
|
|
117
121
|
| `ci_run` | 对 PR 做一次性评审并给出基于规则的结论。 |
|
|
122
|
+
| `pr_review_threads` | 列出代码审查讨论主题及其评论与解决状态(只读)。 |
|
|
123
|
+
| `pr_thread_reply` | 按 threadId 或 commentId 回复审查主题评论(需要 `confirm: true`)。 |
|
|
124
|
+
| `pr_thread_resolve` | 标记代码审查主题为已解决或重新打开(需要 `confirm: true`)。 |
|
|
118
125
|
|
|
119
126
|
### 镜像发布
|
|
120
127
|
|
|
@@ -135,6 +142,8 @@ dsh plugin --profile web add @goodandready/dsh-github-ops
|
|
|
135
142
|
| `gh_run_jobs` | 作业及其步骤,并单独列出失败的步骤——通常足以定位失败原因。 |
|
|
136
143
|
| `gh_run_rerun`、`gh_run_cancel` | 重跑全部或仅失败作业;取消进行中的运行(需要 `confirm: true`)。 |
|
|
137
144
|
| `gh_run_logs` | 返回日志压缩包地址:GitHub 以 302 指向 zip,不会把二进制拉进对话。 |
|
|
145
|
+
| `gh_run_dispatch` | 触发附带输入参数的 workflow_dispatch 工作流(需要 `confirm: true`)。 |
|
|
146
|
+
| `gh_run_log_summary` | 提取失败步骤的精简日志摘要(去除 ANSI 代码并脱敏令牌)(只读)。 |
|
|
138
147
|
|
|
139
148
|
### 仓库设置
|
|
140
149
|
|
package/lib/auth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
1
2
|
// Access resolution across the sources a GitHub user actually has.
|
|
2
3
|
//
|
|
3
4
|
// Order (tokenSource: "auto"): the DSH credentials service, then the environment variable of
|
|
@@ -69,3 +70,66 @@ export async function resolveAccess({ credentials, tokenEnv, tokenSource = 'auto
|
|
|
69
70
|
}
|
|
70
71
|
return { value: '', source: '', guidance: NO_ACCESS_GUIDANCE }
|
|
71
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Deterministic non-reversible token fingerprint for client cache keys:
|
|
75
|
+
* differentiates tokens of identical length without leaking token content into logs or keys.
|
|
76
|
+
*/
|
|
77
|
+
export function tokenFingerprint(value) {
|
|
78
|
+
if (!value || typeof value !== 'string') return ''
|
|
79
|
+
return createHash('sha256').update(value).digest('hex').slice(0, 16)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Cache key for shared GitHub client instances.
|
|
84
|
+
* Incorporates the token fingerprint (not length) so rotation between equal-length tokens
|
|
85
|
+
* creates a new client and uses the new Bearer immediately.
|
|
86
|
+
*/
|
|
87
|
+
export function clientCacheKey(access, cfg = {}) {
|
|
88
|
+
return [
|
|
89
|
+
access?.source || '',
|
|
90
|
+
tokenFingerprint(access?.value),
|
|
91
|
+
cfg?.baseUrl || '',
|
|
92
|
+
cfg?.timeoutMs || '',
|
|
93
|
+
cfg?.cacheTtlMs || '',
|
|
94
|
+
cfg?.maxRetries ?? '',
|
|
95
|
+
].join('|')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Manages the shared GitHub client instance:
|
|
100
|
+
* caches the client while the key matches; recreates it when the token or config changes.
|
|
101
|
+
*/
|
|
102
|
+
export function createClientHolder({ createClient, signalProvider } = {}) {
|
|
103
|
+
let sharedClient = null
|
|
104
|
+
let sharedKey = ''
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
get(access, cfg = {}) {
|
|
108
|
+
if (!access || !access.value) {
|
|
109
|
+
throw new Error(`GitHub access is not configured: ${access?.guidance || NO_ACCESS_GUIDANCE}`)
|
|
110
|
+
}
|
|
111
|
+
const key = clientCacheKey(access, cfg)
|
|
112
|
+
if (!sharedClient || sharedKey !== key) {
|
|
113
|
+
if (typeof createClient !== 'function') {
|
|
114
|
+
throw new Error('createClient factory must be a function')
|
|
115
|
+
}
|
|
116
|
+
sharedClient = createClient({
|
|
117
|
+
token: access.value,
|
|
118
|
+
baseUrl: cfg.baseUrl || 'https://api.github.com',
|
|
119
|
+
timeoutMs: Number(cfg.timeoutMs) || 30000,
|
|
120
|
+
cacheTtlMs: Number.isFinite(Number(cfg.cacheTtlMs)) ? Number(cfg.cacheTtlMs) : 60000,
|
|
121
|
+
maxRetries: Number.isFinite(Number(cfg.maxRetries)) ? Number(cfg.maxRetries) : 2,
|
|
122
|
+
signalProvider,
|
|
123
|
+
})
|
|
124
|
+
sharedKey = key
|
|
125
|
+
}
|
|
126
|
+
return sharedClient
|
|
127
|
+
},
|
|
128
|
+
currentKey: () => sharedKey,
|
|
129
|
+
currentClient: () => sharedClient,
|
|
130
|
+
reset: () => {
|
|
131
|
+
sharedClient = null
|
|
132
|
+
sharedKey = ''
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
}
|
package/lib/github.js
CHANGED
|
@@ -134,15 +134,16 @@ export function createGitHubClient({
|
|
|
134
134
|
cache.set(key, { value, expiresAt: Date.now() + cacheTtlMs })
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
async function attemptOnce({ method, path, query, body, url, accept, redirect, signal }) {
|
|
137
|
+
async function attemptOnce({ method, path, query, body, url, accept, redirect, signal, headers: extraHeaders, rawText }) {
|
|
138
138
|
const target = url || buildUrl(baseUrl, path, query)
|
|
139
139
|
const headers = {
|
|
140
140
|
accept,
|
|
141
141
|
'x-github-api-version': apiVersion,
|
|
142
142
|
'user-agent': userAgent,
|
|
143
|
+
...(extraHeaders || {}),
|
|
143
144
|
}
|
|
144
145
|
if (token) headers.authorization = `Bearer ${token}`
|
|
145
|
-
if (body !== undefined) headers['content-type'] = 'application/json'
|
|
146
|
+
if (body !== undefined && !headers['content-type']) headers['content-type'] = 'application/json'
|
|
146
147
|
|
|
147
148
|
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
148
149
|
const timer = controller && timeoutMs > 0
|
|
@@ -161,10 +162,17 @@ export function createGitHubClient({
|
|
|
161
162
|
|
|
162
163
|
let response
|
|
163
164
|
try {
|
|
165
|
+
const requestBody = (() => {
|
|
166
|
+
if (body === undefined || body === null) return undefined
|
|
167
|
+
if (typeof body === 'string' || (typeof Buffer !== 'undefined' && Buffer.isBuffer(body)) || body instanceof Uint8Array) {
|
|
168
|
+
return body
|
|
169
|
+
}
|
|
170
|
+
return JSON.stringify(body)
|
|
171
|
+
})()
|
|
164
172
|
response = await fetchImpl(target, {
|
|
165
173
|
method,
|
|
166
174
|
headers,
|
|
167
|
-
body:
|
|
175
|
+
body: requestBody,
|
|
168
176
|
signal: combined || undefined,
|
|
169
177
|
...(redirect ? { redirect } : {}),
|
|
170
178
|
})
|
|
@@ -191,16 +199,20 @@ export function createGitHubClient({
|
|
|
191
199
|
const text = typeof response.text === 'function' ? await response.text() : ''
|
|
192
200
|
let payload = null
|
|
193
201
|
if (text) {
|
|
194
|
-
|
|
195
|
-
payload =
|
|
196
|
-
}
|
|
197
|
-
|
|
202
|
+
if (rawText) {
|
|
203
|
+
payload = text
|
|
204
|
+
} else {
|
|
205
|
+
try {
|
|
206
|
+
payload = JSON.parse(text)
|
|
207
|
+
} catch {
|
|
208
|
+
payload = { raw: text.slice(0, 2000) }
|
|
209
|
+
}
|
|
198
210
|
}
|
|
199
211
|
}
|
|
200
212
|
return { response, payload, rateLimit }
|
|
201
213
|
}
|
|
202
214
|
|
|
203
|
-
async function call({ method = 'GET', path, query, body, url, accept = 'application/vnd.github+json', redirect, signal }) {
|
|
215
|
+
async function call({ method = 'GET', path, query, body, url, accept = 'application/vnd.github+json', redirect, signal, headers, rawText }) {
|
|
204
216
|
// The signal may come from the caller's turn: the shared client asks the provider, so a
|
|
205
217
|
// cancelled turn aborts the request in flight instead of waiting for the timeout.
|
|
206
218
|
const turnSignal = signal || (typeof signalProvider === 'function' ? signalProvider() : undefined)
|
|
@@ -218,7 +230,7 @@ export function createGitHubClient({
|
|
|
218
230
|
for (let attempt = 1; attempt <= attemptsAllowed; attempt += 1) {
|
|
219
231
|
let result
|
|
220
232
|
try {
|
|
221
|
-
result = await attemptOnce({ method: upper, path, query, body, url, accept, redirect, signal: turnSignal })
|
|
233
|
+
result = await attemptOnce({ method: upper, path, query, body, url, accept, redirect, signal: turnSignal, headers, rawText })
|
|
222
234
|
} catch (err) {
|
|
223
235
|
lastError = err
|
|
224
236
|
if (!retryable || attempt >= attemptsAllowed) {
|
|
@@ -302,6 +314,9 @@ export function createGitHubClient({
|
|
|
302
314
|
* Resolve an endpoint that answers with a redirect instead of JSON (the workflow
|
|
303
315
|
* logs archive is a 302 to a zip). Nothing is downloaded: the caller gets the URL.
|
|
304
316
|
*/
|
|
317
|
+
baseUrl,
|
|
318
|
+
call,
|
|
319
|
+
getRaw: (path, opts = {}) => call({ ...opts, method: 'GET', path, rawText: true }).then((r) => r.data),
|
|
305
320
|
async getRedirect(path, { query } = {}) {
|
|
306
321
|
const target = buildUrl(baseUrl, path, query)
|
|
307
322
|
const headers = {
|
package/lib/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
15
15
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
16
16
|
|
|
17
17
|
import { createGitHubClient, parseRepoSpec, describeError, GitHubError } from './github.js'
|
|
18
|
-
import { resolveAccess, NO_ACCESS_GUIDANCE } from './auth.js'
|
|
18
|
+
import { resolveAccess, NO_ACCESS_GUIDANCE, createClientHolder } from './auth.js'
|
|
19
19
|
import { resolveRepoContext } from './repo-context.js'
|
|
20
20
|
import { presentationFor } from './present.js'
|
|
21
21
|
import { createRecords, startReviewJob } from './jobs.js'
|
|
@@ -233,39 +233,20 @@ export function apply(ctx, rawConfig) {
|
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
// One client per configuration: the read cache lives in it, and the signal for the
|
|
236
|
-
// current tool call arrives through the async context.
|
|
237
|
-
|
|
238
|
-
|
|
236
|
+
// current tool call arrives through the async context. Token rotation immediately recreates
|
|
237
|
+
// the client even when the new token has the exact same length.
|
|
238
|
+
const clientHolder = createClientHolder({
|
|
239
|
+
createClient: createGitHubClient,
|
|
240
|
+
signalProvider: () => {
|
|
241
|
+
const store = callContext.getStore()
|
|
242
|
+
return store && store.signal ? store.signal : undefined
|
|
243
|
+
},
|
|
244
|
+
})
|
|
239
245
|
|
|
240
246
|
async function client() {
|
|
241
247
|
const cfg = liveConfig()
|
|
242
248
|
const access = await resolveToken()
|
|
243
|
-
|
|
244
|
-
throw new Error(`GitHub access is not configured: ${access.guidance || NO_ACCESS_GUIDANCE}`)
|
|
245
|
-
}
|
|
246
|
-
const key = [
|
|
247
|
-
access.source,
|
|
248
|
-
access.value.length,
|
|
249
|
-
cfg.baseUrl || '',
|
|
250
|
-
cfg.timeoutMs || '',
|
|
251
|
-
cfg.cacheTtlMs || '',
|
|
252
|
-
cfg.maxRetries ?? '',
|
|
253
|
-
].join('|')
|
|
254
|
-
if (!sharedClient || sharedKey !== key) {
|
|
255
|
-
sharedClient = createGitHubClient({
|
|
256
|
-
token: access.value,
|
|
257
|
-
baseUrl: cfg.baseUrl || 'https://api.github.com',
|
|
258
|
-
timeoutMs: Number(cfg.timeoutMs) || 30000,
|
|
259
|
-
cacheTtlMs: Number.isFinite(Number(cfg.cacheTtlMs)) ? Number(cfg.cacheTtlMs) : 60000,
|
|
260
|
-
maxRetries: Number.isFinite(Number(cfg.maxRetries)) ? Number(cfg.maxRetries) : 2,
|
|
261
|
-
signalProvider: () => {
|
|
262
|
-
const store = callContext.getStore()
|
|
263
|
-
return store && store.signal ? store.signal : undefined
|
|
264
|
-
},
|
|
265
|
-
})
|
|
266
|
-
sharedKey = key
|
|
267
|
-
}
|
|
268
|
-
return sharedClient
|
|
249
|
+
return clientHolder.get(access, cfg)
|
|
269
250
|
}
|
|
270
251
|
|
|
271
252
|
/**
|
|
@@ -304,7 +285,7 @@ export function apply(ctx, rawConfig) {
|
|
|
304
285
|
const skippedTools = []
|
|
305
286
|
/** Every registered tool, so gh_help never drifts from reality. */
|
|
306
287
|
const catalog = []
|
|
307
|
-
const WRITE_PREFIXES = ['pr_', 'issue_', 'review_post', 'gh_api', 'gh_run_rerun', 'gh_run_cancel', 'gh_mirror_publish', 'gh_release_', 'gh_tag_', 'gh_repo_create', 'gh_repo_edit', 'gh_secret_', 'gh_variable_', 'gh_ruleset_', 'gh_branch_protection_set', 'gh_branch_protection_delete', 'gh_push_']
|
|
288
|
+
const WRITE_PREFIXES = ['pr_', 'issue_', 'review_post', 'gh_api', 'gh_run_rerun', 'gh_run_cancel', 'gh_run_dispatch', 'gh_mirror_publish', 'gh_release_', 'gh_tag_', 'gh_repo_create', 'gh_repo_edit', 'gh_secret_', 'gh_variable_', 'gh_ruleset_', 'gh_branch_protection_set', 'gh_branch_protection_delete', 'gh_push_']
|
|
308
289
|
|
|
309
290
|
function tool(toolName, description, parameters, run) {
|
|
310
291
|
const definition = defineTool({
|
package/lib/tools/actions.js
CHANGED
|
@@ -105,3 +105,81 @@ export async function getRunLogsUrl(client, { owner, repo, runId }) {
|
|
|
105
105
|
: `GitHub answered HTTP ${status} without a download location — the logs may have expired.`,
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
+
|
|
109
|
+
export async function dispatchWorkflow(client, { owner, repo, workflow, ref, inputs }) {
|
|
110
|
+
ensureRepo({ owner, repo })
|
|
111
|
+
if (!workflow) throw new Error('workflow is required (file name like "release.yml" or numeric id)')
|
|
112
|
+
if (!ref) throw new Error('ref is required (branch or tag name like "main")')
|
|
113
|
+
const payload = { ref: String(ref) }
|
|
114
|
+
if (inputs && typeof inputs === 'object') {
|
|
115
|
+
const formatted = {}
|
|
116
|
+
for (const [k, v] of Object.entries(inputs)) {
|
|
117
|
+
if (v !== undefined && v !== null) {
|
|
118
|
+
formatted[k] = typeof v === 'string' ? v : String(v)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
payload.inputs = formatted
|
|
122
|
+
}
|
|
123
|
+
const route = `/repos/${owner}/${repo}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`
|
|
124
|
+
await client.post(route, payload)
|
|
125
|
+
return {
|
|
126
|
+
dispatched: true,
|
|
127
|
+
workflow: String(workflow),
|
|
128
|
+
ref: String(ref),
|
|
129
|
+
inputs: payload.inputs || {},
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function cleanLogContent(rawText, maxLines = 100) {
|
|
134
|
+
if (!rawText || typeof rawText !== 'string') return []
|
|
135
|
+
const stripped = rawText.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '').replace(/\r/g, '')
|
|
136
|
+
const lines = stripped.split('\n')
|
|
137
|
+
const masked = lines.map((line) => {
|
|
138
|
+
return line
|
|
139
|
+
.replace(/(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}/g, '[REDACTED_TOKEN]')
|
|
140
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [REDACTED_TOKEN]')
|
|
141
|
+
})
|
|
142
|
+
const cap = Math.max(1, Math.min(Number(maxLines) || 100, 250))
|
|
143
|
+
return masked.slice(-cap)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function getJobLogSummary(client, { owner, repo, runId, jobId, maxLines = 100 }) {
|
|
147
|
+
ensureRepo({ owner, repo })
|
|
148
|
+
let targetJobId = jobId
|
|
149
|
+
let targetJobName = ''
|
|
150
|
+
let failedStep = null
|
|
151
|
+
|
|
152
|
+
if (!targetJobId) {
|
|
153
|
+
if (!runId) throw new Error('either runId or jobId is required')
|
|
154
|
+
const { jobs } = await listRunJobs(client, { owner, repo, runId })
|
|
155
|
+
const failedJob = jobs.find((j) => j.conclusion === 'failure') || jobs[0]
|
|
156
|
+
if (!failedJob) {
|
|
157
|
+
return { note: `No jobs found for run ${runId}`, lines: [] }
|
|
158
|
+
}
|
|
159
|
+
targetJobId = failedJob.id
|
|
160
|
+
targetJobName = failedJob.name
|
|
161
|
+
failedStep = (failedJob.failedSteps && failedJob.failedSteps[0]) || null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let logText = ''
|
|
165
|
+
try {
|
|
166
|
+
const res = await client.getRaw(`/repos/${owner}/${repo}/actions/jobs/${targetJobId}/logs`)
|
|
167
|
+
logText = typeof res === 'string' ? res : (res && res.data ? res.data : String(res || ''))
|
|
168
|
+
} catch (err) {
|
|
169
|
+
return {
|
|
170
|
+
jobId: targetJobId,
|
|
171
|
+
jobName: targetJobName,
|
|
172
|
+
error: `Could not retrieve logs: ${err && err.message}`,
|
|
173
|
+
lines: [],
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const lines = cleanLogContent(logText, maxLines)
|
|
178
|
+
return {
|
|
179
|
+
jobId: targetJobId,
|
|
180
|
+
jobName: targetJobName,
|
|
181
|
+
failedStep,
|
|
182
|
+
lineCount: lines.length,
|
|
183
|
+
lines,
|
|
184
|
+
}
|
|
185
|
+
}
|
package/lib/tools/pulls.js
CHANGED
|
@@ -205,3 +205,132 @@ export async function ciRun(client, { owner, repo, number, maxDiffChars, rules }
|
|
|
205
205
|
].filter(Boolean).join('\n'),
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
|
+
|
|
209
|
+
export async function listReviewThreads(client, { owner, repo, number, unresolvedOnly = false }) {
|
|
210
|
+
ensureRepo({ owner, repo })
|
|
211
|
+
if (!number) throw new Error('number is required')
|
|
212
|
+
const query = `query($owner: String!, $repo: String!, $number: Int!) {
|
|
213
|
+
repository(owner: $owner, name: $repo) {
|
|
214
|
+
pullRequest(number: $number) {
|
|
215
|
+
reviewThreads(first: 50) {
|
|
216
|
+
nodes {
|
|
217
|
+
id
|
|
218
|
+
isResolved
|
|
219
|
+
isOutdated
|
|
220
|
+
path
|
|
221
|
+
line
|
|
222
|
+
diffSide
|
|
223
|
+
resolvedBy { login }
|
|
224
|
+
comments(first: 20) {
|
|
225
|
+
nodes {
|
|
226
|
+
id
|
|
227
|
+
databaseId
|
|
228
|
+
body
|
|
229
|
+
author { login }
|
|
230
|
+
createdAt
|
|
231
|
+
url
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}`
|
|
239
|
+
const data = await client.graphql(query, { owner, repo, number: Number(number) })
|
|
240
|
+
const pull = data && data.repository && data.repository.pullRequest
|
|
241
|
+
if (!pull) throw new Error(`pull request #${number} not found in ${owner}/${repo}`)
|
|
242
|
+
const rawNodes = (pull.reviewThreads && pull.reviewThreads.nodes) || []
|
|
243
|
+
let threads = rawNodes.map((t) => ({
|
|
244
|
+
id: t.id,
|
|
245
|
+
isResolved: Boolean(t.isResolved),
|
|
246
|
+
isOutdated: Boolean(t.isOutdated),
|
|
247
|
+
path: t.path || '',
|
|
248
|
+
line: t.line || null,
|
|
249
|
+
diffSide: t.diffSide || 'RIGHT',
|
|
250
|
+
resolvedBy: t.resolvedBy && t.resolvedBy.login ? t.resolvedBy.login : null,
|
|
251
|
+
comments: ((t.comments && t.comments.nodes) || []).map((c) => ({
|
|
252
|
+
id: c.id,
|
|
253
|
+
databaseId: c.databaseId,
|
|
254
|
+
author: (c.author && c.author.login) || 'unknown',
|
|
255
|
+
body: c.body || '',
|
|
256
|
+
createdAt: c.createdAt,
|
|
257
|
+
url: c.url || '',
|
|
258
|
+
})),
|
|
259
|
+
}))
|
|
260
|
+
if (unresolvedOnly) {
|
|
261
|
+
threads = threads.filter((t) => !t.isResolved)
|
|
262
|
+
}
|
|
263
|
+
return { threads, count: threads.length, number: Number(number) }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function replyReviewThread(client, { owner, repo, number, threadId, commentId, body }) {
|
|
267
|
+
ensureRepo({ owner, repo })
|
|
268
|
+
if (!body || typeof body !== 'string' || !body.trim()) {
|
|
269
|
+
throw new Error('body is required')
|
|
270
|
+
}
|
|
271
|
+
if (!threadId && !commentId) {
|
|
272
|
+
throw new Error('either threadId or commentId is required to reply to a review thread')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (threadId) {
|
|
276
|
+
const mutation = `mutation($threadId: ID!, $body: String!) {
|
|
277
|
+
addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) {
|
|
278
|
+
comment {
|
|
279
|
+
id
|
|
280
|
+
databaseId
|
|
281
|
+
body
|
|
282
|
+
createdAt
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}`
|
|
286
|
+
const data = await client.graphql(mutation, { threadId, body })
|
|
287
|
+
const comment = data?.addPullRequestReviewThreadReply?.comment
|
|
288
|
+
return {
|
|
289
|
+
replied: true,
|
|
290
|
+
threadId,
|
|
291
|
+
commentId: comment?.databaseId || null,
|
|
292
|
+
body,
|
|
293
|
+
createdAt: comment?.createdAt || null,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!number) throw new Error('pull number is required when replying via commentId')
|
|
298
|
+
const { data } = await client.post(`/repos/${owner}/${repo}/pulls/${number}/comments`, {
|
|
299
|
+
body,
|
|
300
|
+
in_reply_to: Number(commentId),
|
|
301
|
+
})
|
|
302
|
+
return {
|
|
303
|
+
replied: true,
|
|
304
|
+
number: Number(number),
|
|
305
|
+
commentId: data?.id || Number(commentId),
|
|
306
|
+
body,
|
|
307
|
+
createdAt: data?.created_at || null,
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function resolveReviewThread(client, { owner, repo, threadId, resolve = true }) {
|
|
312
|
+
ensureRepo({ owner, repo })
|
|
313
|
+
if (!threadId) throw new Error('threadId is required (GraphQL ID of the review thread)')
|
|
314
|
+
const shouldResolve = resolve !== false
|
|
315
|
+
const mutation = shouldResolve
|
|
316
|
+
? `mutation($threadId: ID!) {
|
|
317
|
+
resolveReviewThread(input: { threadId: $threadId }) {
|
|
318
|
+
thread { id isResolved }
|
|
319
|
+
}
|
|
320
|
+
}`
|
|
321
|
+
: `mutation($threadId: ID!) {
|
|
322
|
+
unresolveReviewThread(input: { threadId: $threadId }) {
|
|
323
|
+
thread { id isResolved }
|
|
324
|
+
}
|
|
325
|
+
}`
|
|
326
|
+
|
|
327
|
+
const data = await client.graphql(mutation, { threadId })
|
|
328
|
+
const thread = shouldResolve
|
|
329
|
+
? data?.resolveReviewThread?.thread
|
|
330
|
+
: data?.unresolveReviewThread?.thread
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
threadId,
|
|
334
|
+
isResolved: thread ? Boolean(thread.isResolved) : shouldResolve,
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -19,6 +19,9 @@ import {
|
|
|
19
19
|
postReview,
|
|
20
20
|
getChecks,
|
|
21
21
|
ciRun,
|
|
22
|
+
listReviewThreads,
|
|
23
|
+
replyReviewThread,
|
|
24
|
+
resolveReviewThread,
|
|
22
25
|
} from './pulls.js'
|
|
23
26
|
|
|
24
27
|
const REPOSITORY = {
|
|
@@ -230,4 +233,63 @@ export function registerCollabTools({ tool, asRepo, client, liveConfig, reviewRu
|
|
|
230
233
|
return ciRun(await client(), { owner, repo, number: args.number, maxDiffChars: args.maxDiffChars, rules: rules() })
|
|
231
234
|
},
|
|
232
235
|
)
|
|
236
|
+
|
|
237
|
+
tool(
|
|
238
|
+
'pr_review_threads',
|
|
239
|
+
'List review discussion threads of a pull request with their comments and resolved status. Read-only.',
|
|
240
|
+
repoParams({
|
|
241
|
+
number: { type: 'number', description: 'Pull request number.' },
|
|
242
|
+
unresolvedOnly: { type: 'boolean', description: 'If true, returns only unresolved threads (default false).' },
|
|
243
|
+
}),
|
|
244
|
+
async (args) => {
|
|
245
|
+
const { owner, repo } = await asRepo(args, liveConfig())
|
|
246
|
+
return listReviewThreads(await client(), {
|
|
247
|
+
owner, repo,
|
|
248
|
+
number: args.number || args.pullNumber,
|
|
249
|
+
unresolvedOnly: Boolean(args.unresolvedOnly),
|
|
250
|
+
})
|
|
251
|
+
},
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
tool(
|
|
255
|
+
'pr_thread_reply',
|
|
256
|
+
'Reply to a review discussion thread in a pull request (by threadId or commentId). Requires confirm: true.',
|
|
257
|
+
repoParams({
|
|
258
|
+
number: { type: 'number', description: 'Pull request number (required when replying by commentId).' },
|
|
259
|
+
threadId: { type: 'string', description: 'GraphQL ID of the review thread (from pr_review_threads).' },
|
|
260
|
+
commentId: { type: 'number', description: 'Database ID of the comment being replied to.' },
|
|
261
|
+
body: { type: 'string', description: 'Reply comment body.' },
|
|
262
|
+
confirm: { type: 'boolean', description: 'Must be true to post reply.' },
|
|
263
|
+
}),
|
|
264
|
+
async (args) => {
|
|
265
|
+
if (args.confirm !== true) throw new Error('replying to a review thread requires confirm: true')
|
|
266
|
+
const { owner, repo } = await asRepo(args, liveConfig())
|
|
267
|
+
return replyReviewThread(await client(), {
|
|
268
|
+
owner, repo,
|
|
269
|
+
number: args.number || args.pullNumber,
|
|
270
|
+
threadId: args.threadId,
|
|
271
|
+
commentId: args.commentId,
|
|
272
|
+
body: args.body,
|
|
273
|
+
})
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
tool(
|
|
278
|
+
'pr_thread_resolve',
|
|
279
|
+
'Resolve or unresolve a review discussion thread in a pull request. Requires confirm: true.',
|
|
280
|
+
repoParams({
|
|
281
|
+
threadId: { type: 'string', description: 'GraphQL ID of the review thread (from pr_review_threads).' },
|
|
282
|
+
resolve: { type: 'boolean', description: 'true to resolve (default), false to unresolve.' },
|
|
283
|
+
confirm: { type: 'boolean', description: 'Must be true to resolve thread.' },
|
|
284
|
+
}),
|
|
285
|
+
async (args) => {
|
|
286
|
+
if (args.confirm !== true) throw new Error('resolving a review thread requires confirm: true')
|
|
287
|
+
const { owner, repo } = await asRepo(args, liveConfig())
|
|
288
|
+
return resolveReviewThread(await client(), {
|
|
289
|
+
owner, repo,
|
|
290
|
+
threadId: args.threadId,
|
|
291
|
+
resolve: args.resolve !== false,
|
|
292
|
+
})
|
|
293
|
+
},
|
|
294
|
+
)
|
|
233
295
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Everything here takes its dependencies as arguments, so the host half stays thin
|
|
5
5
|
// and the definitions are reusable in tests.
|
|
6
6
|
|
|
7
|
-
import { listReleases, getRelease, createRelease, editRelease, deleteRelease } from './releases.js'
|
|
7
|
+
import { listReleases, getRelease, createRelease, editRelease, deleteRelease, listReleaseAssets, uploadReleaseAsset, deleteReleaseAsset, generateReleaseNotes } from './releases.js'
|
|
8
8
|
import { listTags, createTag, deleteTag } from './tags.js'
|
|
9
9
|
import { callApi } from './api.js'
|
|
10
10
|
import { getRepo, getFile, searchRepos, createRepo, editRepo } from './repos.js'
|
|
@@ -185,6 +185,82 @@ export function registerCoreTools({ tool, asRepo, client, liveConfig }) {
|
|
|
185
185
|
},
|
|
186
186
|
)
|
|
187
187
|
|
|
188
|
+
tool(
|
|
189
|
+
'gh_release_asset_list',
|
|
190
|
+
'List uploaded assets of a GitHub release (by tag or releaseId). Read-only.',
|
|
191
|
+
repoParams({
|
|
192
|
+
tag: { type: 'string', description: 'Release tag.' },
|
|
193
|
+
releaseId: { type: 'number', description: 'Release ID.' },
|
|
194
|
+
limit: { type: 'number', description: 'Max assets to return (default 50).' },
|
|
195
|
+
}),
|
|
196
|
+
async (args) => {
|
|
197
|
+
const { owner, repo } = await asRepo(args, liveConfig())
|
|
198
|
+
return listReleaseAssets(await client(), { owner, repo, tag: args.tag, releaseId: args.releaseId, limit: args.limit })
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
tool(
|
|
203
|
+
'gh_release_asset_upload',
|
|
204
|
+
'Upload a file asset to an existing GitHub release (by tag or releaseId). Requires confirm: true.',
|
|
205
|
+
repoParams({
|
|
206
|
+
tag: { type: 'string', description: 'Release tag.' },
|
|
207
|
+
releaseId: { type: 'number', description: 'Release ID.' },
|
|
208
|
+
filePath: { type: 'string', description: 'Local path to the file to upload.' },
|
|
209
|
+
name: { type: 'string', description: 'File name as it will appear in the release (defaults to file basename).' },
|
|
210
|
+
label: { type: 'string', description: 'Optional user-facing label description for the asset.' },
|
|
211
|
+
contentType: { type: 'string', description: 'MIME type of the asset (defaults to application/octet-stream).' },
|
|
212
|
+
confirm: { type: 'boolean', description: 'Must be true to upload.' },
|
|
213
|
+
}),
|
|
214
|
+
async (args) => {
|
|
215
|
+
if (args.confirm !== true) throw new Error('uploading a release asset requires confirm: true')
|
|
216
|
+
const { owner, repo } = await asRepo(args, liveConfig())
|
|
217
|
+
return uploadReleaseAsset(await client(), {
|
|
218
|
+
owner, repo,
|
|
219
|
+
tag: args.tag,
|
|
220
|
+
releaseId: args.releaseId,
|
|
221
|
+
filePath: args.filePath,
|
|
222
|
+
name: args.name,
|
|
223
|
+
label: args.label,
|
|
224
|
+
contentType: args.contentType,
|
|
225
|
+
})
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
tool(
|
|
230
|
+
'gh_release_asset_delete',
|
|
231
|
+
'Delete an asset from a release by assetId. Requires confirm: true.',
|
|
232
|
+
repoParams({
|
|
233
|
+
assetId: { type: 'number', description: 'ID of the asset to delete.' },
|
|
234
|
+
confirm: { type: 'boolean', description: 'Must be true to actually delete.' },
|
|
235
|
+
}),
|
|
236
|
+
async (args) => {
|
|
237
|
+
if (args.confirm !== true) throw new Error('deleting a release asset requires confirm: true')
|
|
238
|
+
const { owner, repo } = await asRepo(args, liveConfig())
|
|
239
|
+
return deleteReleaseAsset(await client(), { owner, repo, assetId: args.assetId })
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
tool(
|
|
244
|
+
'gh_release_notes_generate',
|
|
245
|
+
'Generate release notes for a release tag from merged pull requests and commits. Read-only.',
|
|
246
|
+
repoParams({
|
|
247
|
+
tag: { type: 'string', description: 'Tag name for the release (e.g. "v1.2.0").' },
|
|
248
|
+
target: { type: 'string', description: 'Target branch or commit if tag does not exist yet.' },
|
|
249
|
+
previousTag: { type: 'string', description: 'Previous tag to compare against.' },
|
|
250
|
+
configFile: { type: 'string', description: 'Path to configuration file in repo (e.g. ".github/release.yml").' },
|
|
251
|
+
}),
|
|
252
|
+
async (args) => {
|
|
253
|
+
const { owner, repo } = await asRepo(args, liveConfig())
|
|
254
|
+
return generateReleaseNotes(await client(), {
|
|
255
|
+
owner, repo,
|
|
256
|
+
tag: args.tag || args.tagName,
|
|
257
|
+
target: args.target || args.targetCommitish,
|
|
258
|
+
previousTag: args.previousTag || args.previousTagName,
|
|
259
|
+
configFile: args.configFile || args.configurationFilePath,
|
|
260
|
+
})
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
|
|
188
264
|
// ------------------------------------------------------------------------ tags
|
|
189
265
|
|
|
190
266
|
tool(
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
rerunRun,
|
|
11
11
|
cancelRun,
|
|
12
12
|
getRunLogsUrl,
|
|
13
|
+
dispatchWorkflow,
|
|
14
|
+
getJobLogSummary,
|
|
13
15
|
} from './actions.js'
|
|
14
16
|
import {
|
|
15
17
|
listVariables,
|
|
@@ -100,6 +102,36 @@ export function registerOpsTools({ tool, asRepo, client, liveConfig }) {
|
|
|
100
102
|
async (args) => withRepo(args, async ({ owner, repo }) => getRunLogsUrl(await client(), { owner, repo, runId: args.runId })),
|
|
101
103
|
)
|
|
102
104
|
|
|
105
|
+
tool(
|
|
106
|
+
'gh_run_dispatch',
|
|
107
|
+
'Trigger a workflow_dispatch event for a GitHub Actions workflow with inputs. Requires confirm: true.',
|
|
108
|
+
repoParams({
|
|
109
|
+
workflow: { type: 'string', description: 'Workflow file name (e.g. "ci.yml") or numeric ID.' },
|
|
110
|
+
ref: { type: 'string', description: 'Branch or tag ref (e.g. "main").' },
|
|
111
|
+
inputs: { type: 'object', additionalProperties: true, description: 'Input values configured in the workflow.' },
|
|
112
|
+
confirm: { type: 'boolean', description: 'Must be true to dispatch.' },
|
|
113
|
+
}),
|
|
114
|
+
async (args) => {
|
|
115
|
+
if (args.confirm !== true) throw new Error('dispatching a workflow run requires confirm: true')
|
|
116
|
+
return withRepo(args, async ({ owner, repo }) => dispatchWorkflow(await client(), {
|
|
117
|
+
owner, repo, workflow: args.workflow, ref: args.ref, inputs: args.inputs,
|
|
118
|
+
}))
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
tool(
|
|
123
|
+
'gh_run_log_summary',
|
|
124
|
+
'Get a compact summary of failed step logs for a run or job (cleans ANSI codes, strips tokens, returns last lines). Read-only.',
|
|
125
|
+
repoParams({
|
|
126
|
+
runId: { type: 'number', description: 'Run ID (if jobId omitted, inspects failed jobs in the run).' },
|
|
127
|
+
jobId: { type: 'number', description: 'Direct Job ID.' },
|
|
128
|
+
maxLines: { type: 'number', description: 'Maximum log lines to return (default 100, max 250).' },
|
|
129
|
+
}),
|
|
130
|
+
async (args) => withRepo(args, async ({ owner, repo }) => getJobLogSummary(await client(), {
|
|
131
|
+
owner, repo, runId: args.runId, jobId: args.jobId, maxLines: args.maxLines,
|
|
132
|
+
})),
|
|
133
|
+
)
|
|
134
|
+
|
|
103
135
|
// ---------------------------------------------------------------- variables
|
|
104
136
|
|
|
105
137
|
tool(
|
package/lib/tools/releases.js
CHANGED
|
@@ -111,3 +111,90 @@ export async function deleteRelease(client, { owner, repo, tag, releaseId }) {
|
|
|
111
111
|
await client.del(`/repos/${owner}/${repo}/releases/${id}`)
|
|
112
112
|
return { deleted: true, id, tag: tag || '' }
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
export function normalizeAsset(data) {
|
|
116
|
+
if (!data || typeof data !== 'object') return data
|
|
117
|
+
return {
|
|
118
|
+
id: data.id,
|
|
119
|
+
name: data.name,
|
|
120
|
+
label: data.label || '',
|
|
121
|
+
state: data.state || 'uploaded',
|
|
122
|
+
size: Number(data.size) || 0,
|
|
123
|
+
downloads: Number(data.download_count) || 0,
|
|
124
|
+
createdAt: data.created_at,
|
|
125
|
+
updatedAt: data.updated_at,
|
|
126
|
+
browserDownloadUrl: data.browser_download_url || data.url || '',
|
|
127
|
+
contentType: data.content_type || 'application/octet-stream',
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function listReleaseAssets(client, { owner, repo, tag, releaseId, limit = 50 } = {}) {
|
|
132
|
+
ensureRepo({ owner, repo })
|
|
133
|
+
const id = await resolveReleaseId(client, { owner, repo, tag, releaseId })
|
|
134
|
+
const capped = Math.max(1, Math.min(Number(limit) || 50, 100))
|
|
135
|
+
const { data, rateLimit } = await client.get(`/repos/${owner}/${repo}/releases/${id}/assets`, {
|
|
136
|
+
query: { per_page: capped },
|
|
137
|
+
})
|
|
138
|
+
const assets = (Array.isArray(data) ? data.map(normalizeAsset) : []).slice(0, capped)
|
|
139
|
+
return { assets, count: assets.length, releaseId: id, rateLimit }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function uploadReleaseAsset(client, { owner, repo, tag, releaseId, filePath, name, label, contentType }) {
|
|
143
|
+
ensureRepo({ owner, repo })
|
|
144
|
+
if (!filePath) throw new Error('filePath is required')
|
|
145
|
+
const id = await resolveReleaseId(client, { owner, repo, tag, releaseId })
|
|
146
|
+
|
|
147
|
+
const fs = await import('node:fs/promises')
|
|
148
|
+
const path = await import('node:path')
|
|
149
|
+
|
|
150
|
+
let stats
|
|
151
|
+
try {
|
|
152
|
+
stats = await fs.stat(filePath)
|
|
153
|
+
} catch (err) {
|
|
154
|
+
throw new Error(`cannot access file "${filePath}": ${err && err.message}`)
|
|
155
|
+
}
|
|
156
|
+
if (!stats.isFile()) throw new Error(`"${filePath}" is not a file`)
|
|
157
|
+
|
|
158
|
+
const fileBuffer = await fs.readFile(filePath)
|
|
159
|
+
const assetName = name || path.basename(filePath)
|
|
160
|
+
const mimeType = contentType || 'application/octet-stream'
|
|
161
|
+
|
|
162
|
+
const baseUrl = client.baseUrl || 'https://api.github.com'
|
|
163
|
+
const uploadsBase = baseUrl.includes('api.github.com') ? 'https://uploads.github.com' : baseUrl
|
|
164
|
+
const targetUrl = `${uploadsBase}/repos/${owner}/${repo}/releases/${id}/assets`
|
|
165
|
+
|
|
166
|
+
const { data } = await client.call({
|
|
167
|
+
method: 'POST',
|
|
168
|
+
url: targetUrl,
|
|
169
|
+
query: { name: assetName, ...(label ? { label } : {}) },
|
|
170
|
+
body: fileBuffer,
|
|
171
|
+
headers: {
|
|
172
|
+
'content-type': mimeType,
|
|
173
|
+
'content-length': String(fileBuffer.length),
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
return normalizeAsset(data)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function deleteReleaseAsset(client, { owner, repo, assetId }) {
|
|
180
|
+
ensureRepo({ owner, repo })
|
|
181
|
+
if (!assetId) throw new Error('assetId is required')
|
|
182
|
+
await client.del(`/repos/${owner}/${repo}/releases/assets/${assetId}`)
|
|
183
|
+
return { deleted: true, assetId: Number(assetId) }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function generateReleaseNotes(client, { owner, repo, tag, target, previousTag, configFile }) {
|
|
187
|
+
ensureRepo({ owner, repo })
|
|
188
|
+
if (!tag) throw new Error('tag is required')
|
|
189
|
+
const payload = { tag_name: tag }
|
|
190
|
+
if (target) payload.target_commitish = target
|
|
191
|
+
if (previousTag) payload.previous_tag_name = previousTag
|
|
192
|
+
if (configFile) payload.configuration_file_path = configFile
|
|
193
|
+
const { data } = await client.post(`/repos/${owner}/${repo}/releases/generate-notes`, payload)
|
|
194
|
+
return {
|
|
195
|
+
name: data && data.name ? data.name : tag,
|
|
196
|
+
body: typeof data?.body === 'string' ? data.body : '',
|
|
197
|
+
tag,
|
|
198
|
+
previousTag: previousTag || '',
|
|
199
|
+
}
|
|
200
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-github-ops",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
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",
|