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
94 changes: 94 additions & 0 deletions build/shared/landing.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* @file Building the commit message a pull request lands as.
* @author The OpenINF Authors & Friends
* @license MIT OR Apache-2.0 OR BlueOak-1.0.0
* @module {type ES6Module} build/shared/landing
*
* Nothing here knows what a subject is supposed to look like. Composing a
* landed message -- keeping every commit's words, gathering the trailers into
* the one paragraph git reads, adding the pull request it came through -- is
* the same job whatever house style a repository writes its subjects in, and
* the repositories in this organization do not agree on that yet.
*/

import { TRAILER_ORDER } from '@openinf/portal/build/commit-message';

/** One commit's message, split into the parts a landed message reuses. */
export type CommitParts = {
subject: string;
body: string[];
trailers: string[];
};

const tokenOf = (line: string) => line.match(/^([A-Za-z][\w-]*):/)?.[1] ?? '';

/**
* Splits a commit message into the parts a landed message reuses. Trailers
* are lifted out wherever they were written, because a squashed message can
* only have one trailer block and it has to be at the end.
* @param {string} message One commit's whole message.
* @returns {CommitParts} Its subject, its body, and the trailers it carried.
*/
export function partsOfMessage(message: string): CommitParts {
const [subject = '', ...rest] = message
.replace(/[\r\n]+$/, '')
Comment on lines +33 to +34
.split(/\r?\n/);
const body: string[] = [];
const trailers: string[] = [];

for (const line of rest) {
if (TRAILER_ORDER.includes(tokenOf(line))) trailers.push(line);
else body.push(line);
}

while (body.at(-1)?.trim() === '') body.pop();
while (body.at(0)?.trim() === '') body.shift();

return { subject, body, trailers };
}

/**
* Builds the message a pull request should land as. One commit lands as
* itself. Several land as one, every message kept whole so that nothing
* written down is lost -- each subject becomes a heading in the body, the
* first included, since a pull request title describes the whole and need not
* be any single commit's subject.
* @param {CommitParts[]} parts Each commit on the branch, oldest first.
* @param {string} prUrl The pull request the commits are landing through.
* @returns {string} Everything below the subject line.
*/
export function composeLandingMessage(parts: CommitParts[], prUrl: string) {
const paragraphs =
parts.length === 1
? (parts[0]?.body ?? [])
: parts.flatMap((part, index) => [
...(index === 0 ? [] : ['']),
part.subject,
'',
...part.body,
]);

const rank = (line: string) => TRAILER_ORDER.indexOf(tokenOf(line));
const gathered = [
...new Set([...parts.flatMap((part) => part.trailers), `PR-URL: ${prUrl}`]),
].sort((one, other) => rank(one) - rank(other));

// `Fixes:` says everything `Refs:` would about the same issue.
const fixed = new Set(
gathered
.filter((line) => line.startsWith('Fixes:'))
.map((line) => line.slice('Fixes:'.length).trim())
);

return [
...paragraphs,
'',
...gathered.filter(
(line) =>
!(
line.startsWith('Refs:') &&
fixed.has(line.slice('Refs:'.length).trim())
)
),
].join('\n');
}
177 changes: 177 additions & 0 deletions build/shared/landing.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* @file Tests for building the message a pull request lands as.
* @author The OpenINF Authors & Friends
* @license MIT OR Apache-2.0 OR BlueOak-1.0.0
* @module {type ES6Module} build/shared/landing.test
*/

import { deepStrictEqual, match, ok } from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { describe, test } from 'node:test';
import { validateCommitMessage } from '@openinf/portal/build/commit-message';
import {
composeLandingMessage,
partsOfMessage,
} from '@openinf/portal/build/landing';

const URL_ = 'https://github.com/OpenINF/openinf.github.io/pull/1234';

describe('partsOfMessage', () => {
test('separates subject, body and trailers', () => {
deepStrictEqual(
partsOfMessage(
'🏗️🔧:fix it\n\nWhy it needed fixing.\n\nSigned-off-by: A <a@e>\n'
),
{
subject: '🏗️🔧:fix it',
body: ['Why it needed fixing.'],
trailers: ['Signed-off-by: A <a@e>'],
}
);
});

test('lifts a trailer out of the middle of a body', () => {
// A squashed message can only have one trailer block, at the end, so one
// written half way up has to be found wherever it is.
const { body, trailers } = partsOfMessage(
'🏗️🔧:fix it\n\nRefs: https://x/1\n\nMore explanation.'
);

deepStrictEqual(trailers, ['Refs: https://x/1']);
deepStrictEqual(body, ['More explanation.']);
});

test('reads a message written with carriage returns', () => {
deepStrictEqual(
partsOfMessage('🏗️🔧:fix it\r\n\r\nBody.\r\n\r\nFixes: https://x/1\r\n')
.trailers,
['Fixes: https://x/1']
);
});
});

describe('composeLandingMessage', () => {
test('a single commit lands as itself, plus where it came from', () => {
const parts = [partsOfMessage('🏗️🔧:fix it\n\nWhy.\n\nRefs: https://x/1')];

// `PR-URL` comes before `Refs` in the documented order, so the trailer
// the commit carried moves below the one the landing adds.
deepStrictEqual(
composeLandingMessage(parts, URL_),
`Why.\n\nPR-URL: ${URL_}\nRefs: https://x/1`
);
});

test('several commits keep every word, subjects as headings', () => {
const parts = [
partsOfMessage('🏗️✨:the first thing\n\nWhy the first.'),
partsOfMessage('🏗️🔧:the second thing\n\nWhy the second.'),
];

deepStrictEqual(
composeLandingMessage(parts, URL_),
[
'🏗️✨:the first thing',
'',
'Why the first.',
'',
'🏗️🔧:the second thing',
'',
'Why the second.',
'',
`PR-URL: ${URL_}`,
].join('\n')
);
});

test('gathers scattered trailers into one block, in order', () => {
const parts = [
partsOfMessage('🏗️✨:one\n\nA.\n\nFixes: https://x/9'),
partsOfMessage(
'🏗️🔧:two\n\nB.\n\nAssisted-by: Claude-Code:claude-opus-5\nSigned-off-by: D <d@e>'
),
];
const message = composeLandingMessage(parts, URL_);
const block = message.slice(message.lastIndexOf('\n\n') + 2).split('\n');

deepStrictEqual(block, [
'Signed-off-by: D <d@e>',
'Assisted-by: Claude-Code:claude-opus-5',
`PR-URL: ${URL_}`,
'Fixes: https://x/9',
]);
});

test('keeps one copy of a trailer both commits carried', () => {
const signed = 'Signed-off-by: D <d@e>';
const parts = [
partsOfMessage(`🏗️✨:one\n\nA.\n\n${signed}`),
partsOfMessage(`🏗️🔧:two\n\nB.\n\n${signed}`),
];

deepStrictEqual(
composeLandingMessage(parts, URL_)
.split('\n')
.filter((l) => l === signed).length,
1
);
});

test('drops a Refs that duplicates a Fixes', () => {
const parts = [
partsOfMessage('🏗️✨:one\n\nA.\n\nRefs: https://x/9'),
partsOfMessage('🏗️🔧:two\n\nB.\n\nFixes: https://x/9'),
];
const message = composeLandingMessage(parts, URL_);

ok(message.includes('Fixes: https://x/9'));
ok(!message.includes('Refs: https://x/9'));
});
});

describe('the message that comes out', () => {
test('passes the rules a commit answers to', () => {
const parts = [
partsOfMessage(
'🏗️✨:one\n\nA reason.\n\nSigned-off-by: D <d@e>\nAssisted-by: Claude-Code:claude-opus-5'
),
partsOfMessage('🏗️🔧:two\n\nAnother reason.'),
];
const subject = '🏗️✨:land two things at once';

deepStrictEqual(
validateCommitMessage(
`${subject}\n\n${composeLandingMessage(parts, URL_)}`
),
[]
);
});

test('ends in a block git reads as trailers', () => {
const parts = [
partsOfMessage('🏗️✨:one\n\nA reason.\n\nSigned-off-by: D <d@e>'),
];
const message = `🏗️✨:one\n\n${composeLandingMessage(parts, URL_)}`;
const parsed = execFileSync('git', ['interpret-trailers', '--parse'], {
encoding: 'utf8',
input: message,
})
.split('\n')
.filter(Boolean);

deepStrictEqual(parsed, ['Signed-off-by: D <d@e>', `PR-URL: ${URL_}`]);
});

test('a body that would be too wide is still reported', () => {
// The rules are applied to the composed message, not to the commits it
// came from, so an over-wide line cannot slip through the join.
const parts = [partsOfMessage(`🏗️✨:one\n\n${'word '.repeat(20)}`)];

match(
validateCommitMessage(
`🏗️✨:one\n\n${composeLandingMessage(parts, URL_)}`
).join(),
/the limit is 72/
);
});
});
Loading