diff --git a/README.md b/README.md index 2436c06..b3dca74 100644 --- a/README.md +++ b/README.md @@ -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 }} +``` diff --git a/dist/index.js b/dist/index.js index 04584e1..6c67150 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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}`, `${match.url}`]); + 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; /***/ }), @@ -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, @@ -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; @@ -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; diff --git a/src/constants.ts b/src/constants.ts index bfcd002..5885199 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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"; diff --git a/src/cron.test.ts b/src/cron.test.ts new file mode 100644 index 0000000..9467bb2 --- /dev/null +++ b/src/cron.test.ts @@ -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); +}); diff --git a/src/cron.ts b/src/cron.ts new file mode 100644 index 0000000..cc7039c --- /dev/null +++ b/src/cron.ts @@ -0,0 +1,171 @@ +import { debug, error, info, warning } from "@actions/core"; +import { summary, SummaryTableRow } from "@actions/core/lib/summary"; +import { ApiPromise, WsProvider } from "@polkadot/api"; + +import { PROVIDER_URL } from "./constants"; +import { extractRfcResult } from "./parse-RFC"; +import { ActionLogger, OctokitInstance } from "./types"; + +const logger: ActionLogger = { + info, + debug, + warn: warning, + error, +}; + +/** Gets the date of a block */ +const getBlockDate = async (blockNr: number, api: ApiPromise): Promise => { + const hash = await api.rpc.chain.getBlockHash(blockNr); + const timestamp = await api.query.timestamp.now.at(hash); + return new Date(timestamp.toPrimitive() as string); +}; + +export const getAllPRs = async ( + octokit: OctokitInstance, + repo: { owner: string; repo: string }, +): Promise<[number, string][]> => { + const prs = await octokit.paginate(octokit.rest.pulls.list, repo); + + logger.info(`Found ${prs.length} open PRs`); + + const prRemarks: [number, string][] = []; + + 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 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; +}; + +export const getAllRFCRemarks = async (startDate: Date): Promise<{ url: string; hash: string }[]> => { + const wsProvider = new WsProvider(PROVIDER_URL); + try { + const api = await 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: { url: string; hash: string }[] = []; + 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() as { ongoing?: OnGoing }; + + 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(); + } +}; + +export const cron = async (startDate: Date, owner: string, repo: string, octokit: OctokitInstance): Promise => { + const remarks = await getAllRFCRemarks(startDate); + if (remarks.length === 0) { + logger.warn("No ongoing RFCs made from pull requests. Shuting down"); + await 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 getAllPRs(octokit, { owner, repo }); + logger.debug(`Found all PR remarks ${JSON.stringify(prRemarks)}`); + + const rows: SummaryTableRow[] = [ + [ + { data: "PR", header: true }, + { data: "Referenda", header: true }, + ], + ]; + + const wsProvider = new WsProvider(PROVIDER_URL); + try { + const api = await 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}`, `${match.url}`]); + await octokit.rest.issues.createComment({ owner, repo, issue_number: pr, body: msg }); + } + } + } catch (e) { + logger.error(e as Error); + throw new Error("There was a problem during the commenting"); + } finally { + await wsProvider.disconnect(); + } + + await summary + .addHeading("Referenda search", 3) + .addHeading(`Found ${rows.length - 1} PRs matching ongoing referendas`, 5) + .addTable(rows) + .write(); + + logger.info("Finished run"); +}; + +interface OnGoing { + track: number; + origin: { fellowshipOrigins: string }; + proposal: { lookup?: { hash: string }; inline?: string }; + enactment: { after: number }; + submitted: number; + submissionDeposit: { + who: string; + amount: number; + }; + decisionDeposit: { + who: string; + amount: number; + }; + deciding: { since: number; confirming: null }; + tally: Record; + inQueue: boolean; +} diff --git a/src/index.ts b/src/index.ts index ac11c40..81750b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,24 +3,31 @@ import * as githubActions from "@actions/github"; import { envVar } from "@eng-automation/js"; import type { IssueCommentCreatedEvent } from "@octokit/webhooks-types"; +import { START_DATE } from "./constants"; +import { cron } from "./cron"; import { handleCommand } from "./handle-command"; import { GithubReactionType } from "./types"; export async function run(): Promise { + const { context } = githubActions; try { + const octokitInstance = githubActions.getOctokit(envVar("GH_TOKEN")); + if (context.eventName === "schedule" || context.eventName === "workflow_dispatch") { + const { owner, repo } = context.repo; + return await cron(new Date(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."); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const [_, command, ...args] = githubActions.context.payload.comment?.body.split(" ") as (string | undefined)[]; + const [_, command, ...args] = context.payload.comment?.body.split(" ") as (string | undefined)[]; const respondParams = { - owner: githubActions.context.repo.owner, - repo: githubActions.context.repo.repo, - issue_number: githubActions.context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, }; - const octokitInstance = githubActions.getOctokit(envVar("GH_TOKEN")); - if (githubActions.context.eventName !== "issue_comment") { - throw new Error("The action is expected to be run on 'issue_comment' events only."); - } - const event: IssueCommentCreatedEvent = githubActions.context.payload as IssueCommentCreatedEvent; + const event: IssueCommentCreatedEvent = context.payload as IssueCommentCreatedEvent; const requester = event.comment.user.login; const githubComment = async (body: string) => @@ -46,7 +53,7 @@ export async function run(): Promise { await githubEmojiReaction("confused"); } } 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}).`, ); diff --git a/src/parse-RFC.ts b/src/parse-RFC.ts index d35bc48..baa0e6e 100644 --- a/src/parse-RFC.ts +++ b/src/parse-RFC.ts @@ -1,6 +1,6 @@ import fetch from "node-fetch"; -import { RequestResultFailed, RequestState } from "./types"; +import { OctokitInstance, RequestResultFailed, RequestState } from "./types"; import { hashProposal, userProcessError } from "./util"; export type ParseRFCResult = { @@ -10,52 +10,75 @@ export type ParseRFCResult = { rfcFileRawUrl: string; }; -/** - * 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) - */ -export const parseRFC = async (requestState: RequestState): Promise => { - const { octokitInstance, event } = requestState; +export const extractRfcResult = async ( + octokit: OctokitInstance, + pr: { owner: string; repo: string; number: number }, +): Promise<{ success: true; result: ParseRFCResult } | { success: false; error: string }> => { + const { owner, repo, number } = pr; const addedMarkdownFiles = ( - await octokitInstance.rest.pulls.listFiles({ - repo: event.repository.name, - owner: event.repository.owner.login, - pull_number: event.issue.number, + 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 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 userProcessError( - requestState, - `The system can only parse **one** markdown file but more than one were found: ${addedMarkdownFiles + 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 fetch(rfcFile.raw_url)).text(); const rfcNumber: string | undefined = rfcFile.filename.split("text/")[1].split("-")[0]; if (rfcNumber === undefined) { - return 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: getApproveRemarkText(rfcNumber, rawText), - rejectRemarkText: getRejectRemarkText(rfcNumber, rawText), - rfcFileRawUrl: rfcFile.raw_url, - rfcNumber, + success: true, + result: { + approveRemarkText: getApproveRemarkText(rfcNumber, rawText), + rejectRemarkText: getRejectRemarkText(rfcNumber, rawText), + rfcFileRawUrl: rfcFile.raw_url, + rfcNumber, + }, }; }; +/** + * 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) + */ +export const parseRFC = async (requestState: RequestState): Promise => { + const { octokitInstance, event } = requestState; + + const result = await extractRfcResult(octokitInstance, { + repo: event.repository.name, + owner: event.repository.owner.login, + number: event.issue.number, + }); + if (!result.success) { + return userProcessError(requestState, result.error); + } + return result.result; +}; + export const getApproveRemarkText = (rfcNumber: string, rawProposalText: string): string => `RFC_APPROVE(${rfcNumber},${hashProposal(rawProposalText)})`; export const getRejectRemarkText = (rfcNumber: string, rawProposalText: string): string => diff --git a/src/types.ts b/src/types.ts index 0be3e80..92538f3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,8 +7,17 @@ export type GithubReactionType = "+1" | "-1" | "laugh" | "confused" | "heart" | export type RequestState = { event: IssueCommentCreatedEvent; requester: string; - octokitInstance: ReturnType<(typeof githubActions)["getOctokit"]>; + octokitInstance: OctokitInstance; }; +export type OctokitInstance = ReturnType<(typeof githubActions)["getOctokit"]>; + export type RequestResult = { success: true; message: string } | { success: false; errorMessage: string }; export type RequestResultFailed = RequestResult & { success: false }; + +export interface ActionLogger { + debug(message: string): void; + info(message: string): void; + warn(message: string | Error): void; + error(message: string | Error): void; +}