Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(votes): use GitHub handles in the JSON summary #1586

Merged
merged 10 commits into from
Jun 26, 2024
Merged
Show file tree
Hide file tree
Changes from 8 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
104 changes: 32 additions & 72 deletions .github/workflows/closeVote.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ on:
workflow_dispatch:
inputs:
pr:
description: ID of the Vote PR that contains a vote ready to be closed
description: Vote PR that contains a vote ready to be closed (<number> | <url> | <branch>)
required: true
type: number

permissions:
contents: write
Expand All @@ -21,31 +20,21 @@ jobs:
(github.event.issue.pull_request && contains(github.event.comment.body, '-----BEGIN SHAMIR KEY PART-----'))
runs-on: ubuntu-latest
steps:
- name: Get PR URL
id: pr-url
- name: Get vote PR info
id: pr-info
run: |
echo "URL=${{ github.event.repository.html_url }}/pull/${{ github.event.inputs.pr || github.event.issue.number }}" >> "$GITHUB_OUTPUT"
- name: Filter comments
id: comments
run: gh pr view ${{ steps.pr-url.outputs.URL }} --json
comments --jq '.comments | map(.body | select(contains("-----BEGIN
SHAMIR KEY PART-----"))) | "comments=" + tostring' >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ github.token }}
- name: Get PR branch
id: branch
run: gh pr view ${{ steps.pr-url.outputs.URL }} --json
headRefName --jq '"head=" + .headRefName' >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ github.token }}
- name: Compute number of commits in the PR
id: nb-of-commits
run: |
NB_OF_COMMITS=$(gh pr view --json commits --jq '.commits | length' "${{ steps.pr-url.outputs.URL }}")
echo "exact=$NB_OF_COMMITS" >> $GITHUB_OUTPUT
echo "minusOne=$(($NB_OF_COMMITS - 1))" >> $GITHUB_OUTPUT
gh pr view "$PR_REF" \
-R "$GITHUB_REPOSITORY" \
--json comments,headRefName,commits,url \
--jq '[
(.comments | map(.body | select(contains("-----BEGIN SHAMIR KEY PART-----"))) | "comments=" + tostring),
("head=" + .headRefName),
(.commits | length - 1 | "nbOfBallots=" + tostring),
("URL=" + .url)
] | join("\n")' >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ github.token }}
PR_REF: ${{ github.event.inputs.pr || github.event.issue.number }}
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
# Loading the default branch so we use the last version of the mailmap
Expand All @@ -55,62 +44,33 @@ jobs:
run:
curl -L https://raw.githubusercontent.com/nodejs/node/main/.mailmap >>
.mailmap
- name: Configure git
run: |
git config --global user.email "[email protected]"
git config --global user.name "Node.js GitHub Bot"
- name: Load vote branch
run: |
git fetch origin '${{ steps.branch.outputs.head }}'
git reset FETCH_HEAD --mixed
git checkout HEAD -- '${{ steps.branch.outputs.head }}'
- run: npm install @node-core/caritat
- name: Fetch vote branch
run: git fetch origin "$BRANCH"
env:
BRANCH: ${{ steps.pr-info.outputs.head }}
- run: npm install --no-save @node-core/caritat
- name: Attempt closing the vote
id: vote-summary
run: |
{
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "markdown<<$EOF"
./votes/initiateNewVote/decryptPrivateKeyAndCloseVote.mjs \
--remote origin --branch "${{ steps.branch.outputs.head }}" \
--fromCommit "FETCH_HEAD~${{ steps.nb-of-commits.outputs.minusOne }}" \
--toCommit "FETCH_HEAD" \
--prURL "${{ steps.pr-url.outputs.URL }}" \
--save-markdown-summary summaryComment.md \
--comments "$COMMENTS" --commit-json-summary
echo "$EOF"
} >> "$GITHUB_OUTPUT"
./votes/initiateNewVote/decryptPrivateKeyAndCloseVote.mjs \
--remote origin --branch "$BRANCH" \
--fromCommit "FETCH_HEAD~${{ steps.pr-info.outputs.numberOfBallots }}" \
--toCommit "FETCH_HEAD" \
--prURL "${{ steps.pr-info.outputs.URL }}" \
--save-markdown-summary summaryComment.md \
--prepare-json-summary-graphql-query createCommitOnBranch.gql --prepare-json-summary-dirname ./votes \
--comments "$COMMENTS"
env:
COMMENTS: ${{ steps.comments.outputs.comments }}
- name: Install ghcommit
run: go install github.com/planetscale/ghcommit@8c6d9af75a7814768ce871cde246224d45bd8c04
BRANCH: ${{ steps.pr-info.outputs.head }}
COMMENTS: ${{ steps.pr-info.outputs.comments }}
- name: Push to the PR branch
run: |
GH_COMMIT_PATH="$(go env GOPATH)/bin/ghcommit" COMMIT_MESSAGE="$(
git log -1 HEAD --pretty=format:%B
)" SHA="$(
git rev-parse HEAD^
)" DELETED_FILES="$(
git show HEAD --name-only --diff-filter=D --pretty=format:
)" ADDED_FILES="$(
git show HEAD --name-only --diff-filter=d --pretty=format:
)" node --input-type=module <<'EOF'
import { spawnSync } from "node:child_process";
const {GH_COMMIT_PATH, COMMIT_MESSAGE, SHA, DELETED_FILES, ADDED_FILES} = process.env;
spawnSync(GH_COMMIT_PATH, [
'-r', ${{ toJSON(github.repository) }},
'-b', ${{ toJSON(steps.branch.outputs.head) }},
'-m', COMMIT_MESSAGE,
'--sha', SHA,
...DELETED_FILES.split('\n').filter(Boolean).flatMap(file => ['--delete', file]),
...ADDED_FILES.split('\n').filter(Boolean).flatMap(file => ['--add', file]),
], { stdio: 'inherit' });
EOF
cat createCommitOnBranch.gql
gh api graphql -F repo="$GITHUB_REPOSITORY" -F commit_title="close vote and aggregate results" -f query="$(< createCommitOnBranch.gql)"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ github.token }}
- name: Publish vote summary comment
run: |
gh pr comment "${{ steps.pr-url.outputs.URL }}" --body-file summaryComment.md
gh pr comment "${{ steps.pr-info.outputs.URL }}" --body-file summaryComment.md
env:
GH_TOKEN: ${{ github.token }}
SUMMARY: ${{ steps.vote-summary.outputs.markdown }}
188 changes: 167 additions & 21 deletions votes/initiateNewVote/decryptPrivateKeyAndCloseVote.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
#!/usr/bin/env node

import { spawn } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { open, writeFile } from "node:fs/promises";
import path from "node:path";
import { createInterface as readLines } from "node:readline";
import { Readable } from "node:stream";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";

import countFromGit from "@node-core/caritat/countBallotsFromGit";
import readReadme from "./extractInfoFromReadme.mjs";

const { values: parsedArgs } = parseArgs({
options: {
Expand Down Expand Up @@ -43,37 +47,61 @@ const { values: parsedArgs } = parseArgs({
describe: "Commit JSON summary",
type: "boolean",
},
"prepare-json-summary-dirname": {
describe: "Directory where to create the JSON summary when using ",
type: "string",
},
"prepare-json-summary-graphql-query": {
describe: "Path to store mutation request to commit JSON summary",
type: "string",
},
"save-json-summary": {
describe: "Write the JSON summary to a file",
type: "string",
},
"save-markdown-summary": {
describe: "Write the markdown to a file (use - for stdout)",
describe: "Write the markdown summary to a file",
type: "string",
},
"nodejs-repository-path": {
type: "string",
},
},
});

const keyParts = [...new Set(JSON.parse(parsedArgs.comments)
.map(
(txt) =>
/-----BEGIN SHAMIR KEY PART-----(.+)-----END SHAMIR KEY PART-----/s.exec(
txt
)?.[1]
)
.filter(Boolean))];
const keyParts = [
...new Set(
JSON.parse(parsedArgs.comments || "[]")
.map(
(txt) =>
/-----BEGIN SHAMIR KEY PART-----(.+)-----END SHAMIR KEY PART-----/s.exec(
txt,
)?.[1],
)
.filter(Boolean),
),
];
aduh95 marked this conversation as resolved.
Show resolved Hide resolved

const firstCommitRef = parsedArgs.fromCommit;
const voteFileCanonicalName = "vote.yml";

const subPath = await new Promise(async (resolve, reject) => {
const cp = spawn("git", [
"--no-pager",
"show",
firstCommitRef,
"--name-only",
]);
cp.on("error", reject);
for await (const line of readLines(cp.stdout)) {
if (line === voteFileCanonicalName) return resolve("./");
if (line.endsWith(`/${voteFileCanonicalName}`))
return resolve(line.slice(0, -voteFileCanonicalName.length));
try {
const cp = spawn("git", [
"--no-pager",
"show",
firstCommitRef,
"--name-only",
]);
cp.on("error", reject);
for await (const line of readLines(cp.stdout)) {
if (line === voteFileCanonicalName) return resolve("./");
if (line.endsWith(`/${voteFileCanonicalName}`))
return resolve(line.slice(0, -voteFileCanonicalName.length));
}
reject(new Error(`Unable to find ${voteFileCanonicalName} in commit "${firstCommitRef}"`))
aduh95 marked this conversation as resolved.
Show resolved Hide resolved
} catch (err) {
reject(err);
}
});

Expand All @@ -94,6 +122,124 @@ const { result, privateKey } = await countFromGit({
: null,
});

async function generateSummaryFilePath() {
const date = new Date().toISOString().slice(0, 10);
for (let i = 0; i < Number.MAX_SAFE_INTEGER; i++) {
const filepath = path.join(
parsedArgs["prepare-json-summary-dirname"],
`${date}-${i}.json`,
);
if (!existsSync(filepath)) {
return filepath;
}
}

throw new Error("Could not find a path for the summary file");
}

async function getSHA(ref) {
return new Promise(async (resolve, reject) => {
const cp = spawn("git", [
"--no-pager",
"rev-parse",
ref,
]);
cp.on("error", reject);
resolve(cp.stdout.toArray().then((b) => Buffer.concat(b).toString("utf-8").trim()));
});
}

async function generateJSONSummary() {
let input, crlfDelay;
if (parsedArgs["nodejs-repository-path"] == null) {
input = await fetch(
"https://raw.githubusercontent.com/nodejs/node/HEAD/README.md",
).then((res) => {
if (!res.ok) {
throw new Error("Wrong status code", { cause: res });
} else {
return Readable.fromWeb(res.body);
}
});
} else {
const fh = await open(
join(resolve(argv["nodejs-repository-path"]), "README.md"),
"r",
);
input = fh.createReadStream();
crlfDelay = Infinity;
}

const summary = result.toJSON();
for await (const { handle, name, email } of readReadme(
readLines({ input, crlfDelay }),
)) {
const authorName = `${name} <${email}>`;
if (authorName in summary.votes) {
summary.votes[handle] = summary.votes[authorName];
delete summary.votes[authorName];
}
}

summary.refs = parsedArgs.prURL;
return JSON.stringify(summary, undefined, 2) + "\n";
}

if (parsedArgs["save-json-summary"]) {
await writeFile(parsedArgs['save-json-summary'], await generateJSONSummary(), 'utf-8');
}
if (parsedArgs["prepare-json-summary-graphql-query"]) {
const lastCommitSHA = await getSHA(parsedArgs.toCommit);
await writeFile(
parsedArgs["prepare-json-summary-graphql-query"],
`mutation ($repo: String!, $commit_title: String!, $commit_body: String) {
createCommitOnBranch(input: {
branch: {
repositoryNameWithOwner: $repo,
branchName: ${JSON.stringify(parsedArgs.branch)}
},
message: {
headline: $commit_title,
body: $commit_body
},
expectedHeadOid: ${JSON.stringify(lastCommitSHA)},
fileChanges: {
additions: [{
path: ${JSON.stringify(await generateSummaryFilePath())},
contents: ${JSON.stringify(
Buffer.from(await generateJSONSummary()).toString("base64")
)},
}],
deletions: [${
await new Promise(async (resolve, reject) => {
try {
const cp = spawn("git", [
"--no-pager",
"diff",
`${firstCommitRef}^..${lastCommitSHA}`,
"--name-only",
]);
cp.on("error", reject);
const files = [];
for await (const path of readLines(cp.stdout)) {
files.push(`\n { path: ${JSON.stringify(path)} }`);
}
resolve(files.join(","));
} catch (err) {
reject(err);
}
})
}
]
}}) {
commit {
url
}
}
}\n`,
);
}

if (parsedArgs["save-markdown-summary"]) {
function* toArmoredMessage(str, chunkSize = 64) {
yield "-----BEGIN PRIVATE KEY-----";
Expand Down