From acb98613a0d5e5ed8b306598fb643617b7d52643 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Thu, 5 Mar 2026 20:22:32 +0100 Subject: [PATCH 1/3] feat: script to check members activity --- scripts/package-lock.json | 249 ++++++++++++++++++++++ scripts/package.json | 12 +- scripts/src/check-activity.ts | 385 ++++++++++++++++++++++++++++++++++ 3 files changed, 644 insertions(+), 2 deletions(-) create mode 100644 scripts/src/check-activity.ts diff --git a/scripts/package-lock.json b/scripts/package-lock.json index 750b3778..a001b713 100644 --- a/scripts/package-lock.json +++ b/scripts/package-lock.json @@ -8,10 +8,17 @@ "name": "@immich/scripts", "version": "1.0.0", "license": "ISC", + "dependencies": { + "@discordjs/rest": "^2.4.3", + "@octokit/graphql": "^8.2.1", + "discord-api-types": "^0.37.119", + "json-bigint": "^1.0.0" + }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.20.0", "@immich/sdk": "^2.0.0", + "@types/json-bigint": "^1.0.4", "@types/luxon": "^3.4.2", "@types/node": "^24.10.15", "@typescript-eslint/eslint-plugin": "^8.24.0", @@ -39,6 +46,74 @@ "node": ">=6.9.0" } }, + "node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/discord-api-types": { + "version": "0.38.40", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.40.tgz", + "integrity": "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util/node_modules/discord-api-types": { + "version": "0.38.40", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.40.tgz", + "integrity": "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz", @@ -708,6 +783,76 @@ "dev": true, "license": "MIT" }, + "node_modules/@octokit/endpoint": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.4.tgz", + "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.2.2.tgz", + "integrity": "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^9.2.3", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "license": "MIT" + }, + "node_modules/@octokit/request": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.4.tgz", + "integrity": "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^10.1.4", + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^2.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.8.tgz", + "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", @@ -721,6 +866,26 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -728,6 +893,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/json-bigint/-/json-bigint-1.0.4.tgz", + "integrity": "sha512-ydHooXLbOmxBbubnA7Eh+RpBzuaIiQjh8WGJYQB50JFGFrdxW7JzVlyEV7fAXw0T2sqJ1ysTneJbiyNLqZRAag==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1034,6 +1206,16 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1114,6 +1296,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1347,6 +1538,12 @@ "dev": true, "license": "MIT" }, + "node_modules/discord-api-types": { + "version": "0.37.120", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.120.tgz", + "integrity": "sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -1695,6 +1892,22 @@ "node": ">=0.10.0" } }, + "node_modules/fast-content-type-parse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", + "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1991,6 +2204,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2069,6 +2291,12 @@ "node": ">=12" } }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2446,6 +2674,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -2517,6 +2751,15 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -2524,6 +2767,12 @@ "dev": true, "license": "MIT" }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/scripts/package.json b/scripts/package.json index 5d3ded5a..0e315ba4 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -4,6 +4,7 @@ "main": "dist/index.js", "type": "module", "scripts": { + "check-activity": "tsx src/check-activity.ts", "seed-demo": "tsx src/seed-demo.ts", "build": "tsc", "format": "prettier --check .", @@ -17,10 +18,17 @@ "author": "", "license": "ISC", "description": "", + "dependencies": { + "@discordjs/rest": "^2.4.3", + "@octokit/graphql": "^8.2.1", + "discord-api-types": "^0.37.119", + "json-bigint": "^1.0.0" + }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.20.0", "@immich/sdk": "^2.0.0", + "@types/json-bigint": "^1.0.4", "@types/luxon": "^3.4.2", "@types/node": "^24.10.15", "@typescript-eslint/eslint-plugin": "^8.24.0", @@ -29,12 +37,12 @@ "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.3", "eslint-plugin-unicorn": "^62.0.0", + "globals": "^17.1.0", "luxon": "^3.5.0", "prettier": "^3.5.0", "prettier-plugin-organize-imports": "^4.1.0", "tsx": "^4.19.4", "typescript": "^5.7.3", - "typescript-eslint": "^8.24.0", - "globals": "^17.1.0" + "typescript-eslint": "^8.24.0" } } diff --git a/scripts/src/check-activity.ts b/scripts/src/check-activity.ts new file mode 100644 index 00000000..e85bbdba --- /dev/null +++ b/scripts/src/check-activity.ts @@ -0,0 +1,385 @@ +import { REST } from '@discordjs/rest'; +import { graphql } from '@octokit/graphql'; +import JSONbig from 'json-bigint'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// --- Configuration --- + +const discordToken = process.env.DISCORD_TOKEN; +const discordGuildId = process.env.DISCORD_GUILD_ID; +const githubToken = process.env.GITHUB_TOKEN; +const githubOrg = process.env.GITHUB_ORG ?? 'immich-app'; +const activityCutoffDays = Number.parseInt(process.env.ACTIVITY_CUTOFF_DAYS ?? '90', 10); + +const missing: string[] = []; +if (!discordToken) { + missing.push( + ' DISCORD_TOKEN — your Discord user token (open discord.com, DevTools → Network, filter for /api/, pick any request, copy the Authorization header value)', + ); +} +if (!discordGuildId) { + missing.push( + ' DISCORD_GUILD_ID — the Immich server ID; retrieve with: op read "op://tf_prod/IMMICH_DISCORD_SERVER_ID/password"', + ); +} +if (!githubToken) { + missing.push(' GITHUB_TOKEN — a GitHub PAT with read:org scope'); +} +if (missing.length > 0) { + console.error('Missing required env vars:\n' + missing.join('\n')); + process.exit(1); +} + +if (Number.isNaN(activityCutoffDays)) { + console.error('ACTIVITY_CUTOFF_DAYS must be a valid integer'); + process.exit(1); +} + +const cutoffDate = new Date(Date.now() - activityCutoffDays * 24 * 60 * 60 * 1000); +const cutoffDateStr = cutoffDate.toISOString().slice(0, 10); + +// --- Types --- + +type UserEntry = { + github: { username: string; id: number }; + // Discord snowflakes exceed MAX_SAFE_INTEGER; json-bigint parses them as bigint + discord: { username: string; id: number | bigint }; + roles: string[]; + dev?: boolean; + active?: boolean; +}; + +type DiscordResult = { + timestamp: string | null; + link: string | null; +}; + +type GithubResult = { + active: boolean; + date: string | null; + link: string | null; +}; + +type UserResult = { + user: UserEntry; + discordTimestamp: string | null; + discordLink: string | null; + discordSkipped: boolean; + githubActive: boolean; + githubDate: string | null; + githubLink: string | null; + githubSkipped: boolean; + active: boolean; +}; + +const TARGET_ROLES = new Set(['contributor', 'support']); + +// --- Load data --- + +const usersJsonPath = resolve(__dirname, '../../tf/deployment/data/users.json'); +// json-bigint preserves Discord snowflakes (64-bit integers) that exceed MAX_SAFE_INTEGER +const JSON64 = JSONbig({ useNativeBigInt: true }); +const users = JSON64.parse(readFileSync(usersJsonPath, 'utf8')) as UserEntry[]; + +// --- API clients --- + +// User tokens require no auth prefix; pass auth: false per-request and supply the header manually. +const discordRest = new REST({ version: '10' }); + +const githubGraphQL = graphql.defaults({ + headers: { authorization: `token ${githubToken}` }, +}); + +// --- Helpers --- + +async function getGithubOrgId(org: string): Promise { + const result = await githubGraphQL<{ organization: { id: string } }>( + `query($login: String!) { + organization(login: $login) { id } + }`, + { login: org }, + ); + return result.organization.id; +} + +async function checkDiscordActivity(userId: number | bigint): Promise { + try { + const result = (await discordRest.get(`/guilds/${discordGuildId}/messages/search`, { + query: new URLSearchParams({ + author_id: String(userId), + sort_by: 'timestamp', + sort_order: 'desc', + limit: '1', + }), + auth: false, + headers: { Authorization: discordToken! }, + })) as { messages?: Array> }; + const msg = result.messages?.[0]?.[0]; + if (!msg) { + return { timestamp: null, link: null }; + } + return { + timestamp: msg.timestamp, + link: `https://discord.com/channels/${discordGuildId}/${msg.channel_id}/${msg.id}`, + }; + } catch (error) { + process.stderr.write(` ⚠ Discord API error: ${error}\n`); + return { timestamp: null, link: null }; + } +} + +async function checkGithubActivity(username: string, orgId: string): Promise { + try { + const contribResult = await githubGraphQL<{ + user: { + contributionsCollection: { + hasAnyContributions: boolean; + pullRequestContributions: { nodes: Array<{ occurredAt: string; pullRequest: { url: string } }> }; + issueContributions: { nodes: Array<{ occurredAt: string; issue: { url: string } }> }; + pullRequestReviewContributions: { nodes: Array<{ occurredAt: string; pullRequest: { url: string } }> }; + commitContributionsByRepository: Array<{ repository: { nameWithOwner: string } }>; + }; + } | null; + }>( + `query($login: String!, $from: DateTime!, $organizationID: ID!) { + user(login: $login) { + contributionsCollection(from: $from, organizationID: $organizationID) { + hasAnyContributions + pullRequestContributions(first: 1) { + nodes { occurredAt pullRequest { url } } + } + issueContributions(first: 1) { + nodes { occurredAt issue { url } } + } + pullRequestReviewContributions(first: 1) { + nodes { occurredAt pullRequest { url } } + } + commitContributionsByRepository(maxRepositories: 1) { + repository { nameWithOwner } + } + } + } + }`, + { login: username, from: cutoffDate.toISOString(), organizationID: orgId }, + ); + + const cc = contribResult.user?.contributionsCollection; + if (cc?.hasAnyContributions) { + const prNode = cc.pullRequestContributions.nodes[0]; + const issueNode = cc.issueContributions.nodes[0]; + const reviewNode = cc.pullRequestReviewContributions.nodes[0]; + const repoWithOwner = cc.commitContributionsByRepository[0]?.repository.nameWithOwner; + const date = (prNode ?? issueNode ?? reviewNode)?.occurredAt ?? null; + const link = + prNode?.pullRequest.url ?? + issueNode?.issue.url ?? + reviewNode?.pullRequest.url ?? + (repoWithOwner ? `https://github.com/${repoWithOwner}/commits?author=${username}` : null); + return { active: true, date, link }; + } + + // contributionsCollection doesn't include issue/PR comments or discussion comments. + // Paginate the user's own comments sorted by updatedAt DESC; stop as soon as a comment's + // updatedAt falls before the cutoff (guaranteed safe due to ordering). + { + type IssueCommentNode = { + createdAt: string; + updatedAt: string; + url: string; + repository: { owner: { login: string } }; + }; + let cursor: string | undefined; + let page = 0; + issueCommentLoop: while (true) { + page++; + if (page > 1) { + process.stderr.write( + ` [github] ↻ ${username}: scanning issue comments (page ${page}, ${(page - 1) * 100} scanned)...\n`, + ); + } + const result = await githubGraphQL<{ + user: { + issueComments: { pageInfo: { hasNextPage: boolean; endCursor: string }; nodes: IssueCommentNode[] }; + } | null; + }>( + `query($login: String!, $after: String) { + user(login: $login) { + issueComments(first: 100, orderBy: {field: UPDATED_AT, direction: DESC}, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { createdAt updatedAt url repository { owner { login } } } + } + } + }`, + { login: username, after: cursor }, + ); + const conn = result.user?.issueComments; + if (!conn?.nodes.length) { + break; + } + for (const c of conn.nodes) { + if (new Date(c.updatedAt) < cutoffDate) { + break issueCommentLoop; + } + if (c.repository.owner.login === githubOrg && new Date(c.createdAt) > cutoffDate) { + return { active: true, date: c.createdAt, link: c.url }; + } + } + if (!conn.pageInfo.hasNextPage) { + break; + } + cursor = conn.pageInfo.endCursor; + } + } + + // Discussion comments have no orderBy, so fetch the most recent 100 (last: 100) and check. + { + type DiscussionCommentNode = { + createdAt: string; + url: string; + discussion: { repository: { owner: { login: string } } }; + }; + const result = await githubGraphQL<{ + user: { repositoryDiscussionComments: { nodes: DiscussionCommentNode[] } } | null; + }>( + `query($login: String!) { + user(login: $login) { + repositoryDiscussionComments(last: 100) { + nodes { createdAt url discussion { repository { owner { login } } } } + } + } + }`, + { login: username }, + ); + for (const c of result.user?.repositoryDiscussionComments.nodes ?? []) { + if (c.discussion.repository.owner.login === githubOrg && new Date(c.createdAt) > cutoffDate) { + return { active: true, date: c.createdAt, link: c.url }; + } + } + } + + return { active: false, date: null, link: null }; + } catch (error) { + process.stderr.write(` ⚠ GitHub API error for ${username}: ${error}\n`); + return { active: false, date: null, link: null }; + } +} + +async function main() { + console.log(`Checking activity since ${cutoffDateStr} (${activityCutoffDays} days) in org ${githubOrg}\n`); + + const orgId = await getGithubOrgId(githubOrg); + const targetUsers = users.filter((u) => u.roles.some((r) => TARGET_ROLES.has(r))); + const githubCount = targetUsers.filter((u) => u.github.username).length; + const discordCount = targetUsers.filter((u) => u.discord.id).length; + + // GitHub: all checks in parallel + process.stderr.write( + `Querying GitHub (${githubCount} users, parallel) and Discord (${discordCount} users, 1s delay)...\n`, + ); + const githubPromises = targetUsers.map(async (user) => { + if (!user.github.username) { + return { active: false, date: null, link: null, skipped: true }; + } + const { active, date, link } = await checkGithubActivity(user.github.username, orgId); + return { active, date, link, skipped: false }; + }); + + // Discord: sequential with ~1s delay to respect rate limits + const discordData: Array = []; + for (const [i, user] of targetUsers.entries()) { + if (i > 0) { + await sleep(1000); + } + const name = user.github.username || user.discord.username; + process.stderr.write(` [${String(i + 1).padStart(2)}/${targetUsers.length}] ${name}\n`); + if (!user.discord.id) { + discordData.push({ timestamp: null, link: null, skipped: true }); + } else { + const result = await checkDiscordActivity(user.discord.id); + discordData.push({ ...result, skipped: false }); + } + } + + const githubData = await Promise.all(githubPromises); + process.stderr.write('\n'); + + const results: UserResult[] = targetUsers.map((user, i) => { + const discord = discordData[i]; + const github = githubData[i]; + const discordActive = !discord.skipped && discord.timestamp !== null && new Date(discord.timestamp) > cutoffDate; + const active = discordActive || (!github.skipped && github.active); + return { + user, + discordTimestamp: discord.timestamp, + discordLink: discord.link, + discordSkipped: discord.skipped, + githubActive: github.active, + githubDate: github.date, + githubLink: github.link, + githubSkipped: github.skipped, + active, + }; + }); + + for (const result of results) { + result.user.active = result.active; + } + + const formatDiscord = (r: UserResult): string => { + if (r.discordSkipped) { + return '—'; + } + if (r.discordTimestamp === null) { + return 'inactive (not found in window)'; + } + const date = r.discordTimestamp.slice(0, 10); + const details = [date, r.discordLink].filter(Boolean).join(', '); + const status = new Date(r.discordTimestamp) > cutoffDate ? 'active' : 'inactive'; + return `${status} (${details})`; + }; + + const formatGithub = (r: UserResult): string => { + if (r.githubSkipped) { + return '—'; + } + if (r.githubActive) { + const details = [r.githubDate?.slice(0, 10), r.githubLink].filter(Boolean).join(', '); + return details ? `active (${details})` : 'active'; + } + return 'inactive (not found in window)'; + }; + + const printRow = (r: UserResult) => { + const name = r.user.github.username || r.user.discord.username; + const role = r.user.roles.find((rl) => TARGET_ROLES.has(rl)) ?? r.user.roles[0]; + console.log(` ${role.padEnd(12)} ${name}`); + console.log(` Discord: ${formatDiscord(r)}`); + console.log(` GitHub: ${formatGithub(r)}`); + }; + + const activeResults = results.filter((r) => r.active); + const inactiveResults = results.filter((r) => !r.active); + + console.log(`ACTIVE (${activeResults.length}):`); + for (const r of activeResults) { + printRow(r); + } + + console.log(`\nINACTIVE (${inactiveResults.length}):`); + for (const r of inactiveResults) { + printRow(r); + } + + writeFileSync(usersJsonPath, JSON64.stringify(users, null, 2) + '\n'); + console.log('\nUpdated users.json written.'); +} + +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); From ee606228505fdc789c8464a8df96dfb7eb4a42b1 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 6 Mar 2026 09:44:28 +0100 Subject: [PATCH 2/3] chore: remove github write perms from inactive users --- tf/deployment/data/users.json | 123 ++++++++++++------ .../modules/shared/github/org/users.tf | 1 + 2 files changed, 83 insertions(+), 41 deletions(-) diff --git a/tf/deployment/data/users.json b/tf/deployment/data/users.json index 496929bd..6ca81f39 100644 --- a/tf/deployment/data/users.json +++ b/tf/deployment/data/users.json @@ -216,7 +216,8 @@ }, "roles": [ "contributor" - ] + ], + "active": false }, { "github": { @@ -229,7 +230,8 @@ }, "roles": [ "contributor" - ] + ], + "active": true }, { "github": { @@ -242,7 +244,8 @@ }, "roles": [ "contributor" - ] + ], + "active": true }, { "github": { @@ -255,7 +258,8 @@ }, "roles": [ "contributor" - ] + ], + "active": true }, { "github": { @@ -268,7 +272,8 @@ }, "roles": [ "contributor" - ] + ], + "active": false }, { "github": { @@ -281,7 +286,8 @@ }, "roles": [ "contributor" - ] + ], + "active": true }, { "github": { @@ -294,7 +300,8 @@ }, "roles": [ "contributor" - ] + ], + "active": true }, { "github": { @@ -307,7 +314,8 @@ }, "roles": [ "contributor" - ] + ], + "active": false }, { "github": { @@ -320,7 +328,8 @@ }, "roles": [ "contributor" - ] + ], + "active": false }, { "github": { @@ -333,7 +342,8 @@ }, "roles": [ "contributor" - ] + ], + "active": false }, { "github": { @@ -346,7 +356,8 @@ }, "roles": [ "contributor" - ] + ], + "active": false }, { "github": { @@ -359,7 +370,8 @@ }, "roles": [ "contributor" - ] + ], + "active": false }, { "github": { @@ -372,7 +384,8 @@ }, "roles": [ "contributor" - ] + ], + "active": true }, { "github": { @@ -385,7 +398,8 @@ }, "roles": [ "contributor" - ] + ], + "active": true }, { "github": { @@ -398,7 +412,8 @@ }, "roles": [ "contributor" - ] + ], + "active": false }, { "github": { @@ -411,7 +426,8 @@ }, "roles": [ "contributor" - ] + ], + "active": false }, { "github": { @@ -424,7 +440,8 @@ }, "roles": [ "contributor" - ] + ], + "active": false }, { "github": { @@ -437,7 +454,8 @@ }, "roles": [ "contributor" - ] + ], + "active": true }, { "github": { @@ -450,7 +468,8 @@ }, "roles": [ "contributor" - ] + ], + "active": false }, { "github": { @@ -463,7 +482,8 @@ }, "roles": [ "contributor" - ] + ], + "active": true }, { "github": { @@ -476,7 +496,8 @@ }, "roles": [ "contributor" - ] + ], + "active": true }, { "github": { @@ -489,7 +510,8 @@ }, "roles": [ "contributor" - ] + ], + "active": true }, { "github": { @@ -502,7 +524,8 @@ }, "roles": [ "contributor" - ] + ], + "active": true }, { "github": { @@ -515,7 +538,8 @@ }, "roles": [ "contributor" - ] + ], + "active": true }, { "github": { @@ -528,7 +552,8 @@ }, "roles": [ "contributor" - ] + ], + "active": true }, { "github": { @@ -541,7 +566,8 @@ }, "roles": [ "support" - ] + ], + "active": true }, { "github": { @@ -554,7 +580,8 @@ }, "roles": [ "support" - ] + ], + "active": true }, { "github": { @@ -567,7 +594,8 @@ }, "roles": [ "support" - ] + ], + "active": false }, { "github": { @@ -580,7 +608,8 @@ }, "roles": [ "support" - ] + ], + "active": false }, { "github": { @@ -593,7 +622,8 @@ }, "roles": [ "support" - ] + ], + "active": true }, { "github": { @@ -606,7 +636,8 @@ }, "roles": [ "support" - ] + ], + "active": true }, { "github": { @@ -619,7 +650,8 @@ }, "roles": [ "support" - ] + ], + "active": true }, { "github": { @@ -632,7 +664,8 @@ }, "roles": [ "support" - ] + ], + "active": true }, { "github": { @@ -645,7 +678,8 @@ }, "roles": [ "support" - ] + ], + "active": true }, { "github": { @@ -658,7 +692,8 @@ }, "roles": [ "support" - ] + ], + "active": true }, { "github": { @@ -671,7 +706,8 @@ }, "roles": [ "support" - ] + ], + "active": true }, { "github": { @@ -684,7 +720,8 @@ }, "roles": [ "support" - ] + ], + "active": true }, { "github": { @@ -697,7 +734,8 @@ }, "roles": [ "support" - ] + ], + "active": true }, { "github": { @@ -710,7 +748,8 @@ }, "roles": [ "support" - ] + ], + "active": true }, { "github": { @@ -723,7 +762,8 @@ }, "roles": [ "support" - ] + ], + "active": true }, { "github": { @@ -736,6 +776,7 @@ }, "roles": [ "support" - ] + ], + "active": true } ] diff --git a/tf/deployment/modules/shared/github/org/users.tf b/tf/deployment/modules/shared/github/org/users.tf index e8b9e608..049aeba6 100644 --- a/tf/deployment/modules/shared/github/org/users.tf +++ b/tf/deployment/modules/shared/github/org/users.tf @@ -12,6 +12,7 @@ locals { (contains(user.roles, "support") ? "triage" : null) ) if length(setintersection(toset(user.roles), toset(["contributor", "support", "futo"]))) > 0 + && try(user.active, true) != false } bots = { From 876510a6d7dd67c894ee53ed11aaa79fbfaf16ac Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 6 Mar 2026 09:56:31 +0100 Subject: [PATCH 3/3] chore: fmt --- tf/deployment/modules/shared/github/org/users.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tf/deployment/modules/shared/github/org/users.tf b/tf/deployment/modules/shared/github/org/users.tf index 049aeba6..157887cd 100644 --- a/tf/deployment/modules/shared/github/org/users.tf +++ b/tf/deployment/modules/shared/github/org/users.tf @@ -12,7 +12,7 @@ locals { (contains(user.roles, "support") ? "triage" : null) ) if length(setintersection(toset(user.roles), toset(["contributor", "support", "futo"]))) > 0 - && try(user.active, true) != false + && try(user.active, true) != false } bots = {