@goodandready/dsh-github-ops 0.6.11 → 0.6.13

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.13
4
+
5
+ ### Features
6
+ - **GitHub Discussions GraphQL Tools (#118)**: Added `gh_discussion_list`, `gh_discussion_view`, `gh_discussion_create`, and `gh_discussion_comment` tools using GitHub GraphQL API with `confirm: true` protection for creating discussions and posting threaded replies.
7
+ - **Bi-directional Issue Mirroring & Sync GitHub ↔ Gitea (#120)**: Added `gh_issue_mirror_sync` tool with anti-loop synchronization markers (`<!-- synced-from: ... -->`), propagation of comments across GitHub and Gitea in both directions, and automatic synchronized closure of linked issues upon release.
8
+
3
9
  ## 0.6.11
4
10
 
5
11
  ### Features
@@ -0,0 +1,275 @@
1
+ // GitHub Discussions GraphQL tools.
2
+ // Pure functions over GitHub client.graphql.
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 discussions for a repository. */
11
+ export async function listDiscussions(client, { owner, repo, categoryId, limit = 20 } = {}) {
12
+ ensureRepo({ owner, repo })
13
+ const perPage = Math.max(1, Math.min(Number(limit) || 20, 50))
14
+
15
+ const query = `
16
+ query ListDiscussions($owner: String!, $repo: String!, $first: Int!, $categoryId: ID) {
17
+ repository(owner: $owner, name: $repo) {
18
+ discussions(first: $first, categoryId: $categoryId, orderBy: {field: CREATED_AT, direction: DESC}) {
19
+ totalCount
20
+ nodes {
21
+ id
22
+ number
23
+ title
24
+ url
25
+ createdAt
26
+ upvoteCount
27
+ author {
28
+ login
29
+ }
30
+ category {
31
+ id
32
+ name
33
+ emoji
34
+ }
35
+ comments {
36
+ totalCount
37
+ }
38
+ }
39
+ }
40
+ }
41
+ }
42
+ `
43
+
44
+ const data = await client.graphql(query, {
45
+ owner,
46
+ repo,
47
+ first: perPage,
48
+ categoryId: categoryId || null,
49
+ })
50
+
51
+ const conn = data?.repository?.discussions
52
+ const nodes = Array.isArray(conn?.nodes) ? conn.nodes : []
53
+
54
+ return {
55
+ totalCount: typeof conn?.totalCount === 'number' ? conn.totalCount : nodes.length,
56
+ discussions: nodes.map((d) => ({
57
+ id: d.id,
58
+ number: d.number,
59
+ title: d.title,
60
+ url: d.url,
61
+ createdAt: d.createdAt,
62
+ upvoteCount: d.upvoteCount || 0,
63
+ author: d.author?.login || 'ghost',
64
+ category: d.category ? { id: d.category.id, name: d.category.name, emoji: d.category.emoji } : null,
65
+ commentsCount: d.comments?.totalCount || 0,
66
+ })),
67
+ }
68
+ }
69
+
70
+ /** View discussion details and comments by number. */
71
+ export async function getDiscussion(client, { owner, repo, number } = {}) {
72
+ ensureRepo({ owner, repo })
73
+ if (!number) throw new Error('discussion number is required')
74
+
75
+ const query = `
76
+ query GetDiscussion($owner: String!, $repo: String!, $number: Int!) {
77
+ repository(owner: $owner, name: $repo) {
78
+ discussion(number: $number) {
79
+ id
80
+ number
81
+ title
82
+ body
83
+ url
84
+ createdAt
85
+ upvoteCount
86
+ author {
87
+ login
88
+ }
89
+ category {
90
+ id
91
+ name
92
+ emoji
93
+ }
94
+ comments(first: 30) {
95
+ totalCount
96
+ nodes {
97
+ id
98
+ body
99
+ createdAt
100
+ upvoteCount
101
+ isAnswer
102
+ author {
103
+ login
104
+ }
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }
110
+ `
111
+
112
+ const data = await client.graphql(query, {
113
+ owner,
114
+ repo,
115
+ number: Number(number),
116
+ })
117
+
118
+ const d = data?.repository?.discussion
119
+ if (!d) throw new Error(`Discussion #${number} not found in ${owner}/${repo}`)
120
+
121
+ const commentNodes = Array.isArray(d.comments?.nodes) ? d.comments.nodes : []
122
+
123
+ return {
124
+ id: d.id,
125
+ number: d.number,
126
+ title: d.title,
127
+ body: d.body || '',
128
+ url: d.url,
129
+ createdAt: d.createdAt,
130
+ upvoteCount: d.upvoteCount || 0,
131
+ author: d.author?.login || 'ghost',
132
+ category: d.category ? { id: d.category.id, name: d.category.name, emoji: d.category.emoji } : null,
133
+ commentsCount: d.comments?.totalCount || commentNodes.length,
134
+ comments: commentNodes.map((c) => ({
135
+ id: c.id,
136
+ body: c.body || '',
137
+ createdAt: c.createdAt,
138
+ upvoteCount: c.upvoteCount || 0,
139
+ isAnswer: Boolean(c.isAnswer),
140
+ author: c.author?.login || 'ghost',
141
+ })),
142
+ }
143
+ }
144
+
145
+ /** Create a new discussion (requires confirm: true). */
146
+ export async function createDiscussion(client, {
147
+ owner, repo, title, body, categoryId, confirm = false,
148
+ } = {}) {
149
+ ensureRepo({ owner, repo })
150
+ if (!title) throw new Error('title is required')
151
+ if (!body) throw new Error('body is required')
152
+
153
+ if (confirm !== true) {
154
+ return {
155
+ dryRun: true,
156
+ refused: 'confirm: true is required to publish a new discussion',
157
+ target: `${owner}/${repo}`,
158
+ title,
159
+ bodyPreview: body.slice(0, 200),
160
+ categoryId: categoryId || 'first_available',
161
+ }
162
+ }
163
+
164
+ let repoId = ''
165
+ let targetCatId = categoryId || ''
166
+
167
+ const repoQuery = `
168
+ query ResolveRepo($owner: String!, $repo: String!) {
169
+ repository(owner: $owner, name: $repo) {
170
+ id
171
+ discussionCategories(first: 10) {
172
+ nodes {
173
+ id
174
+ name
175
+ }
176
+ }
177
+ }
178
+ }
179
+ `
180
+ const repoData = await client.graphql(repoQuery, { owner, repo })
181
+ repoId = repoData?.repository?.id
182
+ if (!repoId) throw new Error(`Cannot resolve repository ID for ${owner}/${repo}`)
183
+
184
+ if (!targetCatId) {
185
+ const cats = repoData?.repository?.discussionCategories?.nodes || []
186
+ if (cats.length === 0) throw new Error(`Repository ${owner}/${repo} has no discussion categories enabled`)
187
+ targetCatId = cats[0].id
188
+ }
189
+
190
+ const mutation = `
191
+ mutation CreateDiscussion($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
192
+ createDiscussion(input: {repositoryId: $repositoryId, categoryId: $categoryId, title: $title, body: $body}) {
193
+ discussion {
194
+ id
195
+ number
196
+ title
197
+ url
198
+ createdAt
199
+ }
200
+ }
201
+ }
202
+ `
203
+
204
+ const result = await client.graphql(mutation, {
205
+ repositoryId: repoId,
206
+ categoryId: targetCatId,
207
+ title,
208
+ body,
209
+ })
210
+
211
+ const disc = result?.createDiscussion?.discussion
212
+ return {
213
+ success: true,
214
+ id: disc?.id,
215
+ number: disc?.number,
216
+ title: disc?.title,
217
+ url: disc?.url,
218
+ createdAt: disc?.createdAt,
219
+ }
220
+ }
221
+
222
+ /** Comment on a discussion (requires confirm: true). */
223
+ export async function commentDiscussion(client, {
224
+ owner, repo, discussionId, number, body, confirm = false,
225
+ } = {}) {
226
+ ensureRepo({ owner, repo })
227
+ if (!body) throw new Error('body is required')
228
+
229
+ let targetId = discussionId
230
+ if (!targetId && number) {
231
+ const d = await getDiscussion(client, { owner, repo, number })
232
+ targetId = d.id
233
+ }
234
+ if (!targetId) throw new Error('discussionId or discussion number is required')
235
+
236
+ if (confirm !== true) {
237
+ return {
238
+ dryRun: true,
239
+ refused: 'confirm: true is required to post a discussion comment',
240
+ discussionId: targetId,
241
+ bodyPreview: body.slice(0, 200),
242
+ }
243
+ }
244
+
245
+ const mutation = `
246
+ mutation AddComment($discussionId: ID!, $body: String!) {
247
+ addDiscussionComment(input: {discussionId: $discussionId, body: $body}) {
248
+ comment {
249
+ id
250
+ body
251
+ url
252
+ createdAt
253
+ author {
254
+ login
255
+ }
256
+ }
257
+ }
258
+ }
259
+ `
260
+
261
+ const result = await client.graphql(mutation, {
262
+ discussionId: targetId,
263
+ body,
264
+ })
265
+
266
+ const c = result?.addDiscussionComment?.comment
267
+ return {
268
+ success: true,
269
+ id: c?.id,
270
+ body: c?.body,
271
+ url: c?.url,
272
+ createdAt: c?.createdAt,
273
+ author: c?.author?.login || 'unknown',
274
+ }
275
+ }
@@ -1,4 +1,10 @@
1
- import { triageExternalIssues } from './triage.js'
1
+ import { triageExternalIssues, syncIssueMirror } from './triage.js'
2
+ import {
3
+ listDiscussions,
4
+ getDiscussion,
5
+ createDiscussion,
6
+ commentDiscussion,
7
+ } from './discussions.js'
2
8
  // Collaboration tool registrations: issues, pull requests, reviews and checks.
3
9
  //
4
10
  // Reads are free; every write goes through the caller's confirmation contour in the
@@ -383,4 +389,101 @@ export function registerCollabTools({ tool, asRepo, client, liveConfig, reviewRu
383
389
  giteaBaseUrl: args.giteaBaseUrl,
384
390
  })),
385
391
  )
392
+
393
+ // ------------------------------------------------------------------------ discussions (#118)
394
+
395
+ tool(
396
+ 'gh_discussion_list',
397
+ 'List repository discussions via GraphQL, including category, upvotes, author, and comment counts. Read-only.',
398
+ repoParams({
399
+ categoryId: { type: 'string', description: 'Filter by discussion category ID.' },
400
+ limit: { type: 'number', description: 'Max discussions to return (default 20, max 50).' },
401
+ }),
402
+ async (args) => withRepo(args, async ({ owner, repo }) => listDiscussions(await client(), {
403
+ owner,
404
+ repo,
405
+ categoryId: args.categoryId,
406
+ limit: args.limit,
407
+ })),
408
+ )
409
+
410
+ tool(
411
+ 'gh_discussion_view',
412
+ 'View a discussion and its threaded replies by discussion number. Read-only.',
413
+ repoParams({
414
+ number: { type: 'number', description: 'Discussion number. Required.' },
415
+ }),
416
+ async (args) => withRepo(args, async ({ owner, repo }) => getDiscussion(await client(), {
417
+ owner,
418
+ repo,
419
+ number: args.number,
420
+ })),
421
+ )
422
+
423
+ tool(
424
+ 'gh_discussion_create',
425
+ 'Start a new GitHub discussion. Requires confirm: true.',
426
+ repoParams({
427
+ title: { type: 'string', description: 'Discussion title. Required.' },
428
+ body: { type: 'string', description: 'Discussion body text (markdown). Required.' },
429
+ categoryId: { type: 'string', description: 'Category ID (defaults to the first available category).' },
430
+ confirm: { type: 'boolean', description: 'Must be true to publish the discussion.' },
431
+ }),
432
+ async (args) => withRepo(args, async ({ owner, repo }) => createDiscussion(await client(), {
433
+ owner,
434
+ repo,
435
+ title: args.title,
436
+ body: args.body,
437
+ categoryId: args.categoryId,
438
+ confirm: args.confirm === true,
439
+ })),
440
+ )
441
+
442
+ tool(
443
+ 'gh_discussion_comment',
444
+ 'Post a reply or comment to an existing GitHub discussion. Requires confirm: true.',
445
+ repoParams({
446
+ discussionId: { type: 'string', description: 'Discussion GraphQL node ID.' },
447
+ number: { type: 'number', description: 'Discussion number (used to resolve ID if omitted).' },
448
+ body: { type: 'string', description: 'Comment body (markdown). Required.' },
449
+ confirm: { type: 'boolean', description: 'Must be true to post the comment.' },
450
+ }),
451
+ async (args) => withRepo(args, async ({ owner, repo }) => commentDiscussion(await client(), {
452
+ owner,
453
+ repo,
454
+ discussionId: args.discussionId,
455
+ number: args.number,
456
+ body: args.body,
457
+ confirm: args.confirm === true,
458
+ })),
459
+ )
460
+
461
+ // ------------------------------------------------------------------------ bi-directional mirror sync (#120)
462
+
463
+ tool(
464
+ 'gh_issue_mirror_sync',
465
+ 'Bi-directional synchronization of comments and issue states between GitHub and Gitea. Requires confirm: true for mutations.',
466
+ repoParams({
467
+ githubIssueNumber: { type: 'number', description: 'GitHub issue number. Required.' },
468
+ giteaIssueNumber: { type: 'number', description: 'Gitea issue number. Required.' },
469
+ giteaOwner: { type: 'string', description: 'Gitea owner (default "goodandready").' },
470
+ giteaRepo: { type: 'string', description: 'Gitea repository name (defaults to GitHub repo name).' },
471
+ direction: { type: 'string', description: 'Sync direction: "both" (default), "gitea_to_github", "github_to_gitea".' },
472
+ syncState: { type: 'boolean', description: 'Whether to synchronize closed/open issue state (default true).' },
473
+ confirm: { type: 'boolean', description: 'Must be true to post comments and update states.' },
474
+ giteaBaseUrl: { type: 'string', description: 'Base URL of local Gitea instance (default "http://127.0.0.1:3005").' },
475
+ }),
476
+ async (args) => withRepo(args, async ({ owner, repo }) => syncIssueMirror(await client(), {
477
+ repository: `${owner}/${repo}`,
478
+ githubIssueNumber: args.githubIssueNumber,
479
+ giteaIssueNumber: args.giteaIssueNumber,
480
+ giteaOwner: args.giteaOwner,
481
+ giteaRepo: args.giteaRepo,
482
+ direction: args.direction || 'both',
483
+ syncState: args.syncState !== false,
484
+ confirm: args.confirm === true,
485
+ giteaBaseUrl: args.giteaBaseUrl,
486
+ })),
487
+ )
488
+
386
489
  }
@@ -211,3 +211,150 @@ export async function triageExternalIssues(client, options = {}, deps = {}) {
211
211
  items,
212
212
  }
213
213
  }
214
+
215
+
216
+ export async function syncIssueMirror(client, options = {}, deps = {}) {
217
+ const {
218
+ repository = '',
219
+ githubIssueNumber,
220
+ giteaOwner = 'goodandready',
221
+ giteaRepo = '',
222
+ giteaIssueNumber,
223
+ direction = 'both',
224
+ syncState = true,
225
+ confirm = false,
226
+ giteaBaseUrl = 'http://127.0.0.1:3005',
227
+ giteaToken = '',
228
+ } = options
229
+ const { fetchFn = globalThis.fetch } = deps
230
+
231
+ if (!repository || !repository.includes('/')) {
232
+ throw new Error('repository must be in "owner/repo" format')
233
+ }
234
+ const [owner, repo] = repository.split('/')
235
+ const targetGiteaRepo = giteaRepo || repo
236
+
237
+ if (!githubIssueNumber) throw new Error('githubIssueNumber is required')
238
+ if (!giteaIssueNumber) throw new Error('giteaIssueNumber is required')
239
+
240
+ const token = resolveGiteaToken(giteaToken)
241
+
242
+ // 1. Fetch GitHub issue and comments
243
+ const { data: ghIssue } = await client.get(`/repos/${owner}/${repo}/issues/${githubIssueNumber}`)
244
+ const { data: ghCommentsRaw } = await client.get(`/repos/${owner}/${repo}/issues/${githubIssueNumber}/comments`)
245
+ const ghComments = Array.isArray(ghCommentsRaw) ? ghCommentsRaw : []
246
+
247
+ // 2. Fetch Gitea issue and comments
248
+ const giteaHeaders = { accept: 'application/json' }
249
+ if (token) giteaHeaders.authorization = `token ${token}`
250
+ const baseGitea = giteaBaseUrl.replace(/\/$/, '')
251
+
252
+ const giteaIssueRes = await fetchFn(`${baseGitea}/api/v1/repos/${giteaOwner}/${targetGiteaRepo}/issues/${giteaIssueNumber}`, { headers: giteaHeaders })
253
+ if (!giteaIssueRes.ok) throw new Error(`Failed to fetch Gitea issue #${giteaIssueNumber}: status ${giteaIssueRes.status}`)
254
+ const giteaIssue = await giteaIssueRes.json()
255
+
256
+ const giteaCommentsRes = await fetchFn(`${baseGitea}/api/v1/repos/${giteaOwner}/${targetGiteaRepo}/issues/${giteaIssueNumber}/comments`, { headers: giteaHeaders })
257
+ const giteaComments = giteaCommentsRes.ok ? (await giteaCommentsRes.json()) : []
258
+
259
+ // 3. Determine state sync action
260
+ let stateAction = null
261
+ if (syncState) {
262
+ if (ghIssue.state === 'closed' && giteaIssue.state === 'open') {
263
+ stateAction = 'close_gitea'
264
+ } else if (giteaIssue.state === 'closed' && ghIssue.state === 'open') {
265
+ stateAction = 'close_github'
266
+ }
267
+ }
268
+
269
+ // 4. Find comments to sync (prevent cycles using markers)
270
+ const pendingGiteaToGithub = []
271
+ if (direction === 'both' || direction === 'gitea_to_github') {
272
+ for (const gc of giteaComments) {
273
+ if (!gc.body || gc.body.includes('synced-from: github#')) continue
274
+ const marker = `synced-from: gitea#${gc.id}`
275
+ const alreadyOnGh = ghComments.some((ghc) => ghc.body && ghc.body.includes(marker))
276
+ if (!alreadyOnGh) {
277
+ pendingGiteaToGithub.push({
278
+ id: gc.id,
279
+ author: gc.user?.login || 'user',
280
+ body: gc.body,
281
+ })
282
+ }
283
+ }
284
+ }
285
+
286
+ const pendingGithubToGitea = []
287
+ if (direction === 'both' || direction === 'github_to_gitea') {
288
+ for (const ghc of ghComments) {
289
+ if (!ghc.body || ghc.body.includes('synced-from: gitea#')) continue
290
+ const marker = `synced-from: github#${ghc.id}`
291
+ const alreadyOnGitea = giteaComments.some((gc) => gc.body && gc.body.includes(marker))
292
+ if (!alreadyOnGitea) {
293
+ pendingGithubToGitea.push({
294
+ id: ghc.id,
295
+ author: ghc.user?.login || 'user',
296
+ body: ghc.body,
297
+ })
298
+ }
299
+ }
300
+ }
301
+
302
+ // If dryRun (confirm !== true)
303
+ if (confirm !== true) {
304
+ return {
305
+ dryRun: true,
306
+ refused: 'confirm: true is required to execute issue synchronization',
307
+ repository: `${owner}/${repo}#${githubIssueNumber}`,
308
+ giteaRepository: `${giteaOwner}/${targetGiteaRepo}#${giteaIssueNumber}`,
309
+ githubState: ghIssue.state,
310
+ giteaState: giteaIssue.state,
311
+ stateAction,
312
+ pendingGiteaToGithubCount: pendingGiteaToGithub.length,
313
+ pendingGithubToGiteaCount: pendingGithubToGitea.length,
314
+ pendingGiteaToGithub,
315
+ pendingGithubToGitea,
316
+ }
317
+ }
318
+
319
+ // 5. Execute sync
320
+ let syncedGiteaToGithub = 0
321
+ for (const gc of pendingGiteaToGithub) {
322
+ const body = `${gc.body}\n\n<!-- synced-from: gitea#${gc.id} by @${gc.author} -->`
323
+ await client.post(`/repos/${owner}/${repo}/issues/${githubIssueNumber}/comments`, { body })
324
+ syncedGiteaToGithub += 1
325
+ }
326
+
327
+ let syncedGithubToGitea = 0
328
+ for (const ghc of pendingGithubToGitea) {
329
+ const body = `${ghc.body}\n\n<!-- synced-from: github#${ghc.id} by @${ghc.author} -->`
330
+ await fetchFn(`${baseGitea}/api/v1/repos/${giteaOwner}/${targetGiteaRepo}/issues/${giteaIssueNumber}/comments`, {
331
+ method: 'POST',
332
+ headers: { ...giteaHeaders, 'content-type': 'application/json' },
333
+ body: JSON.stringify({ body }),
334
+ })
335
+ syncedGithubToGitea += 1
336
+ }
337
+
338
+ let stateSynced = false
339
+ if (stateAction === 'close_github') {
340
+ await client.patch(`/repos/${owner}/${repo}/issues/${githubIssueNumber}`, { state: 'closed' })
341
+ stateSynced = true
342
+ } else if (stateAction === 'close_gitea') {
343
+ await fetchFn(`${baseGitea}/api/v1/repos/${giteaOwner}/${targetGiteaRepo}/issues/${giteaIssueNumber}`, {
344
+ method: 'PATCH',
345
+ headers: { ...giteaHeaders, 'content-type': 'application/json' },
346
+ body: JSON.stringify({ state: 'closed' }),
347
+ })
348
+ stateSynced = true
349
+ }
350
+
351
+ return {
352
+ success: true,
353
+ repository: `${owner}/${repo}#${githubIssueNumber}`,
354
+ giteaRepository: `${giteaOwner}/${targetGiteaRepo}#${giteaIssueNumber}`,
355
+ syncedGiteaToGithub,
356
+ syncedGithubToGitea,
357
+ stateAction,
358
+ stateSynced,
359
+ }
360
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-github-ops",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
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",