Skip to content

Commit

Permalink
added cron job to comment ongoing referenda on PRs (#28)
Browse files Browse the repository at this point in the history
Added functionality for a cron job to iterate over all ongoing
referendas and compare them to the open PRs. If there is any match on
the remark, and the referenda has been opened **after** the last time
the action was run, it will comment a link to the referenda in
Polkassembly.

This is intended to resolve the requirements in polkadot-fellows/RFCs#57
  • Loading branch information
Bullrich authored Jan 19, 2024
1 parent c673cf6 commit 5a1103c
Show file tree
Hide file tree
Showing 8 changed files with 494 additions and 70 deletions.
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,35 @@ The built-in `secrets.GITHUB_TOKEN` can be used, as long as it has the necessary
The `PROVIDER_URL` variable can be specified to override the default public endpoint to the Collectives parachain.

A full archive node is needed to process the confirmed referenda.

## Notification job

You can set the GitHub action to also run notifying on a PR when a referenda is available for voting.

It will look for new referendas available since the last time the action was run, so it won't generate duplicated messages.

```yml
on:
workflow_dispatch:
schedule:
- cron: '0 12 * * *'

jobs:
notify_referendas:
runs-on: ubuntu-latest
steps:
- name: Get last run
run: |
last=$(gh run list -w "$WORKFLOW" --json startedAt,status -q 'map(select(.status == "completed"))[0].startedAt')
echo "last=$last" >> "$GITHUB_OUTPUT"
id: date
env:
GH_TOKEN: ${{ github.token }}
WORKFLOW: ${{ github.workflow }}
GH_REPO: "${{ github.repository_owner }}/${{ github.event.repository.name }}"
- uses: paritytech/rfc-action@main
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PROVIDER_URL: "wss://polkadot-collectives-rpc.polkadot.io" # Optional.
START_DATE: ${{ steps.date.outputs.last }}
```
239 changes: 206 additions & 33 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -67473,9 +67473,153 @@ function socketOnError() {
"use strict";

Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.POLKADOT_APPS_URL = exports.PROVIDER_URL = void 0;
exports.START_DATE = exports.POLKADOT_APPS_URL = exports.PROVIDER_URL = void 0;
exports.PROVIDER_URL = process.env.PROVIDER_URL || "wss://polkadot-collectives-rpc.polkadot.io";
exports.POLKADOT_APPS_URL = `https://polkadot.js.org/apps/?rpc=${encodeURIComponent(exports.PROVIDER_URL)}#/`;
exports.START_DATE = process.env.START_DATE || "0";


/***/ }),

/***/ 59866:
/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {

"use strict";

Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.cron = exports.getAllRFCRemarks = exports.getAllPRs = void 0;
const core_1 = __nccwpck_require__(42186);
const summary_1 = __nccwpck_require__(81327);
const api_1 = __nccwpck_require__(47196);
const constants_1 = __nccwpck_require__(69042);
const parse_RFC_1 = __nccwpck_require__(58542);
const logger = {
info: core_1.info,
debug: core_1.debug,
warn: core_1.warning,
error: core_1.error,
};
/** Gets the date of a block */
const getBlockDate = async (blockNr, api) => {
const hash = await api.rpc.chain.getBlockHash(blockNr);
const timestamp = await api.query.timestamp.now.at(hash);
return new Date(timestamp.toPrimitive());
};
const getAllPRs = async (octokit, repo) => {
const prs = await octokit.paginate(octokit.rest.pulls.list, repo);
logger.info(`Found ${prs.length} open PRs`);
const prRemarks = [];
for (const pr of prs) {
const { owner, name } = pr.base.repo;
logger.info(`Extracting from PR: #${pr.number} in ${owner.login}/${name}`);
const rfcResult = await (0, parse_RFC_1.extractRfcResult)(octokit, { ...repo, number: pr.number });
if (rfcResult.success) {
logger.info(`RFC Result for #${pr.number} is ${rfcResult.result.approveRemarkText}`);
prRemarks.push([pr.number, rfcResult.result?.approveRemarkText]);
}
else {
logger.warn(`Had an error while creating RFC for #${pr.number}: ${rfcResult.error}`);
}
}
return prRemarks;
};
exports.getAllPRs = getAllPRs;
const getAllRFCRemarks = async (startDate) => {
const wsProvider = new api_1.WsProvider(constants_1.PROVIDER_URL);
try {
const api = await api_1.ApiPromise.create({ provider: wsProvider });
// We fetch all the available referendas
const query = (await api.query.fellowshipReferenda.referendumCount()).toPrimitive();
if (typeof query !== "number") {
throw new Error(`Query result is not a number: ${typeof query}`);
}
logger.info(`Available referendas: ${query}`);
const hashes = [];
for (const index of Array.from(Array(query).keys())) {
logger.info(`Fetching elements ${index + 1}/${query}`);
const refQuery = (await api.query.fellowshipReferenda.referendumInfoFor(index)).toJSON();
if (refQuery.ongoing) {
logger.info(`Found ongoing request: ${JSON.stringify(refQuery)}`);
const blockNr = refQuery.ongoing.submitted;
const blockDate = await getBlockDate(blockNr, api);
logger.debug(`Checking if the startDate (${startDate.toString()}) is newer than the block date (${blockDate.toString()})`);
// Skip referendas that have been interacted with last time
if (startDate > blockDate) {
logger.info(`Referenda #${index} is older than previous check. Ignoring.`);
continue;
}
const { proposal } = refQuery.ongoing;
const hash = proposal?.lookup?.hash ?? proposal?.inline;
if (hash) {
hashes.push({ hash, url: `https://collectives.polkassembly.io/referenda/${index}` });
}
else {
logger.warn(`Found no lookup hash nor inline hash for https://collectives.polkassembly.io/referenda/${index}`);
continue;
}
}
else {
logger.debug(`Reference query is not ongoing: ${JSON.stringify(refQuery)}`);
}
}
logger.info(`Found ${hashes.length} ongoing requests`);
return hashes;
}
catch (err) {
logger.error("Error during exectuion");
throw err;
}
finally {
await wsProvider.disconnect();
}
};
exports.getAllRFCRemarks = getAllRFCRemarks;
const cron = async (startDate, owner, repo, octokit) => {
const remarks = await (0, exports.getAllRFCRemarks)(startDate);
if (remarks.length === 0) {
logger.warn("No ongoing RFCs made from pull requests. Shuting down");
await summary_1.summary.addHeading("Referenda search", 3).addHeading("Found no matching referenda to open PRs", 5).write();
return;
}
logger.debug(`Found remarks ${JSON.stringify(remarks)}`);
const prRemarks = await (0, exports.getAllPRs)(octokit, { owner, repo });
logger.debug(`Found all PR remarks ${JSON.stringify(prRemarks)}`);
const rows = [
[
{ data: "PR", header: true },
{ data: "Referenda", header: true },
],
];
const wsProvider = new api_1.WsProvider(constants_1.PROVIDER_URL);
try {
const api = await api_1.ApiPromise.create({ provider: wsProvider });
for (const [pr, remark] of prRemarks) {
// We compare the hash to see if there is a match
const tx = api.tx.system.remark(remark);
const match = remarks.find(({ hash }) => hash === tx.method.hash.toHex() || hash === tx.method.toHex());
if (match) {
logger.info(`Found existing referenda for PR #${pr}`);
const msg = `Voting for this referenda is **ongoing**.\n\nVote for it [here]${match.url}`;
rows.push([`${owner}/${repo}#${pr}`, `<a href="${match.url}">${match.url}</a>`]);
await octokit.rest.issues.createComment({ owner, repo, issue_number: pr, body: msg });
}
}
}
catch (e) {
logger.error(e);
throw new Error("There was a problem during the commenting");
}
finally {
await wsProvider.disconnect();
}
await summary_1.summary
.addHeading("Referenda search", 3)
.addHeading(`Found ${rows.length - 1} PRs matching ongoing referendas`, 5)
.addTable(rows)
.write();
logger.info("Finished run");
};
exports.cron = cron;


/***/ }),
Expand Down Expand Up @@ -67613,21 +67757,28 @@ exports.run = void 0;
const core = __importStar(__nccwpck_require__(42186));
const githubActions = __importStar(__nccwpck_require__(95438));
const js_1 = __nccwpck_require__(49246);
const constants_1 = __nccwpck_require__(69042);
const cron_1 = __nccwpck_require__(59866);
const handle_command_1 = __nccwpck_require__(51534);
async function run() {
const { context } = githubActions;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const [_, command, ...args] = githubActions.context.payload.comment?.body.split(" ");
const respondParams = {
owner: githubActions.context.repo.owner,
repo: githubActions.context.repo.repo,
issue_number: githubActions.context.issue.number,
};
const octokitInstance = githubActions.getOctokit((0, js_1.envVar)("GH_TOKEN"));
if (githubActions.context.eventName !== "issue_comment") {
if (context.eventName === "schedule" || context.eventName === "workflow_dispatch") {
const { owner, repo } = context.repo;
return await (0, cron_1.cron)(new Date(constants_1.START_DATE), owner, repo, octokitInstance);
}
else if (context.eventName !== "issue_comment") {
throw new Error("The action is expected to be run on 'issue_comment' events only.");
}
const event = githubActions.context.payload;
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const [_, command, ...args] = context.payload.comment?.body.split(" ");
const respondParams = {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
};
const event = context.payload;
const requester = event.comment.user.login;
const githubComment = async (body) => await octokitInstance.rest.issues.createComment({
...respondParams,
Expand All @@ -67651,7 +67802,7 @@ async function run() {
}
}
catch (e) {
const logs = `${githubActions.context.serverUrl}/${githubActions.context.repo.owner}/${githubActions.context.repo.repo}/actions/runs/${githubActions.context.runId}`;
const logs = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await githubComment(`@${requester} Handling the RFC command failed :(\nYou can open an issue [here](https://github.com/paritytech/rfc-propose/issues/new).\nSee the logs [here](${logs}).`);
await githubEmojiReaction("confused");
throw e;
Expand All @@ -67678,43 +67829,65 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.getRejectRemarkText = exports.getApproveRemarkText = exports.parseRFC = void 0;
exports.getRejectRemarkText = exports.getApproveRemarkText = exports.parseRFC = exports.extractRfcResult = void 0;
const node_fetch_1 = __importDefault(__nccwpck_require__(80467));
const util_1 = __nccwpck_require__(92629);
/**
* Parses the RFC details contained in the PR.
* The details include the RFC number,
* a link to the RFC text on GitHub,
* and the remark text, e.g. RFC_APPROVE(1234,hash)
*/
const parseRFC = async (requestState) => {
const { octokitInstance, event } = requestState;
const addedMarkdownFiles = (await octokitInstance.rest.pulls.listFiles({
repo: event.repository.name,
owner: event.repository.owner.login,
pull_number: event.issue.number,
const extractRfcResult = async (octokit, pr) => {
const { owner, repo, number } = pr;
const addedMarkdownFiles = (await octokit.rest.pulls.listFiles({
owner,
repo,
pull_number: number,
})).data.filter((file) => file.status === "added" && file.filename.startsWith("text/") && file.filename.includes(".md"));
if (addedMarkdownFiles.length < 1) {
return (0, util_1.userProcessError)(requestState, "RFC markdown file was not found in the PR.");
return { success: false, error: "RFC markdown file was not found in the PR." };
}
if (addedMarkdownFiles.length > 1) {
return (0, util_1.userProcessError)(requestState, `The system can only parse **one** markdown file but more than one were found: ${addedMarkdownFiles
.map((file) => file.filename)
.join(",")}. Please, reduce the number of files to **one file** for the system to work.`);
return {
success: false,
error: `The system can only parse **one** markdown file but more than one were found: ${addedMarkdownFiles
.map((file) => file.filename)
.join(",")}. Please, reduce the number of files to **one file** for the system to work.`,
};
}
const [rfcFile] = addedMarkdownFiles;
const rawText = await (await (0, node_fetch_1.default)(rfcFile.raw_url)).text();
const rfcNumber = rfcFile.filename.split("text/")[1].split("-")[0];
if (rfcNumber === undefined) {
return (0, util_1.userProcessError)(requestState, "Failed to read the RFC number from the filename. Please follow the format: `NNNN-name.md`. Example: `0001-example-proposal.md`");
return {
success: false,
error: "Failed to read the RFC number from the filename. Please follow the format: `NNNN-name.md`. Example: `0001-example-proposal.md`",
};
}
return {
approveRemarkText: (0, exports.getApproveRemarkText)(rfcNumber, rawText),
rejectRemarkText: (0, exports.getRejectRemarkText)(rfcNumber, rawText),
rfcFileRawUrl: rfcFile.raw_url,
rfcNumber,
success: true,
result: {
approveRemarkText: (0, exports.getApproveRemarkText)(rfcNumber, rawText),
rejectRemarkText: (0, exports.getRejectRemarkText)(rfcNumber, rawText),
rfcFileRawUrl: rfcFile.raw_url,
rfcNumber,
},
};
};
exports.extractRfcResult = extractRfcResult;
/**
* Parses the RFC details contained in the PR.
* The details include the RFC number,
* a link to the RFC text on GitHub,
* and the remark text, e.g. RFC_APPROVE(1234,hash)
*/
const parseRFC = async (requestState) => {
const { octokitInstance, event } = requestState;
const result = await (0, exports.extractRfcResult)(octokitInstance, {
repo: event.repository.name,
owner: event.repository.owner.login,
number: event.issue.number,
});
if (!result.success) {
return (0, util_1.userProcessError)(requestState, result.error);
}
return result.result;
};
exports.parseRFC = parseRFC;
const getApproveRemarkText = (rfcNumber, rawProposalText) => `RFC_APPROVE(${rfcNumber},${(0, util_1.hashProposal)(rawProposalText)})`;
exports.getApproveRemarkText = getApproveRemarkText;
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export const PROVIDER_URL = process.env.PROVIDER_URL || "wss://polkadot-collectives-rpc.polkadot.io";
export const POLKADOT_APPS_URL = `https://polkadot.js.org/apps/?rpc=${encodeURIComponent(PROVIDER_URL)}#/`;
export const START_DATE = process.env.START_DATE || "0";
8 changes: 8 additions & 0 deletions src/cron.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { getAllRFCRemarks } from "./cron";

describe("RFC Listing test", () => {
test("Should not return any remark with future date", async () => {
const remarks = await getAllRFCRemarks(new Date());
expect(remarks).toHaveLength(0);
}, 60_000);
});
Loading

0 comments on commit 5a1103c

Please sign in to comment.