Repository navigation
fix(web): pull request replies can be added to chat #34548
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
321e89e
6ec110e
2c79d20
1eebcfe
1e7e255
e3525a0
5633b52
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,7 @@ import { | |
| } from "@t3tools/client-runtime/source-control-clients"; | ||
| import * as Schema from "effect/Schema"; | ||
| import { | ||
| COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS, | ||
| GitCommandError, | ||
| PullRequestDetail, | ||
| PullRequestOperationError, | ||
|
|
@@ -702,6 +703,47 @@ function readableBody(raw: string): string | null { | |
| return readable === "" ? null : readable; | ||
| } | ||
|
|
||
| /** | ||
| * A body too long for the bound, with collapsed `<details>` sections moved after the text a | ||
| * reader sees so the bound keeps that text. Details shown inside code are source and stay put. | ||
| */ | ||
| function visibleTextFirst(body: string): string { | ||
| if (body.length <= FINDING_BODY_MAX_LENGTH) return body; | ||
| const codeLines = new Set<number>(); | ||
| const visit = (node: MdastNodes) => { | ||
| if (node.type === "code" && node.position !== undefined) { | ||
| for (let line = node.position.start.line; line <= node.position.end.line; line += 1) { | ||
| codeLines.add(line); | ||
| } | ||
| } | ||
| if ("children" in node) for (const child of node.children) visit(child); | ||
| }; | ||
| visit(fromMarkdown(body)); | ||
| const shown: string[] = []; | ||
| const folded: string[] = []; | ||
| // Whether each open section starts collapsed; `<details open>` reads as visible text. | ||
| const sections: boolean[] = []; | ||
| for (const [index, line] of body.split("\n").entries()) { | ||
| const inCode = codeLines.has(index + 1); | ||
| const opener = inCode ? null : /^\s*<details(?=[\s>])([^>]*)/iu.exec(line); | ||
| // Quoted values are dropped first so `title="open logs"` does not read as the attribute. | ||
| const attributes = opener?.[1]?.replace(/"[^"]*"|'[^']*'/gu, "") ?? ""; | ||
| if (opener) sections.push(!/(?:^|\s)open(?:[\s=/]|$)/iu.test(attributes)); | ||
| const closer = inCode || sections.length === 0 ? null : /<\/details>/iu.exec(line); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Process every closing details tag on a line. If nested sections end with Process all section boundaries on the line. Extend the existing closing-tag test with nested sections that close on the same line and assert that the following finding survives.
|
||
| if (closer === null) { | ||
| (sections.includes(true) ? folded : shown).push(line); | ||
| continue; | ||
| } | ||
| // Text after the closing tag is outside the section, so it goes with what follows. | ||
| const end = closer.index + closer[0].length; | ||
| (sections.includes(true) ? folded : shown).push(line.slice(0, end)); | ||
| sections.pop(); | ||
| const after = line.slice(end).trim(); | ||
| if (after !== "") (sections.includes(true) ? folded : shown).push(after); | ||
| } | ||
| return folded.length === 0 ? body : [...shown, "", ...folded].join("\n").trim(); | ||
| } | ||
|
|
||
| /** | ||
| * Merged wins over closed: GitHub sets both timestamps on a merge, and reporting "closed" for a | ||
| * merged pull request would misstate what happened. | ||
|
|
@@ -1250,8 +1292,34 @@ export function buildPullRequestContext(input: PullRequestContextMetadata): Revi | |
| return pullRequestContextComment(input, []); | ||
| } | ||
|
|
||
| /** A remark the reader can bring into the chat: a conversation on a line, or one comment. */ | ||
| export type PullRequestChatSubject = Extract<PullRequestFinding, { kind: "thread" | "comment" }>; | ||
| /** | ||
| * A conversation's lines within what the composer sends. Earlier replies give way first, since a | ||
| * reader reaches for the latest one, and the agent is told what it is not seeing. | ||
| */ | ||
| function threadText(lines: ReadonlyArray<string>, moreOnHost: boolean): string { | ||
| const tail = moreOnHost ? ["(More replies are on the host and not included here.)"] : []; | ||
| let kept = lines; | ||
| let omitted = 0; | ||
| const join = () => | ||
| [ | ||
| ...kept.slice(0, 1), | ||
| ...(omitted > 0 | ||
| ? [`(${omitted} earlier ${omitted === 1 ? "reply" : "replies"} omitted.)`] | ||
| : []), | ||
| ...kept.slice(1), | ||
| ...tail, | ||
| ].join("\n"); | ||
| while (kept.length > 2 && join().length > COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS) { | ||
| kept = [kept[0]!, ...kept.slice(2)]; | ||
| omitted += 1; | ||
| } | ||
| return join(); | ||
| } | ||
|
|
||
| /** A remark the reader can bring into the chat: a whole conversation, or one comment. */ | ||
| export type PullRequestChatSubject = | ||
| | { readonly kind: "thread"; readonly thread: PullRequestConversationThread } | ||
| | { readonly kind: "comment"; readonly comment: PullRequestComment }; | ||
|
|
||
| /** | ||
| * A remark or conversation brought into the chat to talk about, not to fix. Its id carries the | ||
|
|
@@ -1262,30 +1330,45 @@ export function buildPullRequestCommentContext( | |
| subject: PullRequestChatSubject, | ||
| ): ReviewCommentContext | null { | ||
| const pullRequestNumber = pullRequest.number; | ||
| if (subject.kind === "thread") { | ||
| const context = reviewThreadContext(subject.thread, pullRequestNumber); | ||
| return context.text === "" | ||
| ? null | ||
| : { ...context, id: `pr-comment:${pullRequest.url}:thread:${subject.thread.id}` }; | ||
| const comments = subject.kind === "thread" ? subject.thread.comments : [subject.comment]; | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| // Bounded per comment so a long bot report cannot crowd the replies out of the thread. | ||
| const lines = comments.flatMap((comment) => { | ||
| const body = readableBody(comment.body); | ||
| return body === null | ||
| ? [] | ||
| : [bounded(`${comment.author?.login ?? "ghost"}: ${visibleTextFirst(body)}`)]; | ||
| }); | ||
| const first = comments[0]; | ||
| if (lines.length === 0 || first === undefined) return null; | ||
| const text = threadText( | ||
| lines, | ||
| subject.kind === "thread" && | ||
| ((subject.thread.commentCount ?? 0) > comments.length || | ||
| subject.thread.nextCommentsCursor !== undefined), | ||
| ); | ||
| if (subject.kind === "thread" && subject.thread.path !== null) { | ||
| return { | ||
| ...reviewThreadContext(subject.thread, pullRequestNumber), | ||
| id: `pr-comment:${pullRequest.url}:thread:${subject.thread.id}`, | ||
| text, | ||
| }; | ||
| } | ||
| const comment = subject.comment; | ||
| const body = readableBody(comment.body); | ||
| if (body === null) return null; | ||
| const author = comment.author?.login ?? "ghost"; | ||
| const path = subject.kind === "comment" ? subject.comment.path : null; | ||
| return { | ||
| id: `pr-comment:${pullRequest.url}:comment:${comment.id}`, | ||
| id: | ||
| subject.kind === "thread" | ||
| ? `pr-comment:${pullRequest.url}:thread:${subject.thread.id}` | ||
| : `pr-comment:${pullRequest.url}:comment:${subject.comment.id}`, | ||
| sectionId: `pull-request:${pullRequestNumber}`, | ||
| sectionTitle: `PR #${pullRequestNumber} conversation`, | ||
| // The chip wears `filePath rangeLabel`: the file a remark names, or who wrote one that names none. | ||
| filePath: comment.path ?? `@${author}`, | ||
| // The chip wears `filePath rangeLabel`: the file a remark names, or who opened one that names none. | ||
| filePath: path ?? `@${first.author?.login ?? "ghost"}`, | ||
| startIndex: 0, | ||
| endIndex: 0, | ||
| rangeLabel: "comment", | ||
| text: bounded(`${author}: ${body}`), | ||
| rangeLabel: subject.kind === "thread" ? "thread" : "comment", | ||
| text, | ||
| diff: "", | ||
| ...(comment.path === null | ||
| ? {} | ||
| : { fenceLanguage: inferReviewCommentFenceLanguage(comment.path) }), | ||
| ...(path === null ? {} : { fenceLanguage: inferReviewCommentFenceLanguage(path) }), | ||
| }; | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.