@goodandready/dsh-github-ops 0.6.8 → 0.6.10
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 +12 -0
- package/lib/client.js +58 -2
- package/lib/events.js +13 -0
- package/lib/routes.js +7 -0
- package/lib/tools/changelog.js +261 -0
- package/lib/tools/community.js +238 -0
- package/lib/tools/deployments.js +154 -0
- package/lib/tools/register-core.js +26 -0
- package/lib/tools/register-ops.js +131 -0
- package/lib/tools/register-write.js +37 -0
- package/lib/tools/webhook-sim.js +289 -0
- package/lib/tools/write-files.js +14 -2
- package/package.json +1 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// GitHub Environments and Deployments tools.
|
|
2
|
+
// Pure functions over a GitHub client.
|
|
3
|
+
|
|
4
|
+
function ensureRepo({ owner, repo }) {
|
|
5
|
+
if (!owner || !repo) {
|
|
6
|
+
throw new Error('owner and repo are required (pass "repository": "owner/repo" or set defaults in settings)')
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** List environments for a repository. */
|
|
11
|
+
export async function listEnvironments(client, { owner, repo, limit = 30 } = {}) {
|
|
12
|
+
ensureRepo({ owner, repo })
|
|
13
|
+
const perPage = Math.max(1, Math.min(Number(limit) || 30, 100))
|
|
14
|
+
const { data } = await client.get(`/repos/${owner}/${repo}/environments`, {
|
|
15
|
+
query: { per_page: perPage },
|
|
16
|
+
})
|
|
17
|
+
const envs = data && Array.isArray(data.environments) ? data.environments : []
|
|
18
|
+
return {
|
|
19
|
+
totalCount: data && typeof data.total_count === 'number' ? data.total_count : envs.length,
|
|
20
|
+
environments: envs.map((env) => ({
|
|
21
|
+
id: env.id,
|
|
22
|
+
name: env.name,
|
|
23
|
+
url: env.html_url || '',
|
|
24
|
+
createdAt: env.created_at,
|
|
25
|
+
updatedAt: env.updated_at,
|
|
26
|
+
protectionRulesCount: Array.isArray(env.protection_rules) ? env.protection_rules.length : 0,
|
|
27
|
+
protectionRules: (env.protection_rules || []).map((r) => ({
|
|
28
|
+
id: r.id,
|
|
29
|
+
type: r.type,
|
|
30
|
+
waitTimer: r.wait_timer,
|
|
31
|
+
})),
|
|
32
|
+
})),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** List deployments for a repository. */
|
|
37
|
+
export async function listDeployments(client, { owner, repo, environment, ref, sha, task, limit = 30 } = {}) {
|
|
38
|
+
ensureRepo({ owner, repo })
|
|
39
|
+
const perPage = Math.max(1, Math.min(Number(limit) || 30, 100))
|
|
40
|
+
const query = { per_page: perPage }
|
|
41
|
+
if (environment) query.environment = environment
|
|
42
|
+
if (ref) query.ref = ref
|
|
43
|
+
if (sha) query.sha = sha
|
|
44
|
+
if (task) query.task = task
|
|
45
|
+
|
|
46
|
+
const { data } = await client.get(`/repos/${owner}/${repo}/deployments`, { query })
|
|
47
|
+
const deployments = Array.isArray(data) ? data : []
|
|
48
|
+
return {
|
|
49
|
+
deployments: deployments.map((d) => ({
|
|
50
|
+
id: d.id,
|
|
51
|
+
sha: d.sha,
|
|
52
|
+
ref: d.ref,
|
|
53
|
+
task: d.task,
|
|
54
|
+
environment: d.environment,
|
|
55
|
+
description: d.description || '',
|
|
56
|
+
creator: d.creator && d.creator.login,
|
|
57
|
+
createdAt: d.created_at,
|
|
58
|
+
updatedAt: d.updated_at,
|
|
59
|
+
url: d.url,
|
|
60
|
+
statusesUrl: d.statuses_url,
|
|
61
|
+
})),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Create a new deployment for a ref. */
|
|
66
|
+
export async function createDeployment(client, {
|
|
67
|
+
owner, repo, ref, environment = 'production', description, task = 'deploy',
|
|
68
|
+
autoMerge = false, requiredContexts = [], transientEnvironment = false,
|
|
69
|
+
productionEnvironment,
|
|
70
|
+
} = {}) {
|
|
71
|
+
ensureRepo({ owner, repo })
|
|
72
|
+
if (!ref) throw new Error('ref is required (branch name, tag or commit SHA)')
|
|
73
|
+
|
|
74
|
+
const payload = {
|
|
75
|
+
ref,
|
|
76
|
+
environment,
|
|
77
|
+
task,
|
|
78
|
+
auto_merge: Boolean(autoMerge),
|
|
79
|
+
required_contexts: Array.isArray(requiredContexts) ? requiredContexts : [],
|
|
80
|
+
transient_environment: Boolean(transientEnvironment),
|
|
81
|
+
}
|
|
82
|
+
if (typeof productionEnvironment === 'boolean') {
|
|
83
|
+
payload.production_environment = productionEnvironment
|
|
84
|
+
}
|
|
85
|
+
if (description) payload.description = String(description)
|
|
86
|
+
|
|
87
|
+
const { data } = await client.post(`/repos/${owner}/${repo}/deployments`, payload)
|
|
88
|
+
return {
|
|
89
|
+
id: data && data.id,
|
|
90
|
+
sha: data && data.sha,
|
|
91
|
+
ref: data && data.ref,
|
|
92
|
+
task: data && data.task,
|
|
93
|
+
environment: data && data.environment,
|
|
94
|
+
description: (data && data.description) || '',
|
|
95
|
+
creator: data && data.creator && data.creator.login,
|
|
96
|
+
createdAt: data && data.created_at,
|
|
97
|
+
url: data && data.url,
|
|
98
|
+
statusesUrl: data && data.statuses_url,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** List deployment statuses. */
|
|
103
|
+
export async function listDeploymentStatuses(client, { owner, repo, deploymentId, limit = 30 } = {}) {
|
|
104
|
+
ensureRepo({ owner, repo })
|
|
105
|
+
if (!deploymentId) throw new Error('deploymentId is required')
|
|
106
|
+
const perPage = Math.max(1, Math.min(Number(limit) || 30, 100))
|
|
107
|
+
const { data } = await client.get(`/repos/${owner}/${repo}/deployments/${deploymentId}/statuses`, {
|
|
108
|
+
query: { per_page: perPage },
|
|
109
|
+
})
|
|
110
|
+
const statuses = Array.isArray(data) ? data : []
|
|
111
|
+
return {
|
|
112
|
+
deploymentId,
|
|
113
|
+
statuses: statuses.map((s) => ({
|
|
114
|
+
id: s.id,
|
|
115
|
+
state: s.state,
|
|
116
|
+
description: s.description || '',
|
|
117
|
+
environmentUrl: s.environment_url || '',
|
|
118
|
+
logUrl: s.log_url || '',
|
|
119
|
+
creator: s.creator && s.creator.login,
|
|
120
|
+
createdAt: s.created_at,
|
|
121
|
+
})),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Create a status for an existing deployment. */
|
|
126
|
+
export async function createDeploymentStatus(client, {
|
|
127
|
+
owner, repo, deploymentId, state, logUrl, description, environmentUrl, autoInactive = true,
|
|
128
|
+
} = {}) {
|
|
129
|
+
ensureRepo({ owner, repo })
|
|
130
|
+
if (!deploymentId) throw new Error('deploymentId is required')
|
|
131
|
+
const validStates = ['error', 'failure', 'in_progress', 'queued', 'pending', 'success']
|
|
132
|
+
if (!state || !validStates.includes(state)) {
|
|
133
|
+
throw new Error(`state must be one of: ${validStates.join(', ')}`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const payload = {
|
|
137
|
+
state,
|
|
138
|
+
auto_inactive: Boolean(autoInactive),
|
|
139
|
+
}
|
|
140
|
+
if (logUrl) payload.log_url = String(logUrl)
|
|
141
|
+
if (description) payload.description = String(description)
|
|
142
|
+
if (environmentUrl) payload.environment_url = String(environmentUrl)
|
|
143
|
+
|
|
144
|
+
const { data } = await client.post(`/repos/${owner}/${repo}/deployments/${deploymentId}/statuses`, payload)
|
|
145
|
+
return {
|
|
146
|
+
id: data && data.id,
|
|
147
|
+
deploymentId,
|
|
148
|
+
state: data && data.state,
|
|
149
|
+
description: (data && data.description) || '',
|
|
150
|
+
environmentUrl: (data && data.environment_url) || '',
|
|
151
|
+
logUrl: (data && data.log_url) || '',
|
|
152
|
+
createdAt: data && data.created_at,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -7,6 +7,7 @@ import { runReleasePreflight } from './release-preflight.js'
|
|
|
7
7
|
// and the definitions are reusable in tests.
|
|
8
8
|
|
|
9
9
|
import { listReleases, getRelease, createRelease, editRelease, deleteRelease, listReleaseAssets, uploadReleaseAsset, deleteReleaseAsset, generateReleaseNotes } from './releases.js'
|
|
10
|
+
import { generateChangelog } from './changelog.js'
|
|
10
11
|
import { listTags, createTag, deleteTag } from './tags.js'
|
|
11
12
|
import { callApi } from './api.js'
|
|
12
13
|
import { getRepo, getFile, searchRepos, createRepo, editRepo } from './repos.js'
|
|
@@ -264,6 +265,31 @@ export function registerCoreTools({ tool, asRepo, client, liveConfig, defaultCwd
|
|
|
264
265
|
},
|
|
265
266
|
)
|
|
266
267
|
|
|
268
|
+
|
|
269
|
+
// ------------------------------------------------------------------------ conventional changelog (#123)
|
|
270
|
+
|
|
271
|
+
tool(
|
|
272
|
+
'gh_changelog_generate',
|
|
273
|
+
'Generate structured Conventional Commits changelog, group by category, extract breaking changes, and recommend SemVer bump (major/minor/patch). Read-only.',
|
|
274
|
+
repoParams({
|
|
275
|
+
base: { type: 'string', description: 'Base tag, branch or commit SHA to compare from. If omitted, uses the latest tag or initial commit.' },
|
|
276
|
+
head: { type: 'string', description: 'Head tag, branch or commit SHA to compare to (default "HEAD").' },
|
|
277
|
+
version: { type: 'string', description: 'Version label for the changelog entry (optional, defaults to calculated next version).' },
|
|
278
|
+
date: { type: 'string', description: 'Date for the changelog header (defaults to current YYYY-MM-DD).' },
|
|
279
|
+
}),
|
|
280
|
+
async (args) => {
|
|
281
|
+
const { owner, repo } = await asRepo(args, liveConfig())
|
|
282
|
+
return generateChangelog(await client(), {
|
|
283
|
+
owner,
|
|
284
|
+
repo,
|
|
285
|
+
base: args.base,
|
|
286
|
+
head: args.head,
|
|
287
|
+
version: args.version,
|
|
288
|
+
date: args.date,
|
|
289
|
+
})
|
|
290
|
+
},
|
|
291
|
+
)
|
|
292
|
+
|
|
267
293
|
// ------------------------------------------------------------------------ tags
|
|
268
294
|
|
|
269
295
|
tool(
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { matrixStatus } from './matrix.js'
|
|
2
|
+
import { listEnvironments, listDeployments, createDeployment, createDeploymentStatus } from './deployments.js'
|
|
3
|
+
import { simulateWebhook } from './webhook-sim.js'
|
|
2
4
|
// Workflow-run and repository-settings tool registrations.
|
|
3
5
|
//
|
|
4
6
|
// Reads are free; writes follow the plugin's model — an explicit confirm flag for the
|
|
@@ -367,4 +369,133 @@ export function registerOpsTools({ tool, asRepo, client, liveConfig }) {
|
|
|
367
369
|
})
|
|
368
370
|
},
|
|
369
371
|
)
|
|
372
|
+
|
|
373
|
+
// ------------------------------------------------------------------------ environments & deployments (#122)
|
|
374
|
+
|
|
375
|
+
tool(
|
|
376
|
+
'gh_environment_list',
|
|
377
|
+
'List deployment environments configured for a repository, including protection rules and wait timers. Read-only.',
|
|
378
|
+
repoParams({
|
|
379
|
+
limit: { type: 'number', description: 'Maximum environments to return (default 30, max 100).' },
|
|
380
|
+
}),
|
|
381
|
+
async (args) => {
|
|
382
|
+
const { owner, repo } = await asRepo(args, liveConfig())
|
|
383
|
+
return listEnvironments(await client(), { owner, repo, limit: args.limit })
|
|
384
|
+
},
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
tool(
|
|
388
|
+
'gh_deployment_list',
|
|
389
|
+
'List deployments for a repository with optional filtering by environment, ref, sha or task. Read-only.',
|
|
390
|
+
repoParams({
|
|
391
|
+
environment: { type: 'string', description: 'Filter by environment name (e.g. "production", "staging").' },
|
|
392
|
+
ref: { type: 'string', description: 'Filter by git ref or branch name.' },
|
|
393
|
+
sha: { type: 'string', description: 'Filter by commit SHA.' },
|
|
394
|
+
task: { type: 'string', description: 'Filter by task name (e.g. "deploy").' },
|
|
395
|
+
limit: { type: 'number', description: 'Maximum deployments to return (default 30, max 100).' },
|
|
396
|
+
}),
|
|
397
|
+
async (args) => {
|
|
398
|
+
const { owner, repo } = await asRepo(args, liveConfig())
|
|
399
|
+
return listDeployments(await client(), {
|
|
400
|
+
owner,
|
|
401
|
+
repo,
|
|
402
|
+
environment: args.environment,
|
|
403
|
+
ref: args.ref,
|
|
404
|
+
sha: args.sha,
|
|
405
|
+
task: args.task,
|
|
406
|
+
limit: args.limit,
|
|
407
|
+
})
|
|
408
|
+
},
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
tool(
|
|
412
|
+
'gh_deployment_create',
|
|
413
|
+
'Create a new deployment for a ref (branch, tag or commit SHA).',
|
|
414
|
+
repoParams({
|
|
415
|
+
ref: { type: 'string', description: 'Git ref to deploy (branch name, tag, or commit SHA). Required.' },
|
|
416
|
+
environment: { type: 'string', description: 'Target environment name (default "production").' },
|
|
417
|
+
task: { type: 'string', description: 'Deployment task (default "deploy").' },
|
|
418
|
+
description: { type: 'string', description: 'Optional human-readable description.' },
|
|
419
|
+
autoMerge: { type: 'boolean', description: 'Whether to auto-merge default branch into ref (default false).' },
|
|
420
|
+
requiredContexts: {
|
|
421
|
+
type: 'array',
|
|
422
|
+
items: { type: 'string' },
|
|
423
|
+
description: 'Status check contexts required to pass before deploying (default empty []).'
|
|
424
|
+
},
|
|
425
|
+
productionEnvironment: { type: 'boolean', description: 'Whether this environment is a production environment.' },
|
|
426
|
+
}),
|
|
427
|
+
async (args) => {
|
|
428
|
+
const { owner, repo } = await asRepo(args, liveConfig())
|
|
429
|
+
return createDeployment(await client(), {
|
|
430
|
+
owner,
|
|
431
|
+
repo,
|
|
432
|
+
ref: args.ref,
|
|
433
|
+
environment: args.environment || 'production',
|
|
434
|
+
task: args.task || 'deploy',
|
|
435
|
+
description: args.description,
|
|
436
|
+
autoMerge: Boolean(args.autoMerge),
|
|
437
|
+
requiredContexts: args.requiredContexts || [],
|
|
438
|
+
productionEnvironment: args.productionEnvironment,
|
|
439
|
+
})
|
|
440
|
+
},
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
tool(
|
|
444
|
+
'gh_deployment_status_create',
|
|
445
|
+
'Create a status for an existing deployment (e.g. "in_progress", "success", "failure", "error").',
|
|
446
|
+
repoParams({
|
|
447
|
+
deploymentId: { type: 'number', description: 'The numeric ID of the deployment. Required.' },
|
|
448
|
+
state: {
|
|
449
|
+
type: 'string',
|
|
450
|
+
description: 'State of the deployment: "error", "failure", "in_progress", "queued", "pending", "success". Required.'
|
|
451
|
+
},
|
|
452
|
+
description: { type: 'string', description: 'Short description of the status.' },
|
|
453
|
+
logUrl: { type: 'string', description: 'URL of the deployment logs.' },
|
|
454
|
+
environmentUrl: { type: 'string', description: 'URL for accessing the deployed environment.' },
|
|
455
|
+
autoInactive: { type: 'boolean', description: 'Add inactive state to previous non-transient, non-production deployments (default true).' },
|
|
456
|
+
}),
|
|
457
|
+
async (args) => {
|
|
458
|
+
const { owner, repo } = await asRepo(args, liveConfig())
|
|
459
|
+
return createDeploymentStatus(await client(), {
|
|
460
|
+
owner,
|
|
461
|
+
repo,
|
|
462
|
+
deploymentId: args.deploymentId,
|
|
463
|
+
state: args.state,
|
|
464
|
+
description: args.description,
|
|
465
|
+
logUrl: args.logUrl,
|
|
466
|
+
environmentUrl: args.environmentUrl,
|
|
467
|
+
autoInactive: args.autoInactive !== false,
|
|
468
|
+
})
|
|
469
|
+
},
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
// ------------------------------------------------------------------------ webhook simulator (#125)
|
|
473
|
+
|
|
474
|
+
tool(
|
|
475
|
+
'gh_webhook_simulate',
|
|
476
|
+
'Simulate a canonical GitHub webhook event (issues, pull_request, workflow_run, release, deployment, push). Emits to local SSE bus and optional HTTP target.',
|
|
477
|
+
repoParams({
|
|
478
|
+
event: {
|
|
479
|
+
type: 'string',
|
|
480
|
+
description: 'Event name: "issues", "pull_request", "workflow_run", "release", "deployment", "deployment_status", "push". Default "issues".'
|
|
481
|
+
},
|
|
482
|
+
action: { type: 'string', description: 'Action for event (e.g. "opened", "closed", "completed", "published"). Default "opened".' },
|
|
483
|
+
data: { type: 'object', additionalProperties: true, description: 'Optional custom payload properties to merge into canonical payload.' },
|
|
484
|
+
broadcast: { type: 'boolean', description: 'Whether to broadcast to local SSE event manager (default true).' },
|
|
485
|
+
targetUrl: { type: 'string', description: 'Optional webhook receiver endpoint URL to deliver payload via HTTP POST.' },
|
|
486
|
+
}),
|
|
487
|
+
async (args) => {
|
|
488
|
+
const { owner, repo } = await asRepo(args, liveConfig())
|
|
489
|
+
return simulateWebhook({
|
|
490
|
+
owner,
|
|
491
|
+
repo,
|
|
492
|
+
event: args.event || 'issues',
|
|
493
|
+
action: args.action || 'opened',
|
|
494
|
+
data: args.data || {},
|
|
495
|
+
broadcast: args.broadcast !== false,
|
|
496
|
+
targetUrl: args.targetUrl,
|
|
497
|
+
})
|
|
498
|
+
},
|
|
499
|
+
)
|
|
500
|
+
|
|
370
501
|
}
|
|
@@ -91,6 +91,7 @@ export function registerWriteTools({ tool, asRepo, client, liveConfig, auth }) {
|
|
|
91
91
|
title: { type: 'string', description: 'Commit message and pull request title.' },
|
|
92
92
|
body: { type: 'string', description: 'Pull request body.' },
|
|
93
93
|
createPullRequest: { type: 'boolean', description: 'Open a pull request after publishing.' },
|
|
94
|
+
includeCommunityFiles: { type: 'boolean', description: 'Automatically include standardized GoodAndReady community files (issue templates, PR template, SECURITY.md, CONTRIBUTING.md, CODE_OF_CONDUCT.md).' },
|
|
94
95
|
},
|
|
95
96
|
async (args) => {
|
|
96
97
|
const cfg = liveConfig()
|
|
@@ -116,6 +117,7 @@ export function registerWriteTools({ tool, asRepo, client, liveConfig, auth }) {
|
|
|
116
117
|
title: args.title,
|
|
117
118
|
body: args.body,
|
|
118
119
|
createPullRequest: args.createPullRequest === true,
|
|
120
|
+
includeCommunityFiles: args.includeCommunityFiles === true,
|
|
119
121
|
})
|
|
120
122
|
},
|
|
121
123
|
)
|
|
@@ -150,6 +152,41 @@ export function registerWriteTools({ tool, asRepo, client, liveConfig, auth }) {
|
|
|
150
152
|
},
|
|
151
153
|
)
|
|
152
154
|
|
|
155
|
+
|
|
156
|
+
// ------------------------------------------------------------------------ community scaffold (#126)
|
|
157
|
+
|
|
158
|
+
tool(
|
|
159
|
+
'gh_community_scaffold',
|
|
160
|
+
'Scaffold standardized GoodAndReady community health files (issue templates, PR template, SECURITY.md, CONTRIBUTING.md, CODE_OF_CONDUCT.md). Writes files or previews with dryRun.',
|
|
161
|
+
repoParams({
|
|
162
|
+
branch: { type: 'string', description: 'Target branch to commit files to (defaults to repository default branch).' },
|
|
163
|
+
templates: {
|
|
164
|
+
type: 'array',
|
|
165
|
+
items: { type: 'string' },
|
|
166
|
+
description: 'Templates to generate: ["all"] or subset of ["bug_report", "feature_request", "security_advisory", "pull_request", "security", "contributing", "code_of_conduct"]. Default "all".',
|
|
167
|
+
},
|
|
168
|
+
projectName: { type: 'string', description: 'Human-readable project name.' },
|
|
169
|
+
contactEmail: { type: 'string', description: 'Security and contact email address.' },
|
|
170
|
+
dryRun: { type: 'boolean', description: 'If true, returns file contents without creating a commit. Default false.' },
|
|
171
|
+
message: { type: 'string', description: 'Commit message for scaffolding commit.' },
|
|
172
|
+
}),
|
|
173
|
+
async (args) => {
|
|
174
|
+
const { owner, repo } = await asRepo(args, liveConfig())
|
|
175
|
+
return scaffoldCommunityFiles(await client(), {
|
|
176
|
+
owner,
|
|
177
|
+
repo,
|
|
178
|
+
branch: args.branch,
|
|
179
|
+
templates: args.templates || 'all',
|
|
180
|
+
options: {
|
|
181
|
+
projectName: args.projectName,
|
|
182
|
+
contactEmail: args.contactEmail,
|
|
183
|
+
},
|
|
184
|
+
dryRun: Boolean(args.dryRun),
|
|
185
|
+
message: args.message,
|
|
186
|
+
})
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
|
|
153
190
|
// ------------------------------------------------------------------ account
|
|
154
191
|
|
|
155
192
|
tool(
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// Webhook Simulator and Local Event Emitter.
|
|
2
|
+
// Generates canonical GitHub webhook payloads and emits to local SSE event bus.
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { getGlobalEventManager } from '../events.js'
|
|
6
|
+
|
|
7
|
+
/** Canonical mock payload generator for GitHub webhook events. */
|
|
8
|
+
export function buildCanonicalWebhookPayload({
|
|
9
|
+
event = 'issues',
|
|
10
|
+
action = 'opened',
|
|
11
|
+
owner = 'goodandready',
|
|
12
|
+
repo = 'dsh-github-ops',
|
|
13
|
+
data = {},
|
|
14
|
+
} = {}) {
|
|
15
|
+
const deliveryId = randomUUID()
|
|
16
|
+
const timestamp = new Date().toISOString()
|
|
17
|
+
const repoFullName = `${owner}/${repo}`
|
|
18
|
+
|
|
19
|
+
const baseRepo = {
|
|
20
|
+
id: 123456789,
|
|
21
|
+
node_id: 'MDEwOlJlcG9zaXRvcnkxMjM0NTY3ODk=',
|
|
22
|
+
name: repo,
|
|
23
|
+
full_name: repoFullName,
|
|
24
|
+
private: false,
|
|
25
|
+
owner: {
|
|
26
|
+
login: owner,
|
|
27
|
+
id: 9876543,
|
|
28
|
+
avatar_url: 'https://avatars.githubusercontent.com/u/9876543?v=4',
|
|
29
|
+
html_url: `https://github.com/${owner}`,
|
|
30
|
+
},
|
|
31
|
+
html_url: `https://github.com/${repoFullName}`,
|
|
32
|
+
default_branch: 'main',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sender = {
|
|
36
|
+
login: 'agent-antigravity',
|
|
37
|
+
id: 424242,
|
|
38
|
+
avatar_url: 'https://avatars.githubusercontent.com/u/424242?v=4',
|
|
39
|
+
html_url: 'https://github.com/agent-antigravity',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let eventPayload = {}
|
|
43
|
+
|
|
44
|
+
switch (event) {
|
|
45
|
+
case 'issues': {
|
|
46
|
+
eventPayload = {
|
|
47
|
+
action,
|
|
48
|
+
issue: {
|
|
49
|
+
id: 1001,
|
|
50
|
+
number: 42,
|
|
51
|
+
title: 'Example Issue from Webhook Simulator',
|
|
52
|
+
user: sender,
|
|
53
|
+
labels: [{ id: 1, name: 'bug', color: 'd73a4a' }],
|
|
54
|
+
state: action === 'closed' ? 'closed' : 'open',
|
|
55
|
+
comments: 0,
|
|
56
|
+
created_at: timestamp,
|
|
57
|
+
updated_at: timestamp,
|
|
58
|
+
body: 'This is a simulated issue payload for testing.',
|
|
59
|
+
html_url: `https://github.com/${repoFullName}/issues/42`,
|
|
60
|
+
},
|
|
61
|
+
repository: baseRepo,
|
|
62
|
+
sender,
|
|
63
|
+
}
|
|
64
|
+
break
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
case 'pull_request': {
|
|
68
|
+
eventPayload = {
|
|
69
|
+
action,
|
|
70
|
+
number: 101,
|
|
71
|
+
pull_request: {
|
|
72
|
+
id: 2002,
|
|
73
|
+
number: 101,
|
|
74
|
+
state: action === 'closed' ? 'closed' : 'open',
|
|
75
|
+
title: 'feat: simulated pull request payload',
|
|
76
|
+
user: sender,
|
|
77
|
+
body: 'Simulated PR body for local testing.',
|
|
78
|
+
created_at: timestamp,
|
|
79
|
+
updated_at: timestamp,
|
|
80
|
+
head: {
|
|
81
|
+
label: `${owner}:feature-sim`,
|
|
82
|
+
ref: 'feature-sim',
|
|
83
|
+
sha: '0123456789abcdef0123456789abcdef01234567',
|
|
84
|
+
},
|
|
85
|
+
base: {
|
|
86
|
+
label: `${owner}:main`,
|
|
87
|
+
ref: 'main',
|
|
88
|
+
sha: 'abcdef0123456789abcdef0123456789abcdef01',
|
|
89
|
+
},
|
|
90
|
+
merged: action === 'closed',
|
|
91
|
+
html_url: `https://github.com/${repoFullName}/pull/101`,
|
|
92
|
+
},
|
|
93
|
+
repository: baseRepo,
|
|
94
|
+
sender,
|
|
95
|
+
}
|
|
96
|
+
break
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
case 'workflow_run': {
|
|
100
|
+
eventPayload = {
|
|
101
|
+
action,
|
|
102
|
+
workflow_run: {
|
|
103
|
+
id: 3003,
|
|
104
|
+
name: 'CI Build & Test',
|
|
105
|
+
head_branch: 'main',
|
|
106
|
+
head_sha: '0123456789abcdef0123456789abcdef01234567',
|
|
107
|
+
status: action === 'completed' ? 'completed' : 'in_progress',
|
|
108
|
+
conclusion: action === 'completed' ? 'success' : null,
|
|
109
|
+
html_url: `https://github.com/${repoFullName}/actions/runs/3003`,
|
|
110
|
+
created_at: timestamp,
|
|
111
|
+
updated_at: timestamp,
|
|
112
|
+
},
|
|
113
|
+
repository: baseRepo,
|
|
114
|
+
sender,
|
|
115
|
+
}
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case 'release': {
|
|
120
|
+
eventPayload = {
|
|
121
|
+
action,
|
|
122
|
+
release: {
|
|
123
|
+
id: 4004,
|
|
124
|
+
tag_name: 'v1.0.0',
|
|
125
|
+
name: 'Release v1.0.0',
|
|
126
|
+
draft: false,
|
|
127
|
+
prerelease: false,
|
|
128
|
+
created_at: timestamp,
|
|
129
|
+
published_at: timestamp,
|
|
130
|
+
html_url: `https://github.com/${repoFullName}/releases/tag/v1.0.0`,
|
|
131
|
+
},
|
|
132
|
+
repository: baseRepo,
|
|
133
|
+
sender,
|
|
134
|
+
}
|
|
135
|
+
break
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case 'deployment': {
|
|
139
|
+
eventPayload = {
|
|
140
|
+
action,
|
|
141
|
+
deployment: {
|
|
142
|
+
id: 5005,
|
|
143
|
+
sha: '0123456789abcdef0123456789abcdef01234567',
|
|
144
|
+
ref: 'main',
|
|
145
|
+
task: 'deploy',
|
|
146
|
+
environment: 'production',
|
|
147
|
+
description: 'Deploying to production server',
|
|
148
|
+
created_at: timestamp,
|
|
149
|
+
updated_at: timestamp,
|
|
150
|
+
},
|
|
151
|
+
repository: baseRepo,
|
|
152
|
+
sender,
|
|
153
|
+
}
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
case 'deployment_status': {
|
|
158
|
+
eventPayload = {
|
|
159
|
+
action,
|
|
160
|
+
deployment_status: {
|
|
161
|
+
id: 6006,
|
|
162
|
+
state: 'success',
|
|
163
|
+
description: 'Deployment finished successfully',
|
|
164
|
+
target_url: 'https://goodandready.app',
|
|
165
|
+
created_at: timestamp,
|
|
166
|
+
updated_at: timestamp,
|
|
167
|
+
},
|
|
168
|
+
deployment: {
|
|
169
|
+
id: 5005,
|
|
170
|
+
environment: 'production',
|
|
171
|
+
},
|
|
172
|
+
repository: baseRepo,
|
|
173
|
+
sender,
|
|
174
|
+
}
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case 'push': {
|
|
179
|
+
eventPayload = {
|
|
180
|
+
ref: 'refs/heads/main',
|
|
181
|
+
before: 'abcdef0123456789abcdef0123456789abcdef01',
|
|
182
|
+
after: '0123456789abcdef0123456789abcdef01234567',
|
|
183
|
+
commits: [
|
|
184
|
+
{
|
|
185
|
+
id: '0123456789abcdef0123456789abcdef01234567',
|
|
186
|
+
message: 'feat: add exciting new capability',
|
|
187
|
+
timestamp,
|
|
188
|
+
author: { name: 'Vadim', email: 'vadim@goodandready.app' },
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
repository: baseRepo,
|
|
192
|
+
sender,
|
|
193
|
+
}
|
|
194
|
+
break
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
default: {
|
|
198
|
+
eventPayload = {
|
|
199
|
+
action,
|
|
200
|
+
event,
|
|
201
|
+
repository: baseRepo,
|
|
202
|
+
sender,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const payload = {
|
|
208
|
+
...eventPayload,
|
|
209
|
+
...data,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { deliveryId, timestamp, payload }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Simulate a webhook event, emit to local SSE bus and optionally dispatch to HTTP receiver. */
|
|
216
|
+
export async function simulateWebhook({
|
|
217
|
+
event = 'issues',
|
|
218
|
+
action = 'opened',
|
|
219
|
+
owner = 'goodandready',
|
|
220
|
+
repo = 'dsh-github-ops',
|
|
221
|
+
data = {},
|
|
222
|
+
broadcast = true,
|
|
223
|
+
targetUrl,
|
|
224
|
+
eventManager,
|
|
225
|
+
fetchFn = (typeof fetch !== 'undefined' ? fetch : null),
|
|
226
|
+
} = {}) {
|
|
227
|
+
const { deliveryId, timestamp, payload } = buildCanonicalWebhookPayload({
|
|
228
|
+
event,
|
|
229
|
+
action,
|
|
230
|
+
owner,
|
|
231
|
+
repo,
|
|
232
|
+
data,
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
let broadcastCount = 0
|
|
236
|
+
if (broadcast) {
|
|
237
|
+
const mgr = eventManager || getGlobalEventManager()
|
|
238
|
+
if (mgr && typeof mgr.broadcast === 'function') {
|
|
239
|
+
mgr.broadcast('webhook', {
|
|
240
|
+
event,
|
|
241
|
+
action,
|
|
242
|
+
deliveryId,
|
|
243
|
+
timestamp,
|
|
244
|
+
payload,
|
|
245
|
+
})
|
|
246
|
+
mgr.broadcast(event, payload)
|
|
247
|
+
broadcastCount = mgr.clientCount || 1
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let delivered = false
|
|
252
|
+
let targetStatus = null
|
|
253
|
+
let deliveryError = null
|
|
254
|
+
|
|
255
|
+
if (targetUrl && typeof fetchFn === 'function') {
|
|
256
|
+
try {
|
|
257
|
+
const res = await fetchFn(targetUrl, {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: {
|
|
260
|
+
'Content-Type': 'application/json',
|
|
261
|
+
'User-Agent': 'GitHub-Hookshot/dsh-github-ops',
|
|
262
|
+
'X-GitHub-Event': event,
|
|
263
|
+
'X-GitHub-Delivery': deliveryId,
|
|
264
|
+
'X-GitHub-Hook-ID': '9999999',
|
|
265
|
+
},
|
|
266
|
+
body: JSON.stringify(payload),
|
|
267
|
+
})
|
|
268
|
+
delivered = Boolean(res && res.ok)
|
|
269
|
+
targetStatus = res ? res.status : null
|
|
270
|
+
} catch (err) {
|
|
271
|
+
deliveryError = String((err && err.message) || err)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
simulated: true,
|
|
277
|
+
event,
|
|
278
|
+
action,
|
|
279
|
+
deliveryId,
|
|
280
|
+
timestamp,
|
|
281
|
+
broadcasted: broadcast && broadcastCount > 0,
|
|
282
|
+
broadcastClients: broadcastCount,
|
|
283
|
+
targetUrl: targetUrl || null,
|
|
284
|
+
delivered,
|
|
285
|
+
targetStatus,
|
|
286
|
+
deliveryError,
|
|
287
|
+
payload,
|
|
288
|
+
}
|
|
289
|
+
}
|