@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 +6 -0
- package/lib/tools/discussions.js +275 -0
- package/lib/tools/register-collab.js +104 -1
- package/lib/tools/triage.js +147 -0
- package/package.json +1 -1
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
|
}
|
package/lib/tools/triage.js
CHANGED
|
@@ -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.
|
|
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",
|