Skip to content
Merged
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
68 changes: 68 additions & 0 deletions build/shared/pull-request.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* @file What a reader would see of a pull request description.
* @author The OpenINF Authors & Friends
* @license MIT OR Apache-2.0 OR BlueOak-1.0.0
* @module {type ES6Module} build/shared/pull-request
*/

/**
* Strips HTML comments the way a renderer does: left to right, in one pass,
* each comment ending at the first `-->` after it. Removing them with a
* repeated replacement instead lets what is left over form a new comment --
* `<!<!-- x -->-- sneaky -->` becomes `<!-- sneaky -->`, which a reader still
* cannot see, so a description made of nothing would have counted as one.
* @param {string} text The description as written.
* @returns {string} What is left once the comments are gone.
*/
const OPEN = '<!--';
const CLOSE = '-->';

/**
* Removes every comment in one left-to-right pass, each ending at the first
* `-->` after it.
* @param {string} text What to read.
* @returns {string} The same, with its comments gone.
*/
const withoutComments = (text: string) => {
let kept = '';
let index = 0;

while (index < text.length) {
const start = text.indexOf(OPEN, index);

if (start === -1) {
kept += text.slice(index);
break;
}

kept += text.slice(index, start);

const end = text.indexOf(CLOSE, start + OPEN.length);

// An unterminated comment runs to the end, which is what a renderer does
// with one too.
if (end === -1) break;

index = end + CLOSE.length;
}

return kept;
};

export function visibleText(text: string) {
let visible = text;

// Removing a comment can leave what surrounded it forming another, so this
// repeats until nothing changes. Each pass is a scan and each removes at
// least one comment, and the cap keeps a pathological description from
// turning that into a great many passes.
for (let pass = 0; pass < 8; pass += 1) {
const shorter = withoutComments(visible);

if (shorter === visible) break;

visible = shorter;
}

return visible.trim();
}
63 changes: 63 additions & 0 deletions build/shared/pull-request.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* @file Tests for what a reader would see of a pull request description.
* @author The OpenINF Authors & Friends
* @license MIT OR Apache-2.0 OR BlueOak-1.0.0
* @module {type ES6Module} build/shared/pull-request.test
*/

import { deepStrictEqual, ok } from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { describe, test } from 'node:test';
import { visibleText } from '@openinf/portal/build/pull-request';

describe('visibleText', () => {
test('keeps what a reader would see', () => {
deepStrictEqual(
visibleText('<!-- guidance -->A real description.'),
'A real description.'
);
});

test('finds nothing in a description that is only a comment', () => {
deepStrictEqual(visibleText('<!-- guidance -->'), '');
});

test('leaves nothing behind that could form another comment', () => {
// A repeated replacement turns this into `<!-- sneaky -->`, which renders
// as nothing -- so a description made of it would have counted as one.
deepStrictEqual(visibleText('<!<!-- x -->-- sneaky -->'), '');
});

test('treats an unterminated comment as running to the end', () => {
deepStrictEqual(visibleText('shown<!-- and then nothing'), 'shown');
});

test('handles several comments around real words', () => {
deepStrictEqual(
visibleText('<!--a-->one<!--b-->two<!--c-->'),
'one\ntwo'.replace('\n', '')
);
});

test('the template on its own leaves nothing', async () => {
// The template is one long comment, so a pull request opened without a
// word written renders as empty. That is the case this exists to catch.
const template = await readFile(
new URL('../../.github/PULL_REQUEST_TEMPLATE.md', import.meta.url),
'utf8'
);

deepStrictEqual(visibleText(template), '');
});

test('reads a long description in linear time', () => {
const text = `${'<!-- c -->x'.repeat(100_000)}end`;
const started = performance.now();

ok(visibleText(text).endsWith('end'));

const spent = performance.now() - started;

ok(spent < 3000, `took ${spent.toFixed(0)}ms`);
});
});
3 changes: 2 additions & 1 deletion build/tasks/verify-pull-request.mts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { validateCommitMessage } from '@openinf/portal/build/commit-message';
import { visibleText } from '@openinf/portal/build/pull-request';

// Read from the environment rather than interpolated into a command by the
// workflow: a title is written by whoever opened the pull request, and pasting
Expand All @@ -32,7 +33,7 @@ if (process.env.PR_TITLE === undefined) {
// The template is one long HTML comment, so a pull request opened without a
// word written renders as nothing at all. That is what this looks for --
// not what the description says, only that there is one.
if (body.replaceAll(/<!--[\s\S]*?-->/g, '').trim() === '') {
if (visibleText(body) === '') {
problems.push(
'description: say why the change is needed and what it does. The ' +
'template is a comment, so a description that is only the template ' +
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"./build/commit-message": "./build/shared/commit-message.mts",
"./build/constants": "./build/shared/constants.mts",
"./build/landing": "./build/shared/landing.mts",
"./build/pull-request": "./build/shared/pull-request.mts",
"./build/utils": "./build/utils.mts"
},
"homepage": "open.inf.is",
Expand Down
Loading