Переглянути джерело

Merge pull request #428 from traumschule/council-report-script

Refactor Council Report Generator
mochet 3 роки тому
батько
коміт
cad355332f

+ 5 - 0
contributions/tech/council-report-generator/cli/.gitignore

@@ -0,0 +1,5 @@
+.idea/*
+lib/*
+node_modules
+yarn.lock
+report.md

+ 25 - 0
contributions/tech/council-report-generator/cli/package.json

@@ -0,0 +1,25 @@
+{
+  "name": "council-report-generator",
+  "version": "0.1.0",
+  "license": "GPL-3.0-only",
+  "scripts": {
+    "index": "ts-node src/index.ts"
+  },
+  "dependencies": {
+    "@joystream/types": "^0.16.1",
+    "@polkadot/api": "4.2.1",
+    "@polkadot/keyring": "^6.0.5",
+    "@polkadot/types": "4.2.1",
+    "@polkadot/util": "^6.0.5",
+    "@polkadot/util-crypto": "^6.0.5",
+    "@types/bn.js": "^4.11.5",
+    "bn.js": "^4.11.8",
+    "express": "^4.17.1"
+  },
+  "devDependencies": {
+    "@polkadot/ts": "^0.3.62",
+    "@types/node": "^16.4.0",
+    "ts-node": "^10.1.0",
+    "typescript": "^3.9.7"
+  }
+}

+ 130 - 0
contributions/tech/council-report-generator/cli/report-template.md

@@ -0,0 +1,130 @@
+# Sumer - Council Election Round #{electionRound}  - Performance Review and Minutes
+## 1 - Basic Information
+### 1.1 - Introduction
+The council is expected to produce reports during each round and provide feedback in the form of workflow, challenges, thinking and performance as well as minutes covering important events during the council session.
+
+Usernames referenced are Joystream usernames.
+All times are calculated based on {averageBlockProductionTime} second blocktimes and not actual blocktimes.
+The Council Round number is taken from the chain, the KPI rounds have an offset number.
+
+### 1.2 - Council Round Overview
+* Council Election Round: #{electionRound}
+* Council Term: {councilTerm}
+* Start Block: #{startBlockHeight}
+* End Block: #{endBlockHeight}
+* Forum thread for round feedback: N/A
+
+### 1.3 - Council members & vote participation
+* All usernames are listed in the order given by `activeCouncil` from chain state.
+* Votes cast includes all types of vote (Approve, Reject, Abstain & Slash)
+* In the event a proposal is not finalized within the current council, it will be indicated and current council votes will not be recorded due to system limitations
+
+{councilTable}
+
+### 1.4 - Council Roles
+* Council Secretary
+    * {councilSecretary}
+* Council Deputy Secretary
+    * {councilDeputySecretary}
+
+### 1.5 - Council Mint & Budget Status
+* Start minted: {startMinted}
+* End minted: {endMinted}
+* Total minted during council round: {totalNewMinted} tokens (+{percNewMinted}% from start)
+
+* Budget proposal link: https://testnet.joystream.org/#/proposals/802
+* Budget forum link: https://testnet.joystream.org/#/forum/threads/717
+
+(Note: The council budget is now based on the rewards for roles, council payments and validator payments. Jsgenesis decides how much to set the mint to.)
+
+## 2 - Minutes
+### 2.1 - Proposal Overview
+Proposal Types
+- {proposalsCreated} Proposals Created
+    - {textProposals} Text Proposals
+    - {spendingProposals} Spending Proposals
+    - {setWorkingGroupLeaderRewardProposals} SetWorkingGroupLeaderReward Proposals
+    - {setWorkingGroupMintCapacityProposals} SetWorkingGroupMintCapacity Proposals
+    - {beginReviewWorkingGroupLeaderApplicationProposals} BeginReviewWorkingGroupLeaderApplication Proposals
+    - {terminateWorkingGroupLeaderRoleProposals} TerminateWorkingGroupLeaderRole
+    - {fillWorkingGroupLeaderOpeningProposals} FillWorkingGroupLeaderOpening
+    - {setValidatorCountProposals} SetValidatorCount
+    - {addWorkingGroupLeaderOpeningProposals} AddWorkingGroupLeaderOpening
+    - {setElectionParametersProposals} SetElectionParameters
+    - {runtimeUpgradeProposals} RuntimeUpgrade
+
+Proposal States
+- {approvedExecutedProposals} Approved & executed proposals
+- {canceledProposals} Canceled proposals
+- {rejectedProposals} Rejected proposals
+- {slashedProposals} Slashed proposals
+- {expiredProposals} Expired proposals
+- {activeProposals} proposals passed to next council
+    - These proposals didn't gather enough quorum in the current council term, so the votes are reset and passed to the next council.
+
+Failed Proposals
+- {proposalsFailedForNotEnoughCapacity} NotEnoughCapacity failures
+- {proposalsFailedForExecutionFailed} ExecutionFailed
+
+- Total time for proposals to finalize: {totalProposalsFinalizeTime} hours
+- Average time for proposals to finalize: {averageTimeForProposalsToFinalize} hours
+    - This average is calculated from all proposals, including expired proposals but excluding canceled proposals.
+
+### 2.2 - Proposal Breakdown
+
+{proposalBreakdown}
+
+### 2.4 - Select threads & events
+N/A
+
+### 2.5 - Working Group Spotchecks
+- Storage Role Spot Check: N/A
+- Curator Role Spot Check: N/A
+- Operations Role Spot Check: N/A
+
+## 3 - Review
+### 3.1 - Workflow, Performance, Challenged & Thinking
+* N/A
+
+## 4 - Obligations
+Council obligations are payments or items that carry through council sessions. These are noted so that future councils can easily see what items they should be aware of. Items can be removed from here once they have been resolved or become outdated.
+
+### 4.1 Current Documents / Processes
+- Council Report
+    - Each council should produce a report which highlights important events, council participation, mint spending and other important facts surrounding the council term
+- Council Budget
+    - This is a proposal which tries to guide how many tokens the current council may have available to it during a term. This is a non binding proposal, so is mainly used as a guide for now.
+
+### 4.2 Regular Payments / Proposals
+- Council Mint
+    - The council mint needs to be checked on a regular basis and in the event it is near depletion, a council member should notify a member of Jsgenesis in order for it to be refilled.
+    - The council mint is set at a value decided by Jsgenesis.
+- Council Roles
+    - Council Secretary
+        - This role was introduced in Council Round #18 and the payments are now managed by KPI rewards.
+- Content Curator Mint
+    - The Content Curator Mint currently has a maximum value of 5 million tokens.
+    - The Content Curator Mint has to be filled periodically and the agreed amount was discussed earlier. The amount may change in the future, but the rewards for this role are dependent on the council passing these proposals in a timely fashion.
+    - The Content Curator Lead role is expected to keep track of their mint level and any member of the Joystream platform can create a proposal to refill this mint.
+- Storage Mint
+    - The Storage Mint currently has a maximum value of 5 million tokens.
+    - This mint has be refilled periodically
+    - The Storage Lead role is expected to keep track of their mint level and any member of the Joystream platform can create a proposal to refill this mint.
+
+### 4.2 Bounties
+- Bounties are shown on the forum Bounties section: https://testnet.joystream.org/#/forum/categories/10
+
+## 5 - Report changelog
+- [October, November 2021](https://github.com/Joystream/community-repo/pull/428)
+    - proposals refactor, show amount for spending proposals
+
+- 04.11.2020
+    - added working group review section
+
+- 22.10.2020
+    - updated budget section to reflect nature of new budget system
+    - updated events to be threads & events
+
+- 14.09.2020
+    - removed member addresses and replaced with member IDs since they take less space
+    - added realized and unrealized spending

+ 83 - 0
contributions/tech/council-report-generator/cli/src/index.ts

@@ -0,0 +1,83 @@
+// @ts-check
+
+import { ApiPromise, WsProvider } from "@polkadot/api";
+import { types as joyTypes } from "@joystream/types";
+import { Hash, Moment } from "@polkadot/types/interfaces";
+import {
+  BlockRange,
+  CouncilMemberInfo,
+  CouncilRoundInfo,
+  ProposalFailedReason,
+  ProposalInfo,
+  ProposalStatus,
+  ProposalType,
+  ReportData,
+} from "./types";
+import { Seats } from "@joystream/types/council";
+import { MemberId, Membership } from "@joystream/types/members";
+import { StorageKey, u32, U32, Vec } from "@polkadot/types";
+import { Mint, MintId } from "@joystream/types/mint";
+import { ProposalDetailsOf, ProposalOf } from "@joystream/types/augment/types";
+
+import { generateReportData } from "./report-functions";
+
+const fsSync = require("fs");
+const fs = fsSync.promises;
+
+const PROPOSAL_URL = "https://testnet.joystream.org/#/proposals/";
+const ELECTION_OFFSET = 1;
+
+async function main() {
+  const args = process.argv.slice(2);
+  if (args.length != 2) {
+    console.error("Usage: [start bock number] [end block number]");
+    process.exit(1);
+  }
+
+  const startCouncilBlock = Number(args[0]);
+  const endCouncilBlock = Number(args[1]);
+
+  const provider = new WsProvider("ws://localhost:9944");
+
+  const api = new ApiPromise({ provider, types: joyTypes });
+  await api.isReady;
+
+  const startHash = (await api.rpc.chain.getBlockHash(
+    startCouncilBlock
+  )) as Hash;
+  const endHash = (await api.rpc.chain.getBlockHash(endCouncilBlock)) as Hash;
+  const blockRange = new BlockRange(
+    startCouncilBlock,
+    startHash,
+    endCouncilBlock,
+    endHash
+  );
+
+  const reportData = await generateReportData(api, blockRange);
+  const reportGenerationResult = await generateReport(reportData);
+  await fs.writeFile("report.md", reportGenerationResult);
+
+  api.disconnect();
+}
+
+async function generateReport(data: ReportData) {
+  try {
+    let fileData = await fs.readFile(__dirname + "/../report-template.md", {
+      encoding: "utf8",
+    });
+
+    let entries = Object.entries(data);
+
+    for (let entry of entries) {
+      let regex = new RegExp("{" + entry[0] + "}", "g");
+      fileData = fileData.replace(regex, entry[1].toString());
+    }
+
+    return fileData;
+  } catch (e) {
+    console.error(e);
+  }
+  return "";
+}
+
+main();

+ 460 - 0
contributions/tech/council-report-generator/cli/src/report-functions.ts

@@ -0,0 +1,460 @@
+import { ApiPromise } from "@polkadot/api";
+import {
+  BlockRange,
+  CouncilMemberInfo,
+  CouncilRoundInfo,
+  ProposalFailedReason,
+  ProposalInfo,
+  ProposalStatus,
+  ProposalType,
+  ReportData,
+} from "./types";
+import { StorageKey, U32, u32, Vec } from "@polkadot/types";
+import { Seats } from "@joystream/types/council";
+import { MemberId, Membership } from "@joystream/types/members";
+import { Mint, MintId } from "@joystream/types/mint";
+import { ProposalDetailsOf, ProposalOf } from "@joystream/types/augment/types";
+import { Moment } from "@polkadot/types/interfaces";
+
+const PROPOSAL_URL = "https://testnet.joystream.org/#/proposals/";
+const ELECTION_OFFSET = 2;
+
+export async function generateReportData(
+  api: ApiPromise,
+  blockRange: BlockRange
+) {
+  const averageBlockProductionTime = await computeAverageBlockProductionTime(
+    api,
+    blockRange
+  );
+
+  const proposals = await getProposals(api, blockRange);
+  const electionRound = (await api.query.councilElection.round.at(
+    blockRange.startBlockHash
+  )) as u32;
+
+  const roundInfo = await getCouncilMembersInfo(api, blockRange, proposals);
+  const { members, membersOwnStake, backersTotalStake } = roundInfo;
+  const { startMinted, endMinted } = roundInfo;
+
+  let councilTable =
+    "| Username             | Member ID | Prop. Votes Cast | CM Own Stake | CM Voter Stake |\n" +
+    "|----------------------|-----------|------------------|--------------|----------------|\n";
+
+  for (const member of members) {
+    const { username, memberId, votesInProposals, ownStake, backersStake } =
+      member;
+    councilTable += `| @${username} | ${memberId} | ${votesInProposals} | ${ownStake} | ${backersStake} |\n`;
+  }
+  councilTable += `| | | Subtotal: | ${membersOwnStake} | ${backersTotalStake} |\n`;
+  const totalStake = membersOwnStake + backersTotalStake;
+  councilTable += `| | | Total: | ${totalStake} |  |\n`;
+
+  const councilSecretary = getCouncilSecretary(proposals);
+  const councilSecretaryDeputy = getCouncilSecretaryDeputy(proposals);
+
+  let proposalsBreakdownText = "";
+  for (const proposal of proposals) {
+    const { id, name, type, status, failedReason, paymentAmount } = proposal;
+    const { creatorUsername, votersUsernames, blocksToFinalized } = proposal;
+
+    let proposalStatusText = "";
+    switch (status) {
+      case ProposalStatus.Active:
+        proposalStatusText = "Passed to next council";
+        break;
+      case ProposalStatus.Executed:
+        proposalStatusText = "Approved & Executed";
+        break;
+      case ProposalStatus.ExecutionFailed:
+        const reason =
+          ProposalFailedReason[failedReason as ProposalFailedReason];
+        proposalStatusText = `Execution failed (${reason})`;
+        break;
+      case ProposalStatus.PendingExecution:
+        proposalStatusText = "Execution Pending";
+        break;
+      case ProposalStatus.Rejected:
+        proposalStatusText = "Rejected";
+        break;
+      case ProposalStatus.Cancelled:
+        proposalStatusText = "Canceled";
+        break;
+      case ProposalStatus.Expired:
+        proposalStatusText = "Expired";
+        break;
+      case ProposalStatus.Slashed:
+        proposalStatusText = "Slashed";
+        break;
+    }
+
+    proposalsBreakdownText += `#### Proposal ${id} - ${name}\n`;
+    proposalsBreakdownText += `- Proposal Link: ${PROPOSAL_URL}${id}\n`;
+    proposalsBreakdownText += `- Proposal Type: ${ProposalType[type]}\n`;
+
+    if (paymentAmount)
+      proposalsBreakdownText += `\t- Amount: ${paymentAmount}\n`;
+
+    if (proposal.paymentDestinationMemberUsername)
+      proposalsBreakdownText += `\t- Destination member: ${proposal.paymentDestinationMemberUsername}\n`;
+
+    proposalsBreakdownText += `- Status: ${proposalStatusText}\n`;
+    if (blocksToFinalized > 0 && status != ProposalStatus.Cancelled) {
+      const time = averageBlockProductionTime;
+      const days = convertBlocksToHours(blocksToFinalized, time);
+      proposalsBreakdownText += `\t- Time to finalize: ${blocksToFinalized} blocks (${days}h)\n`;
+    }
+    proposalsBreakdownText += `- Created by: @${creatorUsername}\n`;
+    let participantsText = votersUsernames.map((vote) => `@${vote}`).join(", ");
+    proposalsBreakdownText += `- Participants: ${participantsText}\n\n`;
+  }
+  proposalsBreakdownText = proposalsBreakdownText.substring(
+    0,
+    proposalsBreakdownText.length - 2
+  ); //Remove last \n\n
+
+  let reportData = new ReportData();
+  reportData.averageBlockProductionTime = averageBlockProductionTime.toFixed(2);
+  reportData.electionRound = Number(electionRound.toBigInt());
+  reportData.councilTerm = reportData.electionRound - ELECTION_OFFSET;
+  reportData.startBlockHeight = blockRange.startBlockHeight;
+  reportData.endBlockHeight = blockRange.endBlockHeight;
+  reportData.startMinted = startMinted;
+  reportData.endMinted = endMinted;
+
+  reportData.totalNewMinted = endMinted - startMinted;
+  reportData.percNewMinted = convertToPercentage(startMinted, endMinted);
+
+  reportData.councilTable = councilTable;
+  reportData.councilSecretary =
+    councilSecretary == "" ? "?" : "@" + councilSecretary;
+  reportData.councilDeputySecretary =
+    councilSecretaryDeputy == "" ? "?" : "@" + councilSecretaryDeputy;
+
+  reportData.proposalsCreated = proposals.length;
+  reportData.textProposals = proposals.filter(
+    (proposal) => proposal.type == ProposalType.Text
+  ).length;
+  reportData.spendingProposals = proposals.filter(
+    (proposal) => proposal.type == ProposalType.Spending
+  ).length;
+  reportData.setWorkingGroupLeaderRewardProposals = proposals.filter(
+    (proposal) => proposal.type == ProposalType.SetWorkingGroupLeaderReward
+  ).length;
+  reportData.setWorkingGroupMintCapacityProposals = proposals.filter(
+    (proposal) => proposal.type == ProposalType.SetWorkingGroupMintCapacity
+  ).length;
+  reportData.beginReviewWorkingGroupLeaderApplicationProposals =
+    proposals.filter(
+      (proposal) =>
+        proposal.type == ProposalType.BeginReviewWorkingGroupLeaderApplication
+    ).length;
+  reportData.terminateWorkingGroupLeaderRoleProposals = proposals.filter(
+    (proposal) => proposal.type == ProposalType.TerminateWorkingGroupLeaderRole
+  ).length;
+  reportData.fillWorkingGroupLeaderOpeningProposals = proposals.filter(
+    (proposal) => proposal.type == ProposalType.FillWorkingGroupLeaderOpening
+  ).length;
+  reportData.setValidatorCountProposals = proposals.filter(
+    (proposal) => proposal.type == ProposalType.SetValidatorCount
+  ).length;
+  reportData.addWorkingGroupLeaderOpeningProposals = proposals.filter(
+    (proposal) => proposal.type == ProposalType.AddWorkingGroupLeaderOpening
+  ).length;
+  reportData.setElectionParametersProposals = proposals.filter(
+    (proposal) => proposal.type == ProposalType.SetElectionParameters
+  ).length;
+  reportData.runtimeUpgradeProposals = proposals.filter(
+    (proposal) => proposal.type == ProposalType.RuntimeUpgrade
+  ).length;
+
+  reportData.approvedExecutedProposals = proposals.filter(
+    (proposal) => proposal.status == ProposalStatus.Executed
+  ).length;
+  reportData.canceledProposals = proposals.filter(
+    (proposal) => proposal.status == ProposalStatus.Cancelled
+  ).length;
+  reportData.rejectedProposals = proposals.filter(
+    (proposal) => proposal.status == ProposalStatus.Rejected
+  ).length;
+  reportData.slashedProposals = proposals.filter(
+    (proposal) => proposal.status == ProposalStatus.Slashed
+  ).length;
+  reportData.expiredProposals = proposals.filter(
+    (proposal) => proposal.status == ProposalStatus.Expired
+  ).length;
+  reportData.activeProposals = proposals.filter(
+    (proposal) => proposal.status == ProposalStatus.Active
+  ).length;
+
+  let executedNonCancelledProposals = proposals.filter(
+    ({ status, blocksToFinalized }) =>
+      blocksToFinalized > 0 && status != ProposalStatus.Cancelled
+  );
+  let totalFinalizeTime = executedNonCancelledProposals.reduce(
+    (accumulator, proposal) => accumulator + proposal.blocksToFinalized,
+    0
+  );
+  let averageFinalizeTime =
+    totalFinalizeTime / executedNonCancelledProposals.length;
+
+  let failedProposals = proposals.filter(
+    (proposal) => proposal.status == ProposalStatus.ExecutionFailed
+  );
+
+  reportData.proposalsFailedForNotEnoughCapacity = failedProposals.filter(
+    ({ failedReason }) => failedReason == ProposalFailedReason.NotEnoughCapacity
+  ).length;
+  reportData.proposalsFailedForExecutionFailed = failedProposals.filter(
+    ({ failedReason }) => failedReason == ProposalFailedReason.ExecutionFailed
+  ).length;
+
+  reportData.totalProposalsFinalizeTime = convertBlocksToHours(
+    totalFinalizeTime,
+    averageBlockProductionTime
+  );
+  reportData.averageTimeForProposalsToFinalize = convertBlocksToHours(
+    averageFinalizeTime,
+    averageBlockProductionTime
+  );
+  reportData.proposalBreakdown = proposalsBreakdownText;
+  return reportData;
+}
+
+async function getCouncilMembersInfo(
+  api: ApiPromise,
+  range: BlockRange,
+  proposals: Array<ProposalInfo>
+) {
+  const seats = (await api.query.council.activeCouncil.at(
+    range.startBlockHash
+  )) as Seats;
+
+  let councilRoundInfo = new CouncilRoundInfo();
+  councilRoundInfo.members = await Promise.all(
+    seats.map(async (seat) => {
+      let info = new CouncilMemberInfo();
+      let memberKey = seat.member.toString();
+      info.memberId = Number(
+        (
+          (await api.query.members.memberIdsByControllerAccountId(
+            memberKey
+          )) as Vec<MemberId>
+        )[0].toBigInt()
+      );
+      const membership = (await api.query.members.membershipById(
+        info.memberId
+      )) as Membership;
+      info.username = membership.handle.toString();
+      info.ownStake = Number(seat.stake.toBigInt());
+      const backersStakeArray = seat.backers.map((backer) =>
+        Number(backer.stake.toBigInt())
+      );
+      info.backersStake = backersStakeArray.reduce((a, b) => a + b, 0);
+      return info;
+    })
+  );
+
+  councilRoundInfo.membersOwnStake = councilRoundInfo.members
+    .map((councilMemberInfo) => councilMemberInfo.ownStake)
+    .reduce((a, b) => a + b, 0);
+  councilRoundInfo.backersTotalStake = councilRoundInfo.members
+    .map((councilMemberInfo) => councilMemberInfo.backersStake)
+    .reduce((a, b) => a + b, 0);
+
+  for (let councilMemberInfo of councilRoundInfo.members) {
+    councilMemberInfo.votesInProposals = proposals.filter((proposal) =>
+      proposal.votersUsernames.includes(councilMemberInfo.username)
+    ).length;
+  }
+
+  let councilMint = (await api.query.council.councilMint()) as MintId;
+  let startCouncilMint = (await api.query.minting.mints.at(
+    range.startBlockHash,
+    councilMint
+  )) as Mint;
+  let endCouncilMint = (await api.query.minting.mints.at(
+    range.endBlockHash,
+    councilMint
+  )) as Mint;
+
+  councilRoundInfo.startMinted = Number(
+    startCouncilMint.total_minted.toBigInt()
+  );
+  councilRoundInfo.endMinted = Number(endCouncilMint.total_minted.toBigInt());
+
+  return councilRoundInfo;
+}
+
+async function getProposals(api: ApiPromise, range: BlockRange) {
+  let startProposalCount = Number(
+    (
+      (await api.query.proposalsEngine.proposalCount.at(
+        range.startBlockHash
+      )) as U32
+    ).toBigInt()
+  );
+  let endProposalCount = Number(
+    (
+      (await api.query.proposalsEngine.proposalCount.at(
+        range.endBlockHash
+      )) as U32
+    ).toBigInt()
+  );
+
+  let proposals = new Array<ProposalInfo>();
+  for (let i = startProposalCount - 1; i <= endProposalCount; i++) {
+    let proposal = (await api.query.proposalsEngine.proposals.at(
+      range.endBlockHash,
+      i
+    )) as ProposalOf;
+    if (proposal.createdAt.toBigInt() < range.startBlockHeight) {
+      continue;
+    }
+
+    let proposer = (await api.query.members.membershipById(
+      proposal.proposerId
+    )) as Membership;
+    let proposalInfo = new ProposalInfo();
+    proposalInfo.id = i;
+    proposalInfo.name = proposal.title.toString();
+    proposalInfo.creatorUsername = proposer.handle.toString();
+
+    if (proposal.status.isFinalized) {
+      const finalizedData = proposal.status.asFinalized;
+
+      if (finalizedData.proposalStatus.isCanceled) {
+        proposalInfo.status = ProposalStatus.Cancelled;
+      } else if (finalizedData.proposalStatus.isExpired) {
+        proposalInfo.status = ProposalStatus.Expired;
+      } else if (finalizedData.proposalStatus.isRejected) {
+        proposalInfo.status = ProposalStatus.Rejected;
+      } else if (finalizedData.proposalStatus.isApproved) {
+        let approvedData = finalizedData.proposalStatus.asApproved;
+        if (approvedData.isExecuted) {
+          proposalInfo.status = ProposalStatus.Executed;
+        } else if (approvedData.isPendingExecution) {
+          proposalInfo.status = ProposalStatus.PendingExecution;
+        } else if (approvedData.isExecutionFailed) {
+          proposalInfo.status = ProposalStatus.ExecutionFailed;
+          let executionFailedData = approvedData.asExecutionFailed;
+          if (executionFailedData.error.toString() == "NotEnoughCapacity") {
+            proposalInfo.failedReason = ProposalFailedReason.NotEnoughCapacity;
+          } else {
+            proposalInfo.failedReason = ProposalFailedReason.ExecutionFailed;
+          }
+        }
+      } else if (finalizedData.proposalStatus.isSlashed) {
+        proposalInfo.status = ProposalStatus.Slashed;
+      }
+
+      proposalInfo.blocksToFinalized =
+        Number(proposal.status.asFinalized.finalizedAt.toBigInt()) -
+        Number(proposal.createdAt.toBigInt());
+
+      const proposalByVoters =
+        await api.query.proposalsEngine.voteExistsByProposalByVoter.entries(i);
+
+      for (let proposalByVoter of proposalByVoters) {
+        let key = proposalByVoter[0] as StorageKey;
+        let memberId = key.args[1] as MemberId;
+        let member = (await api.query.members.membershipById(
+          memberId
+        )) as Membership;
+        proposalInfo.votersUsernames.push(member.handle.toString());
+      }
+    }
+
+    let proposalDetails =
+      (await api.query.proposalsCodex.proposalDetailsByProposalId(
+        i
+      )) as ProposalDetailsOf;
+    let typeString = proposalDetails.type as keyof typeof ProposalType;
+    proposalInfo.type = ProposalType[typeString];
+
+    if (proposalInfo.type == ProposalType.Spending) {
+      let spendingData = proposalDetails.asSpending;
+      let accountId = spendingData[1];
+      proposalInfo.paymentAmount = Number(spendingData[0].toBigInt());
+      let paymentDestinationMemberId =
+        await api.query.members.memberIdsByControllerAccountId(accountId);
+      if (!paymentDestinationMemberId.isEmpty) {
+        let paymentDestinationMembership =
+          (await api.query.members.membershipById(
+            paymentDestinationMemberId
+          )) as Membership;
+        proposalInfo.paymentDestinationMemberUsername =
+          paymentDestinationMembership.handle.toString();
+      }
+    }
+
+    proposals.push(proposalInfo);
+  }
+
+  return proposals;
+}
+
+function getCouncilSecretary(proposals: ProposalInfo[]) {
+  let filteredProposals = proposals.filter((proposal) => {
+    return (
+      proposal.status == ProposalStatus.Executed &&
+      proposal.name.toLowerCase().includes("council") &&
+      proposal.name.toLowerCase().includes("secretary") &&
+      !proposal.name.toLowerCase().includes("deputy")
+    );
+  });
+
+  if (filteredProposals.length != 1) {
+    return "";
+  }
+
+  return filteredProposals[0].creatorUsername;
+}
+
+function getCouncilSecretaryDeputy(proposals: ProposalInfo[]) {
+  let filteredProposals = proposals.filter((proposal) => {
+    return (
+      proposal.status == ProposalStatus.Executed &&
+      proposal.name.toLowerCase().includes("secretary") &&
+      proposal.name.toLowerCase().includes("deputy")
+    );
+  });
+
+  if (filteredProposals.length != 1) {
+    return "";
+  }
+
+  return filteredProposals[0].creatorUsername;
+}
+
+function convertToPercentage(previousValue: number, newValue: number): number {
+  if (previousValue == 0) {
+    return newValue > 0 ? Infinity : 0;
+  }
+  return Number(((newValue * 100) / previousValue - 100).toFixed(2));
+}
+
+function convertBlocksToHours(
+  nrBlocks: number,
+  averageProductionBlockTime: number
+): string {
+  return ((nrBlocks * averageProductionBlockTime) / 60 / 60).toFixed(2);
+}
+
+async function computeAverageBlockProductionTime(
+  api: ApiPromise,
+  range: BlockRange
+) {
+  let startTimestamp = (await api.query.timestamp.now.at(
+    range.startBlockHash
+  )) as Moment;
+  let endTimestamp = (await api.query.timestamp.now.at(
+    range.endBlockHash
+  )) as Moment;
+  let newBlocks = range.endBlockHeight - range.startBlockHeight;
+  return (
+    (Number(endTimestamp.toBigInt()) - Number(startTimestamp.toBigInt())) /
+    1000 /
+    newBlocks
+  );
+}

+ 124 - 0
contributions/tech/council-report-generator/cli/src/types.ts

@@ -0,0 +1,124 @@
+import { Hash } from "@polkadot/types/interfaces/types";
+import { ProposalDetailsOf } from "@joystream/types/augment/types";
+
+export class CouncilMemberInfo {
+  username: string = "";
+  memberId: number = 0;
+  ownStake: number = 0;
+  backersStake: number = 0;
+  votesInProposals: number = 0;
+
+  constructor() {}
+}
+
+export class CouncilRoundInfo {
+  members: CouncilMemberInfo[] = [];
+  membersOwnStake: number = 0;
+  backersTotalStake: number = 0;
+  membersTotalStake: number = 0;
+  startMinted: number = 0;
+  endMinted: number = 0;
+}
+
+export class BlockRange {
+  constructor(
+    public startBlockHeight: number,
+    public startBlockHash: Hash,
+    public endBlockHeight: number,
+    public endBlockHash: Hash
+  ) {}
+}
+
+export enum ProposalStatus {
+  Active,
+  // Approved,
+  Executed,
+  ExecutionFailed,
+  PendingExecution,
+  Rejected,
+  Cancelled,
+  Expired,
+  Slashed,
+}
+
+export enum ProposalFailedReason {
+  NotEnoughCapacity,
+  ExecutionFailed,
+}
+
+export enum ProposalType {
+  Text,
+  RuntimeUpgrade,
+  SetElectionParameters,
+  Spending,
+  SetLead,
+  SetContentWorkingGroupMintCapacity,
+  EvictStorageProvider,
+  SetValidatorCount,
+  SetStorageRoleParameters,
+  AddWorkingGroupLeaderOpening,
+  BeginReviewWorkingGroupLeaderApplication,
+  FillWorkingGroupLeaderOpening,
+  SetWorkingGroupMintCapacity,
+  DecreaseWorkingGroupLeaderStake,
+  SlashWorkingGroupLeaderStake,
+  SetWorkingGroupLeaderReward,
+  TerminateWorkingGroupLeaderRole,
+}
+
+export class ProposalInfo {
+  id: number = 0;
+  name: string = "";
+  creatorUsername: string = "";
+  votersUsernames: string[] = [];
+  status = ProposalStatus.Active;
+  type = ProposalType.Text;
+  blocksToFinalized: number = 0;
+  failedReason?: ProposalFailedReason;
+  paymentDestinationMemberUsername?: string;
+  paymentAmount?: number;
+}
+
+export class ReportData {
+  averageBlockProductionTime = "";
+  electionRound = 0;
+  councilTerm = 0;
+  startBlockHeight = 0;
+  endBlockHeight = 0;
+
+  startMinted = 0;
+  endMinted = 0;
+  totalNewMinted = 0;
+  percNewMinted = 0;
+
+  councilTable = "";
+  councilSecretary = "";
+  councilDeputySecretary = "";
+
+  proposalsCreated = 0;
+  textProposals = 0;
+  spendingProposals = 0;
+  setWorkingGroupLeaderRewardProposals = 0;
+  setWorkingGroupMintCapacityProposals = 0;
+  beginReviewWorkingGroupLeaderApplicationProposals = 0;
+  terminateWorkingGroupLeaderRoleProposals = 0;
+  fillWorkingGroupLeaderOpeningProposals = 0;
+  setValidatorCountProposals = 0;
+  addWorkingGroupLeaderOpeningProposals = 0;
+  setElectionParametersProposals = 0;
+  runtimeUpgradeProposals = 0;
+
+  approvedExecutedProposals = 0;
+  canceledProposals = 0;
+  rejectedProposals = 0;
+  slashedProposals = 0;
+  expiredProposals = 0;
+  activeProposals = 0;
+
+  proposalsFailedForNotEnoughCapacity = 0;
+  proposalsFailedForExecutionFailed = 0;
+
+  totalProposalsFinalizeTime = "";
+  averageTimeForProposalsToFinalize = "";
+  proposalBreakdown = "";
+}