Skip to content

Commit 8128929

Browse files
neo-gpttobiu
andauthored
feat(portal): structure discussion replies (#12216) (#12308)
Co-authored-by: tobiu <tobiasuhlig78@gmail.com>
1 parent 341e7ec commit 8128929

6 files changed

Lines changed: 244 additions & 30 deletions

File tree

ai/services/github-workflow/sync/CONTENT_GRAMMAR.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ Verified against the emit code (`PullRequestSyncer.mjs`, `DiscussionSyncer.mjs`,
3535
- Frontmatter: `number, title, author, category` (`Ideas`|`General`|`Q&A`|…), `createdAt, updatedAt, closed` (boolean), `closedAt`. No `state`, no `labels`.
3636
- Sections: `## Description`, then `## Comments`.
3737
- `## Comments` entries: `` ### `@user` commented on <ISO_Z> `` — the backtick form, **like PRs, not** the issue `- <ts> @user` event form.
38-
- Threaded replies follow their parent comment, each headed `` > **Reply by `@user`** on <ISO_Z> `` with the reply body as `> `-blockquoted lines (flattened; parent→child depth is not yet structured — see #12216).
39-
- An accepted answer is **not yet emitted** — see #12215 (gate any answer affordance to `category === 'Q&A'`).
38+
- Threaded replies follow their parent comment, each headed `` #### Reply depth=<N> by `@user` on <ISO_Z> `` with raw reply markdown after the header. Parent association is lexical: a reply belongs to the preceding top-level comment until the next top-level `` ### `@user` commented on <ISO_Z> `` header.
39+
- Legacy flattened reply blocks headed `` > **Reply by `@user`** on <ISO_Z> `` remain readable as part of the parent comment body; consumers must not split on those legacy blockquotes.
40+
- Accepted answers are emitted as GitHub-style callouts (`> [!ANSWER]`) before accepted top-level comments or accepted replies.
4041

4142
## Quick reference — entry header by type
4243

@@ -47,6 +48,7 @@ Verified against the emit code (`PullRequestSyncer.mjs`, `DiscussionSyncer.mjs`,
4748
| PR comment | `## Comments` | `` ### `@user` commented on <ISO_Z> `` |
4849
| PR review | `## Reviews` | `` ### `@user` (<STATE>) reviewed on <ISO_Z> `` |
4950
| Discussion comment | `## Comments` | `` ### `@user` commented on <ISO_Z> `` |
51+
| Discussion reply | Parent discussion comment | `` #### Reply depth=<N> by `@user` on <ISO_Z> `` |
5052

5153
## Consumers
5254

ai/services/github-workflow/sync/DiscussionSyncer.mjs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -277,12 +277,11 @@ class DiscussionSyncer extends Base {
277277
// Parse replies if any
278278
if (comment.replies && comment.replies.nodes && comment.replies.nodes.length > 0) {
279279
for (const reply of comment.replies.nodes) {
280+
body += `#### Reply depth=1 by \`@${reply.author?.login || 'unknown'}\` on ${reply.createdAt}\n\n`;
280281
if (reply.isAnswer) {
281-
body += '> [!ANSWER]\n>\n';
282+
body += '> [!ANSWER]\n\n';
282283
}
283-
body += `> **Reply by \`@${reply.author?.login || 'unknown'}\`** on ${reply.createdAt}\n>\n`;
284-
const blockquotedReply = reply.body.split('\n').map(l => `> ${l}`).join('\n');
285-
body += `${blockquotedReply}\n\n`;
284+
body += `${reply.body}\n\n`;
286285
}
287286
}
288287
body += '---\n\n';

apps/portal/view/news/discussions/Component.mjs

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ const
55
regexComments = /\n## Comments\s*\n([\s\S]*)$/,
66
regexCommentHeader = /^### `@([^`]+)` commented on ([\dTZ:.-]+)$/,
77
regexFrontMatter = /^---\n([\s\S]*?)\n---\n/,
8-
regexH1 = /(<h1[^>]*>.*?<\/h1>)/;
8+
regexH1 = /(<h1[^>]*>.*?<\/h1>)/,
9+
regexReplyHeader = /^#### Reply depth=(\d+) by `?@([^`]+)`? on ([\dTZ:.-]+)$/;
910

1011
/**
1112
* @summary The Markdown transformer for GitHub Discussions.
@@ -236,18 +237,20 @@ ${fullHtml}
236237
* @summary Parses the Discussion `## Comments` body into stable comment entries.
237238
*
238239
* The synced Discussion grammar uses backtick-wrapped `@user` headings as the durable entry
239-
* boundary. Separator rules (`---`) are emitted between comments, but comment bodies can also
240-
* contain their own `###` headings and horizontal rules, so this method splits only on the
241-
* full comment header and trims just the trailing separator before the next entry.
240+
* boundary and `#### Reply depth=N by @user on <timestamp>` headings as lexical children of
241+
* the preceding top-level comment. Separator rules (`---`) are emitted between comments, but
242+
* comment and reply bodies can also contain their own headings and horizontal rules, so this
243+
* method splits only on the full comment/reply headers and trims just the trailing separator.
242244
* @param {String} content
243-
* @returns {Object[]} `[{user, date, body}]`
245+
* @returns {Object[]} `[{user, date, body, replies: [{depth, user, date, body}]}]`
244246
*/
245247
parseComments(content) {
246-
let lines = content.split('\n'),
247-
entries = [],
248-
current = null,
249-
i = 0,
250-
len = lines.length,
248+
let lines = content.split('\n'),
249+
entries = [],
250+
current = null,
251+
currentReply = null,
252+
i = 0,
253+
len = lines.length,
251254
line, match;
252255

253256
const trimTrailingDelimiter = (rows) => {
@@ -264,20 +267,43 @@ ${fullHtml}
264267
}
265268
};
266269

270+
const flushReply = () => {
271+
if (!currentReply) return;
272+
273+
trimTrailingDelimiter(currentReply.bodyLines);
274+
275+
let body = currentReply.bodyLines.join('\n').trim();
276+
277+
if (body) {
278+
current.replies.push({
279+
depth: currentReply.depth,
280+
user : currentReply.user,
281+
date : currentReply.date,
282+
body
283+
})
284+
}
285+
286+
currentReply = null
287+
};
288+
267289
const flushComment = () => {
268290
if (!current) return;
269291

292+
flushReply();
270293
trimTrailingDelimiter(current.bodyLines);
271294

272295
let body = current.bodyLines.join('\n').trim();
273296

274-
if (body) {
297+
if (body || current.replies.length) {
275298
entries.push({
276-
user: current.user,
277-
date: current.date,
278-
body
299+
user : current.user,
300+
date : current.date,
301+
body,
302+
replies: current.replies
279303
})
280304
}
305+
306+
current = null
281307
};
282308

283309
for (; i < len; i++) {
@@ -286,9 +312,18 @@ ${fullHtml}
286312

287313
if (match) {
288314
flushComment();
289-
current = {user: match[1], date: match[2], bodyLines: []}
315+
current = {user: match[1], date: match[2], bodyLines: [], replies: []}
290316
} else if (current) {
291-
current.bodyLines.push(line)
317+
match = line.match(regexReplyHeader);
318+
319+
if (match) {
320+
flushReply();
321+
currentReply = {depth: Number(match[1]), user: match[2], date: match[3], bodyLines: []}
322+
} else if (currentReply) {
323+
currentReply.bodyLines.push(line)
324+
} else {
325+
current.bodyLines.push(line)
326+
}
292327
}
293328
}
294329

@@ -326,13 +361,42 @@ ${fullHtml}
326361
<a class="neo-timeline-user" href="${repoUserUrl}${comment.user}" target="_blank">${comment.user}</a>
327362
<span class="neo-timeline-date">commented on ${me.formatTimestamp(comment.date)}</span>
328363
</div>
329-
<div class="neo-timeline-body">${marked.parse(comment.body)}</div>
364+
<div class="neo-timeline-body">
365+
${marked.parse(comment.body)}
366+
${me.renderReplies(comment.replies)}
367+
</div>
330368
</div>
331369
</div>`
332370
});
333371

334372
return html
335373
}
374+
375+
/**
376+
* @summary Renders structured Discussion replies inside the parent comment bubble.
377+
*
378+
* Replies intentionally do not add records to `timelineData`: the shared timeline/canvas
379+
* contract remains top-level comments plus description, while reply identity stays visible
380+
* as nested DOM inside the owning comment.
381+
* @param {Object[]} replies
382+
* @returns {String}
383+
*/
384+
renderReplies(replies = []) {
385+
let {repoUserUrl} = this;
386+
387+
if (!replies.length) {
388+
return ''
389+
}
390+
391+
return `<div class="neo-discussion-replies">${replies.map(reply => `
392+
<div class="neo-discussion-reply depth-${reply.depth}">
393+
<div class="neo-discussion-reply-header">
394+
<a class="neo-timeline-user" href="${repoUserUrl}${reply.user}" target="_blank">${reply.user}</a>
395+
<span class="neo-timeline-date">replied on ${this.formatTimestamp(reply.date)}</span>
396+
</div>
397+
<div class="neo-discussion-reply-body">${marked.parse(reply.body)}</div>
398+
</div>`).join('')}</div>`
399+
}
336400
}
337401

338402
export default Neo.setupClass(Component);

resources/scss/src/apps/portal/news/discussions/Component.scss

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,26 @@
1818
padding-left: 12px;
1919
}
2020
}
21+
22+
.neo-discussion-replies {
23+
border-left : 2px solid var(--gh-timeline-border);
24+
margin-top : 12px;
25+
padding-left: 14px;
26+
}
27+
28+
.neo-discussion-reply {
29+
margin-top: 12px;
30+
31+
.neo-discussion-reply-header {
32+
align-items: baseline;
33+
display : flex;
34+
flex-wrap : wrap;
35+
gap : 8px;
36+
margin : 0 0 6px;
37+
}
38+
39+
.neo-discussion-reply-body > :last-child {
40+
margin-bottom: 0;
41+
}
42+
}
2143
}

test/playwright/unit/ai/services/github-workflow/DiscussionSyncer.spec.mjs

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,56 @@ test.describe('Neo.ai.services.github-workflow.sync.DiscussionSyncer', () => {
153153
'Accepted answer body.'
154154
].join('\n'));
155155
expect(content).toContain([
156+
'#### Reply depth=1 by `@neo-reply-answer` on 2026-05-02T03:00:00Z',
157+
'',
156158
'> [!ANSWER]',
157-
'>',
158-
'> **Reply by `@neo-reply-answer`** on 2026-05-02T03:00:00Z',
159-
'>',
160-
'> Nested accepted answer.'
159+
'',
160+
'Nested accepted answer.'
161+
].join('\n'));
162+
});
163+
164+
test('emits structured reply markers that preserve parent comment identity', async () => {
165+
const discussion = buildDiscussion(24005, {
166+
comments: {nodes: [{
167+
author: {login: 'neo-parent'},
168+
body: 'Parent comment body.',
169+
createdAt: '2026-05-02T01:00:00Z',
170+
replies: {nodes: [{
171+
author: {login: 'neo-child'},
172+
body: [
173+
'Reply body.',
174+
'',
175+
'### Inner heading remains reply markdown.'
176+
].join('\n'),
177+
createdAt: '2026-05-02T02:00:00Z'
178+
}]}
179+
}]}
180+
});
181+
182+
GraphqlService.query = async () => ({
183+
repository: {
184+
discussions: {
185+
nodes: [discussion],
186+
pageInfo: {hasNextPage: false, endCursor: null}
187+
}
188+
}
189+
});
190+
191+
await DiscussionSyncer.syncDiscussions({discussions: {}});
192+
193+
const targetPath = path.join(aiConfig.issueSync.discussionsDir, 'chunk-1', 'discussion-24005.md');
194+
const content = await fs.readFile(targetPath, 'utf8');
195+
196+
expect(content).toContain([
197+
'### `@neo-parent` commented on 2026-05-02T01:00:00Z',
198+
'',
199+
'Parent comment body.',
200+
'',
201+
'#### Reply depth=1 by `@neo-child` on 2026-05-02T02:00:00Z',
202+
'',
203+
'Reply body.',
204+
'',
205+
'### Inner heading remains reply markdown.'
161206
].join('\n'));
162207
});
163208

test/playwright/unit/apps/portal/view/news/discussions/Component.spec.mjs

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ import Component from '../../../../../../../../apps/portal/view/news/discus
1616
*
1717
* The view inherits the shared timeline/canvas contract from the ticket content stack; these tests pin
1818
* only the irreducibly Discussion-specific pieces: folded YAML titles, the backtick-`@user` comment
19-
* entry boundary, and category / closed-state badges. `parseComments` and the badge helpers are tested
20-
* directly on the prototype to avoid constructing the full state-provider-backed content component.
19+
* entry boundary, structured reply parsing, and category / closed-state badges. Parser and badge
20+
* helpers are tested directly on the prototype to avoid constructing the full state-provider-backed
21+
* content component.
2122
*/
22-
const {parseFrontMatter, parseComments, getClosedBadgeHtml, getCategoryBadgeHtml} = Component.prototype;
23+
const {parseFrontMatter, parseComments, renderReplies, getClosedBadgeHtml, getCategoryBadgeHtml} = Component.prototype;
2324

2425
test.describe('Portal.view.news.discussions.Component - Discussion markdown parser', () => {
2526
test('parses folded and literal frontmatter block scalars used by discussion titles', () => {
@@ -76,6 +77,87 @@ test.describe('Portal.view.news.discussions.Component - Discussion markdown pars
7677
})
7778
});
7879

80+
test('parses structured replies as children of the preceding top-level comment', () => {
81+
const commentsRaw = [
82+
'### `@parent` commented on 2026-05-20T17:39:52Z',
83+
'',
84+
'Parent body.',
85+
'',
86+
'#### Reply depth=1 by `@child` on 2026-05-20T18:00:00Z',
87+
'',
88+
'> [!ANSWER]',
89+
'',
90+
'Reply body.',
91+
'',
92+
'### Nested reply heading stays in reply markdown.',
93+
'',
94+
'---',
95+
'',
96+
'### `@next` commented on 2026-05-20T19:00:00Z',
97+
'',
98+
'Next comment.'
99+
].join('\n');
100+
101+
const entries = parseComments(commentsRaw);
102+
103+
expect(entries).toHaveLength(2);
104+
expect(entries[0]).toMatchObject({
105+
user: 'parent',
106+
date: '2026-05-20T17:39:52Z',
107+
body: 'Parent body.'
108+
});
109+
expect(entries[0].replies).toHaveLength(1);
110+
expect(entries[0].replies[0]).toMatchObject({
111+
depth: 1,
112+
user : 'child',
113+
date : '2026-05-20T18:00:00Z'
114+
});
115+
expect(entries[0].replies[0].body).toContain('> [!ANSWER]');
116+
expect(entries[0].replies[0].body).toContain('### Nested reply heading stays in reply markdown.');
117+
expect(entries[0].replies[0].body).not.toMatch(/\n---$/);
118+
expect(entries[1]).toMatchObject({
119+
user: 'next',
120+
body: 'Next comment.'
121+
})
122+
});
123+
124+
test('legacy flattened reply blockquotes remain parent-body markdown', () => {
125+
const commentsRaw = [
126+
'### `@parent` commented on 2026-05-20T17:39:52Z',
127+
'',
128+
'Parent body.',
129+
'',
130+
'> **Reply by `@legacy-child`** on 2026-05-20T18:00:00Z',
131+
'>',
132+
'> Legacy reply body.'
133+
].join('\n');
134+
135+
const entries = parseComments(commentsRaw);
136+
137+
expect(entries).toHaveLength(1);
138+
expect(entries[0].replies).toHaveLength(0);
139+
expect(entries[0].body).toContain('> **Reply by `@legacy-child`** on 2026-05-20T18:00:00Z');
140+
expect(entries[0].body).toContain('> Legacy reply body.')
141+
});
142+
143+
test('renders replies inside the parent comment bubble without timeline section records', () => {
144+
const html = renderReplies.call({
145+
repoUserUrl : 'https://github.com/',
146+
formatTimestamp: value => value
147+
}, [{
148+
depth: 1,
149+
user : 'child',
150+
date : '2026-05-20T18:00:00Z',
151+
body : 'Reply body.'
152+
}]);
153+
154+
expect(html).toContain('neo-discussion-replies');
155+
expect(html).toContain('neo-discussion-reply depth-1');
156+
expect(html).toContain('https://github.com/child');
157+
expect(html).toContain('replied on 2026-05-20T18:00:00Z');
158+
expect(html).toContain('<p>Reply body.</p>')
159+
});
160+
79161
test('category and closed-state badges render stable semantic classes', () => {
80162
const category = getCategoryBadgeHtml('Ideas'),
81163
closed = getClosedBadgeHtml(true),

0 commit comments

Comments
 (0)