Skip to content

Instantly share code, notes, and snippets.

@tomfa
Created September 4, 2026 12:54
Show Gist options
  • Select an option

  • Save tomfa/6012a57326407563137f3268fb3a6dd4 to your computer and use it in GitHub Desktop.

Select an option

Save tomfa/6012a57326407563137f3268fb3a6dd4 to your computer and use it in GitHub Desktop.
Standup skill
---
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.
{
"scripts": {
"standup": "packages/graphql/node_modules/.bin/tsx scripts/standup.ts"
}
}
/* 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