feature_votes.sh 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. #!/bin/bash
  2. # Rank open enhancement issues by community thumbs-up votes.
  3. #
  4. # A vote is a 👍 on the issue itself or on the "gauge community interest"
  5. # comment. Each person counts once, and the issue author and the maintainer
  6. # are left out. Read-only: it only queries the GitHub API through gh.
  7. #
  8. # Usage: ./feature_votes.sh --help
  9. set -euo pipefail
  10. show_help() {
  11. cat <<'EOF'
  12. Usage: ./feature_votes.sh [options] [limit]
  13. Rank open enhancement issues (without the contrib label) by thumbs-up votes.
  14. Arguments:
  15. limit Show only the top N issues (default: all)
  16. Options:
  17. --md Print a Markdown table with issue links
  18. -h, --help Show this help message
  19. Columns:
  20. votes Different people who voted on the issue or the poll comment,
  21. without the issue author and the maintainer (ranked by this)
  22. on issue Thumbs-up on the issue itself
  23. on poll Thumbs-up on the "gauge community interest" comment
  24. ("-" when the issue has no such comment)
  25. Examples:
  26. ./feature_votes.sh All issues
  27. ./feature_votes.sh 20 Top 20
  28. ./feature_votes.sh --md 20 Top 20 as Markdown
  29. EOF
  30. }
  31. FORMAT=text
  32. LIMIT=0
  33. while [[ $# -gt 0 ]]; do
  34. case "$1" in
  35. -h|--help) show_help; exit 0 ;;
  36. --md) FORMAT=md ;;
  37. *[!0-9]*|"")
  38. echo "Unknown option: $1" >&2
  39. echo "Run ./feature_votes.sh --help for usage." >&2
  40. exit 1
  41. ;;
  42. *) LIMIT="$1" ;;
  43. esac
  44. shift
  45. done
  46. command -v gh >/dev/null || { echo "gh (GitHub CLI) is required" >&2; exit 1; }
  47. gh auth status >/dev/null 2>&1 || { echo "gh is not logged in; run: gh auth login" >&2; exit 1; }
  48. FORMAT="$FORMAT" LIMIT="$LIMIT" python3 - <<'PY'
  49. import json
  50. import os
  51. import subprocess
  52. REPO = "maziggy/bambuddy"
  53. MAINTAINER = "maziggy"
  54. SEARCH = f"repo:{REPO} is:issue state:open label:enhancement -label:contrib"
  55. POLL_MARKER = "I'd like to gauge community interest"
  56. QUERY = """
  57. query($q: String!, $after: String) {
  58. search(query: $q, type: ISSUE, first: 30, after: $after) {
  59. issueCount
  60. pageInfo { hasNextPage endCursor }
  61. nodes {
  62. ... on Issue {
  63. number title url author { login }
  64. reactions(content: THUMBS_UP, first: 100) { nodes { user { login } } }
  65. comments(first: 100) {
  66. totalCount
  67. nodes { body reactions(content: THUMBS_UP, first: 100) { nodes { user { login } } } }
  68. }
  69. }
  70. }
  71. }
  72. }
  73. """
  74. def voters(reactions):
  75. return {r["user"]["login"] for r in reactions["nodes"] if r["user"]}
  76. rows, after, total, truncated = [], None, 0, 0
  77. while True:
  78. args = ["gh", "api", "graphql", "-f", f"query={QUERY}", "-f", f"q={SEARCH}"]
  79. if after:
  80. args += ["-f", f"after={after}"]
  81. page = json.loads(subprocess.check_output(args))["data"]["search"]
  82. total = page["issueCount"]
  83. for issue in page["nodes"]:
  84. body = voters(issue["reactions"])
  85. poll, has_poll = set(), False
  86. for comment in issue["comments"]["nodes"]:
  87. if POLL_MARKER in (comment["body"] or ""):
  88. has_poll = True
  89. poll |= voters(comment["reactions"])
  90. if issue["comments"]["totalCount"] > 100:
  91. truncated += 1
  92. author = (issue["author"] or {}).get("login")
  93. votes = len((body | poll) - {author, MAINTAINER})
  94. rows.append((votes, len(body), len(poll) if has_poll else None, issue["number"], issue["title"], issue["url"]))
  95. if not page["pageInfo"]["hasNextPage"]:
  96. break
  97. after = page["pageInfo"]["endCursor"]
  98. rows.sort(key=lambda r: (-r[0], -r[1], r[3]))
  99. limit = int(os.environ["LIMIT"])
  100. shown = rows[:limit] if limit > 0 else rows
  101. print(f"{total} open enhancement issues, {sum(r[2] is not None for r in rows)} with a poll comment, "
  102. f"{sum(r[0] == 0 for r in rows)} without outside votes")
  103. if truncated:
  104. print(f"Note: {truncated} issues have more than 100 comments; only the first 100 were checked")
  105. print()
  106. if os.environ["FORMAT"] == "md":
  107. print("| votes | on issue | on poll | issue |\n|---:|---:|---:|---|")
  108. for votes, body, poll, number, title, url in shown:
  109. print(f"| {votes} | {body} | {'-' if poll is None else poll} | [#{number}]({url}) {title.replace('|', '/')} |")
  110. else:
  111. print(f"{'votes':>5} {'on issue':>8} {'on poll':>7} issue")
  112. for votes, body, poll, number, title, _ in shown:
  113. print(f"{votes:>5} {body:>8} {'-' if poll is None else poll:>7} #{number} {title}")
  114. PY