Commit 15f0e9b
Changed files (37)
badges
reactions
src
task
issue-timeline
reactions
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"
},