Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions apps/server/src/orchestration-v2/pullRequestWatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,58 @@ describe("evaluatePullRequestWatch", () => {
assert.include(text, `"${"x".repeat(196)}..."`);
});

it("quotes a remark's text instead of its leading HTML, and keeps tags in prose and code", () => {
const badge =
'<a href="https://app.coderabbit.ai/change-stack/o/r/pull/1?cs_source=review_comment"><picture><source media="(prefers-color-scheme: dark)" srcset="https://coderabbit.ai/badge-dark.svg"><img alt="Severity > `Minor`" src="https://coderabbit.ai/badge.svg"></picture></a>';
const report = evaluatePullRequestWatch(watch(), detail(), [
remark(
"coderabbitai",
"2026-10-02T12:06:00Z",
`${badge}\n\n_⚠️ Potential issue_ | _🟠 Major_\n\n**Guard the empty list.**`,
),
remark(
"reviewer",
"2026-10-02T12:07:00Z",
"Return Promise<void>, not Promise<A> or Promise<a>.",
),
remark(
"designer",
"2026-10-02T12:08:00Z",
"Replace `<div>` with ``<span>``:\n~~~html\n<p>Done</p>\n~~~",
),
remark("maintainer", "2026-10-02T12:09:00Z", "<p>Guard this</p>, and `<!--` stays escaped."),
remark(
"summary-bot",
"2026-10-02T12:10:00Z",
`<!-- ${"x".repeat(3_000)} -->\nCheck the retry.`,
),
remark(
"fencer",
"2026-10-02T12:12:00Z",
"Use this literal opener:\n```html\n<!--\n```\nGuard the empty list before indexing it.",
),
remark("spacer", "2026-10-02T12:11:00Z", `${"\n".repeat(2_001)}Guard the empty list.`),
]);
const { text } = pullRequestWatchMessage({
number: 1,
url: "https://github.com/o/r/pull/1",
baseBranch: "main",
headSha: null,
report,
});
assert.include(text, `"_⚠️ Potential issue_ | _🟠 Major_ **Guard the empty list.**"`);
assert.include(text, `"Return Promise<void>, not Promise<A> or Promise<a>."`);
assert.include(text, '"Replace `<div>` with ``<span>``: ~~~html <p>Done</p> ~~~"');
assert.include(text, 'maintainer on src/index.ts: "Guard this , and `<!--` stays escaped."');
assert.include(text, '"Check the retry."');
assert.include(
text,
'"Use this literal opener: ```html <!-- ``` Guard the empty list before indexing it."',
);
assert.include(text, 'spacer on src/index.ts: "Guard the empty list."');
assert.notInclude(text, "<picture");
});

it("reports a conflict once, until the branch is clean again", () => {
const conflicting = detail({ mergeability: "conflicting" });
const first = evaluatePullRequestWatch(watch(), conflicting, noRemarks);
Expand Down
29 changes: 27 additions & 2 deletions apps/server/src/orchestration-v2/pullRequestWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10;
export const PULL_REQUEST_WATCH_NO_CHECKS_MS = 15 * 60_000;
const LISTED_ITEMS = 10;
const SNIPPET_LENGTH = 200;
// Remark bodies are untrusted and run to 64 KB, and unclosed openers make the markup pass
// backtrack, so it only reads far enough to fill an excerpt once HTML comments are gone.
const SNIPPET_SCAN_LENGTH = 2_000;
// One left-to-right pass keeps code spans' tags and reads attribute backticks as part of the tag.
// A lookahead pins each delimiter run at full length, so a long run can't backtrack cubically.
// Element names are lowercase. A one-letter element needs attributes or a later closing tag,
// so Promise<a> stays.
const MARKUP =
/(?<code>(?<!`)(?=(?<ticks>`+))\k<ticks>[\s\S]*?(?<!`)\k<ticks>(?!`)|(?<!~)(?=(?<tildes>~{3,}))\k<tildes>[\s\S]*?\k<tildes>)|<(?<one>[abip])>(?=[\s\S]*?<\/\k<one>>)|<\/(?:a|b|blockquote|br|code|dd|details|div|dl|dt|em|h[1-6]|hr|i|img|li|ol|p|picture|pre|source|span|strong|sub|summary|sup|table|tbody|td|th|thead|tr|ul)\s*>|<(?:blockquote|br|code|dd|details|div|dl|dt|em|h[1-6]|hr|img|li|ol|picture|pre|source|span|strong|sub|summary|sup|table|tbody|td|th|thead|tr|ul)(?=[\s/>])(?:[^>"']|"[^"]*"|'[^']*')*>|<(?:a|b|i|p)\s(?:[^>"']|"[^"]*"|'[^']*')*>/g;

export type PullRequestWatchChange =
| { readonly kind: "checks-failed"; readonly failed: ReadonlyArray<PullRequestCheck> }
Expand Down Expand Up @@ -261,9 +270,25 @@ export function evaluatePullRequestWatch(
};
}

// indexOf keeps this linear on unclosed openers, so it can read the whole body.
function withoutHtmlComments(body: string): string {
let text = "";
let from = 0;
for (let open = body.indexOf("<!--"); open !== -1; open = body.indexOf("<!--", from)) {
const close = body.indexOf("-->", open + 4);
if (close === -1) break;
text += `${body.slice(from, open)} `;
from = close + 3;
}
return text + body.slice(from);
}

function snippet(body: string): string {
const text = body
.replaceAll(/<!--[\s\S]*?-->/g, " ")
const text = withoutHtmlComments(body)
.replaceAll(/\s+/g, " ")
.slice(0, SNIPPET_SCAN_LENGTH)
Comment thread
onlydole marked this conversation as resolved.
Resolved
.toWellFormed()
.replaceAll(MARKUP, (match, code: string | undefined) => (code === undefined ? " " : match))
.replaceAll(/\s+/g, " ")
.trim();
if (text.length <= SNIPPET_LENGTH) return text;
Expand Down
Loading