feat(commitperclip): surface the Co-Authored-By trailers a squash merge needs (#11498)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work, and it takes contributions from outside the core team
> - Those contributions arrive as PRs, and this repository squash-merges
every one of them
> - A squash collapses the whole branch into a single commit authored by
whoever pressed the button
> - So when a maintainer rebases and lands a contributor's stale PR, the
contributor's name survives only if the squash message carries a
`Co-Authored-By` trailer
> - Nothing prompts for that trailer, and the PR page keeps showing the
original author either way, so losing it is invisible at the moment it
happens
> - This pull request has commitperclip detect the situation and print
the exact trailers to paste
> - The benefit is that keeping an outside contributor's name is a
default rather than something a maintainer has to remember

## Linked Issues or Issue Description

No public issue exists. The problem follows, and it is not hypothetical.

**What happened?**

#11370, #11371 and #11379 landed @stubbi's work yesterday. Each of those
PRs carries a comment from me telling them their authorship would be
preserved. All three squash commits went in without a `Co-Authored-By`
trailer, so `git log` credits none of them:

| commit | landed from | credited |
| --- | --- | --- |
| `66515582e` | #9900 | Claude only |
| `bc0b5a164` | #9501 | Claude only |
| `35a9b9873` | #8982 | Claude only |
| `6542ad1f4` | #11259 |  Jannes Stubbemann + Claude |

The last one has the trailer because that message was written by hand
with the contributor in mind. The only difference between the two
outcomes was memory. Master history cannot be rewritten, so those three
are now credited by comment on the original PRs — which is a worse
record than a commit trailer, and the reason to make this automatic.

**Expected behavior**

When a branch carries commits by someone other than the PR author, the
merger is told what trailers the squash needs.

**Paperclip version or commit**

`master` at `92047cac4`.

## What Changed

- `.github/scripts/check-pr-coauthors.mjs` — new gate.
- `.github/scripts/run-quality-gates.mjs` — fetches the PR's commits and
runs it.
- `.github/scripts/tests/check-pr-coauthors.test.mjs` — 12 cases.
- `.github/workflows/pr.yml` — runs `.github/scripts/tests/`.

### Informational, not a failure

The squash message does not exist while the PR is open. This can neither
be verified there nor fixed there, so failing a PR on it would block
work on something its author cannot satisfy. The gate notices that the
situation applies and prints the lines to paste.

Run against #11370's actual commits it produces exactly what was
missing:

```
This branch carries commits by stubbi. Squash-merging drops that authorship
unless the squash message carries their trailers, and nothing else will notice
if it does not. Add to the squash body when merging:

      Co-Authored-By: Jannes Stubbemann <stubbi@users.noreply.github.com>
```

### Edge cases it handles

Bots skipped; the PR author's own commits skipped; logins compared
case-insensitively (`PR_AUTHOR` does not always arrive in the same case
as the commit author login); each contributor listed once however many
commits they wrote; and a commit GitHub could not match to an account
falls back to its raw git author — that identity being the one most
likely to be lost, not least likely.

Paging stops at the API's own 250-commit ceiling rather than spinning on
full pages of nothing new.

### The test directory was not running

`.github/scripts/tests/` held ten test files covering the existing
gates, and no workflow ran any of them. Adding an eleventh would have
meant adding a test that never executes, so `pr.yml` now runs the
directory. All **149** pass, including the 137 that were already there
and previously unverified in CI.

## Verification

- 149 tests pass via `node --test '.github/scripts/tests/*.test.mjs'` —
the exact command CI now runs.
- The gate was run against the real commit shape from #11370 and
produces the missing trailer verbatim.

This PR is its own negative control: the branch carries only my commits,
so the new gate should stay silent on it. If commitperclip prints a
co-author note below, the gate is wrong.

## Risks

Low. Informational output only — it cannot fail a PR, and `allPassed` is
unchanged.

It adds one API call per gate run (`/pulls/{n}/commits`), fetched in the
same `Promise.all` as the existing PR and files calls.

Enabling the previously-unrun test directory could in principle surface
a pre-existing failure; all 149 pass locally, so it does not.

Revert the commit to restore.

## Model Used

Claude Opus 5 (`claude-opus-5`), through Claude Code. Extended thinking
enabled. Tool use enabled: file read and edit, shell for test runs.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tonio 2026-08-21 16:44:26 -07:00 committed by GitHub
parent 3d366ba15f
commit 24913064ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 330 additions and 0 deletions

135
.github/scripts/check-pr-coauthors.mjs vendored Normal file
View File

@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* check-pr-coauthors.mjs
* Surfaces the `Co-Authored-By` trailers a squash merge needs to keep
* contributors credited.
* Export: checkCoauthors(commits, prAuthor) { passed, informational }
*
* This repository squash-merges, so every commit on a branch collapses into
* one commit authored by whoever presses the button. When a branch carries
* someone else's work a rebase of a stale contributor PR, a port of an
* abandoned branch, a pairing session their name survives only if the squash
* message carries a `Co-Authored-By` trailer for them. Nothing prompts for it,
* and the PR page keeps showing the original author either way, so the loss is
* invisible at exactly the moment it happens.
*
* Identity matching is a heuristic and is deliberately biased. A commit GitHub
* could not match to an account is credited unless its name or email resolves
* to the PR author, which will occasionally credit someone as a co-author of
* themselves their git config carrying a real name where the comparison has
* only a login. That error costs a line a human drops while pasting. The
* opposite error costs a contributor their attribution silently, which is the
* failure this gate exists to prevent, so the bias runs towards over-crediting.
*
* Informational rather than a failure, on purpose. The squash message does not
* exist while the PR is open, so this cannot be verified here and cannot be
* fixed here either. Failing the PR would block work on something its author
* has no way to satisfy. What this can do is notice that the situation applies
* and hand over the exact lines to paste.
*/
import { fileURLToPath } from 'node:url';
/**
* Fetches every commit on a PR across GitHub pagination.
*
* Capped at the API's own ceiling: `/pulls/{n}/commits` returns at most 250
* commits and silently stops. A branch that large is not the case this gate is
* about, and a partial list still surfaces the contributors it did see.
*/
export async function fetchAllPullRequestCommits(ghFetchFn, repo, prNumber, token) {
const commits = [];
for (let page = 1; page <= 3; page += 1) {
const batch = await ghFetchFn(
`/repos/${repo}/pulls/${prNumber}/commits?per_page=100&page=${page}`,
token
);
commits.push(...batch);
if (batch.length < 100) break;
}
return commits;
}
/** GitHub's own no-reply address for a login, which is what trailers should use. */
function noReplyEmail(login) {
return `${login}@users.noreply.github.com`;
}
export function checkCoauthors(commits, prAuthor) {
const author = (prAuthor ?? '').toLowerCase();
const contributors = new Map();
// Emails already accounted for under a GitHub login. One person can appear
// both ways in the same branch — some commits matched to their account, some
// authored with an email GitHub does not know — and keying on login alone
// would then emit two trailers for them.
const seenEmails = new Set();
for (const entry of commits ?? []) {
const login = entry?.author?.login ?? null;
const gitName = entry?.commit?.author?.name ?? null;
const gitEmail = entry?.commit?.author?.email ?? null;
// The PR author's own commits need no trailer — the squash is already
// theirs. Compared case-insensitively because GitHub logins are.
if (login && author && login.toLowerCase() === author) continue;
// Bots author plenty of commits and crediting them is noise.
if (login && /\[bot\]$/.test(login)) continue;
if (!login && !gitName) continue;
// A commit GitHub could not match to an account may still be the PR
// author's own — their git config carrying an email GitHub does not know.
// Without this they are listed as a co-author of themselves.
if (!login && author) {
const nameMatches = gitName && gitName.toLowerCase() === author;
const emailMatches = gitEmail && gitEmail.toLowerCase().startsWith(`${author}@`);
if (nameMatches || emailMatches) continue;
}
// Prefer the GitHub identity, so the trailer links to a profile. Fall back
// to the raw git author for a commit GitHub could not match to an account.
const name = login ?? gitName;
const email = login ? noReplyEmail(login) : gitEmail;
if (!email) continue;
// Keyed on identity, not on the rendered line. One person whose git config
// name changed across commits is still one person, and emitting them twice
// would put two trailers for the same contributor into the squash body.
const key = (login ?? gitEmail ?? name).toLowerCase();
if (contributors.has(key)) continue;
const emailKey = (gitEmail ?? '').toLowerCase();
if (emailKey && seenEmails.has(emailKey)) continue;
if (emailKey) seenEmails.add(emailKey);
const displayName = gitName && login ? gitName : name;
contributors.set(key, {
trailer: `Co-Authored-By: ${displayName} <${email}>`,
name: displayName,
});
}
if (contributors.size === 0) return { passed: true, informational: [] };
const trailers = [...contributors.values()].map(c => c.trailer).sort();
const names = [...new Set([...contributors.values()].map(c => c.name))].sort();
const who = names.length === 1 ? names[0] : `${names.length} other contributors`;
return {
passed: true,
informational: [
`This branch carries commits by ${who}. Squash-merging drops that authorship unless ` +
'the squash message carries their trailers, and nothing else will notice if it does not. ' +
'Add to the squash body when merging:\n\n' +
trailers.map(line => ` ${line}`).join('\n'),
],
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const commits = JSON.parse(process.env.PR_COMMITS ?? '[]');
const result = checkCoauthors(commits, process.env.PR_AUTHOR ?? '');
console.log(JSON.stringify(result));
process.exit(0);
}

View File

@ -17,6 +17,7 @@ import { checkTestCoverage } from './check-pr-test-coverage.mjs';
import { checkLockfile } from './check-pr-lockfile.mjs';
import { checkDependencies } from './check-pr-dependencies.mjs';
import { checkReleaseBootstrap } from './check-pr-release-bootstrap.mjs';
import { checkCoauthors, fetchAllPullRequestCommits } from './check-pr-coauthors.mjs';
const COMMENT_SIGNATURE = '— commitperclip';
@ -106,6 +107,17 @@ async function main() {
fetchAllPullRequestFiles(ghFetch, GH_REPO, prNumber, GH_TOKEN),
]);
// Separate, and allowed to fail. The co-author note is informational: it
// cannot fail a PR by design, so it must not be able to fail the workflow by
// accident either. Sharing the Promise.all above would let one transient
// 5xx on this request take down every gate, including the ones that block.
let commits = [];
try {
commits = await fetchAllPullRequestCommits(ghFetch, GH_REPO, prNumber, GH_TOKEN);
} catch (error) {
console.error(`co-author lookup skipped: ${error.message}`);
}
const prBody = pr.body ?? '';
const author = PR_AUTHOR ?? pr.user.login;
const branch = PR_BRANCH ?? pr.head.ref;
@ -122,6 +134,7 @@ async function main() {
checkDependencies(files, GH_TOKEN, GH_REPO, prNumber, pr.base?.ref),
checkReleaseBootstrap(files, GH_TOKEN, GH_REPO, prNumber, pr.base?.ref),
]);
const coauthorResult = checkCoauthors(commits, author);
const allFailures = [
...templateResult.failures,
@ -133,6 +146,7 @@ async function main() {
const informational = [
...(depsResult.informational ?? []),
...(bootstrapResult.informational ?? []),
...coauthorResult.informational,
];
const allPassed = allFailures.length === 0;

View File

@ -0,0 +1,179 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkCoauthors, fetchAllPullRequestCommits } from '../check-pr-coauthors.mjs';
function commit(login, name = null, email = null) {
return {
author: login ? { login } : null,
commit: { author: { name: name ?? login, email: email ?? `${login}@users.noreply.github.com` } },
};
}
test('checkCoauthors: says nothing when every commit is the PR author\'s own', () => {
const result = checkCoauthors(
[commit('tonio-alucema'), commit('tonio-alucema')],
'tonio-alucema'
);
assert.equal(result.passed, true);
assert.deepEqual(result.informational, []);
});
test('checkCoauthors: hands over the trailer when the branch carries someone else\'s commit', () => {
// The case this exists for: a stale contributor PR rebased and landed by a
// maintainer. Squash-merging drops the contributor unless the squash body
// carries their trailer.
const result = checkCoauthors(
[commit('stubbi', 'Jannes Stubbemann'), commit('tonio-alucema')],
'tonio-alucema'
);
assert.equal(result.informational.length, 1);
assert.match(result.informational[0], /Jannes Stubbemann/);
assert.match(
result.informational[0],
/Co-Authored-By: Jannes Stubbemann <stubbi@users\.noreply\.github\.com>/
);
});
test('checkCoauthors: never fails the PR, because the squash message does not exist yet', () => {
// Informational only. The author of the PR cannot satisfy this from the PR,
// so failing here would block work on something unfixable at that point.
const result = checkCoauthors([commit('stubbi')], 'tonio-alucema');
assert.equal(result.passed, true);
});
test('checkCoauthors: matches the PR author case-insensitively', () => {
// GitHub logins are case-insensitive, and PR_AUTHOR does not always arrive
// in the same case as the commit author login.
const result = checkCoauthors([commit('Tonio-Alucema')], 'tonio-alucema');
assert.deepEqual(result.informational, []);
});
test('checkCoauthors: ignores bots', () => {
const result = checkCoauthors(
[commit('github-actions[bot]'), commit('dependabot[bot]')],
'tonio-alucema'
);
assert.deepEqual(result.informational, []);
});
test('checkCoauthors: lists each contributor once, however many commits they wrote', () => {
const result = checkCoauthors(
[commit('stubbi', 'Jannes Stubbemann'), commit('stubbi', 'Jannes Stubbemann')],
'tonio-alucema'
);
const trailers = result.informational[0].match(/Co-Authored-By:/g) ?? [];
assert.equal(trailers.length, 1);
});
test('checkCoauthors: falls back to the raw git author when GitHub matched no account', () => {
// A commit authored with an email GitHub cannot resolve still deserves a
// trailer — that is precisely the identity most likely to be lost.
const result = checkCoauthors(
[{ author: null, commit: { author: { name: 'Ada Lovelace', email: 'ada@example.com' } } }],
'tonio-alucema'
);
assert.match(result.informational[0], /Co-Authored-By: Ada Lovelace <ada@example\.com>/);
});
test('checkCoauthors: skips an unattributable commit rather than emitting a broken trailer', () => {
const result = checkCoauthors(
[{ author: null, commit: { author: { name: 'Nameless', email: null } } }],
'tonio-alucema'
);
assert.deepEqual(result.informational, []);
});
test('checkCoauthors: names the count rather than everyone when several contributed', () => {
const result = checkCoauthors(
[commit('stubbi', 'Jannes Stubbemann'), commit('elJayAdvisor', 'LJ')],
'tonio-alucema'
);
assert.match(result.informational[0], /2 other contributors/);
assert.match(result.informational[0], /Jannes Stubbemann/);
assert.match(result.informational[0], /LJ/);
});
test('checkCoauthors: tolerates a PR with no commits', () => {
assert.deepEqual(checkCoauthors([], 'tonio-alucema').informational, []);
assert.deepEqual(checkCoauthors(undefined, 'tonio-alucema').informational, []);
});
test('fetchAllPullRequestCommits: pages until a short batch', async () => {
const seen = [];
const commits = await fetchAllPullRequestCommits(async (path) => {
seen.push(path);
if (path.endsWith('page=1')) return Array.from({ length: 100 }, () => commit('stubbi'));
return [commit('tonio-alucema')];
}, 'paperclipai/paperclip', 9900, 'token');
assert.equal(commits.length, 101);
assert.equal(seen.length, 2);
});
test('fetchAllPullRequestCommits: stops at the API ceiling instead of looping', async () => {
// `/pulls/{n}/commits` caps at 250 and keeps returning full pages of nothing
// new past that. A branch that large is not what this gate is about, but it
// must not spin.
let calls = 0;
const commits = await fetchAllPullRequestCommits(async () => {
calls += 1;
return Array.from({ length: 100 }, () => commit('stubbi'));
}, 'paperclipai/paperclip', 9900, 'token');
assert.equal(calls, 3);
assert.equal(commits.length, 300);
});
test('checkCoauthors: counts one person once when their git name varies across commits', () => {
// People change their git config. Keying the dedup on the rendered trailer
// would put two lines for the same contributor into the squash body.
const result = checkCoauthors(
[commit('stubbi', 'Jannes Stubbemann'), commit('stubbi', 'J. Stubbemann')],
'tonio-alucema'
);
const trailers = result.informational[0].match(/Co-Authored-By:/g) ?? [];
assert.equal(trailers.length, 1);
assert.doesNotMatch(result.informational[0], /other contributors/);
});
test('checkCoauthors: does not credit the PR author as a co-author of themselves', () => {
// Their own commit, authored with an email GitHub could not match to the
// account. Without the guard they appear in their own trailer list.
const byName = checkCoauthors(
[{ author: null, commit: { author: { name: 'tonio-alucema', email: 'tonio@example.com' } } }],
'tonio-alucema'
);
const byEmail = checkCoauthors(
[{ author: null, commit: { author: { name: 'Tonio', email: 'tonio-alucema@users.noreply.github.com' } } }],
'tonio-alucema'
);
assert.deepEqual(byName.informational, []);
assert.deepEqual(byEmail.informational, []);
});
test('checkCoauthors: counts one person once when some commits matched their account and some did not', () => {
// The mixed case: GitHub resolved one commit to the login and left another
// unmatched, both carrying the same email. Keying on login alone emits two
// trailers for one contributor.
const result = checkCoauthors(
[
commit('stubbi', 'Jannes Stubbemann', 'jannes@example.com'),
{ author: null, commit: { author: { name: 'Jannes Stubbemann', email: 'jannes@example.com' } } },
],
'tonio-alucema'
);
const trailers = result.informational[0].match(/Co-Authored-By:/g) ?? [];
assert.equal(trailers.length, 1);
});

View File

@ -57,6 +57,8 @@ jobs:
- name: Test no-git-push check
run: node --test ./scripts/check-no-git-push.test.mjs
- name: Test PR quality-gate scripts
run: node --test '.github/scripts/tests/*.test.mjs'
- name: Test general-server shard partition
run: node --test ./scripts/__tests__/run-vitest-stable-shard.test.mjs