Commit 15f0e9b

Anton Medvedev <anton@medv.io>
2024-12-02 00:37:14
Add proper reactions loading (#80)
1 parent 323a979
badges/reactions/reactions.ts
@@ -1,125 +1,61 @@
-import { define, Reactions } from '#src'
+import { define, Reaction } from '#src'
+
+type Where = {
+  count: number
+  url: string
+}
 
 export default define({
   url: import.meta.url,
-  badges: ['thumbs-up', 'thumbs-down', 'confused'] as const,
+  badges: ['confused'] as const,
   present(data, grant) {
-    type Reaction =
-      | 'CONFUSED'
-      | 'EYES'
-      | 'HEART'
-      | 'HOORAY'
-      | 'LAUGH'
-      | 'ROCKET'
-      | 'THUMBS_DOWN'
-      | 'THUMBS_UP'
-
-    const reactions: {
-      totalCount: number
-      counts: Record<Reaction, number>
-      where: string
-    }[] = []
-
-    function count(reactions: Reactions['reactions']['nodes']) {
-      const counts: Record<Reaction, number> = {
-        CONFUSED: 0,
-        EYES: 0,
-        HEART: 0,
-        HOORAY: 0,
-        LAUGH: 0,
-        ROCKET: 0,
-        THUMBS_DOWN: 0,
-        THUMBS_UP: 0,
-      }
-      for (const reaction of reactions ?? []) {
-        counts[reaction.content] = (counts[reaction.content] || 0) + 1
-      }
-      return counts
-    }
-
-    for (const issue of data.issues) {
-      if (issue.reactions.totalCount > 0) {
-        reactions.push({
-          totalCount: issue.reactions.totalCount,
-          counts: count(issue.reactions.nodes),
-          where: issue.url,
-        })
-      }
-    }
-
-    for (const pull of data.pulls) {
-      if (pull.reactions.totalCount > 0) {
-        reactions.push({
-          totalCount: pull.reactions.totalCount,
-          counts: count(pull.reactions.nodes),
-          where: pull.url,
-        })
+    const moreThan10: Where[] = []
+
+    for (const x of [
+      ...data.issues,
+      ...data.pulls,
+      ...data.discussionComments,
+      ...data.issueComments,
+    ]) {
+      if (x.reactions && x.reactions.length > 0) {
+        const counts = count(x.reactions)
+        if (counts.CONFUSED > 10) {
+          moreThan10.push({ count: counts.THUMBS_UP, url: x.url })
+        }
       }
     }
 
-    for (const comment of data.issueComments) {
-      if (comment.reactions.totalCount > 0) {
-        reactions.push({
-          totalCount: comment.reactions.totalCount,
-          counts: count(comment.reactions.nodes),
-          where: comment.url,
-        })
-      }
-    }
-
-    for (const discussion of data.discussionComments) {
-      if (discussion.reactions.totalCount > 0 && discussion.discussion) {
-        reactions.push({
-          totalCount: discussion.reactions.totalCount,
-          counts: count(discussion.reactions.nodes),
-          where: discussion.url,
-        })
-      }
-    }
+    moreThan10.sort((a, b) => b.count - a.count)
 
-    const up = Object.values(reactions)
-    up.sort((a, b) => b.counts.THUMBS_UP - a.counts.THUMBS_UP)
-    if (up.length > 0 && up[0].counts.THUMBS_UP > 10) {
-      grant(
-        'thumbs-up',
-        `I have received a lot of thumbs up ๐Ÿ‘ reactions!`,
-      ).evidence(
-        up
-          .filter((p) => p.counts.THUMBS_UP > 0)
-          .slice(0, 10)
-          .map((p) => `- [${p.counts.THUMBS_UP} thumbs ups](${p.where})`)
-          .join('\n'),
-      )
-    }
-
-    const down = Object.values(reactions)
-    down.sort((a, b) => b.counts.THUMBS_DOWN - a.counts.THUMBS_DOWN)
-    if (down.length > 0 && down[0].counts.THUMBS_DOWN > 10) {
-      grant(
-        'thumbs-down',
-        `I have received a lot of thumbs down ๐Ÿ‘Ž reactions!`,
-      ).evidence(
-        down
-          .filter((p) => p.counts.THUMBS_DOWN > 0)
-          .slice(0, 10)
-          .map((p) => `- [${p.counts.THUMBS_DOWN} thumbs downs](${p.where})`)
-          .join('\n'),
-      )
-    }
-
-    const confused = Object.values(reactions)
-    confused.sort((a, b) => b.counts.CONFUSED - a.counts.CONFUSED)
-    if (confused.length > 0 && confused[0].counts.CONFUSED > 10) {
-      grant(
-        'confused',
-        `I have received a lot of confused ๐Ÿ˜• reactions!`,
-      ).evidence(
-        confused
-          .filter((p) => p.counts.CONFUSED > 0)
-          .slice(0, 10)
-          .map((p) => `- [${p.counts.CONFUSED} confused reactions](${p.where})`)
-          .join('\n'),
-      )
+    if (moreThan10.length > 0) {
+      grant('confused', `I confused more than 10 people.`)
+        .evidence(text(moreThan10))
+        .tier(1)
     }
   },
 })
+
+function count(reactions: Reaction[] | undefined) {
+  const counts: Record<Reaction['content'], number> = {
+    CONFUSED: 0,
+    EYES: 0,
+    HEART: 0,
+    HOORAY: 0,
+    LAUGH: 0,
+    ROCKET: 0,
+    THUMBS_DOWN: 0,
+    THUMBS_UP: 0,
+  }
+  for (const reaction of reactions ?? []) {
+    counts[reaction.content] = (counts[reaction.content] || 0) + 1
+  }
+  return counts
+}
+
+function text(entries: Where[]): string {
+  const lines: string[] = []
+  for (const where of entries) {
+    lines.push(`* <a href="${where.url}">${where.count} ๐Ÿ˜•</a>`)
+  }
+  return lines.join('\n')
+}
badges/thumbs-down/thumbs-down-10.png
Binary file
badges/thumbs-down/thumbs-down-100.png
Binary file
badges/thumbs-down/thumbs-down-50.png
Binary file
badges/thumbs-down/thumbs-down.ts
@@ -0,0 +1,80 @@
+import { define, Reaction } from '#src'
+
+type Where = {
+  count: number
+  url: string
+}
+
+export default define({
+  url: import.meta.url,
+  tiers: true,
+  badges: ['thumbs-down-10', 'thumbs-down-50', 'thumbs-down-100'] as const,
+  present(data, grant) {
+    const moreThan10: Where[] = []
+    const moreThan50: Where[] = []
+    const moreThan100: Where[] = []
+
+    for (const x of [
+      ...data.issues,
+      ...data.pulls,
+      ...data.discussionComments,
+      ...data.issueComments,
+    ]) {
+      if (x.reactions && x.reactions.length > 0) {
+        const counts = count(x.reactions)
+        if (counts.THUMBS_DOWN > 10) {
+          moreThan10.push({ count: counts.THUMBS_DOWN, url: x.url })
+        } else if (counts.THUMBS_DOWN > 50) {
+          moreThan50.push({ count: counts.THUMBS_DOWN, url: x.url })
+        } else if (counts.THUMBS_DOWN > 100) {
+          moreThan100.push({ count: counts.THUMBS_DOWN, url: x.url })
+        }
+      }
+    }
+
+    moreThan10.sort((a, b) => b.count - a.count)
+    moreThan50.sort((a, b) => b.count - a.count)
+    moreThan100.sort((a, b) => b.count - a.count)
+
+    if (moreThan10.length > 0) {
+      grant('thumbs-down-10', `I got more than 10 thumbs down.`)
+        .evidence(text(moreThan10))
+        .tier(1)
+    }
+    if (moreThan50.length > 0) {
+      grant('thumbs-down-50', `I got more than 50 thumbs down.`)
+        .evidence(text(moreThan50))
+        .tier(2)
+    }
+    if (moreThan100.length > 0) {
+      grant('thumbs-down-100', `I got more than 100 thumbs down.`)
+        .evidence(text(moreThan100))
+        .tier(3)
+    }
+  },
+})
+
+function count(reactions: Reaction[] | undefined) {
+  const counts: Record<Reaction['content'], number> = {
+    CONFUSED: 0,
+    EYES: 0,
+    HEART: 0,
+    HOORAY: 0,
+    LAUGH: 0,
+    ROCKET: 0,
+    THUMBS_DOWN: 0,
+    THUMBS_UP: 0,
+  }
+  for (const reaction of reactions ?? []) {
+    counts[reaction.content] = (counts[reaction.content] || 0) + 1
+  }
+  return counts
+}
+
+function text(entries: Where[]): string {
+  const lines: string[] = []
+  for (const where of entries) {
+    lines.push(`* <a href="${where.url}">${where.count} ๐Ÿ‘</a>`)
+  }
+  return lines.join('\n')
+}
badges/thumbs-up/thumbs-up-10.png
Binary file
badges/thumbs-up/thumbs-up-100.png
Binary file
badges/thumbs-up/thumbs-up-50.png
Binary file
badges/thumbs-up/thumbs-up.ts
@@ -0,0 +1,80 @@
+import { define, Reaction } from '#src'
+
+type Where = {
+  count: number
+  url: string
+}
+
+export default define({
+  url: import.meta.url,
+  tiers: true,
+  badges: ['thumbs-up-10', 'thumbs-up-50', 'thumbs-up-100'] as const,
+  present(data, grant) {
+    const moreThan10: Where[] = []
+    const moreThan50: Where[] = []
+    const moreThan100: Where[] = []
+
+    for (const x of [
+      ...data.issues,
+      ...data.pulls,
+      ...data.discussionComments,
+      ...data.issueComments,
+    ]) {
+      if (x.reactions && x.reactions.length > 0) {
+        const counts = count(x.reactions)
+        if (counts.THUMBS_UP > 10) {
+          moreThan10.push({ count: counts.THUMBS_UP, url: x.url })
+        } else if (counts.THUMBS_UP > 50) {
+          moreThan50.push({ count: counts.THUMBS_UP, url: x.url })
+        } else if (counts.THUMBS_UP > 100) {
+          moreThan100.push({ count: counts.THUMBS_UP, url: x.url })
+        }
+      }
+    }
+
+    moreThan10.sort((a, b) => b.count - a.count)
+    moreThan50.sort((a, b) => b.count - a.count)
+    moreThan100.sort((a, b) => b.count - a.count)
+
+    if (moreThan10.length > 0) {
+      grant('thumbs-up-10', `I got more than 10 thumbs up.`)
+        .evidence(text(moreThan10))
+        .tier(1)
+    }
+    if (moreThan50.length > 0) {
+      grant('thumbs-up-50', `I got more than 50 thumbs up.`)
+        .evidence(text(moreThan50))
+        .tier(2)
+    }
+    if (moreThan100.length > 0) {
+      grant('thumbs-up-100', `I got more than 100 thumbs up.`)
+        .evidence(text(moreThan100))
+        .tier(3)
+    }
+  },
+})
+
+function count(reactions: Reaction[] | undefined) {
+  const counts: Record<Reaction['content'], number> = {
+    CONFUSED: 0,
+    EYES: 0,
+    HEART: 0,
+    HOORAY: 0,
+    LAUGH: 0,
+    ROCKET: 0,
+    THUMBS_DOWN: 0,
+    THUMBS_UP: 0,
+  }
+  for (const reaction of reactions ?? []) {
+    counts[reaction.content] = (counts[reaction.content] || 0) + 1
+  }
+  return counts
+}
+
+function text(entries: Where[]): string {
+  const lines: string[] = []
+  for (const where of entries) {
+    lines.push(`* <a href="${where.url}">${where.count} ๐Ÿ‘</a>`)
+  }
+  return lines.join('\n')
+}
src/task/comments/comments.graphql
@@ -1,4 +1,5 @@
 fragment DiscussionComment on DiscussionComment {
+  id
   url
   author {
     login
@@ -18,10 +19,13 @@ fragment DiscussionComment on DiscussionComment {
   editor {
     login
   }
-  ...Reactions
+  reactionsTotal: reactions {
+    totalCount
+  }
 }
 
 fragment IssueComment on IssueComment {
+  id
   url
   author {
     login
@@ -41,18 +45,8 @@ fragment IssueComment on IssueComment {
   editor {
     login
   }
-  ...Reactions
-}
-
-fragment Reactions on Reactable {
-  reactions(first: 100) {
+  reactionsTotal: reactions {
     totalCount
-    nodes {
-      content
-      user {
-        login
-      }
-    }
   }
 }
 
src/task/comments/comments.graphql.ts
@@ -2,6 +2,7 @@
 
 const DiscussionComment = `#graphql
 fragment DiscussionComment on DiscussionComment {
+  id
   url
   author {
     login
@@ -21,10 +22,13 @@ fragment DiscussionComment on DiscussionComment {
   editor {
     login
   }
-  ...Reactions
+  reactionsTotal: reactions {
+    totalCount
+  }
 }`
 
 export type DiscussionComment = {
+  id: string
   url: string
   author: {
     login: string
@@ -44,10 +48,14 @@ export type DiscussionComment = {
   editor: {
     login: string
   } | null
-} & Reactions
+  reactionsTotal: {
+    totalCount: number
+  }
+}
 
 const IssueComment = `#graphql
 fragment IssueComment on IssueComment {
+  id
   url
   author {
     login
@@ -67,10 +75,13 @@ fragment IssueComment on IssueComment {
   editor {
     login
   }
-  ...Reactions
+  reactionsTotal: reactions {
+    totalCount
+  }
 }`
 
 export type IssueComment = {
+  id: string
   url: string
   author: {
     login: string
@@ -90,43 +101,12 @@ export type IssueComment = {
   editor: {
     login: string
   } | null
-} & Reactions
-
-const Reactions = `#graphql
-fragment Reactions on Reactable {
-  reactions(first: 100) {
-    totalCount
-    nodes {
-      content
-      user {
-        login
-      }
-    }
-  }
-}`
-
-export type Reactions = {
-  reactions: {
+  reactionsTotal: {
     totalCount: number
-    nodes: Array<{
-      content:
-        | 'CONFUSED'
-        | 'EYES'
-        | 'HEART'
-        | 'HOORAY'
-        | 'LAUGH'
-        | 'ROCKET'
-        | 'THUMBS_DOWN'
-        | 'THUMBS_UP'
-      user: {
-        login: string
-      } | null
-    }> | null
   }
 }
 
 export const DiscussionCommentsQuery = `#graphql
-${Reactions}
 ${DiscussionComment}
 query DiscussionCommentsQuery($login: String!, $num: Int = 100, $cursor: String) {
   user(login: $login) {
@@ -173,7 +153,6 @@ export type DiscussionCommentsQuery = (vars: {
 }
 
 export const IssueCommentsQuery = `#graphql
-${Reactions}
 ${IssueComment}
 query IssueCommentsQuery($login: String!, $num: Int = 100, $cursor: String) {
   user(login: $login) {
src/task/comments/discussion-comments.ts
@@ -13,6 +13,9 @@ export default task({
     })
 
     data.discussionComments = []
+
+    let reactionsBatch: string[] = []
+
     for await (const resp of discussionComments) {
       if (!resp.user?.repositoryDiscussionComments.nodes) {
         throw new Error('Failed to load discussion comments')
@@ -20,10 +23,31 @@ export default task({
 
       for (const comment of resp.user.repositoryDiscussionComments.nodes) {
         data.discussionComments.push(comment)
+        if (comment.reactionsTotal.totalCount > 0) {
+          if (reactionsBatch.length > 100) {
+            next('reactions-discussion-comments', {
+              id: comment.id,
+            })
+          } else {
+            reactionsBatch.push(comment.id)
+            if (reactionsBatch.length === 50) {
+              next('reactions-batch', {
+                ids: reactionsBatch,
+              })
+              reactionsBatch = []
+            }
+          }
+        }
       }
       console.log(
         `| discussion comments ${data.discussionComments.length}/${resp.user.repositoryDiscussionComments.totalCount} (cost: ${resp.rateLimit?.cost}, remaining: ${resp.rateLimit?.remaining})`,
       )
     }
+
+    if (reactionsBatch.length > 0) {
+      next('reactions-batch', {
+        ids: reactionsBatch,
+      })
+    }
   },
 })
src/task/comments/issue-comments.ts
@@ -10,6 +10,9 @@ export default task({
     })
 
     data.issueComments = []
+
+    let reactionsBatch: string[] = []
+
     for await (const resp of issueComments) {
       if (!resp.user?.issueComments.nodes) {
         throw new Error('Failed to load issue comments')
@@ -17,10 +20,32 @@ export default task({
 
       for (const comment of resp.user.issueComments.nodes) {
         data.issueComments.push(comment)
+        if (comment.reactionsTotal.totalCount > 0) {
+          if (reactionsBatch.length > 100) {
+            next('reactions-issue-comments', {
+              id: comment.id,
+            })
+          } else {
+            reactionsBatch.push(comment.id)
+            if (reactionsBatch.length === 50) {
+              next('reactions-batch', {
+                ids: reactionsBatch,
+              })
+              reactionsBatch = []
+            }
+          }
+        }
       }
+
       console.log(
         `| issue comments ${data.issueComments.length}/${resp.user.issueComments.totalCount} (cost: ${resp.rateLimit?.cost}, remaining: ${resp.rateLimit?.remaining})`,
       )
     }
+
+    if (reactionsBatch.length > 0) {
+      next('reactions-batch', {
+        ids: reactionsBatch,
+      })
+    }
   },
 })
src/task/issue-timeline/issue-timeline-batch.ts
@@ -0,0 +1,28 @@
+import { task } from '../../task.js'
+import { query } from '../../utils.js'
+import { IssueTimelineBatchQuery } from './issue-timeline.graphql.js'
+
+export default task({
+  name: 'issue-timeline-batch' as const,
+  run: async ({ octokit, data, next }, { ids }: { ids: string[] }) => {
+    const resp = await query(octokit, IssueTimelineBatchQuery, {
+      ids,
+    })
+
+    console.log(
+      `| issue timeline batch ${resp.nodes.length} (cost: ${resp.rateLimit?.cost}, remaining: ${resp.rateLimit?.remaining})`,
+    )
+    for (const node of resp.nodes) {
+      if (node && node.__typename === 'Issue') {
+        const issue = data.issues.find((x) => x.id === node.id)
+        if (issue) {
+          for (const event of node.timelineItems.nodes ?? []) {
+            if (event?.__typename == 'ClosedEvent') {
+              issue.closedAt = event.createdAt
+            }
+          }
+        }
+      }
+    }
+  },
+})
src/task/issue-timeline/issue-timeline.graphql
@@ -1,22 +1,20 @@
-query IssueTimelineQuery(
-  $owner: String!
-  $name: String!
-  $number: Int!
-  $num: Int = 100
-  $cursor: String
-) {
-  repository(owner: $owner, name: $name) {
-    issue(number: $number) {
+fragment IssueTimelineItem on IssueTimelineItem {
+  __typename
+  ... on ClosedEvent {
+    createdAt
+    actor {
+      login
+    }
+  }
+}
+
+query IssueTimelineQuery($id: ID!, $num: Int = 100, $cursor: String) {
+  node(id: $id) {
+    ... on Issue {
       timelineItems(first: $num, after: $cursor) {
         totalCount
         nodes {
-          __typename
-          ... on ClosedEvent {
-            createdAt
-            actor {
-              login
-            }
-          }
+          ...IssueTimelineItem
         }
         pageInfo {
           hasNextPage
@@ -32,3 +30,24 @@ query IssueTimelineQuery(
     resetAt
   }
 }
+
+query IssueTimelineBatchQuery($ids: [ID!]!) {
+  nodes(ids: $ids) {
+    __typename
+    ... on Issue {
+      id
+      timelineItems(first: 100) {
+        totalCount
+        nodes {
+          ...IssueTimelineItem
+        }
+      }
+    }
+  }
+  rateLimit {
+    limit
+    cost
+    remaining
+    resetAt
+  }
+}
src/task/issue-timeline/issue-timeline.graphql.ts
@@ -1,19 +1,36 @@
 // DO NOT EDIT. This is a generated file. Instead of this file, edit "issue-timeline.graphql".
 
+const IssueTimelineItem = `#graphql
+fragment IssueTimelineItem on IssueTimelineItem {
+  __typename
+  ... on ClosedEvent {
+    createdAt
+    actor {
+      login
+    }
+  }
+}`
+
+export type IssueTimelineItem =
+  | ({
+      __typename: string
+    } & {
+      createdAt: string
+      actor: {
+        login: string
+      } | null
+    })
+  | null
+
 export const IssueTimelineQuery = `#graphql
-query IssueTimelineQuery($owner: String!, $name: String!, $number: Int!, $num: Int = 100, $cursor: String) {
-  repository(owner: $owner, name: $name) {
-    issue(number: $number) {
+${IssueTimelineItem}
+query IssueTimelineQuery($id: ID!, $num: Int = 100, $cursor: String) {
+  node(id: $id) {
+    ... on Issue {
       timelineItems(first: $num, after: $cursor) {
         totalCount
         nodes {
-          __typename
-          ... on ClosedEvent {
-            createdAt
-            actor {
-              login
-            }
-          }
+          ...IssueTimelineItem
         }
         pageInfo {
           hasNextPage
@@ -31,34 +48,67 @@ query IssueTimelineQuery($owner: String!, $name: String!, $number: Int!, $num: I
 }` as string & IssueTimelineQuery
 
 export type IssueTimelineQuery = (vars: {
-  owner: string
-  name: string
-  number: number
+  id: string
   num?: number | null
   cursor?: string | null
 }) => {
-  repository: {
-    issue: {
-      timelineItems: {
-        totalCount: number
-        nodes: Array<
-          | ({
-              __typename: string
-            } & {
-              createdAt: string
-              actor: {
-                login: string
-              } | null
-            })
-          | null
-        > | null
-        pageInfo: {
-          hasNextPage: boolean
-          endCursor: string | null
+  node:
+    | ({} & {
+        timelineItems: {
+          totalCount: number
+          nodes: Array<{} & IssueTimelineItem> | null
+          pageInfo: {
+            hasNextPage: boolean
+            endCursor: string | null
+          }
         }
-      }
-    } | null
+      })
+    | null
+    | null
+  rateLimit: {
+    limit: number
+    cost: number
+    remaining: number
+    resetAt: string
   } | null
+}
+
+export const IssueTimelineBatchQuery = `#graphql
+${IssueTimelineItem}
+query IssueTimelineBatchQuery($ids: [ID!]!) {
+  nodes(ids: $ids) {
+    __typename
+    ... on Issue {
+      id
+      timelineItems(first: 100) {
+        totalCount
+        nodes {
+          ...IssueTimelineItem
+        }
+      }
+    }
+  }
+  rateLimit {
+    limit
+    cost
+    remaining
+    resetAt
+  }
+}` as string & IssueTimelineBatchQuery
+
+export type IssueTimelineBatchQuery = (vars: { ids: string[] }) => {
+  nodes: Array<
+    | ({
+        __typename: string
+      } & {
+        id: string
+        timelineItems: {
+          totalCount: number
+          nodes: Array<{} & IssueTimelineItem> | null
+        }
+      })
+    | null
+  >
   rateLimit: {
     limit: number
     cost: number
src/task/issue-timeline/issue-timeline.ts
@@ -4,31 +4,25 @@ import { IssueTimelineQuery } from './issue-timeline.graphql.js'
 
 export default task({
   name: 'issue-timeline' as const,
-  run: async (
-    { octokit, data, next },
-    { owner, name, number }: { owner: string; name: string; number: number },
-  ) => {
-    console.log(`Loading issue timeline for ${name}#${number}`)
+  run: async ({ octokit, data, next }, { id }: { id: string }) => {
     const timeline = paginate(octokit, IssueTimelineQuery, {
-      owner: owner,
-      name: name,
-      number: number,
+      id,
     })
 
-    const issue = data.issues.find((x) => x.number === number)
+    const issue = data.issues.find((x) => x.id === id)
     if (!issue) {
-      throw new Error(`Issue ${number} not found`)
+      throw new Error(`Issue ${id} not found`)
     }
 
     for await (const resp of timeline) {
-      if (!resp.repository?.issue?.timelineItems.nodes) {
+      if (!resp.node?.timelineItems.nodes) {
         throw new Error('Failed to load issue timeline')
       }
 
       console.log(
-        `| timeline ${resp.repository.issue.timelineItems.nodes.length}/${resp.repository.issue.timelineItems.totalCount} (cost: ${resp.rateLimit?.cost}, remaining: ${resp.rateLimit?.remaining})`,
+        `| timeline ${resp.node.timelineItems.nodes.length}/${resp.node.timelineItems.totalCount} (cost: ${resp.rateLimit?.cost}, remaining: ${resp.rateLimit?.remaining})`,
       )
-      for (const event of resp.repository.issue.timelineItems.nodes) {
+      for (const event of resp.node.timelineItems.nodes) {
         if (event?.__typename == 'ClosedEvent') {
           issue.closedBy = event.actor?.login
         }
src/task/issues/issues.graphql
@@ -1,4 +1,5 @@
 fragment Issue on Issue {
+  id
   createdAt
   closedAt
   closed
@@ -18,15 +19,6 @@ fragment Issue on Issue {
   comments(first: 1) {
     totalCount
   }
-  reactions(first: 100) {
-    totalCount
-    nodes {
-      content
-      user {
-        login
-      }
-    }
-  }
   assignees(first: 3) {
     totalCount
   }
@@ -37,6 +29,12 @@ fragment Issue on Issue {
     }
     name
   }
+  reactionsTotal: reactions {
+    totalCount
+  }
+  timelineItemsTotal: timelineItems {
+    totalCount
+  }
 }
 
 query IssuesQuery($username: String!, $num: Int = 100, $cursor: String) {
src/task/issues/issues.graphql.ts
@@ -2,6 +2,7 @@
 
 const Issue = `#graphql
 fragment Issue on Issue {
+  id
   createdAt
   closedAt
   closed
@@ -21,15 +22,6 @@ fragment Issue on Issue {
   comments(first: 1) {
     totalCount
   }
-  reactions(first: 100) {
-    totalCount
-    nodes {
-      content
-      user {
-        login
-      }
-    }
-  }
   assignees(first: 3) {
     totalCount
   }
@@ -40,9 +32,16 @@ fragment Issue on Issue {
     }
     name
   }
+  reactionsTotal: reactions {
+    totalCount
+  }
+  timelineItemsTotal: timelineItems {
+    totalCount
+  }
 }`
 
 export type Issue = {
+  id: string
   createdAt: string
   closedAt: string | null
   closed: boolean
@@ -62,23 +61,6 @@ export type Issue = {
   comments: {
     totalCount: number
   }
-  reactions: {
-    totalCount: number
-    nodes: Array<{
-      content:
-        | 'CONFUSED'
-        | 'EYES'
-        | 'HEART'
-        | 'HOORAY'
-        | 'LAUGH'
-        | 'ROCKET'
-        | 'THUMBS_DOWN'
-        | 'THUMBS_UP'
-      user: {
-        login: string
-      } | null
-    }> | null
-  }
   assignees: {
     totalCount: number
   }
@@ -89,6 +71,12 @@ export type Issue = {
     }
     name: string
   }
+  reactionsTotal: {
+    totalCount: number
+  }
+  timelineItemsTotal: {
+    totalCount: number
+  }
 }
 
 export const IssuesQuery = `#graphql
src/task/issues/issues.ts
@@ -10,6 +10,10 @@ export default task({
     })
 
     data.issues = []
+
+    let reactionsBatch: string[] = []
+    let issueTimelineBatch: string[] = []
+
     for await (const resp of issues) {
       if (!resp.user?.issues.nodes) {
         throw new Error('Failed to load issues')
@@ -24,12 +28,50 @@ export default task({
       )
       for (const issue of resp.user.issues.nodes) {
         data.issues.push(issue)
-        next('issue-timeline', {
-          owner: issue.repository.owner.login,
-          name: issue.repository.name,
-          number: issue.number,
-        })
+        if (issue.reactionsTotal.totalCount > 0) {
+          if (reactionsBatch.length > 100) {
+            next('reactions-issue', {
+              id: issue.id,
+            })
+          } else {
+            reactionsBatch.push(issue.id)
+            if (reactionsBatch.length === 50) {
+              next('reactions-batch', {
+                ids: reactionsBatch,
+              })
+              reactionsBatch = []
+            }
+          }
+        }
+
+        if (issue.timelineItemsTotal.totalCount > 0) {
+          if (issue.timelineItemsTotal.totalCount > 100) {
+            next('issue-timeline', {
+              id: issue.id,
+            })
+          } else {
+            issueTimelineBatch.push(issue.id)
+            if (issueTimelineBatch.length === 50) {
+              next('issue-timeline-batch', {
+                ids: issueTimelineBatch,
+              })
+              issueTimelineBatch = []
+            }
+          }
+        }
       }
     }
+
+    if (reactionsBatch.length > 0) {
+      next('reactions-batch', {
+        ids: reactionsBatch,
+      })
+    }
+
+    if (issueTimelineBatch.length > 0) {
+      next('issue-timeline-batch', {
+        ids: issueTimelineBatch,
+      })
+    }
   },
 })
src/task/pulls/pulls.graphql
@@ -1,4 +1,5 @@
 fragment PullRequest on PullRequest {
+  id
   createdAt
   url
   number
@@ -58,14 +59,8 @@ fragment PullRequest on PullRequest {
       }
     }
   }
-  reactions(first: 100) {
+  reactionsTotal: reactions {
     totalCount
-    nodes {
-      content
-      user {
-        login
-      }
-    }
   }
 }
 
src/task/pulls/pulls.graphql.ts
@@ -2,6 +2,7 @@
 
 const PullRequest = `#graphql
 fragment PullRequest on PullRequest {
+  id
   createdAt
   url
   number
@@ -61,18 +62,13 @@ fragment PullRequest on PullRequest {
       }
     }
   }
-  reactions(first: 100) {
+  reactionsTotal: reactions {
     totalCount
-    nodes {
-      content
-      user {
-        login
-      }
-    }
   }
 }`
 
 export type PullRequest = {
+  id: string
   createdAt: string
   url: string
   number: number
@@ -147,22 +143,8 @@ export type PullRequest = {
       }
     }> | null
   }
-  reactions: {
+  reactionsTotal: {
     totalCount: number
-    nodes: Array<{
-      content:
-        | 'CONFUSED'
-        | 'EYES'
-        | 'HEART'
-        | 'HOORAY'
-        | 'LAUGH'
-        | 'ROCKET'
-        | 'THUMBS_DOWN'
-        | 'THUMBS_UP'
-      user: {
-        login: string
-      } | null
-    }> | null
   }
 }
 
src/task/pulls/pulls.ts
@@ -10,6 +10,9 @@ export default task({
     })
 
     data.pulls = []
+
+    let reactionsBatch: string[] = []
+
     for await (const resp of pulls) {
       if (!resp.user?.pullRequests.nodes) {
         throw new Error('Failed to load pull requests')
@@ -24,7 +27,28 @@ export default task({
       )
       for (const pull of resp.user.pullRequests.nodes) {
         data.pulls.push(pull)
+        if (pull.reactionsTotal.totalCount > 0) {
+          if (reactionsBatch.length > 100) {
+            next('reactions-pull', {
+              id: pull.id,
+            })
+          } else {
+            reactionsBatch.push(pull.id)
+            if (reactionsBatch.length === 50) {
+              next('reactions-batch', {
+                ids: reactionsBatch,
+              })
+              reactionsBatch = []
+            }
+          }
+        }
       }
     }
+
+    if (reactionsBatch.length > 0) {
+      next('reactions-batch', {
+        ids: reactionsBatch,
+      })
+    }
   },
 })
src/task/reactions/reactions-batch.ts
@@ -0,0 +1,44 @@
+import { task } from '../../task.js'
+import { paginate, query } from '../../utils.js'
+import { ReactionsBatchQuery, ReactionsQuery } from './reactions.graphql.js'
+
+export default task({
+  name: 'reactions-batch' as const,
+  run: async ({ octokit, data }, { ids }: { ids: string[] }) => {
+    const { nodes, rateLimit } = await query(octokit, ReactionsBatchQuery, {
+      ids,
+    })
+
+    console.log(
+      `| reactions batch ${nodes.length} (cost: ${rateLimit?.cost}, remaining: ${rateLimit?.remaining})`,
+    )
+    for (const node of nodes) {
+      if (node.__typename === 'Issue') {
+        const issue = data.issues.find((x) => x.id === node.id)
+        if (issue) {
+          issue.reactions = node.reactions.nodes ?? undefined
+        }
+      }
+      if (node.__typename === 'PullRequest') {
+        const pull = data.pulls.find((x) => x.id === node.id)
+        if (pull) {
+          pull.reactions = node.reactions.nodes ?? undefined
+        }
+      }
+      if (node.__typename === 'DiscussionComment') {
+        const discussionComment = data.discussionComments.find(
+          (x) => x.id === node.id,
+        )
+        if (discussionComment) {
+          discussionComment.reactions = node.reactions.nodes ?? undefined
+        }
+      }
+      if (node.__typename === 'IssueComment') {
+        const issueComment = data.issueComments.find((x) => x.id === node.id)
+        if (issueComment) {
+          issueComment.reactions = node.reactions.nodes ?? undefined
+        }
+      }
+    }
+  },
+})
src/task/reactions/reactions-discussion-comments.ts
@@ -0,0 +1,33 @@
+import { task } from '../../task.js'
+import { paginate } from '../../utils.js'
+import { ReactionsQuery } from './reactions.graphql.js'
+
+export default task({
+  name: 'reactions-discussion-comments' as const,
+  run: async ({ octokit, data }, { id }: { id: string }) => {
+    const discussionReactions = paginate(octokit, ReactionsQuery, {
+      id: id,
+    })
+
+    const discussionComment = data.discussionComments.find((x) => x.id === id)
+    if (!discussionComment) {
+      throw new Error(`Discussion comment ${id} not found`)
+    }
+
+    discussionComment.reactions = []
+
+    for await (const resp of discussionReactions) {
+      if (!resp.node?.reactions.nodes) {
+        throw new Error('Failed to load discussion comment reactions')
+      }
+
+      for (const reaction of resp.node.reactions.nodes) {
+        discussionComment.reactions.push(reaction)
+      }
+
+      console.log(
+        `| discussion comment reactions ${discussionComment.reactions.length}/${resp.node.reactions.totalCount} (cost: ${resp.rateLimit?.cost}, remaining: ${resp.rateLimit?.remaining})`,
+      )
+    }
+  },
+})
src/task/reactions/reactions-issue-comments.ts
@@ -0,0 +1,33 @@
+import { task } from '../../task.js'
+import { paginate } from '../../utils.js'
+import { ReactionsQuery } from './reactions.graphql.js'
+
+export default task({
+  name: 'reactions-issue-comments' as const,
+  run: async ({ octokit, data }, { id }: { id: string }) => {
+    const issueReactions = paginate(octokit, ReactionsQuery, {
+      id: id,
+    })
+
+    const issueComment = data.issueComments.find((x) => x.id === id)
+    if (!issueComment) {
+      throw new Error(`Issue comment ${id} not found`)
+    }
+
+    issueComment.reactions = []
+
+    for await (const resp of issueReactions) {
+      if (!resp.node?.reactions.nodes) {
+        throw new Error('Failed to load issue comment reactions')
+      }
+
+      for (const reaction of resp.node.reactions.nodes) {
+        issueComment.reactions.push(reaction)
+      }
+
+      console.log(
+        `| issue comment reactions ${issueComment.reactions.length}/${resp.node.reactions.totalCount} (cost: ${resp.rateLimit?.cost}, remaining: ${resp.rateLimit?.remaining})`,
+      )
+    }
+  },
+})
src/task/reactions/reactions-issue.ts
@@ -0,0 +1,33 @@
+import { task } from '../../task.js'
+import { paginate } from '../../utils.js'
+import { ReactionsQuery } from './reactions.graphql.js'
+
+export default task({
+  name: 'reactions-issue' as const,
+  run: async ({ octokit, data }, { id }: { id: string }) => {
+    const issueReactions = paginate(octokit, ReactionsQuery, {
+      id,
+    })
+
+    const issue = data.issues.find((x) => x.id === id)
+    if (!issue) {
+      throw new Error(`Issue ${id} not found`)
+    }
+
+    issue.reactions = []
+
+    for await (const resp of issueReactions) {
+      if (!resp.node?.reactions.nodes) {
+        throw new Error('Failed to load issue reactions')
+      }
+
+      for (const reaction of resp.node.reactions.nodes) {
+        issue.reactions.push(reaction)
+      }
+
+      console.log(
+        `| issue reactions ${data.issueComments.length}/${resp.node.reactions.totalCount} (cost: ${resp.rateLimit?.cost}, remaining: ${resp.rateLimit?.remaining})`,
+      )
+    }
+  },
+})
src/task/reactions/reactions-pull.ts
@@ -0,0 +1,33 @@
+import { task } from '../../task.js'
+import { paginate } from '../../utils.js'
+import { ReactionsQuery } from './reactions.graphql.js'
+
+export default task({
+  name: 'reactions-pull' as const,
+  run: async ({ octokit, data }, { id }: { id: string }) => {
+    const pullReactions = paginate(octokit, ReactionsQuery, {
+      id,
+    })
+
+    const pull = data.pulls.find((x) => x.id === id)
+    if (!pull) {
+      throw new Error(`Pull ${id} not found`)
+    }
+
+    pull.reactions = []
+
+    for await (const resp of pullReactions) {
+      if (!resp.node?.reactions.nodes) {
+        throw new Error('Failed to load pull reactions')
+      }
+
+      for (const reaction of resp.node.reactions.nodes) {
+        pull.reactions.push(reaction)
+      }
+
+      console.log(
+        `| pull reactions ${data.issueComments.length}/${resp.node.reactions.totalCount} (cost: ${resp.rateLimit?.cost}, remaining: ${resp.rateLimit?.remaining})`,
+      )
+    }
+  },
+})
src/task/reactions/reactions.graphql
@@ -0,0 +1,47 @@
+fragment Reaction on Reaction {
+  user {
+    login
+  }
+  content
+  createdAt
+}
+
+fragment Reactions on Reactable {
+  reactions(first: $num, after: $cursor) {
+    totalCount
+    nodes {
+      ...Reaction
+    }
+    pageInfo {
+      hasNextPage
+      endCursor
+    }
+  }
+}
+
+query ReactionsQuery($id: ID!, $num: Int = 100, $cursor: String) {
+  node(id: $id) {
+    __typename
+    ...Reactions
+  }
+  rateLimit {
+    limit
+    cost
+    remaining
+    resetAt
+  }
+}
+
+query ReactionsBatchQuery($ids: [ID!]!, $num: Int = 100, $cursor: String) {
+  nodes(ids: $ids) {
+    __typename
+    id
+    ...Reactions
+  }
+  rateLimit {
+    limit
+    cost
+    remaining
+    resetAt
+  }
+}
src/task/reactions/reactions.graphql.ts
@@ -0,0 +1,121 @@
+// DO NOT EDIT. This is a generated file. Instead of this file, edit "reactions.graphql".
+
+const Reaction = `#graphql
+fragment Reaction on Reaction {
+  user {
+    login
+  }
+  content
+  createdAt
+}`
+
+export type Reaction = {
+  user: {
+    login: string
+  } | null
+  content:
+    | 'CONFUSED'
+    | 'EYES'
+    | 'HEART'
+    | 'HOORAY'
+    | 'LAUGH'
+    | 'ROCKET'
+    | 'THUMBS_DOWN'
+    | 'THUMBS_UP'
+  createdAt: string
+}
+
+const Reactions = `#graphql
+fragment Reactions on Reactable {
+  reactions(first: $num, after: $cursor) {
+    totalCount
+    nodes {
+      ...Reaction
+    }
+    pageInfo {
+      hasNextPage
+      endCursor
+    }
+  }
+}`
+
+export type Reactions = {
+  reactions: {
+    totalCount: number
+    nodes: Array<{} & Reaction> | null
+    pageInfo: {
+      hasNextPage: boolean
+      endCursor: string | null
+    }
+  }
+}
+
+export const ReactionsQuery = `#graphql
+${Reaction}
+${Reactions}
+query ReactionsQuery($id: ID!, $num: Int = 100, $cursor: String) {
+  node(id: $id) {
+    __typename
+    ...Reactions
+  }
+  rateLimit {
+    limit
+    cost
+    remaining
+    resetAt
+  }
+}` as string & ReactionsQuery
+
+export type ReactionsQuery = (vars: {
+  id: string
+  num?: number | null
+  cursor?: string | null
+}) => {
+  node:
+    | ({
+        __typename: string
+      } & Reactions)
+    | null
+  rateLimit: {
+    limit: number
+    cost: number
+    remaining: number
+    resetAt: string
+  } | null
+}
+
+export const ReactionsBatchQuery = `#graphql
+${Reaction}
+${Reactions}
+query ReactionsBatchQuery($ids: [ID!]!, $num: Int = 100, $cursor: String) {
+  nodes(ids: $ids) {
+    __typename
+    id
+    ...Reactions
+  }
+  rateLimit {
+    limit
+    cost
+    remaining
+    resetAt
+  }
+}` as string & ReactionsBatchQuery
+
+export type ReactionsBatchQuery = (vars: {
+  ids: string[]
+  num?: number | null
+  cursor?: string | null
+}) => {
+  nodes: Array<
+    {
+      __typename: string
+      id: string
+    } & Reactions
+  >
+  rateLimit: {
+    limit: number
+    cost: number
+    remaining: number
+    resetAt: string
+  } | null
+}
src/task/index.ts
@@ -5,7 +5,13 @@ export default [
   await import('./commits/commits.js'),
   await import('./issues/issues.js'),
   await import('./issue-timeline/issue-timeline.js'),
+  await import('./issue-timeline/issue-timeline-batch.js'),
   await import('./comments/issue-comments.js'),
   await import('./comments/discussion-comments.js'),
   await import('./stars/stars.js'),
+  await import('./reactions/reactions-issue.js'),
+  await import('./reactions/reactions-issue-comments.js'),
+  await import('./reactions/reactions-discussion-comments.js'),
+  await import('./reactions/reactions-pull.js'),
+  await import('./reactions/reactions-batch.js'),
 ] as const
src/data.ts
@@ -1,13 +1,14 @@
 import { Endpoints } from '@octokit/types'
 import { User } from './task/user/user.graphql.js'
-import { PullRequest } from './task/pulls/pulls.graphql.js'
+import { PullRequest as PullRequestType } from './task/pulls/pulls.graphql.js'
 import { Issue as IssueType } from './task/issues/issues.graphql.js'
 import {
-  DiscussionComment,
-  IssueComment,
+  DiscussionComment as DiscussionCommentType,
+  IssueComment as IssueCommentType,
 } from './task/comments/comments.graphql.js'
 import { StarredRepo } from './task/stars/stars.graphql.js'
 import { Commit } from './task/commits/commits.graphql.js'
+import { Reaction } from './task/reactions/reactions.graphql.js'
 
 // Data is collected by tasks, enriched if needed, and saved to disk.
 // Use this data to determine which badges to present to the user.
@@ -21,11 +22,24 @@ export type Data = {
   discussionComments: DiscussionComment[]
 }
 
-export type Issue = {
-  closedBy?: string
-} & IssueType
-
 export type Repo =
   Endpoints['GET /users/{username}/repos']['response']['data'][0] & {
     commits: Commit[]
   }
+
+export type Issue = {
+  closedBy?: string
+  reactions?: Reaction[]
+} & IssueType
+
+export type PullRequest = {
+  reactions?: Reaction[]
+} & PullRequestType
+
+export type IssueComment = {
+  reactions?: Reaction[]
+} & IssueCommentType
+
+export type DiscussionComment = {
+  reactions?: Reaction[]
+} & DiscussionCommentType
src/index.ts
@@ -4,6 +4,6 @@ export { User } from './task/user/user.graphql.js'
 export { Issue } from './task/issues/issues.graphql.js'
 export { PullRequest } from './task/pulls/pulls.graphql.js'
 export { Commit } from './task/commits/commits.graphql.js'
-export { Reactions } from './task/comments/comments.graphql.js'
+export { Reaction } from './task/reactions/reactions.graphql.js'
 
 export { linkCommit, linkIssue, linkPull, latest, plural } from './utils.js'
src/main.ts
@@ -17,7 +17,18 @@ void (async function main() {
   try {
     const { env } = process
     const argv = minimist(process.argv.slice(2), {
-      string: ['data', 'repo', 'token', 'size', 'user', 'pick', 'omit'],
+      string: [
+        'data',
+        'repo',
+        'token',
+        'size',
+        'user',
+        'pick',
+        'omit',
+        'task',
+        'params',
+        'skip-task',
+      ],
       boolean: ['dryrun', 'compact'],
     })
     const {
@@ -30,6 +41,9 @@ void (async function main() {
       pick,
       omit,
       compact,
+      task,
+      params,
+      'skip-task': skipTask,
     } = argv
     const [owner, repo]: [string | undefined, string | undefined] =
       repository?.split('/', 2) || [username, username]
@@ -75,7 +89,11 @@ void (async function main() {
       data = JSON.parse(fs.readFileSync(dataPath, 'utf8')) as Data
     } else {
       let ok: boolean
-      ;[ok, data] = await processTasks(octokit, username)
+      ;[ok, data] = await processTasks(octokit, username, {
+        task,
+        params,
+        skipTask,
+      })
       if (!ok) {
         return
       }
src/process-tasks.ts
@@ -8,12 +8,18 @@ import allTasks from './task/index.js'
 export async function processTasks(
   octokit: Octokit,
   username: string,
+  {
+    task,
+    params,
+    skipTask,
+  }: { task?: string; params?: string; skipTask?: string } = {},
 ): Promise<[boolean, Data]> {
   if (!fs.existsSync('data')) {
     fs.mkdirSync('data')
   }
   const dataPath = `data/${username}.json`
   const tasksPath = `data/${username}.tasks.json`
+  const skipTasks = new Set(skipTask?.split(',') || [])
 
   let data: Data = {
     user: null!,
@@ -45,7 +51,15 @@ export async function processTasks(
     { taskName: 'stars', params: { username }, attempts: 0 },
   ]
 
-  if (fs.existsSync(tasksPath)) {
+  if (task && params) {
+    todo = [
+      {
+        taskName: task as TaskName,
+        params: Object.fromEntries(new URLSearchParams(params).entries()),
+        attempts: 0,
+      },
+    ]
+  } else if (fs.existsSync(tasksPath)) {
     const savedTodo = JSON.parse(fs.readFileSync(tasksPath, 'utf8')) as Todo[]
     if (savedTodo.length > 0) {
       todo = savedTodo
@@ -54,6 +68,10 @@ export async function processTasks(
 
   while (todo.length > 0) {
     const { taskName, params, attempts } = todo.shift()!
+    if (skipTasks.has(taskName)) {
+      console.log(`Skipping task ${taskName}`)
+      continue
+    }
 
     const task = allTasks.find(({ default: t }) => t.name === taskName)?.default
     if (!task) {
@@ -64,7 +82,10 @@ export async function processTasks(
       todo.push({ taskName, params, attempts: 0 })
     }
 
-    console.log(`==> Running task ${taskName}`, params)
+    console.log(
+      `==> Running task ${taskName}`,
+      new URLSearchParams(params).toString(),
+    )
     try {
       await task.run({ octokit, data, next }, params)
     } catch (e) {
@@ -76,14 +97,14 @@ export async function processTasks(
       if (attempts >= 3 || !retry) {
         console.error(
           `!!! Failed to run task ${taskName}`,
-          params,
+          new URLSearchParams(params).toString(),
           `after ${attempts} attempts`,
         )
         console.error(e)
       } else {
         console.error(
           `!!! Failed to run task ${taskName}`,
-          params,
+          new URLSearchParams(params).toString(),
           `, retrying... (attempts: ${attempts + 1})`,
         )
         console.error(e)
package-lock.json
@@ -12,7 +12,7 @@
         "@octokit/plugin-paginate-graphql": "^5.2.4",
         "@octokit/plugin-retry": "^7.1.2",
         "@octokit/plugin-throttling": "^9.3.2",
-        "megaera": "^1.0.1",
+        "megaera": "^1.0.2",
         "minimist": "^1.2.8",
         "octokit": "^4.0.2"
       },
@@ -1276,9 +1276,9 @@
       "dev": true
     },
     "node_modules/megaera": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/megaera/-/megaera-1.0.1.tgz",
-      "integrity": "sha512-QW0YHLz7mzwY+RhwzwXP5KwUIaPUM5i/CD/DVzCrtneUlx7tZIcnOAFoGRj2xQRasPmMfMJM4s+ah46WlrpN3A==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/megaera/-/megaera-1.0.2.tgz",
+      "integrity": "sha512-EtGYf2NOaLo7FlW6T3aV1f1aIvs98x0hHcFllKTVzVCva7vaI4RoX6ue+EYJXto4mXmYVwcnQxJT53q8by47zw==",
       "license": "MIT",
       "dependencies": {
         "graphql": "^16.0.0"
package.json
@@ -27,7 +27,7 @@
     "@octokit/plugin-paginate-graphql": "^5.2.4",
     "@octokit/plugin-retry": "^7.1.2",
     "@octokit/plugin-throttling": "^9.3.2",
-    "megaera": "^1.0.1",
+    "megaera": "^1.0.2",
     "minimist": "^1.2.8",
     "octokit": "^4.0.2"
   },