Created
September 4, 2026 12:54
-
-
Save tomfa/6012a57326407563137f3268fb3a6dd4 to your computer and use it in GitHub Desktop.
Standup skill
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --- | |
| name: standup | |
| description: Summarize GitHub PR activity for a time window. Default is the local GitHub user in detail (titles) for windows under 7d; pass all, dense, or detail to override. Optional period: 24h (default; 72h on Monday), 48h, 7d, 2w. | |
| argument-hint: [period] [all] [detail|dense] | |
| disable-model-invocation: true | |
| allowed-tools: Bash(gh *), Bash(pnpm standup *) | |
| --- | |
| Fetch GitHub PR activity for the current repo and present it grouped by username. | |
| ## Arguments | |
| Optional period as `{amount}{unit}`: `h` hours, `d` days, `w` weeks. Examples: `48h`, `7d`, `2w`. Default: `24h`, or `72h` when today is Monday. | |
| Pass through whatever period the user named. If they did not name one, omit it. | |
| Default output is the local GitHub user (`gh api user`). If the user asked for everyone, the team, or all users, pass `all`. Otherwise omit it. | |
| Layout defaults to `detail` when no period is passed, or the period is under 7 days. Periods of 7 days or more default to dense. If the user asked for titles or detail, pass `detail`. If they asked for compact or dense, pass `dense`. Otherwise omit both and let the script choose. | |
| ## Steps | |
| 1. From the repo root, run: | |
| ```bash | |
| pnpm standup | |
| ``` | |
| With a period and everyone: | |
| ```bash | |
| pnpm standup 7d all | |
| ``` | |
| Force dense (or detail) regardless of period: | |
| ```bash | |
| pnpm standup dense | |
| ``` | |
| 2. Present the script output as the standup. Do not re-group, expand, or drop people. Bot users (including `karl-qa`) are omitted. | |
| Default (single user, Slack-oriented). Hide the author name. Use **I går** for yesterday's activity and **I dag** for follow-ups: | |
| ``` | |
| *Yesterday:* | |
| - Merged [deps: upgrade AWS SDK] | |
| - Opened [Request consent from the client consent action menu] | |
| - Reviewed #6961, #6954 | |
| *Today:* | |
| - Make review ready [some draft] | |
| - Address comments in [Missing card banner] | |
| - Merge [journal: backfill userId on PractitionerRef] | |
| - Review [some requested PR] | |
| *Blockers:* | |
| - Needs review: #1238, #1239 | |
| ``` | |
| **Today** bullets: | |
| 1. Draft PRs you authored → `Make review ready #1230, #1231` | |
| 2. Approved open PRs you authored → `Merge #1234, #1235` | |
| 3. Open PRs you authored with unresolved review comments, excluding drafts and any already listed under Merge → `Address comments in #1234, #1235` | |
| 4. Open PRs that request you as a reviewer (not via a team), where you have not submitted a review → `Review #1240, #1241` | |
| Omit an **I dag** bullet when its list is empty. If all are empty, print `- ?`. | |
| **Blockers** (open, non-draft, not approved, no unresolved comments): | |
| ``` | |
| *Blockers:* | |
| - Trenger review: #1234, #1235 | |
| ``` | |
| Omit **Blockers** when the list is empty. | |
| With `all`, keep usernames and skip **I går** / **I dag** / **Block | |
| ers**: | |
| ``` | |
| *git-user-handle:* | |
| - Merged #1234, #1235 | |
| - Opened #1236 | |
| - Reviewed #1237, #1238 | |
| ``` | |
| If a dense line has more than 4 PRs, include the count after the label (`Merged 6 PRs #1, #2, …`). | |
| 1. **Merged** — PRs merged with them as author | |
| 2. **Opened** — PRs they opened (skip a PR that already appears under Merged) | |
| 3. **Reviewed** — PRs they reviewed (latest review in the window; not their own PRs) | |
| 3. If `gh` fails (auth, network), report the error and stop. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "scripts": { | |
| "standup": "packages/graphql/node_modules/.bin/tsx scripts/standup.ts" | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* eslint-disable no-console */ | |
| import { execFileSync } from 'node:child_process'; | |
| const QUERY = ` | |
| query($q: String!, $cursor: String) { | |
| search(query: $q, type: ISSUE, first: 100, after: $cursor) { | |
| pageInfo { hasNextPage endCursor } | |
| nodes { | |
| ... on PullRequest { | |
| number | |
| title | |
| url | |
| createdAt | |
| mergedAt | |
| isDraft | |
| state | |
| author { login } | |
| reviews(last: 100) { | |
| nodes { | |
| author { login } | |
| submittedAt | |
| state | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| `; | |
| const OPEN_QUERY = ` | |
| query($q: String!, $cursor: String) { | |
| search(query: $q, type: ISSUE, first: 100, after: $cursor) { | |
| pageInfo { hasNextPage endCursor } | |
| nodes { | |
| ... on PullRequest { | |
| number | |
| title | |
| url | |
| mergedAt | |
| isDraft | |
| state | |
| reviewDecision | |
| reviewThreads(first: 100) { | |
| nodes { | |
| isResolved | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| `; | |
| const REVIEW_REQUESTED_QUERY = ` | |
| query($q: String!, $cursor: String) { | |
| search(query: $q, type: ISSUE, first: 100, after: $cursor) { | |
| pageInfo { hasNextPage endCursor } | |
| nodes { | |
| ... on PullRequest { | |
| number | |
| title | |
| url | |
| mergedAt | |
| isDraft | |
| state | |
| reviewRequests(first: 100) { | |
| nodes { | |
| requestedReviewer { | |
| ... on User { | |
| login | |
| } | |
| } | |
| } | |
| } | |
| reviews(last: 100) { | |
| nodes { | |
| author { login } | |
| submittedAt | |
| state | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| `; | |
| type Actor = { | |
| login: string; | |
| }; | |
| type ReviewNode = { | |
| author: Actor | null; | |
| submittedAt: string | null; | |
| state: string; | |
| }; | |
| type PullRequestNode = { | |
| number: number; | |
| title: string; | |
| url: string; | |
| createdAt: string; | |
| mergedAt: string | null; | |
| isDraft: boolean; | |
| state: string; | |
| author: Actor | null; | |
| reviews: { | |
| nodes: ReviewNode[]; | |
| }; | |
| }; | |
| type OpenPullRequestNode = { | |
| number: number; | |
| title: string; | |
| url: string; | |
| mergedAt: string | null; | |
| isDraft: boolean; | |
| state: string; | |
| reviewDecision: string | null; | |
| reviewThreads: { | |
| nodes: Array<{ isResolved: boolean }>; | |
| }; | |
| }; | |
| type ReviewRequestedPullRequestNode = { | |
| number: number; | |
| title: string; | |
| url: string; | |
| mergedAt: string | null; | |
| isDraft: boolean; | |
| state: string; | |
| reviewRequests: { | |
| nodes: Array<{ | |
| requestedReviewer: Actor | null; | |
| }>; | |
| }; | |
| reviews: { | |
| nodes: ReviewNode[]; | |
| }; | |
| }; | |
| type SearchResponse = { | |
| data: { | |
| search: { | |
| pageInfo: { | |
| hasNextPage: boolean; | |
| endCursor: string | null; | |
| }; | |
| nodes: unknown[]; | |
| }; | |
| }; | |
| }; | |
| type PrInfo = { | |
| number: number; | |
| title: string; | |
| url: string; | |
| state: string; | |
| isDraft: boolean; | |
| mergedAt: string | null; | |
| }; | |
| type TimestampedPr = PrInfo & { at: Date }; | |
| type ReviewedPr = TimestampedPr & { reviewState: string }; | |
| type PersonBuckets = { | |
| merged: TimestampedPr[]; | |
| opened: TimestampedPr[]; | |
| reviewed: ReviewedPr[]; | |
| }; | |
| function ghJson(args: string[]): unknown { | |
| try { | |
| const stdout = execFileSync('gh', args, { encoding: 'utf8' }); | |
| return JSON.parse(stdout) as unknown; | |
| } catch (error) { | |
| const stderr = | |
| error instanceof Error && 'stderr' in error | |
| ? String(error.stderr) | |
| : undefined; | |
| const fallback = | |
| error instanceof Error ? error.message : 'gh command failed'; | |
| process.stderr.write(`${stderr || fallback}\n`); | |
| process.exit(1); | |
| } | |
| } | |
| function isPullRequest(node: unknown): node is PullRequestNode { | |
| if (typeof node !== 'object' || node === null) { | |
| return false; | |
| } | |
| return 'number' in node && typeof node.number === 'number'; | |
| } | |
| function isOpenPullRequest(node: unknown): node is OpenPullRequestNode { | |
| if (typeof node !== 'object' || node === null) { | |
| return false; | |
| } | |
| if (!('number' in node) || typeof node.number !== 'number') { | |
| return false; | |
| } | |
| return 'reviewThreads' in node; | |
| } | |
| function isReviewRequestedPullRequest( | |
| node: unknown, | |
| ): node is ReviewRequestedPullRequestNode { | |
| if (typeof node !== 'object' || node === null) { | |
| return false; | |
| } | |
| if (!('number' in node) || typeof node.number !== 'number') { | |
| return false; | |
| } | |
| return 'reviewRequests' in node; | |
| } | |
| const IGNORED_LOGINS = new Set(['karl-qa', 'dependabot']); | |
| function isHuman(login: string | null): login is string { | |
| if (!login) { | |
| return false; | |
| } | |
| if (login.endsWith('[bot]') || IGNORED_LOGINS.has(login)) { | |
| return false; | |
| } | |
| return true; | |
| } | |
| function loginOf(actor: Actor | null | undefined): string | null { | |
| if (!actor) { | |
| return null; | |
| } | |
| return actor.login; | |
| } | |
| function formatPrLink(pr: PrInfo): string { | |
| return `[#${pr.number}](${pr.url})`; | |
| } | |
| function formatPrLinks(prs: PrInfo[]): string { | |
| return prs.map(formatPrLink).join(', '); | |
| } | |
| function printBlockers(prs: PrInfo[]): void { | |
| if (prs.length === 0) { | |
| return; | |
| } | |
| console.log(); | |
| console.log('*Blockers:*'); | |
| console.log(`- Needs review: ${formatPrLinks(prs)}`); | |
| } | |
| function printToday({ | |
| makeReviewReady, | |
| addressComments, | |
| readyToMerge, | |
| toReview, | |
| detail, | |
| }: { | |
| makeReviewReady: PrInfo[]; | |
| addressComments: PrInfo[]; | |
| readyToMerge: PrInfo[]; | |
| toReview: PrInfo[]; | |
| detail: boolean; | |
| }): void { | |
| console.log(); | |
| console.log('*Today:*'); | |
| if ( | |
| makeReviewReady.length === 0 && | |
| addressComments.length === 0 && | |
| readyToMerge.length === 0 && | |
| toReview.length === 0 | |
| ) { | |
| console.log('- ?'); | |
| return; | |
| } | |
| printLabeledPrs({ | |
| label: 'Make review ready', | |
| prs: makeReviewReady, | |
| detail, | |
| }); | |
| printLabeledPrs({ | |
| label: 'Address comments in', | |
| prs: addressComments, | |
| detail, | |
| }); | |
| printLabeledPrs({ | |
| label: 'Merge', | |
| prs: readyToMerge, | |
| detail, | |
| }); | |
| printLabeledPrs({ | |
| label: 'Review', | |
| prs: toReview, | |
| detail, | |
| }); | |
| } | |
| function formatDetailLine({ | |
| label, | |
| pr, | |
| }: { | |
| label: string; | |
| pr: PrInfo; | |
| }): string { | |
| return `- ${label} [${pr.title}](${pr.url})`; | |
| } | |
| function printLabeledPrs({ | |
| label, | |
| prs, | |
| detail, | |
| }: { | |
| label: string; | |
| prs: PrInfo[]; | |
| detail: boolean; | |
| }): void { | |
| if (prs.length === 0) { | |
| return; | |
| } | |
| if (detail) { | |
| for (const pr of prs) { | |
| console.log(formatDetailLine({ label, pr })); | |
| } | |
| return; | |
| } | |
| console.log(formatBucketLine({ label, prs })); | |
| } | |
| function printAuthoredBucket({ | |
| label, | |
| prs, | |
| detail, | |
| }: { | |
| label: string; | |
| prs: TimestampedPr[]; | |
| detail: boolean; | |
| }): void { | |
| printLabeledPrs({ | |
| label, | |
| prs: [...prs].sort(byNewestFirst), | |
| detail, | |
| }); | |
| } | |
| function formatBucketLine({ | |
| label, | |
| prs, | |
| }: { | |
| label: string; | |
| prs: PrInfo[]; | |
| }): string { | |
| const countLabel = prs.length > 4 ? ` ${prs.length} PRs` : ''; | |
| return `- ${label}${countLabel} ${formatPrLinks(prs)}`; | |
| } | |
| function formatUtcMinute(date: Date): string { | |
| return date.toISOString().slice(0, 16).replace('T', ' '); | |
| } | |
| function emptyBuckets(): PersonBuckets { | |
| return { merged: [], opened: [], reviewed: [] }; | |
| } | |
| function bucketsFor({ | |
| people, | |
| username, | |
| }: { | |
| people: Map<string, PersonBuckets>; | |
| username: string; | |
| }): PersonBuckets { | |
| const existing = people.get(username); | |
| if (existing) { | |
| return existing; | |
| } | |
| const created = emptyBuckets(); | |
| people.set(username, created); | |
| return created; | |
| } | |
| type DurationUnit = 'h' | 'd' | 'w'; | |
| type Period = { | |
| label: string; | |
| ms: number; | |
| }; | |
| const MS_PER_HOUR = 60 * 60 * 1000; | |
| const MS_PER_UNIT: Record<DurationUnit, number> = { | |
| h: MS_PER_HOUR, | |
| d: 24 * MS_PER_HOUR, | |
| w: 7 * 24 * MS_PER_HOUR, | |
| }; | |
| function isDurationUnit(value: string): value is DurationUnit { | |
| return value === 'h' || value === 'd' || value === 'w'; | |
| } | |
| function defaultPeriodLabel(now: Date): string { | |
| const isMonday = now.getDay() === 1; | |
| if (isMonday) { | |
| return '72h'; | |
| } | |
| return '24h'; | |
| } | |
| function parsePeriod(raw: string | undefined): Period { | |
| const label = raw ?? defaultPeriodLabel(new Date()); | |
| const match = /^(\d+)(h|d|w)$/i.exec(label); | |
| const amountRaw = match?.[1]; | |
| const unitRaw = match?.[2]?.toLowerCase(); | |
| const amount = amountRaw ? Number(amountRaw) : Number.NaN; | |
| if ( | |
| !unitRaw || | |
| !isDurationUnit(unitRaw) || | |
| !Number.isInteger(amount) || | |
| amount < 1 | |
| ) { | |
| process.stderr.write( | |
| `Invalid period "${label}". Use e.g. 24h, 48h, 7d, or 2w.\n`, | |
| ); | |
| process.exit(1); | |
| } | |
| return { label: `${amount}${unitRaw}`, ms: amount * MS_PER_UNIT[unitRaw] }; | |
| } | |
| function parseArgs(args: string[]): { | |
| period: Period; | |
| includeAll: boolean; | |
| detail: boolean; | |
| } { | |
| if (args.length > 3) { | |
| process.stderr.write('Usage: pnpm standup [period] [all] [detail|dense]\n'); | |
| process.exit(1); | |
| } | |
| let periodRaw: string | undefined; | |
| let includeAll = false; | |
| let layout: 'detail' | 'dense' | undefined; | |
| for (const arg of args) { | |
| const normalized = arg.toLowerCase(); | |
| if (normalized === 'all') { | |
| includeAll = true; | |
| continue; | |
| } | |
| if (normalized === 'detail' || normalized === 'dense') { | |
| if (layout !== undefined) { | |
| process.stderr.write( | |
| 'Usage: pnpm standup [period] [all] [detail|dense]\n', | |
| ); | |
| process.exit(1); | |
| } | |
| layout = normalized; | |
| continue; | |
| } | |
| if (periodRaw !== undefined) { | |
| process.stderr.write( | |
| 'Usage: pnpm standup [period] [all] [detail|dense]\n', | |
| ); | |
| process.exit(1); | |
| } | |
| periodRaw = arg; | |
| } | |
| const period = parsePeriod(periodRaw); | |
| const defaultDetail = | |
| periodRaw === undefined || period.ms < 7 * MS_PER_UNIT.d; | |
| if (layout === 'detail') { | |
| return { period, includeAll, detail: true }; | |
| } | |
| if (layout === 'dense') { | |
| return { period, includeAll, detail: false }; | |
| } | |
| return { period, includeAll, detail: defaultDetail }; | |
| } | |
| function localGithubLogin(): string { | |
| const user = ghJson(['api', 'user']) as { login?: string }; | |
| const login = user.login; | |
| if (!login) { | |
| process.stderr.write( | |
| 'Could not determine GitHub login for the local user.\n', | |
| ); | |
| process.exit(1); | |
| } | |
| return login; | |
| } | |
| function filterToUser({ | |
| people, | |
| username, | |
| }: { | |
| people: Map<string, PersonBuckets>; | |
| username: string; | |
| }): Map<string, PersonBuckets> { | |
| const buckets = people.get(username); | |
| if (!buckets) { | |
| return new Map(); | |
| } | |
| return new Map([[username, buckets]]); | |
| } | |
| function paginateSearch({ | |
| query, | |
| search, | |
| }: { | |
| query: string; | |
| search: string; | |
| }): unknown[] { | |
| let cursor: string | undefined; | |
| const nodes: unknown[] = []; | |
| while (true) { | |
| const args = [ | |
| 'api', | |
| 'graphql', | |
| '-f', | |
| `query=${query}`, | |
| '-f', | |
| `q=${search}`, | |
| ]; | |
| if (cursor) { | |
| args.push('-f', `cursor=${cursor}`); | |
| } | |
| const payload = ghJson(args) as SearchResponse; | |
| const searchResult = payload.data.search; | |
| nodes.push(...searchResult.nodes); | |
| if (!searchResult.pageInfo.hasNextPage) { | |
| break; | |
| } | |
| cursor = searchResult.pageInfo.endCursor ?? undefined; | |
| } | |
| return nodes; | |
| } | |
| function fetchPullRequests({ | |
| repo, | |
| searchDay, | |
| }: { | |
| repo: string; | |
| searchDay: string; | |
| }): PullRequestNode[] { | |
| const search = `repo:${repo} is:pr updated:>=${searchDay}`; | |
| const nodes: PullRequestNode[] = []; | |
| for (const node of paginateSearch({ query: QUERY, search })) { | |
| if (isPullRequest(node)) { | |
| nodes.push(node); | |
| } | |
| } | |
| return nodes; | |
| } | |
| function fetchOpenAuthoredPullRequests({ | |
| repo, | |
| author, | |
| }: { | |
| repo: string; | |
| author: string; | |
| }): OpenPullRequestNode[] { | |
| const search = `repo:${repo} is:pr is:open author:${author}`; | |
| const nodes: OpenPullRequestNode[] = []; | |
| for (const node of paginateSearch({ query: OPEN_QUERY, search })) { | |
| if (isOpenPullRequest(node)) { | |
| nodes.push(node); | |
| } | |
| } | |
| return nodes; | |
| } | |
| function isDirectUserReviewRequest({ | |
| pr, | |
| username, | |
| }: { | |
| pr: ReviewRequestedPullRequestNode; | |
| username: string; | |
| }): boolean { | |
| return pr.reviewRequests.nodes.some( | |
| (request) => loginOf(request.requestedReviewer) === username, | |
| ); | |
| } | |
| function hasSubmittedReview({ | |
| pr, | |
| username, | |
| }: { | |
| pr: ReviewRequestedPullRequestNode; | |
| username: string; | |
| }): boolean { | |
| return pr.reviews.nodes.some((review) => { | |
| if (loginOf(review.author) !== username) { | |
| return false; | |
| } | |
| return review.state !== 'PENDING'; | |
| }); | |
| } | |
| function fetchPendingReviewRequests({ | |
| repo, | |
| username, | |
| }: { | |
| repo: string; | |
| username: string; | |
| }): PrInfo[] { | |
| const search = `repo:${repo} is:pr is:open review-requested:${username}`; | |
| const nodes: PrInfo[] = []; | |
| for (const node of paginateSearch({ | |
| query: REVIEW_REQUESTED_QUERY, | |
| search, | |
| })) { | |
| if (!isReviewRequestedPullRequest(node)) { | |
| continue; | |
| } | |
| if (!isDirectUserReviewRequest({ pr: node, username })) { | |
| continue; | |
| } | |
| if (hasSubmittedReview({ pr: node, username })) { | |
| continue; | |
| } | |
| nodes.push(toPrInfo(node)); | |
| } | |
| return nodes.sort((left, right) => right.number - left.number); | |
| } | |
| function toPrInfo(pr: PrInfo): PrInfo { | |
| return { | |
| number: pr.number, | |
| title: pr.title, | |
| url: pr.url, | |
| state: pr.state, | |
| isDraft: pr.isDraft, | |
| mergedAt: pr.mergedAt, | |
| }; | |
| } | |
| function hasUnresolvedComments(pr: OpenPullRequestNode): boolean { | |
| return pr.reviewThreads.nodes.some((thread) => !thread.isResolved); | |
| } | |
| function classifyOpenPrs(prs: OpenPullRequestNode[]): { | |
| makeReviewReady: PrInfo[]; | |
| addressComments: PrInfo[]; | |
| readyToMerge: PrInfo[]; | |
| reviewNeeded: PrInfo[]; | |
| } { | |
| const makeReviewReady: PrInfo[] = []; | |
| const addressComments: PrInfo[] = []; | |
| const readyToMerge: PrInfo[] = []; | |
| const reviewNeeded: PrInfo[] = []; | |
| for (const pr of prs) { | |
| if (pr.isDraft) { | |
| makeReviewReady.push(toPrInfo(pr)); | |
| continue; | |
| } | |
| if (pr.reviewDecision === 'APPROVED') { | |
| readyToMerge.push(toPrInfo(pr)); | |
| continue; | |
| } | |
| if (hasUnresolvedComments(pr)) { | |
| addressComments.push(toPrInfo(pr)); | |
| continue; | |
| } | |
| reviewNeeded.push(toPrInfo(pr)); | |
| } | |
| const byNewestNumber = (left: PrInfo, right: PrInfo) => | |
| right.number - left.number; | |
| return { | |
| makeReviewReady: makeReviewReady.sort(byNewestNumber), | |
| addressComments: addressComments.sort(byNewestNumber), | |
| readyToMerge: readyToMerge.sort(byNewestNumber), | |
| reviewNeeded: reviewNeeded.sort(byNewestNumber), | |
| }; | |
| } | |
| function byNewestFirst<T extends { at: Date }>(left: T, right: T): number { | |
| return right.at.getTime() - left.at.getTime(); | |
| } | |
| function hasBucketActivity(buckets: PersonBuckets): boolean { | |
| return ( | |
| buckets.merged.length > 0 || | |
| buckets.opened.length > 0 || | |
| buckets.reviewed.length > 0 | |
| ); | |
| } | |
| function printPersonBuckets({ | |
| heading, | |
| buckets, | |
| detail, | |
| }: { | |
| heading: string; | |
| buckets: PersonBuckets; | |
| detail: boolean; | |
| }): void { | |
| console.log(); | |
| console.log(`*${heading}*`); | |
| printAuthoredBucket({ | |
| label: 'Merged', | |
| prs: buckets.merged, | |
| detail, | |
| }); | |
| printAuthoredBucket({ | |
| label: 'Opened', | |
| prs: buckets.opened, | |
| detail, | |
| }); | |
| if (buckets.reviewed.length > 0) { | |
| console.log( | |
| formatBucketLine({ | |
| label: 'Reviewed', | |
| prs: [...buckets.reviewed].sort(byNewestFirst), | |
| }), | |
| ); | |
| } | |
| } | |
| function printStandup({ | |
| period, | |
| since, | |
| now, | |
| repo, | |
| people, | |
| detail, | |
| includeAll, | |
| makeReviewReady, | |
| addressComments, | |
| readyToMerge, | |
| toReview, | |
| blockers, | |
| }: { | |
| period: Period; | |
| since: Date; | |
| now: Date; | |
| repo: string; | |
| people: Map<string, PersonBuckets>; | |
| detail: boolean; | |
| includeAll: boolean; | |
| makeReviewReady: PrInfo[]; | |
| addressComments: PrInfo[]; | |
| readyToMerge: PrInfo[]; | |
| toReview: PrInfo[]; | |
| blockers: PrInfo[]; | |
| }): void { | |
| console.log(`# Standup (last ${period.label})`); | |
| console.log(`${formatUtcMinute(since)} UTC → ${formatUtcMinute(now)} UTC`); | |
| console.log(`Repo: ${repo}`); | |
| const usernames = [...people.keys()] | |
| .filter((username) => { | |
| const buckets = people.get(username); | |
| return buckets !== undefined && hasBucketActivity(buckets); | |
| }) | |
| .sort((left, right) => | |
| left.localeCompare(right, undefined, { sensitivity: 'base' }), | |
| ); | |
| if (includeAll) { | |
| if (usernames.length === 0) { | |
| console.log(); | |
| console.log(`No PR activity in the last ${period.label}.`); | |
| return; | |
| } | |
| for (const username of usernames) { | |
| const buckets = people.get(username); | |
| if (!buckets) { | |
| continue; | |
| } | |
| printPersonBuckets({ | |
| heading: `${username}:`, | |
| buckets, | |
| detail, | |
| }); | |
| } | |
| return; | |
| } | |
| const yesterday = usernames[0] ? people.get(usernames[0]) : undefined; | |
| printPersonBuckets({ | |
| heading: 'Yesterday:', | |
| buckets: yesterday ?? emptyBuckets(), | |
| detail, | |
| }); | |
| printToday({ | |
| makeReviewReady, | |
| addressComments, | |
| readyToMerge, | |
| toReview, | |
| detail, | |
| }); | |
| printBlockers(blockers); | |
| } | |
| function printSlackHint(): void { | |
| if (!process.stderr.isTTY) { | |
| return; | |
| } | |
| process.stderr.write( | |
| '\nHint: paste into Slack with cmd+v, then cmd+shift+f to parse markdown.\n', | |
| ); | |
| } | |
| function main(): void { | |
| const { period, includeAll, detail } = parseArgs(process.argv.slice(2)); | |
| const now = new Date(); | |
| const since = new Date(now.getTime() - period.ms); | |
| const searchDay = since.toISOString().slice(0, 10); | |
| const repo = ( | |
| ghJson(['repo', 'view', '--json', 'nameWithOwner']) as { | |
| nameWithOwner: string; | |
| } | |
| ).nameWithOwner; | |
| const pullRequests = fetchPullRequests({ repo, searchDay }); | |
| const people = new Map<string, PersonBuckets>(); | |
| for (const pr of pullRequests) { | |
| const author = loginOf(pr.author); | |
| const createdAt = new Date(pr.createdAt); | |
| const mergedAt = pr.mergedAt ? new Date(pr.mergedAt) : null; | |
| const mergedInWindow = mergedAt !== null && mergedAt >= since; | |
| const info: PrInfo = { | |
| number: pr.number, | |
| title: pr.title, | |
| url: pr.url, | |
| state: pr.state, | |
| isDraft: pr.isDraft, | |
| mergedAt: pr.mergedAt, | |
| }; | |
| if (mergedAt !== null && mergedInWindow && isHuman(author)) { | |
| bucketsFor({ people, username: author }).merged.push({ | |
| ...info, | |
| at: mergedAt, | |
| }); | |
| } | |
| if (createdAt >= since && isHuman(author) && !mergedInWindow) { | |
| bucketsFor({ people, username: author }).opened.push({ | |
| ...info, | |
| at: createdAt, | |
| }); | |
| } | |
| const latestByReviewer = new Map<string, ReviewedPr>(); | |
| for (const review of pr.reviews.nodes) { | |
| const reviewer = loginOf(review.author); | |
| if (!isHuman(reviewer) || reviewer === author) { | |
| continue; | |
| } | |
| if (!review.submittedAt) { | |
| continue; | |
| } | |
| const submittedAt = new Date(review.submittedAt); | |
| if (submittedAt < since) { | |
| continue; | |
| } | |
| const previous = latestByReviewer.get(reviewer); | |
| if (previous === undefined || submittedAt > previous.at) { | |
| latestByReviewer.set(reviewer, { | |
| ...info, | |
| at: submittedAt, | |
| reviewState: review.state, | |
| }); | |
| } | |
| } | |
| for (const [reviewer, item] of latestByReviewer) { | |
| bucketsFor({ people, username: reviewer }).reviewed.push(item); | |
| } | |
| } | |
| let scopedPeople = people; | |
| let makeReviewReady: PrInfo[] = []; | |
| let addressComments: PrInfo[] = []; | |
| let readyToMerge: PrInfo[] = []; | |
| let toReview: PrInfo[] = []; | |
| let blockers: PrInfo[] = []; | |
| if (!includeAll) { | |
| const username = localGithubLogin(); | |
| scopedPeople = filterToUser({ people, username }); | |
| const classified = classifyOpenPrs( | |
| fetchOpenAuthoredPullRequests({ repo, author: username }), | |
| ); | |
| makeReviewReady = classified.makeReviewReady; | |
| addressComments = classified.addressComments; | |
| readyToMerge = classified.readyToMerge; | |
| blockers = classified.reviewNeeded; | |
| toReview = fetchPendingReviewRequests({ repo, username }); | |
| } | |
| printStandup({ | |
| period, | |
| since, | |
| now, | |
| repo, | |
| people: scopedPeople, | |
| detail, | |
| includeAll, | |
| makeReviewReady, | |
| addressComments, | |
| readyToMerge, | |
| toReview, | |
| blockers, | |
| }); | |
| printSlackHint(); | |
| } | |
| main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment