// This script generates a permanent archive of Contributor Licence Agreement (CLA) signatures for // the Joplin project. It reads a list of signed contributors, retrieves the associated pull request // and its comments from GitHub, and saves them as JSON files. This ensures a verifiable record is // preserved even if the original pull request or comments are later deleted. It validates that each // CLA comment was made by the correct contributor. import { getRootDir } from '@joplin/utils'; import { githubOauthToken } from './tool-utils'; import { mkdirp, pathExists } from 'fs-extra'; import { readdir, readFile, writeFile } from 'fs/promises'; interface Issue { user: { id: number; login: string; }; pull_request: { merged_at: string|null; }; number: number; body: string|null; } interface IssueComment { user: { id: number; login: string; }; body: string; } interface SignedContributor { name: string; id: number; comment_id: number; created_at: string; repoId: number; pullRequestNo: number; } const signature = 'I have read the CLA Document and I hereby sign the CLA'; const getSignedContributors = async () => { const filePath = `${await getRootDir()}/readme/cla/signatures.json`; const content = await readFile(filePath, 'utf-8'); return JSON.parse(content).signedContributors as SignedContributor[]; }; const runRequest = async (path: string) => { const oauthToken = await githubOauthToken(); const url = `https://api.github.com/repos/laurent22/joplin/${path}`; const headers = { 'Authorization': `token ${oauthToken}`, 'Accept': 'application/vnd.github.v3+json', }; const response = await fetch(url, { headers }); if (!response.ok) { const body = await response.text(); const truncated = body.length > 500 ? `${body.substring(0, 500)}... [truncated]` : body; throw new Error(`Error: ${path}: ${response.status} ${response.statusText}: ${truncated}`); } return response.json(); }; const getIssue = async (issueNumber: number) => { return (await runRequest(`issues/${issueNumber}`)) as Issue; }; const getIssueComments = async (issueNumber: number) => { return (await runRequest(`issues/${issueNumber}/comments`)) as IssueComment[]; }; const hasSignature = (body: string|null) => { if (!body) return false; const okSignatures = [ signature, `${signature}.`, ]; // Check each line: the bot accepts the signature on its own line, even // when mixed with other content (e.g. in a PR description). for (const line of body.split('\n')) { if (okSignatures.includes(line.trim())) return true; } return false; }; const validateSignature = (issue: Issue, comments: IssueComment[], expectedClaAuthorId: number) => { // The CLA bot also accepts the signature in the PR body (top comment), so // check there too in addition to the regular comments. if (hasSignature(issue.body) && issue.user.id === expectedClaAuthorId) return true; for (const comment of comments) { if (hasSignature(comment.body)) { if (comment.user.id === expectedClaAuthorId) return true; } } return false; }; const findDuplicates = (array: (string|number|symbol)[])=> { const counts: Record = {}; const dupes = []; for (const item of array) { counts[item] = (counts[item] || 0) + 1; if (counts[item] === 2) { dupes.push(item); } } return dupes; }; const main = async () => { console.info('⚠️ To generate an accurate record, remember to merge the cla_signatures branch into dev first ⚠️'); const consentRecordsDir = `${await getRootDir()}/readme/cla/consent_records`; await mkdirp(consentRecordsDir); // Issues that are referenced in the signatures.json file but that do not contain a // signature. In that case, the pull request should not have been merged. const excludedIssueIds = [ 7785, // Not merged 8531, // Not merged 11567, // Changed year on license file: https://github.com/laurent22/joplin/commit/2b43a9a4d667fe6b81bc97b66e0b3700688ec3cf 13790, // Manually added for Linkosed 14501, // Not merged 14150, // Not merged and contributor and PR disappeared ]; const signedContributors = await getSignedContributors(); for (const signedContributor of signedContributors) { if (excludedIssueIds.includes(signedContributor.pullRequestNo)) continue; const filePath = `${consentRecordsDir}/${signedContributor.name}_${signedContributor.id}.json`; if (await pathExists(filePath)) continue; const issue = await getIssue(signedContributor.pullRequestNo); const comments = await getIssueComments(issue.number); const ok = validateSignature(issue, comments, issue.user.id); if (!ok) throw new Error(`Could not find the signature on PR ${issue.number}`); console.info(`Creating: ${filePath}`); await writeFile(filePath, JSON.stringify({ issue, comments }, null, '\t')); } const files = await readdir(consentRecordsDir); const contributorCount = signedContributors.length - excludedIssueIds.length; if (files.length !== contributorCount) { const notOk = []; for (const contributor of signedContributors) { let found = false; for (const file of files) { if (file.startsWith(contributor.name)) { found = true; break; } } if (!found) { if (!excludedIssueIds.includes(contributor.pullRequestNo)) { notOk.push(contributor); } } } if (notOk.length) { console.info('In signatures.json but not in archived issues:', notOk); } const userIds = signedContributors.map(s => s.id); const duplicates = findDuplicates(userIds); if (duplicates.length) { console.info('Duplicate user Ids in signatures.json:', duplicates); } throw new Error(`‼️ Found ${contributorCount} contributors in signatures.json but ${files.length} archived issues.`); } else { console.info(`👍 Found ${contributorCount} contributors in signatures.json and ${files.length} archived issues.`); } }; if (require.main === module) { // eslint-disable-next-line promise/prefer-await-to-then main().catch((error) => { console.error(error); process.exit(1); }); }