@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.
- package/CHANGELOG.md +81 -0
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/README.ru.md +161 -0
- package/README.zh.md +151 -0
- package/cordis.patch.yml +3 -0
- package/lib/approval.js +168 -0
- package/lib/auth.js +59 -0
- package/lib/client.js +304 -0
- package/lib/commands.js +151 -0
- package/lib/github.js +315 -0
- package/lib/index.js +332 -0
- package/lib/jobs.js +107 -0
- package/lib/present.js +214 -0
- package/lib/repo-context.js +72 -0
- package/lib/review-rules.js +147 -0
- package/lib/tools/actions.js +107 -0
- package/lib/tools/api.js +44 -0
- package/lib/tools/issues.js +101 -0
- package/lib/tools/mirror.js +163 -0
- package/lib/tools/pulls.js +207 -0
- package/lib/tools/register-collab.js +233 -0
- package/lib/tools/register-core.js +259 -0
- package/lib/tools/register-jobs.js +57 -0
- package/lib/tools/register-mirror.js +88 -0
- package/lib/tools/register-ops.js +283 -0
- package/lib/tools/releases.js +111 -0
- package/lib/tools/repo-settings.js +207 -0
- package/lib/tools/repos.js +108 -0
- package/lib/tools/tags.js +68 -0
- package/package.json +73 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Workflow-run and repository-settings tool registrations.
|
|
2
|
+
//
|
|
3
|
+
// Reads are free; writes follow the plugin's model — an explicit confirm flag for the
|
|
4
|
+
// destructive ones, and a clear refusal where one confirmation cannot make the call safe.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
listRuns,
|
|
8
|
+
getRun,
|
|
9
|
+
listRunJobs,
|
|
10
|
+
rerunRun,
|
|
11
|
+
cancelRun,
|
|
12
|
+
getRunLogsUrl,
|
|
13
|
+
} from './actions.js'
|
|
14
|
+
import {
|
|
15
|
+
listVariables,
|
|
16
|
+
setVariable,
|
|
17
|
+
deleteVariable,
|
|
18
|
+
listSecrets,
|
|
19
|
+
setSecret,
|
|
20
|
+
deleteSecret,
|
|
21
|
+
listRulesets,
|
|
22
|
+
getRuleset,
|
|
23
|
+
applyRuleset,
|
|
24
|
+
deleteRuleset,
|
|
25
|
+
getBranchProtection,
|
|
26
|
+
setBranchProtection,
|
|
27
|
+
deleteBranchProtection,
|
|
28
|
+
} from './repo-settings.js'
|
|
29
|
+
|
|
30
|
+
const REPOSITORY = {
|
|
31
|
+
repository: { type: 'string', description: 'Repository as "owner/repo". Omit to use the default from settings.' },
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function registerOpsTools({ tool, asRepo, client, liveConfig }) {
|
|
35
|
+
const repoParams = (extra = {}) => ({ ...REPOSITORY, ...extra })
|
|
36
|
+
const withRepo = async (args, run) => {
|
|
37
|
+
const { owner, repo } = await asRepo(args, liveConfig())
|
|
38
|
+
return run({ owner, repo })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ------------------------------------------------------------- workflow runs
|
|
42
|
+
|
|
43
|
+
tool(
|
|
44
|
+
'gh_run_list',
|
|
45
|
+
'List GitHub Actions workflow runs of a repository, newest first, optionally filtered by branch, workflow file or status. Read-only.',
|
|
46
|
+
repoParams({
|
|
47
|
+
branch: { type: 'string', description: 'Filter by head branch.' },
|
|
48
|
+
workflow: { type: 'string', description: 'Workflow file name or id, e.g. "ci.yml".' },
|
|
49
|
+
status: { type: 'string', description: 'Filter by status or conclusion, e.g. completed, failure, in_progress.' },
|
|
50
|
+
limit: { type: 'number', description: 'How many runs to return (max 100, default 20).' },
|
|
51
|
+
}),
|
|
52
|
+
async (args) => withRepo(args, async ({ owner, repo }) => listRuns(await client(), {
|
|
53
|
+
owner, repo, branch: args.branch, workflow: args.workflow, status: args.status, limit: args.limit,
|
|
54
|
+
})),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
tool(
|
|
58
|
+
'gh_run_view',
|
|
59
|
+
'Read one GitHub Actions run: workflow, event, branch, status, conclusion and URL. Read-only.',
|
|
60
|
+
repoParams({ runId: { type: 'number', description: 'Run id from gh_run_list.' } }),
|
|
61
|
+
async (args) => withRepo(args, async ({ owner, repo }) => getRun(await client(), { owner, repo, runId: args.runId })),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
tool(
|
|
65
|
+
'gh_run_jobs',
|
|
66
|
+
'List the jobs of a run with their steps and the steps that failed. Read-only, and usually enough to understand a failure without downloading logs.',
|
|
67
|
+
repoParams({ runId: { type: 'number', description: 'Run id.' } }),
|
|
68
|
+
async (args) => withRepo(args, async ({ owner, repo }) => listRunJobs(await client(), { owner, repo, runId: args.runId })),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
tool(
|
|
72
|
+
'gh_run_rerun',
|
|
73
|
+
'Re-run a workflow run, or only its failed jobs (failedOnly: true).',
|
|
74
|
+
repoParams({
|
|
75
|
+
runId: { type: 'number', description: 'Run id.' },
|
|
76
|
+
failedOnly: { type: 'boolean', description: 'Re-run only the failed jobs.' },
|
|
77
|
+
}),
|
|
78
|
+
async (args) => withRepo(args, async ({ owner, repo }) => rerunRun(await client(), {
|
|
79
|
+
owner, repo, runId: args.runId, failedOnly: args.failedOnly,
|
|
80
|
+
})),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
tool(
|
|
84
|
+
'gh_run_cancel',
|
|
85
|
+
'Cancel an in-progress workflow run. Requires confirm: true.',
|
|
86
|
+
repoParams({
|
|
87
|
+
runId: { type: 'number', description: 'Run id.' },
|
|
88
|
+
confirm: { type: 'boolean', description: 'Must be true to actually cancel.' },
|
|
89
|
+
}),
|
|
90
|
+
async (args) => {
|
|
91
|
+
if (args.confirm !== true) throw new Error('refusing to cancel a run without confirm: true')
|
|
92
|
+
return withRepo(args, async ({ owner, repo }) => cancelRun(await client(), { owner, repo, runId: args.runId }))
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
tool(
|
|
97
|
+
'gh_run_logs',
|
|
98
|
+
'Return the download URL of a run logs archive. GitHub answers with a redirect to a zip, so the archive is not pulled into the conversation: the URL needs the same token and is short-lived.',
|
|
99
|
+
repoParams({ runId: { type: 'number', description: 'Run id.' } }),
|
|
100
|
+
async (args) => withRepo(args, async ({ owner, repo }) => getRunLogsUrl(await client(), { owner, repo, runId: args.runId })),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------- variables
|
|
104
|
+
|
|
105
|
+
tool(
|
|
106
|
+
'gh_variable_list',
|
|
107
|
+
'List Actions variables of a repository (or of one environment). Values are shown; they are not secret material.',
|
|
108
|
+
repoParams({ environment: { type: 'string', description: 'Environment name, when the variable lives there.' } }),
|
|
109
|
+
async (args) => withRepo(args, async ({ owner, repo }) => listVariables(await client(), {
|
|
110
|
+
owner, repo, environment: args.environment,
|
|
111
|
+
})),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
tool(
|
|
115
|
+
'gh_variable_set',
|
|
116
|
+
'Create or update an Actions variable (the tool decides between create and update).',
|
|
117
|
+
repoParams({
|
|
118
|
+
name: { type: 'string', description: 'Variable name.' },
|
|
119
|
+
value: { type: 'string', description: 'Variable value (not secret).' },
|
|
120
|
+
environment: { type: 'string', description: 'Environment name, when the variable lives there.' },
|
|
121
|
+
}),
|
|
122
|
+
async (args) => withRepo(args, async ({ owner, repo }) => setVariable(await client(), {
|
|
123
|
+
owner, repo, name: args.name, value: args.value, environment: args.environment,
|
|
124
|
+
})),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
tool(
|
|
128
|
+
'gh_variable_delete',
|
|
129
|
+
'Delete an Actions variable. Requires confirm: true.',
|
|
130
|
+
repoParams({
|
|
131
|
+
name: { type: 'string', description: 'Variable name.' },
|
|
132
|
+
environment: { type: 'string', description: 'Environment name, when the variable lives there.' },
|
|
133
|
+
confirm: { type: 'boolean', description: 'Must be true to actually delete.' },
|
|
134
|
+
}),
|
|
135
|
+
async (args) => {
|
|
136
|
+
if (args.confirm !== true) throw new Error('refusing to delete a variable without confirm: true')
|
|
137
|
+
return withRepo(args, async ({ owner, repo }) => deleteVariable(await client(), {
|
|
138
|
+
owner, repo, name: args.name, environment: args.environment,
|
|
139
|
+
}))
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
// ------------------------------------------------------------------ secrets
|
|
144
|
+
|
|
145
|
+
tool(
|
|
146
|
+
'gh_secret_list',
|
|
147
|
+
'List Actions secrets of a repository (or of one environment): names and update times. GitHub never returns the values.',
|
|
148
|
+
repoParams({ environment: { type: 'string', description: 'Environment name, when the secret lives there.' } }),
|
|
149
|
+
async (args) => withRepo(args, async ({ owner, repo }) => listSecrets(await client(), {
|
|
150
|
+
owner, repo, environment: args.environment,
|
|
151
|
+
})),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
tool(
|
|
155
|
+
'gh_secret_set',
|
|
156
|
+
'Create or update an Actions secret. The value is encrypted with GitHub sealed-box encryption, which needs the optional "tweetnacl" package; without it the tool explains what to install instead of writing a broken value.',
|
|
157
|
+
repoParams({
|
|
158
|
+
name: { type: 'string', description: 'Secret name.' },
|
|
159
|
+
value: { type: 'string', description: 'Secret value; it is never echoed back.' },
|
|
160
|
+
environment: { type: 'string', description: 'Environment name, when the secret lives there.' },
|
|
161
|
+
}),
|
|
162
|
+
async (args) => withRepo(args, async ({ owner, repo }) => setSecret(await client(), {
|
|
163
|
+
owner, repo, name: args.name, value: args.value, environment: args.environment,
|
|
164
|
+
})),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
tool(
|
|
168
|
+
'gh_secret_delete',
|
|
169
|
+
'Delete an Actions secret. Requires confirm: true.',
|
|
170
|
+
repoParams({
|
|
171
|
+
name: { type: 'string', description: 'Secret name.' },
|
|
172
|
+
environment: { type: 'string', description: 'Environment name, when the secret lives there.' },
|
|
173
|
+
confirm: { type: 'boolean', description: 'Must be true to actually delete.' },
|
|
174
|
+
}),
|
|
175
|
+
async (args) => {
|
|
176
|
+
if (args.confirm !== true) throw new Error('refusing to delete a secret without confirm: true')
|
|
177
|
+
return withRepo(args, async ({ owner, repo }) => deleteSecret(await client(), {
|
|
178
|
+
owner, repo, name: args.name, environment: args.environment,
|
|
179
|
+
}))
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
// ------------------------------------------------------------------ rulesets
|
|
184
|
+
|
|
185
|
+
tool(
|
|
186
|
+
'gh_ruleset_list',
|
|
187
|
+
'List repository rulesets with their target and enforcement level. Read-only.',
|
|
188
|
+
repoParams(),
|
|
189
|
+
async (args) => withRepo(args, async ({ owner, repo }) => listRulesets(await client(), { owner, repo })),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
tool(
|
|
193
|
+
'gh_ruleset_view',
|
|
194
|
+
'Read one ruleset in full: conditions, rules and enforcement. Read-only.',
|
|
195
|
+
repoParams({ rulesetId: { type: 'number', description: 'Ruleset id from gh_ruleset_list.' } }),
|
|
196
|
+
async (args) => withRepo(args, async ({ owner, repo }) => getRuleset(await client(), { owner, repo, rulesetId: args.rulesetId })),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
tool(
|
|
200
|
+
'gh_ruleset_apply',
|
|
201
|
+
'Create a ruleset, or update it when rulesetId is given. Defaults target the default branch with active enforcement.',
|
|
202
|
+
repoParams({
|
|
203
|
+
rulesetId: { type: 'number', description: 'Update this ruleset instead of creating one.' },
|
|
204
|
+
name: { type: 'string', description: 'Ruleset name (required when creating).' },
|
|
205
|
+
target: { type: 'string', description: 'branch (default) or tag.' },
|
|
206
|
+
enforcement: { type: 'string', description: 'active (default), evaluate or disabled.' },
|
|
207
|
+
conditions: { type: 'object', additionalProperties: true, description: 'Ruleset conditions, e.g. { ref_name: { include: ["refs/heads/main"], exclude: [] } }.' },
|
|
208
|
+
rules: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Ruleset rules, e.g. [{ type: "deletion" }].' },
|
|
209
|
+
}),
|
|
210
|
+
async (args) => withRepo(args, async ({ owner, repo }) => applyRuleset(await client(), {
|
|
211
|
+
owner, repo,
|
|
212
|
+
rulesetId: args.rulesetId,
|
|
213
|
+
name: args.name,
|
|
214
|
+
target: args.target,
|
|
215
|
+
enforcement: args.enforcement,
|
|
216
|
+
conditions: args.conditions,
|
|
217
|
+
rules: args.rules,
|
|
218
|
+
})),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
tool(
|
|
222
|
+
'gh_ruleset_delete',
|
|
223
|
+
'Delete a ruleset. Requires confirm: true.',
|
|
224
|
+
repoParams({
|
|
225
|
+
rulesetId: { type: 'number', description: 'Ruleset id.' },
|
|
226
|
+
confirm: { type: 'boolean', description: 'Must be true to actually delete.' },
|
|
227
|
+
}),
|
|
228
|
+
async (args) => {
|
|
229
|
+
if (args.confirm !== true) throw new Error('refusing to delete a ruleset without confirm: true')
|
|
230
|
+
return withRepo(args, async ({ owner, repo }) => deleteRuleset(await client(), { owner, repo, rulesetId: args.rulesetId }))
|
|
231
|
+
},
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
// -------------------------------------------------------- branch protection
|
|
235
|
+
|
|
236
|
+
tool(
|
|
237
|
+
'gh_branch_protection_get',
|
|
238
|
+
'Read classic branch protection of a branch: required reviews, status checks, admin enforcement and force-push/deletion flags. Read-only.',
|
|
239
|
+
repoParams({ branch: { type: 'string', description: 'Branch name, e.g. main.' } }),
|
|
240
|
+
async (args) => withRepo(args, async ({ owner, repo }) => getBranchProtection(await client(), {
|
|
241
|
+
owner, repo, branch: args.branch,
|
|
242
|
+
})),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
tool(
|
|
246
|
+
'gh_branch_protection_set',
|
|
247
|
+
'Apply classic branch protection: required approving reviews, strict status checks with contexts, admin enforcement, force-push and deletion flags. This replaces the whole protection object for the branch.',
|
|
248
|
+
repoParams({
|
|
249
|
+
branch: { type: 'string', description: 'Branch name.' },
|
|
250
|
+
approvals: { type: 'number', description: 'Required approving reviews (0 disables the review requirement).' },
|
|
251
|
+
dismissStale: { type: 'boolean', description: 'Dismiss stale approvals on new commits (default true).' },
|
|
252
|
+
strict: { type: 'boolean', description: 'Require branches to be up to date (default true).' },
|
|
253
|
+
contexts: { type: 'array', items: { type: 'string' }, description: 'Required status check contexts.' },
|
|
254
|
+
enforceAdmins: { type: 'boolean', description: 'Apply the rules to admins too.' },
|
|
255
|
+
allowForcePushes: { type: 'boolean', description: 'Allow force pushes.' },
|
|
256
|
+
allowDeletions: { type: 'boolean', description: 'Allow branch deletion.' },
|
|
257
|
+
}),
|
|
258
|
+
async (args) => withRepo(args, async ({ owner, repo }) => setBranchProtection(await client(), {
|
|
259
|
+
owner, repo,
|
|
260
|
+
branch: args.branch,
|
|
261
|
+
approvals: args.approvals,
|
|
262
|
+
dismissStale: args.dismissStale,
|
|
263
|
+
strict: args.strict,
|
|
264
|
+
contexts: args.contexts,
|
|
265
|
+
enforceAdmins: args.enforceAdmins,
|
|
266
|
+
allowForcePushes: args.allowForcePushes,
|
|
267
|
+
allowDeletions: args.allowDeletions,
|
|
268
|
+
})),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
tool(
|
|
272
|
+
'gh_branch_protection_delete',
|
|
273
|
+
'Remove classic branch protection from a branch. Requires confirm: true.',
|
|
274
|
+
repoParams({
|
|
275
|
+
branch: { type: 'string', description: 'Branch name.' },
|
|
276
|
+
confirm: { type: 'boolean', description: 'Must be true to actually remove protection.' },
|
|
277
|
+
}),
|
|
278
|
+
async (args) => {
|
|
279
|
+
if (args.confirm !== true) throw new Error('refusing to remove branch protection without confirm: true')
|
|
280
|
+
return withRepo(args, async ({ owner, repo }) => deleteBranchProtection(await client(), { owner, repo, branch: args.branch }))
|
|
281
|
+
},
|
|
282
|
+
)
|
|
283
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Release operations. Pure functions over a GitHub client: no harness imports here,
|
|
2
|
+
// so they are testable with a fake fetch and reusable from the tool layer.
|
|
3
|
+
|
|
4
|
+
const RELEASE_LIST_LIMIT = 30
|
|
5
|
+
|
|
6
|
+
function ensureRepo({ owner, repo }) {
|
|
7
|
+
if (!owner || !repo) {
|
|
8
|
+
throw new Error('owner and repo are required (pass "repository": "owner/repo" or set defaults in settings)')
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeRelease(data) {
|
|
13
|
+
if (!data || typeof data !== 'object') return data
|
|
14
|
+
return {
|
|
15
|
+
id: data.id,
|
|
16
|
+
tag: data.tag_name,
|
|
17
|
+
name: data.name || data.tag_name,
|
|
18
|
+
draft: Boolean(data.draft),
|
|
19
|
+
prerelease: Boolean(data.prerelease),
|
|
20
|
+
createdAt: data.created_at,
|
|
21
|
+
publishedAt: data.published_at,
|
|
22
|
+
author: data.author && data.author.login,
|
|
23
|
+
url: data.html_url,
|
|
24
|
+
tarball: data.tarball_url,
|
|
25
|
+
zipball: data.zipball_url,
|
|
26
|
+
assets: Array.isArray(data.assets)
|
|
27
|
+
? data.assets.map((a) => ({ name: a.name, size: a.size, downloads: a.download_count, url: a.browser_download_url }))
|
|
28
|
+
: [],
|
|
29
|
+
body: typeof data.body === 'string' ? data.body : '',
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function listReleases(client, { owner, repo, limit = RELEASE_LIST_LIMIT } = {}) {
|
|
34
|
+
ensureRepo({ owner, repo })
|
|
35
|
+
const capped = Math.max(1, Math.min(Number(limit) || RELEASE_LIST_LIMIT, 100))
|
|
36
|
+
const { data, rateLimit } = await client.get(`/repos/${owner}/${repo}/releases`, {
|
|
37
|
+
query: { per_page: capped },
|
|
38
|
+
})
|
|
39
|
+
const releases = Array.isArray(data) ? data.map(normalizeRelease) : []
|
|
40
|
+
return { releases, count: releases.length, rateLimit }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function getRelease(client, { owner, repo, tag }) {
|
|
44
|
+
ensureRepo({ owner, repo })
|
|
45
|
+
if (!tag) throw new Error('tag is required')
|
|
46
|
+
const { data } = await client.get(`/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`)
|
|
47
|
+
return normalizeRelease(data)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function createRelease(client, { owner, repo, tag, name, body, draft = false, prerelease = false, target, generateNotes = false }) {
|
|
51
|
+
ensureRepo({ owner, repo })
|
|
52
|
+
if (!tag) throw new Error('tag is required')
|
|
53
|
+
const payload = {
|
|
54
|
+
tag_name: tag,
|
|
55
|
+
name: name || tag,
|
|
56
|
+
draft: Boolean(draft),
|
|
57
|
+
prerelease: Boolean(prerelease),
|
|
58
|
+
generate_release_notes: Boolean(generateNotes),
|
|
59
|
+
}
|
|
60
|
+
if (body !== undefined) payload.body = body
|
|
61
|
+
if (target) payload.target_commitish = target
|
|
62
|
+
const { data } = await client.post(`/repos/${owner}/${repo}/releases`, payload)
|
|
63
|
+
return normalizeRelease(data)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a release id from a tag.
|
|
68
|
+
*
|
|
69
|
+
* The by-tag endpoint does not see draft releases — GitHub answers 404 for a draft even
|
|
70
|
+
* to the token that created it — so a miss falls back to scanning the list, which does
|
|
71
|
+
* include drafts for an authenticated caller.
|
|
72
|
+
*/
|
|
73
|
+
export async function resolveReleaseId(client, { owner, repo, tag, releaseId }) {
|
|
74
|
+
ensureRepo({ owner, repo })
|
|
75
|
+
if (releaseId) return releaseId
|
|
76
|
+
if (!tag) throw new Error('tag or releaseId is required')
|
|
77
|
+
try {
|
|
78
|
+
const existing = await getRelease(client, { owner, repo, tag })
|
|
79
|
+
if (existing && existing.id) return existing.id
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (!err || err.status !== 404) throw err
|
|
82
|
+
}
|
|
83
|
+
const { releases } = await listReleases(client, { owner, repo, limit: 100 })
|
|
84
|
+
const found = releases.find((r) => r.tag === tag)
|
|
85
|
+
if (found && found.id) return found.id
|
|
86
|
+
throw new Error(
|
|
87
|
+
`release not found for tag ${tag}. A draft release is invisible to the by-tag endpoint `
|
|
88
|
+
+ '(GitHub answers 404 even to its creator) and may also be missing from the list: '
|
|
89
|
+
+ 'pass releaseId — gh_release_create returns it — to edit or delete a draft',
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function editRelease(client, { owner, repo, tag, releaseId, name, body, draft, prerelease, makeLatest }) {
|
|
94
|
+
ensureRepo({ owner, repo })
|
|
95
|
+
const id = await resolveReleaseId(client, { owner, repo, tag, releaseId })
|
|
96
|
+
const payload = {}
|
|
97
|
+
if (name !== undefined) payload.name = name
|
|
98
|
+
if (body !== undefined) payload.body = body
|
|
99
|
+
if (draft !== undefined) payload.draft = Boolean(draft)
|
|
100
|
+
if (prerelease !== undefined) payload.prerelease = Boolean(prerelease)
|
|
101
|
+
if (makeLatest !== undefined) payload.make_latest = makeLatest ? 'true' : 'false'
|
|
102
|
+
const { data } = await client.patch(`/repos/${owner}/${repo}/releases/${id}`, payload)
|
|
103
|
+
return normalizeRelease(data)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function deleteRelease(client, { owner, repo, tag, releaseId }) {
|
|
107
|
+
ensureRepo({ owner, repo })
|
|
108
|
+
const id = await resolveReleaseId(client, { owner, repo, tag, releaseId })
|
|
109
|
+
await client.del(`/repos/${owner}/${repo}/releases/${id}`)
|
|
110
|
+
return { deleted: true, id, tag: tag || '' }
|
|
111
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Repository settings: actions variables, actions secrets, rulesets and classic
|
|
2
|
+
// branch protection.
|
|
3
|
+
//
|
|
4
|
+
// Secrets need the sealed-box encryption GitHub requires, which is not part of Node's
|
|
5
|
+
// crypto. The optional `tweetnacl` package is used when it is installed; without it the
|
|
6
|
+
// tool says exactly what is missing instead of writing a broken value.
|
|
7
|
+
|
|
8
|
+
function ensureRepo({ owner, repo }) {
|
|
9
|
+
if (!owner || !repo) {
|
|
10
|
+
throw new Error('owner and repo are required (pass "repository": "owner/repo" or set defaults in settings)')
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ------------------------------------------------------------------- variables
|
|
15
|
+
|
|
16
|
+
export async function listVariables(client, { owner, repo, environment }) {
|
|
17
|
+
ensureRepo({ owner, repo })
|
|
18
|
+
const path = environment
|
|
19
|
+
? `/repos/${owner}/${repo}/environments/${environment}/variables`
|
|
20
|
+
: `/repos/${owner}/${repo}/actions/variables`
|
|
21
|
+
const { data } = await client.get(path, { query: { per_page: 100 } })
|
|
22
|
+
return { variables: ((data && data.variables) || []).map((v) => ({ name: v.name, value: v.value })) }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function setVariable(client, { owner, repo, name, value, environment }) {
|
|
26
|
+
ensureRepo({ owner, repo })
|
|
27
|
+
if (!name) throw new Error('name is required')
|
|
28
|
+
const listPath = environment
|
|
29
|
+
? `/repos/${owner}/${repo}/environments/${environment}/variables`
|
|
30
|
+
: `/repos/${owner}/${repo}/actions/variables`
|
|
31
|
+
const existing = await listVariables(client, { owner, repo, environment })
|
|
32
|
+
const known = existing.variables.some((v) => v.name === name)
|
|
33
|
+
const payload = { name, value: String(value ?? '') }
|
|
34
|
+
if (known) {
|
|
35
|
+
await client.patch(`${listPath}/${name}`, payload)
|
|
36
|
+
return { updated: true, name }
|
|
37
|
+
}
|
|
38
|
+
await client.post(listPath, payload)
|
|
39
|
+
return { created: true, name }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function deleteVariable(client, { owner, repo, name, environment }) {
|
|
43
|
+
ensureRepo({ owner, repo })
|
|
44
|
+
if (!name) throw new Error('name is required')
|
|
45
|
+
const path = environment
|
|
46
|
+
? `/repos/${owner}/${repo}/environments/${environment}/variables/${name}`
|
|
47
|
+
: `/repos/${owner}/${repo}/actions/variables/${name}`
|
|
48
|
+
await client.del(path)
|
|
49
|
+
return { deleted: true, name }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------- secrets
|
|
53
|
+
|
|
54
|
+
export async function listSecrets(client, { owner, repo, environment }) {
|
|
55
|
+
ensureRepo({ owner, repo })
|
|
56
|
+
const path = environment
|
|
57
|
+
? `/repos/${owner}/${repo}/environments/${environment}/secrets`
|
|
58
|
+
: `/repos/${owner}/${repo}/actions/secrets`
|
|
59
|
+
const { data } = await client.get(path, { query: { per_page: 100 } })
|
|
60
|
+
return {
|
|
61
|
+
secrets: ((data && data.secrets) || []).map((s) => ({ name: s.name, updatedAt: s.updated_at })),
|
|
62
|
+
note: 'Values are never returned by GitHub.',
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function deleteSecret(client, { owner, repo, name, environment }) {
|
|
67
|
+
ensureRepo({ owner, repo })
|
|
68
|
+
if (!name) throw new Error('name is required')
|
|
69
|
+
const path = environment
|
|
70
|
+
? `/repos/${owner}/${repo}/environments/${environment}/secrets/${name}`
|
|
71
|
+
: `/repos/${owner}/${repo}/actions/secrets/${name}`
|
|
72
|
+
await client.del(path)
|
|
73
|
+
return { deleted: true, name }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Sealed-box encryption, performed only when the optional dependency is present. */
|
|
77
|
+
export async function encryptSecretValue(value, publicKeyBase64) {
|
|
78
|
+
let nacl
|
|
79
|
+
try {
|
|
80
|
+
nacl = await import('tweetnacl')
|
|
81
|
+
} catch {
|
|
82
|
+
throw new Error(
|
|
83
|
+
'setting a secret needs the optional "tweetnacl" package for GitHub sealed-box encryption: '
|
|
84
|
+
+ 'install it next to the plugin, or set the value through the GitHub UI and use gh_secret_list to verify it',
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
const lib = nacl && nacl.default ? nacl.default : nacl
|
|
88
|
+
const box = lib && lib.box
|
|
89
|
+
if (!box || typeof box.seal !== 'function') {
|
|
90
|
+
throw new Error('the installed "tweetnacl" package does not expose box.seal')
|
|
91
|
+
}
|
|
92
|
+
const publicKey = Buffer.from(publicKeyBase64, 'base64')
|
|
93
|
+
const message = Buffer.from(String(value), 'utf8')
|
|
94
|
+
return Buffer.from(box.seal(message, publicKey)).toString('base64')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function setSecret(client, { owner, repo, name, value, environment }) {
|
|
98
|
+
ensureRepo({ owner, repo })
|
|
99
|
+
if (!name) throw new Error('name is required')
|
|
100
|
+
if (value === undefined || value === null) throw new Error('value is required')
|
|
101
|
+
const keyPath = environment
|
|
102
|
+
? `/repos/${owner}/${repo}/environments/${environment}/secrets/public-key`
|
|
103
|
+
: `/repos/${owner}/${repo}/actions/secrets/public-key`
|
|
104
|
+
const { data } = await client.get(keyPath)
|
|
105
|
+
if (!data || !data.key || !data.key_id) throw new Error('GitHub did not return a public key for sealed-box encryption')
|
|
106
|
+
const encrypted = await encryptSecretValue(value, data.key)
|
|
107
|
+
const putPath = environment
|
|
108
|
+
? `/repos/${owner}/${repo}/environments/${environment}/secrets/${name}`
|
|
109
|
+
: `/repos/${owner}/${repo}/actions/secrets/${name}`
|
|
110
|
+
await client.put(putPath, { encrypted_value: encrypted, key_id: data.key_id })
|
|
111
|
+
return { written: true, name, keyId: data.key_id }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------- rulesets
|
|
115
|
+
|
|
116
|
+
export async function listRulesets(client, { owner, repo }) {
|
|
117
|
+
ensureRepo({ owner, repo })
|
|
118
|
+
const { data } = await client.get(`/repos/${owner}/${repo}/rulesets`)
|
|
119
|
+
return {
|
|
120
|
+
rulesets: (Array.isArray(data) ? data : []).map((r) => ({
|
|
121
|
+
id: r.id,
|
|
122
|
+
name: r.name,
|
|
123
|
+
target: r.target,
|
|
124
|
+
enforcement: r.enforcement,
|
|
125
|
+
})),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function getRuleset(client, { owner, repo, rulesetId }) {
|
|
130
|
+
ensureRepo({ owner, repo })
|
|
131
|
+
if (!rulesetId) throw new Error('rulesetId is required')
|
|
132
|
+
const { data } = await client.get(`/repos/${owner}/${repo}/rulesets/${rulesetId}`)
|
|
133
|
+
return data
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function applyRuleset(client, { owner, repo, rulesetId, name, target = 'branch', enforcement = 'active', conditions, rules }) {
|
|
137
|
+
ensureRepo({ owner, repo })
|
|
138
|
+
if (!name && !rulesetId) throw new Error('name is required to create a ruleset (or rulesetId to update one)')
|
|
139
|
+
const payload = {
|
|
140
|
+
name,
|
|
141
|
+
target,
|
|
142
|
+
enforcement,
|
|
143
|
+
conditions: conditions || { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
144
|
+
rules: rules || [],
|
|
145
|
+
}
|
|
146
|
+
const { data } = rulesetId
|
|
147
|
+
? await client.put(`/repos/${owner}/${repo}/rulesets/${rulesetId}`, payload)
|
|
148
|
+
: await client.post(`/repos/${owner}/${repo}/rulesets`, payload)
|
|
149
|
+
return { id: data && data.id, name: data && data.name, enforcement: data && data.enforcement, updated: Boolean(rulesetId) }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function deleteRuleset(client, { owner, repo, rulesetId }) {
|
|
153
|
+
ensureRepo({ owner, repo })
|
|
154
|
+
if (!rulesetId) throw new Error('rulesetId is required')
|
|
155
|
+
await client.del(`/repos/${owner}/${repo}/rulesets/${rulesetId}`)
|
|
156
|
+
return { deleted: true, rulesetId }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ------------------------------------------------------------- branch protection
|
|
160
|
+
|
|
161
|
+
export async function getBranchProtection(client, { owner, repo, branch }) {
|
|
162
|
+
ensureRepo({ owner, repo })
|
|
163
|
+
if (!branch) throw new Error('branch is required')
|
|
164
|
+
const { data } = await client.get(`/repos/${owner}/${repo}/branches/${branch}/protection`)
|
|
165
|
+
return {
|
|
166
|
+
branch,
|
|
167
|
+
requiredReviews: data && data.required_pull_request_reviews
|
|
168
|
+
? {
|
|
169
|
+
approvals: data.required_pull_request_reviews.required_approving_review_count,
|
|
170
|
+
dismissStale: data.required_pull_request_reviews.dismiss_stale_reviews,
|
|
171
|
+
}
|
|
172
|
+
: null,
|
|
173
|
+
requiredStatusChecks: data && data.required_status_checks
|
|
174
|
+
? { strict: data.required_status_checks.strict, contexts: data.required_status_checks.contexts }
|
|
175
|
+
: null,
|
|
176
|
+
enforceAdmins: Boolean(data && data.enforce_admins && data.enforce_admins.enabled),
|
|
177
|
+
allowForcePushes: Boolean(data && data.allow_force_pushes && data.allow_force_pushes.enabled),
|
|
178
|
+
allowDeletions: Boolean(data && data.allow_deletions && data.allow_deletions.enabled),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function setBranchProtection(client, {
|
|
183
|
+
owner, repo, branch, approvals = 0, dismissStale = true, strict = true, contexts = [],
|
|
184
|
+
enforceAdmins = false, allowForcePushes = false, allowDeletions = false,
|
|
185
|
+
}) {
|
|
186
|
+
ensureRepo({ owner, repo })
|
|
187
|
+
if (!branch) throw new Error('branch is required')
|
|
188
|
+
const payload = {
|
|
189
|
+
required_status_checks: { strict: Boolean(strict), contexts: contexts || [] },
|
|
190
|
+
enforce_admins: Boolean(enforceAdmins),
|
|
191
|
+
required_pull_request_reviews: approvals
|
|
192
|
+
? { required_approving_review_count: Number(approvals), dismiss_stale_reviews: Boolean(dismissStale) }
|
|
193
|
+
: null,
|
|
194
|
+
restrictions: null,
|
|
195
|
+
allow_force_pushes: Boolean(allowForcePushes),
|
|
196
|
+
allow_deletions: Boolean(allowDeletions),
|
|
197
|
+
}
|
|
198
|
+
const { data } = await client.put(`/repos/${owner}/${repo}/branches/${branch}/protection`, payload)
|
|
199
|
+
return { branch, applied: true, url: data && data.url }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function deleteBranchProtection(client, { owner, repo, branch }) {
|
|
203
|
+
ensureRepo({ owner, repo })
|
|
204
|
+
if (!branch) throw new Error('branch is required')
|
|
205
|
+
await client.del(`/repos/${owner}/${repo}/branches/${branch}/protection`)
|
|
206
|
+
return { deleted: true, branch }
|
|
207
|
+
}
|