Skip to content

Commit b5b3d5e

Browse files
committed
Actionable guidance when a commit email is not linked to GitHub
When a commit author's email does not map to any GitHub user, the action can't tell whether that contributor has signed the CLA. Before: the PR comment buried this as a one-line aside ('Alice seems not to be a GitHub user. You need a GitHub account...'), which leaves the contributor unsure what to actually do. Now: the PR comment includes a dedicated '> [!WARNING]' block that spells out the exact email address(es) that failed to match, and gives two concrete remediation paths: 1. Link the email at github.com/settings/emails. (One-click fix if the contributor already has a GitHub account.) 2. Rewrite the commits using an address that is already linked, with the exact git rebase command included. Implementation: - graphql.ts now preserves the commit author's email when the commit is not linked to a GitHub user, by extending Committer with an optional 'email' field. Deduplication now keys on (id, email, name) so two different unlinked addresses produce two entries but two commits from the same unlinked address collapse. - pullRequestCommentContent.ts extracts renderUnlinkedCommitBlock(), which renders the new warning block. The existing 'not a GitHub user' aside is removed. Tests: - commentContent unit: one unlinked / multiple unlinked / missing email fallback. - scenarios integration: PR with a commit by 'typo@example.com' (no login) produces a bot comment containing the warning block, the email, and both remediation options.
1 parent f1b818d commit b5b3d5e

7 files changed

Lines changed: 250 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@ the logical unit of work. Each entry links to the commit that introduced it.
99

1010
## Unreleased
1111

12+
### Added
13+
- **Dedicated "unlinked email" guidance on unknown committers.** When a commit
14+
author's email is not linked to any GitHub user, the bot now posts a
15+
prominent `> [!WARNING]` block that lists each unlinked email and gives the
16+
contributor two concrete remediation paths (link the email at
17+
`github.com/settings/emails`, or rewrite the commits with a known email
18+
using the exact git commands). Previously this case rendered as a terse
19+
aside on the main pending-signatures comment with generic "not a GitHub
20+
user" copy. The commit author's email is now carried through the GraphQL
21+
committers query and attached to `Committer.email` so the comment can
22+
surface the specific address that failed to match.
23+
24+
1225
### Code-review pass (April 2026)
1326

1427
Driven by `PLAN.md` following a deep review. Seven phases:

__tests__/integration/scenarios.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,4 +285,49 @@ describe('CLA action end-to-end scenarios', () => {
285285
).toEqual([])
286286
watch.restore()
287287
})
288+
289+
it('PR with a commit authored by an email not linked to any GitHub user: posts the unlinked-email warning', async () => {
290+
const watch = watchCore()
291+
fake.repo('acme', 'widgets').addPullRequest({
292+
number: 12,
293+
head: { sha: 'headsha', ref: 'feature/email' },
294+
// No `login` / `id` — this maps to an unknown committer in the action.
295+
commits: [
296+
{ author: { name: 'Mystery Contributor', email: 'typo@example.com' } }
297+
]
298+
})
299+
fake
300+
.repo('acme', 'widgets')
301+
.setFile('signatures/cla.json', { signedContributors: [] })
302+
303+
setContext({
304+
owner: 'acme',
305+
repo: 'widgets',
306+
issueNumber: 12,
307+
actor: 'mystery',
308+
eventName: 'pull_request_target',
309+
payload: {
310+
pull_request: { number: 12, state: 'open' },
311+
repository: { id: fake.repo('acme', 'widgets').state.id },
312+
action: 'opened'
313+
}
314+
})
315+
316+
await runAction()
317+
318+
const comments = fake.repo('acme', 'widgets').listComments(12)
319+
expect(comments).toHaveLength(1)
320+
const body = comments[0]!.body
321+
// The warning block, the email, and both remediation paths.
322+
expect(body).toContain('[!WARNING]')
323+
expect(body).toContain('typo@example.com')
324+
expect(body).toContain('github.com/settings/emails')
325+
expect(body).toContain('Rewrite the commits')
326+
// Still marked failed — the action cannot tell whether this committer
327+
// has signed.
328+
expect(watch.failures.join('\n')).toMatch(
329+
/Committers of Pull Request number 12/
330+
)
331+
watch.restore()
332+
})
288333
})

__tests__/unit/commentContent.test.ts

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,61 @@ describe('commentContent (CLA mode)', () => {
6161
expect(body).toContain('ask that you all sign')
6262
})
6363

64-
it('mentions unknown (non-GitHub-user) committers separately', () => {
65-
const body = commentContent(
66-
false,
67-
committerMap({
68-
unknown: [{ name: 'typo@example.com', id: 0, pullRequestNo: 7 }]
69-
})
70-
)
71-
expect(body).toContain('typo@example.com')
72-
expect(body).toContain('seems not to be a GitHub user')
64+
describe('unlinked-email block', () => {
65+
it('surfaces a warning with the email and both fix options when exactly one commit has an unlinked author', () => {
66+
const body = commentContent(
67+
false,
68+
committerMap({
69+
unknown: [
70+
{
71+
name: 'Alice',
72+
id: 0,
73+
pullRequestNo: 7,
74+
email: 'alice@example.com'
75+
}
76+
]
77+
})
78+
)
79+
expect(body).toContain('[!WARNING]')
80+
expect(body).toContain('1 commit in this PR was authored')
81+
expect(body).toContain('alice@example.com')
82+
// Both fix options must be present.
83+
expect(body).toContain('github.com/settings/emails')
84+
expect(body).toContain('Rewrite the commits')
85+
expect(body).toContain('git rebase -i --root')
86+
})
87+
88+
it('uses plural phrasing for multiple unlinked authors and includes every email', () => {
89+
const body = commentContent(
90+
false,
91+
committerMap({
92+
unknown: [
93+
{
94+
name: 'Alice',
95+
id: 0,
96+
pullRequestNo: 7,
97+
email: 'alice@example.com'
98+
},
99+
{ name: 'Bob', id: 0, pullRequestNo: 7, email: 'bob@example.com' }
100+
]
101+
})
102+
)
103+
expect(body).toContain('2 commits in this PR were authored')
104+
expect(body).toContain('alice@example.com')
105+
expect(body).toContain('bob@example.com')
106+
})
107+
108+
it('falls back to the committer name when the email is missing', () => {
109+
const body = commentContent(
110+
false,
111+
committerMap({
112+
unknown: [{ name: 'typo-name', id: 0, pullRequestNo: 7 }]
113+
})
114+
)
115+
expect(body).toContain('typo-name')
116+
// No email markdown / no backticks around missing email.
117+
expect(body).not.toMatch(/`<\s*>`/)
118+
})
73119
})
74120

75121
it('adds the "recheck" hint when suggest-recheck is true', () => {

dist/index.js

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30281,8 +30281,9 @@ query($owner:String! $name:String! $number:Int! $cursor:String){
3028130281
}`;
3028230282
function getCommitters() {
3028330283
return __awaiter(this, void 0, void 0, function* () {
30284+
var _a, _b, _c;
3028430285
try {
30285-
const seenNames = new Set();
30286+
const seenKeys = new Set();
3028630287
const committers = [];
3028730288
let cursor = null;
3028830289
let hasNextPage = true;
@@ -30295,14 +30296,18 @@ function getCommitters() {
3029530296
}));
3029630297
const page = response.repository.pullRequest.commits;
3029730298
for (const edge of page.edges) {
30298-
const actor = extractUserFromCommit(edge.node.commit);
30299-
const user = {
30300-
name: actor.login || actor.name || '',
30301-
id: actor.databaseId || 0,
30302-
pullRequestNo: github_1.context.issue.number
30303-
};
30304-
if (!seenNames.has(user.name)) {
30305-
seenNames.add(user.name);
30299+
const commit = edge.node.commit;
30300+
const linkedUser = ((_a = commit.author) === null || _a === void 0 ? void 0 : _a.user) || ((_b = commit.committer) === null || _b === void 0 ? void 0 : _b.user);
30301+
// Fall back to the raw author/committer actor for the display name +
30302+
// the email that will be surfaced when no GitHub user is linked.
30303+
const rawActor = commit.author || commit.committer || {};
30304+
const isLinked = Boolean(linkedUser === null || linkedUser === void 0 ? void 0 : linkedUser.databaseId);
30305+
const user = Object.assign({ name: (linkedUser === null || linkedUser === void 0 ? void 0 : linkedUser.login) || rawActor.name || rawActor.email || '', id: (linkedUser === null || linkedUser === void 0 ? void 0 : linkedUser.databaseId) || 0, pullRequestNo: github_1.context.issue.number }, (isLinked ? {} : { email: rawActor.email }));
30306+
// Dedup by (id, email) so two commits from the same unlinked address
30307+
// collapse but two different unlinked addresses don't.
30308+
const key = `${user.id}:${(_c = user.email) !== null && _c !== void 0 ? _c : ''}:${user.name}`;
30309+
if (!seenKeys.has(key)) {
30310+
seenKeys.add(key);
3030630311
committers.push(user);
3030730312
}
3030830313
}
@@ -30316,14 +30321,6 @@ function getCommitters() {
3031630321
}
3031730322
});
3031830323
}
30319-
function extractUserFromCommit(commit) {
30320-
var _a, _b;
30321-
return (((_a = commit.author) === null || _a === void 0 ? void 0 : _a.user) ||
30322-
((_b = commit.committer) === null || _b === void 0 ? void 0 : _b.user) ||
30323-
commit.author ||
30324-
commit.committer ||
30325-
{});
30326-
}
3032730324

3032830325

3032930326
/***/ }),
@@ -30995,10 +30992,7 @@ function renderPending(mode, committerMap) {
3099530992
text += '<br/>';
3099630993
}
3099730994
if (committerMap.unknown.length > 0) {
30998-
const seem = committerMap.unknown.length > 1 ? 'seem' : 'seems';
30999-
const names = committerMap.unknown.map(c => c.name).join(', ');
31000-
text += `**${names}** ${seem} not to be a GitHub user.`;
31001-
text += ` You need a GitHub account to be able to sign the ${mode.label}. If you have already a GitHub account, please [add the email address used for this commit to your account](https://help.github.com/articles/why-are-my-commits-linked-to-the-wrong-user/#commits-are-not-linked-to-any-user).<br/>`;
30995+
text += renderUnlinkedCommitBlock(mode, committerMap.unknown);
3100230996
}
3100330997
if (input.suggestRecheck()) {
3100430998
text +=
@@ -31010,6 +31004,51 @@ function renderPending(mode, committerMap) {
3101031004
function botSignature(mode) {
3101131005
return `<sub>Posted by the **${mode.botName}**.</sub>`;
3101231006
}
31007+
/**
31008+
* Renders the "commit author email isn't linked to a GitHub account" block.
31009+
* Shown both inline (when mixed with signed/unsigned committers) and as the
31010+
* sole body (when every committer is unlinked — in which case this is the
31011+
* only actionable thing in the comment).
31012+
*/
31013+
function renderUnlinkedCommitBlock(mode, unlinked) {
31014+
const plural = unlinked.length > 1;
31015+
const verb = plural ? 'were' : 'was';
31016+
const commits = plural ? 'commits' : 'commit';
31017+
// Render each unlinked identity as "name <email>" when we have an email to
31018+
// show, otherwise just the name. Wrap email in backticks so Markdown does
31019+
// not interpret it as a mailto: auto-link.
31020+
const identityLines = unlinked
31021+
.map(c => {
31022+
const display = c.email && c.email !== c.name ? `${c.name} \`<${c.email}>\`` : c.name;
31023+
return `- ${display}`;
31024+
})
31025+
.join('\n');
31026+
return `
31027+
31028+
> [!WARNING]
31029+
> ${unlinked.length} ${commits} in this PR ${verb} authored by an email address that is not linked to any GitHub user, so we cannot tell whether the author has signed the ${mode.label}.
31030+
>
31031+
> Unlinked author${plural ? 's' : ''}:
31032+
>
31033+
> ${identityLines.replace(/\n/g, '\n> ')}
31034+
>
31035+
> **To unblock this PR, do one of the following:**
31036+
>
31037+
> 1. **Link the email to your GitHub account** (recommended). Add each address above at [github.com/settings/emails](https://github.com/settings/emails), then push another commit (or comment \`recheck\`) so this check re-runs. See [why commits are not linked to a user](https://docs.github.com/en/pull-requests/committing-changes-to-your-project/troubleshooting-commits/why-are-my-commits-linked-to-the-wrong-user#commits-are-not-linked-to-any-user) for details.
31038+
>
31039+
> 2. **Rewrite the commits** to use an email that is already linked to your GitHub account:
31040+
>
31041+
> \`\`\`bash
31042+
> # Set the correct email locally (one-off, for this repo):
31043+
> git config user.email you@example.com
31044+
> # Rewrite every commit on this branch with the corrected identity:
31045+
> git rebase -i --root --exec 'git commit --amend --reset-author --no-edit'
31046+
> git push --force-with-lease
31047+
> \`\`\`
31048+
>
31049+
> After the push, comment \`recheck\` on this PR (or just re-push) to re-run the check.
31050+
<br/>`;
31051+
}
3101331052

3101431053

3101531054
/***/ }),

src/graphql.ts

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ query($owner:String! $name:String! $number:Int! $cursor:String){
6666

6767
export default async function getCommitters(): Promise<Committer[]> {
6868
try {
69-
const seenNames = new Set<string>()
69+
const seenKeys = new Set<string>()
7070
const committers: Committer[] = []
7171
let cursor: string | null = null
7272
let hasNextPage = true
@@ -81,14 +81,23 @@ export default async function getCommitters(): Promise<Committer[]> {
8181

8282
const page = response.repository.pullRequest.commits
8383
for (const edge of page.edges) {
84-
const actor = extractUserFromCommit(edge.node.commit)
84+
const commit = edge.node.commit
85+
const linkedUser = commit.author?.user || commit.committer?.user
86+
// Fall back to the raw author/committer actor for the display name +
87+
// the email that will be surfaced when no GitHub user is linked.
88+
const rawActor = commit.author || commit.committer || {}
89+
const isLinked = Boolean(linkedUser?.databaseId)
8590
const user: Committer = {
86-
name: actor.login || actor.name || '',
87-
id: actor.databaseId || 0,
88-
pullRequestNo: context.issue.number
91+
name: linkedUser?.login || rawActor.name || rawActor.email || '',
92+
id: linkedUser?.databaseId || 0,
93+
pullRequestNo: context.issue.number,
94+
...(isLinked ? {} : { email: rawActor.email })
8995
}
90-
if (!seenNames.has(user.name)) {
91-
seenNames.add(user.name)
96+
// Dedup by (id, email) so two commits from the same unlinked address
97+
// collapse but two different unlinked addresses don't.
98+
const key = `${user.id}:${user.email ?? ''}:${user.name}`
99+
if (!seenKeys.has(key)) {
100+
seenKeys.add(key)
92101
committers.push(user)
93102
}
94103
}
@@ -103,13 +112,3 @@ export default async function getCommitters(): Promise<Committer[]> {
103112
)
104113
}
105114
}
106-
107-
function extractUserFromCommit(
108-
commit: GraphQLCommit
109-
): GraphQLUser & GraphQLActor {
110-
return (commit.author?.user ||
111-
commit.committer?.user ||
112-
commit.author ||
113-
commit.committer ||
114-
{}) as GraphQLUser & GraphQLActor
115-
}

src/interfaces.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ export interface Committer {
66
name: string
77
id: number
88
pullRequestNo?: number | undefined
9+
/**
10+
* Commit-author email. Present only when the GraphQL lookup could not map
11+
* the commit to a GitHub user (i.e. when this committer ends up in
12+
* CommitterMap.unknown). Surfaced to the contributor in the PR comment so
13+
* they know which specific email address to link or rewrite.
14+
*/
15+
email?: string | undefined
916
}
1017

1118
/**

src/pullrequest/pullRequestCommentContent.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CommitterMap } from '../interfaces'
1+
import { Committer, CommitterMap } from '../interfaces'
22
import * as input from '../shared/getInputs'
33
import { getPrSignComment } from '../shared/pr-sign-comment'
44

@@ -70,10 +70,7 @@ function renderPending(mode: ModeText, committerMap: CommitterMap): string {
7070
}
7171

7272
if (committerMap.unknown.length > 0) {
73-
const seem = committerMap.unknown.length > 1 ? 'seem' : 'seems'
74-
const names = committerMap.unknown.map(c => c.name).join(', ')
75-
text += `**${names}** ${seem} not to be a GitHub user.`
76-
text += ` You need a GitHub account to be able to sign the ${mode.label}. If you have already a GitHub account, please [add the email address used for this commit to your account](https://help.github.com/articles/why-are-my-commits-linked-to-the-wrong-user/#commits-are-not-linked-to-any-user).<br/>`
73+
text += renderUnlinkedCommitBlock(mode, committerMap.unknown)
7774
}
7875

7976
if (input.suggestRecheck()) {
@@ -88,3 +85,55 @@ function renderPending(mode: ModeText, committerMap: CommitterMap): string {
8885
function botSignature(mode: ModeText): string {
8986
return `<sub>Posted by the **${mode.botName}**.</sub>`
9087
}
88+
89+
/**
90+
* Renders the "commit author email isn't linked to a GitHub account" block.
91+
* Shown both inline (when mixed with signed/unsigned committers) and as the
92+
* sole body (when every committer is unlinked — in which case this is the
93+
* only actionable thing in the comment).
94+
*/
95+
function renderUnlinkedCommitBlock(
96+
mode: ModeText,
97+
unlinked: Committer[]
98+
): string {
99+
const plural = unlinked.length > 1
100+
const verb = plural ? 'were' : 'was'
101+
const commits = plural ? 'commits' : 'commit'
102+
103+
// Render each unlinked identity as "name <email>" when we have an email to
104+
// show, otherwise just the name. Wrap email in backticks so Markdown does
105+
// not interpret it as a mailto: auto-link.
106+
const identityLines = unlinked
107+
.map(c => {
108+
const display =
109+
c.email && c.email !== c.name ? `${c.name} \`<${c.email}>\`` : c.name
110+
return `- ${display}`
111+
})
112+
.join('\n')
113+
114+
return `
115+
116+
> [!WARNING]
117+
> ${unlinked.length} ${commits} in this PR ${verb} authored by an email address that is not linked to any GitHub user, so we cannot tell whether the author has signed the ${mode.label}.
118+
>
119+
> Unlinked author${plural ? 's' : ''}:
120+
>
121+
> ${identityLines.replace(/\n/g, '\n> ')}
122+
>
123+
> **To unblock this PR, do one of the following:**
124+
>
125+
> 1. **Link the email to your GitHub account** (recommended). Add each address above at [github.com/settings/emails](https://github.com/settings/emails), then push another commit (or comment \`recheck\`) so this check re-runs. See [why commits are not linked to a user](https://docs.github.com/en/pull-requests/committing-changes-to-your-project/troubleshooting-commits/why-are-my-commits-linked-to-the-wrong-user#commits-are-not-linked-to-any-user) for details.
126+
>
127+
> 2. **Rewrite the commits** to use an email that is already linked to your GitHub account:
128+
>
129+
> \`\`\`bash
130+
> # Set the correct email locally (one-off, for this repo):
131+
> git config user.email you@example.com
132+
> # Rewrite every commit on this branch with the corrected identity:
133+
> git rebase -i --root --exec 'git commit --amend --reset-author --no-edit'
134+
> git push --force-with-lease
135+
> \`\`\`
136+
>
137+
> After the push, comment \`recheck\` on this PR (or just re-push) to re-run the check.
138+
<br/>`
139+
}

0 commit comments

Comments
 (0)