From 836dcee06fccaa80a56e78276f2231de2b9082d2 Mon Sep 17 00:00:00 2001 From: coji Date: Wed, 18 Mar 2026 23:59:18 +0900 Subject: [PATCH 01/11] feat: migrate excludedUsers to companyGithubUsers.type (#146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit excludedUsersをorganizationSettingsのカンマ区切りテキストから companyGithubUsers.type='Bot'に統合。batch処理のfilterActors()が DBのBot typeを直接参照するようになり、設定UIのexcludedUsersフィールドを削除。 - マイグレーションで既存excludedUsers値をcompanyGithubUsers.type='Bot'に移行 - DEFAULT_EXCLUDED_USERSハードコードを削除し、DBベースのBot管理に統一 - well-known bot(copilot, dependabot, renovate等)を新規登録時に自動Bot設定 - BuildConfig.excludedUsers → botLogins: Setに変更(case-insensitive) - getBotLoginsをgetTenantDataに統合し並行クエリ化 Closes #146 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../_index/+forms/organization-settings.tsx | 17 --- .../_index/+functions/mutations.server.ts | 1 - .../$orgSlug/settings/_index/+schema.ts | 1 - app/routes/$orgSlug/settings/_index/index.tsx | 2 - .../repositories/$repository/$pull/index.tsx | 10 +- .../$repository/$pull/queries.server.ts | 2 +- app/services/jobs/analyze-worker.ts | 4 +- app/services/jobs/crawl.server.ts | 1 + app/services/jobs/recalculate.server.ts | 1 + app/services/jobs/run-in-worker.ts | 2 +- app/services/jobs/shared-steps.server.ts | 5 +- app/services/tenant-type.ts | 1 - batch/db/mutations.ts | 15 +++ batch/db/queries.ts | 102 ++++++++++-------- .../buildPullRequests-filter.test.ts | 2 +- batch/github/pullrequest.ts | 20 +--- ...000_migrate_excluded_users_to_bot_type.sql | 20 ++++ db/migrations/tenant/atlas.sum | 3 +- db/tenant.sql | 1 - 19 files changed, 119 insertions(+), 91 deletions(-) create mode 100644 db/migrations/tenant/20260318120000_migrate_excluded_users_to_bot_type.sql diff --git a/app/routes/$orgSlug/settings/_index/+forms/organization-settings.tsx b/app/routes/$orgSlug/settings/_index/+forms/organization-settings.tsx index ee991f56..fefd7910 100644 --- a/app/routes/$orgSlug/settings/_index/+forms/organization-settings.tsx +++ b/app/routes/$orgSlug/settings/_index/+forms/organization-settings.tsx @@ -40,7 +40,6 @@ export const OrganizationSettings = ({ releaseDetectionMethod: string releaseDetectionKey: string isActive: number - excludedUsers: string timezone: string language: string } @@ -60,7 +59,6 @@ export const OrganizationSettings = ({ releaseDetectionMethod: organizationSetting?.releaseDetectionMethod, releaseDetectionKey: organizationSetting?.releaseDetectionKey, isActive: organizationSetting?.isActive ? '1' : undefined, - excludedUsers: organizationSetting?.excludedUsers, timezone: organizationSetting?.timezone ?? DEFAULT_TIMEZONE, language: organizationSetting?.language, }, @@ -173,21 +171,6 @@ export const OrganizationSettings = ({
{fields.isActive.errors}
-
- - -

- Copilot is excluded by default. Add additional usernames to exclude - from cycle time calculations. -

-
{fields.excludedUsers.errors}
-
- {form.errors && ( System Error diff --git a/app/routes/$orgSlug/settings/_index/+functions/mutations.server.ts b/app/routes/$orgSlug/settings/_index/+functions/mutations.server.ts index fad98b39..d2a491b4 100644 --- a/app/routes/$orgSlug/settings/_index/+functions/mutations.server.ts +++ b/app/routes/$orgSlug/settings/_index/+functions/mutations.server.ts @@ -26,7 +26,6 @@ export const updateOrganizationSetting = async ( | 'releaseDetectionMethod' | 'releaseDetectionKey' | 'isActive' - | 'excludedUsers' | 'timezone' | 'language' >, diff --git a/app/routes/$orgSlug/settings/_index/+schema.ts b/app/routes/$orgSlug/settings/_index/+schema.ts index d5851804..cba368e4 100644 --- a/app/routes/$orgSlug/settings/_index/+schema.ts +++ b/app/routes/$orgSlug/settings/_index/+schema.ts @@ -11,7 +11,6 @@ export const organizationSettingsSchema = z.object({ .literal('on') .optional() .transform((val) => (val === 'on' ? 1 : 0)), - excludedUsers: z.string().max(2000).default(''), timezone: z.string().refine((v) => VALID_TIMEZONES.has(v), { message: 'Invalid timezone', }), diff --git a/app/routes/$orgSlug/settings/_index/index.tsx b/app/routes/$orgSlug/settings/_index/index.tsx index f5a5cb56..e75da0e5 100644 --- a/app/routes/$orgSlug/settings/_index/index.tsx +++ b/app/routes/$orgSlug/settings/_index/index.tsx @@ -44,7 +44,6 @@ export const action = async ({ request, context }: Route.ActionArgs) => { releaseDetectionMethod, releaseDetectionKey, isActive, - excludedUsers, timezone, language, } = submission.value @@ -55,7 +54,6 @@ export const action = async ({ request, context }: Route.ActionArgs) => { releaseDetectionMethod, releaseDetectionKey, isActive, - excludedUsers, timezone, language, }) diff --git a/app/routes/$orgSlug/settings/repositories/$repository/$pull/index.tsx b/app/routes/$orgSlug/settings/repositories/$repository/$pull/index.tsx index 7acad11d..e89c2b92 100644 --- a/app/routes/$orgSlug/settings/repositories/$repository/$pull/index.tsx +++ b/app/routes/$orgSlug/settings/repositories/$repository/$pull/index.tsx @@ -11,6 +11,7 @@ import { upsertPullRequestReview, upsertPullRequestReviewers, } from '~/batch/db/mutations' +import { getBotLogins } from '~/batch/db/queries' import { createFetcher } from '~/batch/github/fetcher' import type { ShapedGitHubPullRequest } from '~/batch/github/model' import { buildPullRequests } from '~/batch/github/pullrequest' @@ -161,15 +162,18 @@ export const action = async ({ timelineItems, }) - // 4. Get organization settings for build config - const settings = await getOrganizationSettings(organization.id) + // 4. Get organization settings and bot logins for build config + const [settings, botLogins] = await Promise.all([ + getOrganizationSettings(organization.id), + getBotLogins(organization.id), + ]) // 5. Build pull request data (analyze) const result = await buildPullRequests( { organizationId: organization.id, repositoryId, - excludedUsers: settings?.excludedUsers ?? '', + botLogins, releaseDetectionMethod: settings?.releaseDetectionMethod ?? 'branch', releaseDetectionKey: settings?.releaseDetectionKey ?? '', }, diff --git a/app/routes/$orgSlug/settings/repositories/$repository/$pull/queries.server.ts b/app/routes/$orgSlug/settings/repositories/$repository/$pull/queries.server.ts index 87569087..78747c6b 100644 --- a/app/routes/$orgSlug/settings/repositories/$repository/$pull/queries.server.ts +++ b/app/routes/$orgSlug/settings/repositories/$repository/$pull/queries.server.ts @@ -59,7 +59,7 @@ export const getOrganizationSettings = async ( const tenantDb = getTenantDb(organizationId) return await tenantDb .selectFrom('organizationSettings') - .select(['releaseDetectionMethod', 'releaseDetectionKey', 'excludedUsers']) + .select(['releaseDetectionMethod', 'releaseDetectionKey']) .executeTakeFirst() } diff --git a/app/services/jobs/analyze-worker.ts b/app/services/jobs/analyze-worker.ts index 60e2cd5e..9186925d 100644 --- a/app/services/jobs/analyze-worker.ts +++ b/app/services/jobs/analyze-worker.ts @@ -17,7 +17,7 @@ interface WorkerInput { repositoryId: string releaseDetectionMethod: string releaseDetectionKey: string - excludedUsers: string + botLogins: string[] filterPrNumbers?: number[] } @@ -47,7 +47,7 @@ const result: Awaited> = repositoryId: input.repositoryId, releaseDetectionMethod: input.releaseDetectionMethod, releaseDetectionKey: input.releaseDetectionKey, - excludedUsers: input.excludedUsers, + botLogins: new Set(input.botLogins), }, await store.loader.pullrequests(), store.loader, diff --git a/app/services/jobs/crawl.server.ts b/app/services/jobs/crawl.server.ts index 993a7857..8284342f 100644 --- a/app/services/jobs/crawl.server.ts +++ b/app/services/jobs/crawl.server.ts @@ -34,6 +34,7 @@ export const crawlJob = defineJob({ } return { organizationSetting: org.organizationSetting, + botLogins: org.botLogins, repositories: org.repositories, exportSetting: org.exportSetting, } diff --git a/app/services/jobs/recalculate.server.ts b/app/services/jobs/recalculate.server.ts index d0d11478..1cface71 100644 --- a/app/services/jobs/recalculate.server.ts +++ b/app/services/jobs/recalculate.server.ts @@ -31,6 +31,7 @@ export const recalculateJob = defineJob({ } return { organizationSetting: org.organizationSetting, + botLogins: org.botLogins, repositories: org.repositories, exportSetting: org.exportSetting, } diff --git a/app/services/jobs/run-in-worker.ts b/app/services/jobs/run-in-worker.ts index a4c46388..6d9559ac 100644 --- a/app/services/jobs/run-in-worker.ts +++ b/app/services/jobs/run-in-worker.ts @@ -7,7 +7,7 @@ interface AnalyzeWorkerInput { repositoryId: string releaseDetectionMethod: string releaseDetectionKey: string - excludedUsers: string + botLogins: string[] filterPrNumbers?: number[] } diff --git a/app/services/jobs/shared-steps.server.ts b/app/services/jobs/shared-steps.server.ts index 5f687dc2..23a49cb5 100644 --- a/app/services/jobs/shared-steps.server.ts +++ b/app/services/jobs/shared-steps.server.ts @@ -30,8 +30,9 @@ interface AnalyzeResult { interface OrganizationData { organizationSetting: Pick< Selectable, - 'releaseDetectionMethod' | 'releaseDetectionKey' | 'excludedUsers' + 'releaseDetectionMethod' | 'releaseDetectionKey' > + botLogins: string[] repositories: Selectable[] exportSetting?: Selectable | null } @@ -127,7 +128,7 @@ export async function analyzeAndFinalizeSteps( repo.releaseDetectionMethod ?? orgSetting.releaseDetectionMethod, releaseDetectionKey: repo.releaseDetectionKey ?? orgSetting.releaseDetectionKey, - excludedUsers: orgSetting.excludedUsers, + botLogins: organization.botLogins, filterPrNumbers: prNumbers ? [...prNumbers] : undefined, }, { diff --git a/app/services/tenant-type.ts b/app/services/tenant-type.ts index 4f072942..936311d8 100644 --- a/app/services/tenant-type.ts +++ b/app/services/tenant-type.ts @@ -70,7 +70,6 @@ export interface Integrations { export interface OrganizationSettings { createdAt: Generated; - excludedUsers: Generated; id: string; isActive: Generated<0 | 1>; language: Generated<"en" | "ja">; diff --git a/batch/db/mutations.ts b/batch/db/mutations.ts index b3acd3e6..519e84cb 100644 --- a/batch/db/mutations.ts +++ b/batch/db/mutations.ts @@ -5,6 +5,20 @@ import type { OrganizationId } from '~/app/types/organization' import type { AnalyzedReview, AnalyzedReviewer } from '../github/types' import { logger } from '../helper/logger' +/** GitHub API で [bot] 接尾辞なしで記録される well-known bot (lowercase) */ +const KNOWN_BOTS = new Set([ + 'copilot', + 'copilot-pull-request-reviewer', + 'copilot-swe-agent', + 'github-actions', + 'dependabot', + 'renovate', + 'coderabbitai', + 'devin-ai-integration', + 'chatgpt-codex-connector', + 'claude', +]) + export function upsertPullRequest( organizationId: OrganizationId, data: Insertable, @@ -240,6 +254,7 @@ export async function upsertCompanyGithubUsers( uniqueLogins.map((login) => ({ login, displayName: login, + type: KNOWN_BOTS.has(login) ? 'Bot' : null, isActive: 0, updatedAt: now, })), diff --git a/batch/db/queries.ts b/batch/db/queries.ts index b0452661..b9f1fb0b 100644 --- a/batch/db/queries.ts +++ b/batch/db/queries.ts @@ -14,56 +14,74 @@ export const getPullRequestReport = async (organizationId: OrganizationId) => { async function getTenantData(organizationId: OrganizationId) { const tenantDb = getTenantDb(organizationId) - const [organizationSetting, integration, repositories, exportSetting] = - await Promise.all([ - tenantDb - .selectFrom('organizationSettings') - .select([ - 'releaseDetectionMethod', - 'releaseDetectionKey', - 'isActive', - 'excludedUsers', - ]) - .executeTakeFirst(), - tenantDb - .selectFrom('integrations') - .select(['id', 'method', 'provider', 'privateToken']) - .executeTakeFirst(), - tenantDb - .selectFrom('repositories') - .select([ - 'id', - 'repo', - 'owner', - 'integrationId', - 'provider', - 'releaseDetectionKey', - 'releaseDetectionMethod', - 'teamId', - 'updatedAt', - 'createdAt', - ]) - .execute(), - tenantDb - .selectFrom('exportSettings') - .select([ - 'id', - 'sheetId', - 'clientEmail', - 'privateKey', - 'updatedAt', - 'createdAt', - ]) - .executeTakeFirst(), - ]) + const [ + organizationSetting, + integration, + repositories, + exportSetting, + botLoginRows, + ] = await Promise.all([ + tenantDb + .selectFrom('organizationSettings') + .select(['releaseDetectionMethod', 'releaseDetectionKey', 'isActive']) + .executeTakeFirst(), + tenantDb + .selectFrom('integrations') + .select(['id', 'method', 'provider', 'privateToken']) + .executeTakeFirst(), + tenantDb + .selectFrom('repositories') + .select([ + 'id', + 'repo', + 'owner', + 'integrationId', + 'provider', + 'releaseDetectionKey', + 'releaseDetectionMethod', + 'teamId', + 'updatedAt', + 'createdAt', + ]) + .execute(), + tenantDb + .selectFrom('exportSettings') + .select([ + 'id', + 'sheetId', + 'clientEmail', + 'privateKey', + 'updatedAt', + 'createdAt', + ]) + .executeTakeFirst(), + tenantDb + .selectFrom('companyGithubUsers') + .select('login') + .where('type', '=', 'Bot') + .execute(), + ]) return { organizationSetting: organizationSetting ?? null, integration: integration ?? null, repositories, exportSetting: exportSetting ?? null, + botLogins: botLoginRows.map((r) => r.login), } } +export async function getBotLogins( + organizationId: OrganizationId, +): Promise> { + const tenantDb = getTenantDb(organizationId) + const rows = await tenantDb + .selectFrom('companyGithubUsers') + .select('login') + .where('type', '=', 'Bot') + .execute() + return new Set(rows.map((r) => r.login)) +} + export const listAllOrganizations = async () => { const orgs = await db .selectFrom('organizations') diff --git a/batch/github/__tests__/buildPullRequests-filter.test.ts b/batch/github/__tests__/buildPullRequests-filter.test.ts index 358ce490..014e45b4 100644 --- a/batch/github/__tests__/buildPullRequests-filter.test.ts +++ b/batch/github/__tests__/buildPullRequests-filter.test.ts @@ -206,7 +206,7 @@ const config = { repositoryId: 'repo-1', releaseDetectionMethod: 'branch', releaseDetectionKey: 'main', - excludedUsers: '', + botLogins: new Set(), } // --- テスト --- diff --git a/batch/github/pullrequest.ts b/batch/github/pullrequest.ts index 5a07a6e4..9c419c08 100644 --- a/batch/github/pullrequest.ts +++ b/batch/github/pullrequest.ts @@ -31,9 +31,6 @@ import type { PullRequestLoaders, } from './types' -// デフォルトで除外するユーザー (GitHub API で [bot] 接尾辞なしで記録されるbot) -const DEFAULT_EXCLUDED_USERS = ['Copilot'] - /** PR に関連するアーティファクトの型 */ interface PrArtifacts { commits: ShapedGitHubCommit[] @@ -55,7 +52,7 @@ interface BuildConfig { repositoryId: string releaseDetectionMethod: string releaseDetectionKey: string - excludedUsers: string + botLogins: Set } const nullOrDate = (dateStr?: Date | string | null) => { @@ -82,7 +79,7 @@ async function loadPrArtifacts( function filterActors( artifacts: PrArtifacts, pr: ShapedGitHubPullRequest, - excludedUsers: string[], + botLogins: Set, ): PrArtifacts { return { commits: artifacts.commits, @@ -90,13 +87,13 @@ function filterActors( (r) => !r.isBot && r.user !== pr.author && - !excludedUsers.includes(r.user ?? ''), + !botLogins.has((r.user ?? '').toLowerCase()), ), discussions: artifacts.discussions.filter( (d) => !d.isBot && d.user !== pr.author && - !excludedUsers.includes(d.user ?? ''), + !botLogins.has((d.user ?? '').toLowerCase()), ), } } @@ -188,13 +185,6 @@ export const buildPullRequests = async ( loaders: PullRequestLoaders, filterPrNumbers?: Set, ) => { - // カンマ区切りの除外ユーザーリストをパース - const customExcludedUsers = config.excludedUsers - .split(',') - .map((u) => u.trim()) - .filter((u) => u.length > 0) - const excludedUsers = [...DEFAULT_EXCLUDED_USERS, ...customExcludedUsers] - // リリース日ルックアップを事前構築 // 注: filterPrNumbers に関係なく全 PR から構築する(リリースPR自体がフィルタ外でも必要) let branchReleaseLookup: Map | null = null @@ -234,7 +224,7 @@ export const buildPullRequests = async ( const rawArtifacts = await loadPrArtifacts(pr, loaders) // 2. アクター除外フィルタ(純粋関数) - const artifacts = filterActors(rawArtifacts, pr, excludedUsers) + const artifacts = filterActors(rawArtifacts, pr, config.botLogins) // 3. レビューレスポンス解析 reviewResponses.push( diff --git a/db/migrations/tenant/20260318120000_migrate_excluded_users_to_bot_type.sql b/db/migrations/tenant/20260318120000_migrate_excluded_users_to_bot_type.sql new file mode 100644 index 00000000..bc7f8250 --- /dev/null +++ b/db/migrations/tenant/20260318120000_migrate_excluded_users_to_bot_type.sql @@ -0,0 +1,20 @@ +-- Migrate excludedUsers setting to companyGithubUsers.type='Bot' +-- 1. Copy excluded users from organization_settings to company_github_users with type='Bot' +INSERT INTO company_github_users (login, display_name, type, is_active, updated_at) +SELECT + LOWER(TRIM(value)) AS login, + LOWER(TRIM(value)) AS display_name, + 'Bot' AS type, + 0 AS is_active, + CURRENT_TIMESTAMP AS updated_at +FROM organization_settings, json_each('["' || REPLACE(excluded_users, ',', '","') || '"]') +WHERE TRIM(value) != '' +ON CONFLICT (login) DO UPDATE SET type = 'Bot'; + +-- 2. Ensure 'copilot' is registered as Bot (case-insensitive - stored lowercase) +INSERT INTO company_github_users (login, display_name, type, is_active, updated_at) +VALUES ('copilot', 'copilot', 'Bot', 0, CURRENT_TIMESTAMP) +ON CONFLICT (login) DO UPDATE SET type = 'Bot'; + +-- 3. Drop excluded_users column from organization_settings +ALTER TABLE organization_settings DROP COLUMN excluded_users; diff --git a/db/migrations/tenant/atlas.sum b/db/migrations/tenant/atlas.sum index 6d774d25..7c04ddf8 100644 --- a/db/migrations/tenant/atlas.sum +++ b/db/migrations/tenant/atlas.sum @@ -1,4 +1,4 @@ -h1:57BFTZmY6MHNsyxYn2I1qlUPbtDkdrAtNqeUx2hu+Sg= +h1:8Wqr+KxBQLw5r0KA2RcBXrq75r9mucAyprneQ+rJbpw= 20260226112249_initial_tenant.sql h1:dIhBg2gzyh+ZjLzPXdHYafd5e62yIEjk1eFlllEyYX0= 20260226233619_add_teams.sql h1:n8MRMUA4BgeXYEnL9HJPc8mnXh8lqIfrCcdYtFFoWqw= 20260227163239.sql h1:ENMZUW7zHK8UjG2TdYlBOZSVPPUCXftIw5U5k2C54oo= @@ -18,3 +18,4 @@ h1:57BFTZmY6MHNsyxYn2I1qlUPbtDkdrAtNqeUx2hu+Sg= 20260315050936.sql h1:/o/ku2qrlT14mxxhETs26eHguobD5wPYES2khLSN2wA= 20260315120000_add_language.sql h1:O1oFQ+aUAI9+uGdIuhjEV9bM8ImXKMMwRQAw3vYhcVM= 20260317120000_drop_refresh_requested_at.sql h1:R4jHtMkCpdY09orFA4RPvtLeUJ2Z7S4WPMmg/bDJuGg= +20260318120000_migrate_excluded_users_to_bot_type.sql h1:IcCA2pNEjwD+RDZainibPEv3bOYTrYHn1vG6/mv0X9s= diff --git a/db/tenant.sql b/db/tenant.sql index 84404135..50f5452b 100644 --- a/db/tenant.sql +++ b/db/tenant.sql @@ -4,7 +4,6 @@ CREATE TABLE `organization_settings` ( `release_detection_method` text NOT NULL DEFAULT 'branch', `release_detection_key` text NOT NULL DEFAULT 'production', `is_active` boolean NOT NULL DEFAULT true, - `excluded_users` text NOT NULL DEFAULT '', `timezone` text NOT NULL DEFAULT 'Asia/Tokyo', `updated_at` datetime NOT NULL, `created_at` datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), From 3059344f4fd5a3d3d9fe3356e5582f2c9d60c862 Mon Sep 17 00:00:00 2001 From: coji Date: Thu, 19 Mar 2026 00:15:21 +0900 Subject: [PATCH 02/11] fix: remove ambiguous bot names and harden migration - Remove 'claude' and 'chatgpt-codex-connector' from KNOWN_BOTS (human name collision risk) - Add COALESCE and empty-string guard to migration SQL for robustness Co-Authored-By: Claude Opus 4.6 (1M context) --- batch/db/mutations.ts | 2 -- .../20260318120000_migrate_excluded_users_to_bot_type.sql | 5 +++-- db/migrations/tenant/atlas.sum | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/batch/db/mutations.ts b/batch/db/mutations.ts index 519e84cb..b3dc9486 100644 --- a/batch/db/mutations.ts +++ b/batch/db/mutations.ts @@ -15,8 +15,6 @@ const KNOWN_BOTS = new Set([ 'renovate', 'coderabbitai', 'devin-ai-integration', - 'chatgpt-codex-connector', - 'claude', ]) export function upsertPullRequest( diff --git a/db/migrations/tenant/20260318120000_migrate_excluded_users_to_bot_type.sql b/db/migrations/tenant/20260318120000_migrate_excluded_users_to_bot_type.sql index bc7f8250..18a3c5f7 100644 --- a/db/migrations/tenant/20260318120000_migrate_excluded_users_to_bot_type.sql +++ b/db/migrations/tenant/20260318120000_migrate_excluded_users_to_bot_type.sql @@ -7,8 +7,9 @@ SELECT 'Bot' AS type, 0 AS is_active, CURRENT_TIMESTAMP AS updated_at -FROM organization_settings, json_each('["' || REPLACE(excluded_users, ',', '","') || '"]') -WHERE TRIM(value) != '' +FROM organization_settings, json_each('["' || REPLACE(COALESCE(excluded_users, ''), ',', '","') || '"]') +WHERE COALESCE(excluded_users, '') != '' + AND TRIM(value) != '' ON CONFLICT (login) DO UPDATE SET type = 'Bot'; -- 2. Ensure 'copilot' is registered as Bot (case-insensitive - stored lowercase) diff --git a/db/migrations/tenant/atlas.sum b/db/migrations/tenant/atlas.sum index 7c04ddf8..a7bdf39a 100644 --- a/db/migrations/tenant/atlas.sum +++ b/db/migrations/tenant/atlas.sum @@ -1,4 +1,4 @@ -h1:8Wqr+KxBQLw5r0KA2RcBXrq75r9mucAyprneQ+rJbpw= +h1:BLFRY6WoYqFErpRx/O0lVGzSkVCMZZpC8KSiwUgsjfo= 20260226112249_initial_tenant.sql h1:dIhBg2gzyh+ZjLzPXdHYafd5e62yIEjk1eFlllEyYX0= 20260226233619_add_teams.sql h1:n8MRMUA4BgeXYEnL9HJPc8mnXh8lqIfrCcdYtFFoWqw= 20260227163239.sql h1:ENMZUW7zHK8UjG2TdYlBOZSVPPUCXftIw5U5k2C54oo= @@ -18,4 +18,4 @@ h1:8Wqr+KxBQLw5r0KA2RcBXrq75r9mucAyprneQ+rJbpw= 20260315050936.sql h1:/o/ku2qrlT14mxxhETs26eHguobD5wPYES2khLSN2wA= 20260315120000_add_language.sql h1:O1oFQ+aUAI9+uGdIuhjEV9bM8ImXKMMwRQAw3vYhcVM= 20260317120000_drop_refresh_requested_at.sql h1:R4jHtMkCpdY09orFA4RPvtLeUJ2Z7S4WPMmg/bDJuGg= -20260318120000_migrate_excluded_users_to_bot_type.sql h1:IcCA2pNEjwD+RDZainibPEv3bOYTrYHn1vG6/mv0X9s= +20260318120000_migrate_excluded_users_to_bot_type.sql h1:QhLNJld3iqMSd/Zi8/jHadFB+0u4ocStzpprBqCaG+M= From e68c75495ff81384ff31be864536085eeacbab08 Mon Sep 17 00:00:00 2001 From: coji Date: Thu, 19 Mar 2026 08:37:40 +0900 Subject: [PATCH 03/11] refactor: replace KNOWN_BOTS with GitHub API bot detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KNOWN_BOTSハードコードリストを削除し、GitHub APIの__typename === 'Bot' 情報をbuildPullRequestsからupsertCompanyGithubUsersに流すように変更。 新規ユーザー登録時にGitHub APIが返すbot判定を自動的にtype='Bot'として保存。 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/services/jobs/analyze-worker.ts | 5 ++++- app/services/jobs/shared-steps.server.ts | 4 ++++ batch/db/mutations.ts | 22 ++++++++-------------- batch/github/pullrequest.ts | 13 +++++++++++-- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/app/services/jobs/analyze-worker.ts b/app/services/jobs/analyze-worker.ts index 9186925d..dc4488b2 100644 --- a/app/services/jobs/analyze-worker.ts +++ b/app/services/jobs/analyze-worker.ts @@ -54,4 +54,7 @@ const result: Awaited> = input.filterPrNumbers ? new Set(input.filterPrNumbers) : undefined, ) -parentPort?.postMessage(result) +parentPort?.postMessage({ + ...result, + botUsers: [...result.botUsers], +}) diff --git a/app/services/jobs/shared-steps.server.ts b/app/services/jobs/shared-steps.server.ts index 23a49cb5..6f21e452 100644 --- a/app/services/jobs/shared-steps.server.ts +++ b/app/services/jobs/shared-steps.server.ts @@ -25,6 +25,7 @@ interface AnalyzeResult { reviews: AnalyzedReview[] reviewers: AnalyzedReviewer[] reviewResponses: AnalyzedReviewResponse[] + botUsers: string[] } interface OrganizationData { @@ -108,6 +109,7 @@ export async function analyzeAndFinalizeSteps( const allReviews: AnalyzedReview[] = [] const allReviewers: AnalyzedReviewer[] = [] const allReviewResponses: AnalyzedReviewResponse[] = [] + const allBotUsers = new Set() const sqliteBusyEvents: SqliteBusyEvent[] = [] for (let i = 0; i < organization.repositories.length; i++) { @@ -141,6 +143,7 @@ export async function analyzeAndFinalizeSteps( allReviews.push(...result.reviews) allReviewers.push(...result.reviewers) allReviewResponses.push(...result.reviewResponses) + for (const login of result.botUsers) allBotUsers.add(login) } // Upsert @@ -152,6 +155,7 @@ export async function analyzeAndFinalizeSteps( pulls: allPulls, reviews: allReviews, reviewers: allReviewers, + botUsers: allBotUsers, }) }) }) diff --git a/batch/db/mutations.ts b/batch/db/mutations.ts index b3dc9486..bf37fe49 100644 --- a/batch/db/mutations.ts +++ b/batch/db/mutations.ts @@ -5,18 +5,6 @@ import type { OrganizationId } from '~/app/types/organization' import type { AnalyzedReview, AnalyzedReviewer } from '../github/types' import { logger } from '../helper/logger' -/** GitHub API で [bot] 接尾辞なしで記録される well-known bot (lowercase) */ -const KNOWN_BOTS = new Set([ - 'copilot', - 'copilot-pull-request-reviewer', - 'copilot-swe-agent', - 'github-actions', - 'dependabot', - 'renovate', - 'coderabbitai', - 'devin-ai-integration', -]) - export function upsertPullRequest( organizationId: OrganizationId, data: Insertable, @@ -237,6 +225,7 @@ export async function batchReplacePullRequestReviewers( export async function upsertCompanyGithubUsers( organizationId: OrganizationId, logins: string[], + botUsers?: Set, ) { if (logins.length === 0) return @@ -252,7 +241,7 @@ export async function upsertCompanyGithubUsers( uniqueLogins.map((login) => ({ login, displayName: login, - type: KNOWN_BOTS.has(login) ? 'Bot' : null, + type: botUsers?.has(login) ? 'Bot' : null, isActive: 0, updatedAt: now, })), @@ -275,6 +264,7 @@ export async function upsertAnalyzedData( pulls: Selectable[] reviews: AnalyzedReview[] reviewers: AnalyzedReviewer[] + botUsers?: Set }, ) { // Auto-register discovered GitHub users @@ -290,7 +280,11 @@ export async function upsertAnalyzedData( if (r.login) discoveredLogins.add(r.login) } } - await upsertCompanyGithubUsers(organizationId, [...discoveredLogins]) + await upsertCompanyGithubUsers( + organizationId, + [...discoveredLogins], + data.botUsers, + ) // Upsert pull requests logger.info('upsert started...', organizationId) diff --git a/batch/github/pullrequest.ts b/batch/github/pullrequest.ts index 9c419c08..3b44e9b9 100644 --- a/batch/github/pullrequest.ts +++ b/batch/github/pullrequest.ts @@ -206,6 +206,7 @@ export const buildPullRequests = async ( const reviews: AnalyzedReview[] = [] const reviewers: AnalyzedReviewer[] = [] const reviewResponses: AnalyzedReviewResponse[] = [] + const botUsers = new Set() let processed = 0 for (const pr of pullrequests) { @@ -223,7 +224,15 @@ export const buildPullRequests = async ( // 1. アーティファクト読み込み(I/O) const rawArtifacts = await loadPrArtifacts(pr, loaders) - // 2. アクター除外フィルタ(純粋関数) + // 2. bot ユーザーを収集(GitHub API の __typename === 'Bot') + for (const r of rawArtifacts.reviews) { + if (r.isBot && r.user) botUsers.add(r.user.toLowerCase()) + } + for (const d of rawArtifacts.discussions) { + if (d.isBot && d.user) botUsers.add(d.user.toLowerCase()) + } + + // 3. アクター除外フィルタ(純粋関数) const artifacts = filterActors(rawArtifacts, pr, config.botLogins) // 3. レビューレスポンス解析 @@ -297,5 +306,5 @@ export const buildPullRequests = async ( } } - return { pulls, reviews, reviewers, reviewResponses } + return { pulls, reviews, reviewers, reviewResponses, botUsers } } From 992c901334926a09273aeaed770c4412025c932e Mon Sep 17 00:00:00 2001 From: coji Date: Thu, 19 Mar 2026 08:44:14 +0900 Subject: [PATCH 04/11] refactor: fix step numbering, DRY bot query, skip bot fetch in listAllOrganizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix duplicate step 3 comment in buildPullRequests (renumber 3-8 → 3-9) - Make getBotLogins the single source for bot login queries (remove from getTenantData) - getOrganization fetches botLogins in parallel with tenant data - listAllOrganizations no longer fetches unnecessary bot logins Co-Authored-By: Claude Opus 4.6 (1M context) --- .../repositories/$repository/$pull/index.tsx | 4 +- batch/db/queries.ts | 96 +++++++++---------- batch/github/pullrequest.ts | 12 +-- 3 files changed, 52 insertions(+), 60 deletions(-) diff --git a/app/routes/$orgSlug/settings/repositories/$repository/$pull/index.tsx b/app/routes/$orgSlug/settings/repositories/$repository/$pull/index.tsx index e89c2b92..9f593162 100644 --- a/app/routes/$orgSlug/settings/repositories/$repository/$pull/index.tsx +++ b/app/routes/$orgSlug/settings/repositories/$repository/$pull/index.tsx @@ -163,7 +163,7 @@ export const action = async ({ }) // 4. Get organization settings and bot logins for build config - const [settings, botLogins] = await Promise.all([ + const [settings, botLoginsList] = await Promise.all([ getOrganizationSettings(organization.id), getBotLogins(organization.id), ]) @@ -173,7 +173,7 @@ export const action = async ({ { organizationId: organization.id, repositoryId, - botLogins, + botLogins: new Set(botLoginsList), releaseDetectionMethod: settings?.releaseDetectionMethod ?? 'branch', releaseDetectionKey: settings?.releaseDetectionKey ?? '', }, diff --git a/batch/db/queries.ts b/batch/db/queries.ts index b9f1fb0b..272c7136 100644 --- a/batch/db/queries.ts +++ b/batch/db/queries.ts @@ -14,72 +14,61 @@ export const getPullRequestReport = async (organizationId: OrganizationId) => { async function getTenantData(organizationId: OrganizationId) { const tenantDb = getTenantDb(organizationId) - const [ - organizationSetting, - integration, - repositories, - exportSetting, - botLoginRows, - ] = await Promise.all([ - tenantDb - .selectFrom('organizationSettings') - .select(['releaseDetectionMethod', 'releaseDetectionKey', 'isActive']) - .executeTakeFirst(), - tenantDb - .selectFrom('integrations') - .select(['id', 'method', 'provider', 'privateToken']) - .executeTakeFirst(), - tenantDb - .selectFrom('repositories') - .select([ - 'id', - 'repo', - 'owner', - 'integrationId', - 'provider', - 'releaseDetectionKey', - 'releaseDetectionMethod', - 'teamId', - 'updatedAt', - 'createdAt', - ]) - .execute(), - tenantDb - .selectFrom('exportSettings') - .select([ - 'id', - 'sheetId', - 'clientEmail', - 'privateKey', - 'updatedAt', - 'createdAt', - ]) - .executeTakeFirst(), - tenantDb - .selectFrom('companyGithubUsers') - .select('login') - .where('type', '=', 'Bot') - .execute(), - ]) + const [organizationSetting, integration, repositories, exportSetting] = + await Promise.all([ + tenantDb + .selectFrom('organizationSettings') + .select(['releaseDetectionMethod', 'releaseDetectionKey', 'isActive']) + .executeTakeFirst(), + tenantDb + .selectFrom('integrations') + .select(['id', 'method', 'provider', 'privateToken']) + .executeTakeFirst(), + tenantDb + .selectFrom('repositories') + .select([ + 'id', + 'repo', + 'owner', + 'integrationId', + 'provider', + 'releaseDetectionKey', + 'releaseDetectionMethod', + 'teamId', + 'updatedAt', + 'createdAt', + ]) + .execute(), + tenantDb + .selectFrom('exportSettings') + .select([ + 'id', + 'sheetId', + 'clientEmail', + 'privateKey', + 'updatedAt', + 'createdAt', + ]) + .executeTakeFirst(), + ]) return { organizationSetting: organizationSetting ?? null, integration: integration ?? null, repositories, exportSetting: exportSetting ?? null, - botLogins: botLoginRows.map((r) => r.login), } } export async function getBotLogins( organizationId: OrganizationId, -): Promise> { +): Promise { const tenantDb = getTenantDb(organizationId) const rows = await tenantDb .selectFrom('companyGithubUsers') .select('login') .where('type', '=', 'Bot') .execute() - return new Set(rows.map((r) => r.login)) + return rows.map((r) => r.login) } export const listAllOrganizations = async () => { @@ -103,6 +92,9 @@ export const getOrganization = async (organizationId: OrganizationId) => { .where('id', '=', organizationId) .executeTakeFirstOrThrow() - const tenantData = await getTenantData(org.id as OrganizationId) - return { ...org, ...tenantData } + const [tenantData, botLogins] = await Promise.all([ + getTenantData(org.id as OrganizationId), + getBotLogins(org.id as OrganizationId), + ]) + return { ...org, ...tenantData, botLogins } } diff --git a/batch/github/pullrequest.ts b/batch/github/pullrequest.ts index 3b44e9b9..188573a9 100644 --- a/batch/github/pullrequest.ts +++ b/batch/github/pullrequest.ts @@ -235,7 +235,7 @@ export const buildPullRequests = async ( // 3. アクター除外フィルタ(純粋関数) const artifacts = filterActors(rawArtifacts, pr, config.botLogins) - // 3. レビューレスポンス解析 + // 4. レビューレスポンス解析 reviewResponses.push( ...analyzeReviewResponse(artifacts.discussions).map((res) => ({ repo: String(pr.repo), @@ -246,10 +246,10 @@ export const buildPullRequests = async ( })), ) - // 4. 日時計算(純粋関数) + // 5. 日時計算(純粋関数) const dates = computeDates(pr, artifacts) - // 5. リリース日時計算(事前計算済みルックアップから O(1) or O(log n) で取得) + // 6. リリース日時計算(事前計算済みルックアップから O(1) or O(log n) で取得) let releasedAt: string | null = null if (pr.mergedAt) { if (branchReleaseLookup) { @@ -259,12 +259,12 @@ export const buildPullRequests = async ( } } - // 6. PR 行データ生成(純粋関数) + // 7. PR 行データ生成(純粋関数) pulls.push( buildPullRequestRow(pr, dates, releasedAt, config.repositoryId), ) - // 7. レビュー情報を収集(PENDING レビューは submittedAt がないため除外) + // 8. レビュー情報を収集(PENDING レビューは submittedAt がないため除外) for (const review of rawArtifacts.reviews) { if (!review.user || !review.submittedAt || review.state === 'PENDING') continue @@ -279,7 +279,7 @@ export const buildPullRequests = async ( }) } - // 8. レビュアー(レビュー依頼先)情報を収集 + // 9. レビュアー(レビュー依頼先)情報を収集 // timeline_items から requestedAt を補完する // reviewer が 0 人でも push して、removed された reviewer の DB レコードを削除させる const prReviewers = pr.reviewers ?? [] From efa0a94f19e83dae090f6a5455a5d71329448749 Mon Sep 17 00:00:00 2001 From: coji Date: Thu, 19 Mar 2026 08:49:09 +0900 Subject: [PATCH 05/11] feat: add lastActivityAt to GitHub Users and sort by recent activity - Add last_activity_at column to company_github_users with backfill migration - Update batch upsert to track latest PR/review activity per user - Default sort GitHub Users by last activity (desc), NULL last - Display last activity as relative time (fromNow) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../+components/github-users-columns.tsx | 20 ++++---- .../+hooks/use-data-table-state.ts | 2 +- .../github-users._index/queries.server.ts | 8 +++- app/services/tenant-type.ts | 1 + batch/db/mutations.ts | 46 +++++++++++++++++++ .../20260319120000_add_last_activity_at.sql | 13 ++++++ db/migrations/tenant/atlas.sum | 3 +- db/tenant.sql | 1 + 8 files changed, 81 insertions(+), 13 deletions(-) create mode 100644 db/migrations/tenant/20260319120000_add_last_activity_at.sql diff --git a/app/routes/$orgSlug/settings/github-users._index/+components/github-users-columns.tsx b/app/routes/$orgSlug/settings/github-users._index/+components/github-users-columns.tsx index f96cdff9..9c45ae78 100644 --- a/app/routes/$orgSlug/settings/github-users._index/+components/github-users-columns.tsx +++ b/app/routes/$orgSlug/settings/github-users._index/+components/github-users-columns.tsx @@ -149,18 +149,18 @@ export function createColumns(timezone: string): ColumnDef[] { ), }, { - accessorKey: 'createdAt', + accessorKey: 'lastActivityAt', header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
- {dayjs - .utc(row.getValue('createdAt')) - .tz(timezone) - .format('YYYY-MM-DD')} -
+ ), + cell: ({ row }) => { + const value = row.getValue('lastActivityAt') + return ( +
+ {value ? dayjs.utc(value).tz(timezone).fromNow() : '-'} +
+ ) + }, }, { id: 'actions', diff --git a/app/routes/$orgSlug/settings/github-users._index/+hooks/use-data-table-state.ts b/app/routes/$orgSlug/settings/github-users._index/+hooks/use-data-table-state.ts index b1009406..9f08424d 100644 --- a/app/routes/$orgSlug/settings/github-users._index/+hooks/use-data-table-state.ts +++ b/app/routes/$orgSlug/settings/github-users._index/+hooks/use-data-table-state.ts @@ -27,7 +27,7 @@ export const SortSchema = z.object({ z .union([z.literal('asc'), z.literal('desc')]) .optional() - .default('asc'), + .default('desc'), ), }) diff --git a/app/routes/$orgSlug/settings/github-users._index/queries.server.ts b/app/routes/$orgSlug/settings/github-users._index/queries.server.ts index 733bdf86..60720769 100644 --- a/app/routes/$orgSlug/settings/github-users._index/queries.server.ts +++ b/app/routes/$orgSlug/settings/github-users._index/queries.server.ts @@ -31,6 +31,7 @@ export const listFilteredGithubUsers = async ({ 'companyGithubUsers.displayName', 'companyGithubUsers.type', 'companyGithubUsers.isActive', + 'companyGithubUsers.lastActivityAt', 'companyGithubUsers.createdAt', ]) @@ -71,12 +72,17 @@ export const listFilteredGithubUsers = async ({ displayName: 'companyGithubUsers.displayName', type: 'companyGithubUsers.type', isActive: 'companyGithubUsers.isActive', + lastActivityAt: 'companyGithubUsers.lastActivityAt', createdAt: 'companyGithubUsers.createdAt', } - const safeSortBy = sortFieldMap[sortBy ?? ''] ?? sortFieldMap.login + const safeSortBy = sortFieldMap[sortBy ?? ''] ?? sortFieldMap.lastActivityAt const [rows, countResult] = await Promise.all([ query + .orderBy( + sql`${sql.ref(safeSortBy)} IS NULL`, + sortOrder === 'desc' ? 'asc' : 'desc', + ) .orderBy(sql.ref(safeSortBy), sortOrder) .limit(pageSize) .offset((currentPage - 1) * pageSize) diff --git a/app/services/tenant-type.ts b/app/services/tenant-type.ts index 936311d8..7baace66 100644 --- a/app/services/tenant-type.ts +++ b/app/services/tenant-type.ts @@ -28,6 +28,7 @@ export interface CompanyGithubUsers { createdAt: Generated; displayName: string; isActive: 0 | 1; + lastActivityAt: string | null; login: string; type: string | null; updatedAt: string; diff --git a/batch/db/mutations.ts b/batch/db/mutations.ts index bf37fe49..cd13f608 100644 --- a/batch/db/mutations.ts +++ b/batch/db/mutations.ts @@ -254,6 +254,32 @@ export async function upsertCompanyGithubUsers( ) } +/** + * ユーザーごとの最終活動日時を更新する。 + * 既存値より新しい場合のみ上書き。 + */ +async function updateLastActivityAt( + organizationId: OrganizationId, + lastActivity: Map, +) { + if (lastActivity.size === 0) return + const tenantDb = getTenantDb(organizationId) + + for (const [login, ts] of lastActivity) { + await tenantDb + .updateTable('companyGithubUsers') + .set({ lastActivityAt: ts }) + .where('login', '=', login) + .where((eb) => + eb.or([ + eb('lastActivityAt', 'is', null), + eb('lastActivityAt', '<', ts), + ]), + ) + .execute() + } +} + /** * analyze 結果を一括で DB に書き込む共通関数。 * durably ジョブ(crawl, recalculate)の共通 upsert 処理。 @@ -286,6 +312,26 @@ export async function upsertAnalyzedData( data.botUsers, ) + // Update last activity timestamps + const lastActivity = new Map() + for (const pr of data.pulls) { + if (!pr.author) continue + const login = pr.author.toLowerCase() + const ts = pr.pullRequestCreatedAt + if (ts && (!lastActivity.has(login) || ts > lastActivity.get(login)!)) { + lastActivity.set(login, ts) + } + } + for (const review of data.reviews) { + if (!review.reviewer) continue + const login = review.reviewer.toLowerCase() + const ts = review.submittedAt + if (ts && (!lastActivity.has(login) || ts > lastActivity.get(login)!)) { + lastActivity.set(login, ts) + } + } + await updateLastActivityAt(organizationId, lastActivity) + // Upsert pull requests logger.info('upsert started...', organizationId) await batchUpsertPullRequests(organizationId, data.pulls) diff --git a/db/migrations/tenant/20260319120000_add_last_activity_at.sql b/db/migrations/tenant/20260319120000_add_last_activity_at.sql new file mode 100644 index 00000000..07acaa3a --- /dev/null +++ b/db/migrations/tenant/20260319120000_add_last_activity_at.sql @@ -0,0 +1,13 @@ +-- Add last_activity_at column to company_github_users +ALTER TABLE company_github_users ADD COLUMN last_activity_at text NULL; + +-- Backfill from existing PR and review data +UPDATE company_github_users SET last_activity_at = ( + SELECT MAX(activity_at) FROM ( + SELECT MAX(pull_request_created_at) AS activity_at + FROM pull_requests WHERE LOWER(author) = company_github_users.login + UNION ALL + SELECT MAX(submitted_at) AS activity_at + FROM pull_request_reviews WHERE LOWER(reviewer) = company_github_users.login + ) +); diff --git a/db/migrations/tenant/atlas.sum b/db/migrations/tenant/atlas.sum index a7bdf39a..33872d3e 100644 --- a/db/migrations/tenant/atlas.sum +++ b/db/migrations/tenant/atlas.sum @@ -1,4 +1,4 @@ -h1:BLFRY6WoYqFErpRx/O0lVGzSkVCMZZpC8KSiwUgsjfo= +h1:rOVqify+bmStQ5sanUg4O68NBnTR+imhNdcJHY2YYfs= 20260226112249_initial_tenant.sql h1:dIhBg2gzyh+ZjLzPXdHYafd5e62yIEjk1eFlllEyYX0= 20260226233619_add_teams.sql h1:n8MRMUA4BgeXYEnL9HJPc8mnXh8lqIfrCcdYtFFoWqw= 20260227163239.sql h1:ENMZUW7zHK8UjG2TdYlBOZSVPPUCXftIw5U5k2C54oo= @@ -19,3 +19,4 @@ h1:BLFRY6WoYqFErpRx/O0lVGzSkVCMZZpC8KSiwUgsjfo= 20260315120000_add_language.sql h1:O1oFQ+aUAI9+uGdIuhjEV9bM8ImXKMMwRQAw3vYhcVM= 20260317120000_drop_refresh_requested_at.sql h1:R4jHtMkCpdY09orFA4RPvtLeUJ2Z7S4WPMmg/bDJuGg= 20260318120000_migrate_excluded_users_to_bot_type.sql h1:QhLNJld3iqMSd/Zi8/jHadFB+0u4ocStzpprBqCaG+M= +20260319120000_add_last_activity_at.sql h1:7NGv54YJVxyvv62zNQNRWILXOcVWRuHQCPMpAkVVCJM= diff --git a/db/tenant.sql b/db/tenant.sql index 50f5452b..5671127f 100644 --- a/db/tenant.sql +++ b/db/tenant.sql @@ -160,6 +160,7 @@ CREATE TABLE `company_github_users` ( `display_name` text NOT NULL, `type` text NULL, `is_active` integer NOT NULL DEFAULT 0, + `last_activity_at` text NULL, `updated_at` datetime NOT NULL, `created_at` datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), PRIMARY KEY (`login`) From fde6e858fe7be88a94b49f4a00a722315795fbd6 Mon Sep 17 00:00:00 2001 From: coji Date: Thu, 19 Mar 2026 08:49:44 +0900 Subject: [PATCH 06/11] fix: replace non-null assertions with fallback in lastActivity tracking Co-Authored-By: Claude Opus 4.6 (1M context) --- batch/db/mutations.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/batch/db/mutations.ts b/batch/db/mutations.ts index cd13f608..470eb7b3 100644 --- a/batch/db/mutations.ts +++ b/batch/db/mutations.ts @@ -318,7 +318,10 @@ export async function upsertAnalyzedData( if (!pr.author) continue const login = pr.author.toLowerCase() const ts = pr.pullRequestCreatedAt - if (ts && (!lastActivity.has(login) || ts > lastActivity.get(login)!)) { + if ( + ts && + (!lastActivity.has(login) || ts > (lastActivity.get(login) ?? '')) + ) { lastActivity.set(login, ts) } } @@ -326,7 +329,10 @@ export async function upsertAnalyzedData( if (!review.reviewer) continue const login = review.reviewer.toLowerCase() const ts = review.submittedAt - if (ts && (!lastActivity.has(login) || ts > lastActivity.get(login)!)) { + if ( + ts && + (!lastActivity.has(login) || ts > (lastActivity.get(login) ?? '')) + ) { lastActivity.set(login, ts) } } From b725c857bfe4513ad165dc36abdffb81d619a7aa Mon Sep 17 00:00:00 2001 From: coji Date: Thu, 19 Mar 2026 08:59:12 +0900 Subject: [PATCH 07/11] refactor: scope NULL sort to lastActivityAt only, extract trackLatest helper - NULL-last ordering only applies when sorting by lastActivityAt column - Extract trackLatest() helper to deduplicate Map.get/set pattern Co-Authored-By: Claude Opus 4.6 (1M context) --- .../github-users._index/queries.server.ts | 15 ++++++---- batch/db/mutations.ts | 28 +++++++------------ 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/app/routes/$orgSlug/settings/github-users._index/queries.server.ts b/app/routes/$orgSlug/settings/github-users._index/queries.server.ts index 60720769..4109d749 100644 --- a/app/routes/$orgSlug/settings/github-users._index/queries.server.ts +++ b/app/routes/$orgSlug/settings/github-users._index/queries.server.ts @@ -77,12 +77,17 @@ export const listFilteredGithubUsers = async ({ } const safeSortBy = sortFieldMap[sortBy ?? ''] ?? sortFieldMap.lastActivityAt + // NULL を末尾に配置(lastActivityAt など nullable カラムのソート用) + let sortedQuery = query + if (safeSortBy === sortFieldMap.lastActivityAt) { + sortedQuery = sortedQuery.orderBy( + sql`${sql.ref(safeSortBy)} IS NULL`, + sortOrder === 'desc' ? 'asc' : 'desc', + ) + } + const [rows, countResult] = await Promise.all([ - query - .orderBy( - sql`${sql.ref(safeSortBy)} IS NULL`, - sortOrder === 'desc' ? 'asc' : 'desc', - ) + sortedQuery .orderBy(sql.ref(safeSortBy), sortOrder) .limit(pageSize) .offset((currentPage - 1) * pageSize) diff --git a/batch/db/mutations.ts b/batch/db/mutations.ts index 470eb7b3..8cd15f84 100644 --- a/batch/db/mutations.ts +++ b/batch/db/mutations.ts @@ -254,6 +254,13 @@ export async function upsertCompanyGithubUsers( ) } +function trackLatest(map: Map, login: string, ts: string) { + const key = login.toLowerCase() + if (!map.has(key) || ts > (map.get(key) ?? '')) { + map.set(key, ts) + } +} + /** * ユーザーごとの最終活動日時を更新する。 * 既存値より新しい場合のみ上書き。 @@ -315,26 +322,11 @@ export async function upsertAnalyzedData( // Update last activity timestamps const lastActivity = new Map() for (const pr of data.pulls) { - if (!pr.author) continue - const login = pr.author.toLowerCase() - const ts = pr.pullRequestCreatedAt - if ( - ts && - (!lastActivity.has(login) || ts > (lastActivity.get(login) ?? '')) - ) { - lastActivity.set(login, ts) - } + if (pr.author) trackLatest(lastActivity, pr.author, pr.pullRequestCreatedAt) } for (const review of data.reviews) { - if (!review.reviewer) continue - const login = review.reviewer.toLowerCase() - const ts = review.submittedAt - if ( - ts && - (!lastActivity.has(login) || ts > (lastActivity.get(login) ?? '')) - ) { - lastActivity.set(login, ts) - } + if (review.reviewer) + trackLatest(lastActivity, review.reviewer, review.submittedAt) } await updateLastActivityAt(organizationId, lastActivity) From d2cac0e6c63034f005921d03ae2955d6ad57cdbd Mon Sep 17 00:00:00 2001 From: coji Date: Thu, 19 Mar 2026 09:18:29 +0900 Subject: [PATCH 08/11] fix: normalize botLogins to lowercase, detect bot PR authors - getBotLogins returns lowercase-normalized logins for consistent matching - Add authorIsBot to ShapedGitHubPullRequest via GraphQL __typename - Collect PR author bots in buildPullRequests (e.g. dependabot PRs) Co-Authored-By: Claude Opus 4.6 (1M context) --- batch/db/queries.ts | 2 +- batch/github/__tests__/buildPullRequests-filter.test.ts | 1 + batch/github/__tests__/release-detect.test.ts | 1 + batch/github/fetcher.ts | 4 ++++ batch/github/model.ts | 1 + batch/github/pullrequest.ts | 1 + batch/github/store.test.ts | 1 + 7 files changed, 10 insertions(+), 1 deletion(-) diff --git a/batch/db/queries.ts b/batch/db/queries.ts index 272c7136..88da5948 100644 --- a/batch/db/queries.ts +++ b/batch/db/queries.ts @@ -68,7 +68,7 @@ export async function getBotLogins( .select('login') .where('type', '=', 'Bot') .execute() - return rows.map((r) => r.login) + return rows.map((r) => r.login.toLowerCase()) } export const listAllOrganizations = async () => { diff --git a/batch/github/__tests__/buildPullRequests-filter.test.ts b/batch/github/__tests__/buildPullRequests-filter.test.ts index 014e45b4..e67a0b9e 100644 --- a/batch/github/__tests__/buildPullRequests-filter.test.ts +++ b/batch/github/__tests__/buildPullRequests-filter.test.ts @@ -22,6 +22,7 @@ const basePr: ShapedGitHubPullRequest = { body: null, url: 'https://github.com/test-org/test-repo/pull/0', author: 'author1', + authorIsBot: false, assignees: [], reviewers: [], draft: false, diff --git a/batch/github/__tests__/release-detect.test.ts b/batch/github/__tests__/release-detect.test.ts index 86852111..6cfdd855 100644 --- a/batch/github/__tests__/release-detect.test.ts +++ b/batch/github/__tests__/release-detect.test.ts @@ -16,6 +16,7 @@ const basePr: ShapedGitHubPullRequest = { body: null, url: '', author: 'author1', + authorIsBot: false, assignees: [], reviewers: [], draft: false, diff --git a/batch/github/fetcher.ts b/batch/github/fetcher.ts index eeb3e13f..f1547094 100644 --- a/batch/github/fetcher.ts +++ b/batch/github/fetcher.ts @@ -52,6 +52,7 @@ const GetPullRequestsQuery = graphql(` oid } author { + __typename login } assignees(first: 100) { @@ -350,6 +351,7 @@ const GetPullRequestsWithDetailsQuery = graphql(` oid } author { + __typename login } assignees(first: 10) { @@ -869,6 +871,7 @@ export const createFetcher = ({ owner, repo, token }: createFetcherProps) => { body: node.body ?? null, url: node.url, author: node.author?.login ?? null, + authorIsBot: node.author?.__typename === 'Bot', assignees: node.assignees.nodes ?.filter((n) => n != null) @@ -1190,6 +1193,7 @@ export const createFetcher = ({ owner, repo, token }: createFetcherProps) => { body: node.body ?? null, url: node.url, author: node.author?.login ?? null, + authorIsBot: node.author?.__typename === 'Bot', assignees: node.assignees.nodes ?.filter((n) => n != null) diff --git a/batch/github/model.ts b/batch/github/model.ts index f3cf1e5c..268cd77f 100644 --- a/batch/github/model.ts +++ b/batch/github/model.ts @@ -20,6 +20,7 @@ export type ShapedGitHubPullRequest = { body: string | null url: GitHubPullRequest['html_url'] author: NonNullable['login'] | null + authorIsBot: boolean assignees: string[] reviewers: { login: string; requestedAt: string | null }[] draft: boolean diff --git a/batch/github/pullrequest.ts b/batch/github/pullrequest.ts index 188573a9..82532386 100644 --- a/batch/github/pullrequest.ts +++ b/batch/github/pullrequest.ts @@ -225,6 +225,7 @@ export const buildPullRequests = async ( const rawArtifacts = await loadPrArtifacts(pr, loaders) // 2. bot ユーザーを収集(GitHub API の __typename === 'Bot') + if (pr.authorIsBot && pr.author) botUsers.add(pr.author.toLowerCase()) for (const r of rawArtifacts.reviews) { if (r.isBot && r.user) botUsers.add(r.user.toLowerCase()) } diff --git a/batch/github/store.test.ts b/batch/github/store.test.ts index 872cd830..a1ddfff1 100644 --- a/batch/github/store.test.ts +++ b/batch/github/store.test.ts @@ -87,6 +87,7 @@ const makePr = (number: number): ShapedGitHubPullRequest => ({ body: null, url: `https://github.com/test-owner/test-repo/pull/${number}`, author: 'user1', + authorIsBot: false, assignees: [], reviewers: [{ login: 'reviewer1', requestedAt: '2024-01-01T06:00:00Z' }], draft: false, From d48cd7d912355c08f4d742de774461a89cecf25b Mon Sep 17 00:00:00 2001 From: coji Date: Thu, 19 Mar 2026 09:21:02 +0900 Subject: [PATCH 09/11] fix: auto-set bot type for existing users when type is null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change onConflict from doNothing to doUpdateSet with COALESCE — if an existing user has type=null and the API detects them as Bot, their type is now updated. Manually set types (User/Bot by admin) are preserved. Co-Authored-By: Claude Opus 4.6 (1M context) --- batch/db/mutations.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/batch/db/mutations.ts b/batch/db/mutations.ts index 8cd15f84..413c3474 100644 --- a/batch/db/mutations.ts +++ b/batch/db/mutations.ts @@ -246,7 +246,15 @@ export async function upsertCompanyGithubUsers( updatedAt: now, })), ) - .onConflict((oc) => oc.column('login').doNothing()) + .onConflict((oc) => + oc.column('login').doUpdateSet((eb) => ({ + // API で bot と判定されたユーザーの type を自動設定(未設定の場合のみ) + type: eb.fn.coalesce( + eb.ref('companyGithubUsers.type'), + eb.ref('excluded.type'), + ), + })), + ) .execute() logger.info( `upserted ${uniqueLogins.length} company github users.`, From ec018c39468608522ad073334245ef2df9a72afe Mon Sep 17 00:00:00 2001 From: coji Date: Thu, 19 Mar 2026 09:40:54 +0900 Subject: [PATCH 10/11] fix: normalize both sides in lastActivityAt backfill migration Apply LOWER() to company_github_users.login in the backfill query for defensive case-insensitive matching. Co-Authored-By: Claude Opus 4.6 (1M context) --- db/migrations/tenant/20260319120000_add_last_activity_at.sql | 4 ++-- db/migrations/tenant/atlas.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/db/migrations/tenant/20260319120000_add_last_activity_at.sql b/db/migrations/tenant/20260319120000_add_last_activity_at.sql index 07acaa3a..d7d45615 100644 --- a/db/migrations/tenant/20260319120000_add_last_activity_at.sql +++ b/db/migrations/tenant/20260319120000_add_last_activity_at.sql @@ -5,9 +5,9 @@ ALTER TABLE company_github_users ADD COLUMN last_activity_at text NULL; UPDATE company_github_users SET last_activity_at = ( SELECT MAX(activity_at) FROM ( SELECT MAX(pull_request_created_at) AS activity_at - FROM pull_requests WHERE LOWER(author) = company_github_users.login + FROM pull_requests WHERE LOWER(author) = LOWER(company_github_users.login) UNION ALL SELECT MAX(submitted_at) AS activity_at - FROM pull_request_reviews WHERE LOWER(reviewer) = company_github_users.login + FROM pull_request_reviews WHERE LOWER(reviewer) = LOWER(company_github_users.login) ) ); diff --git a/db/migrations/tenant/atlas.sum b/db/migrations/tenant/atlas.sum index 33872d3e..dcb63a67 100644 --- a/db/migrations/tenant/atlas.sum +++ b/db/migrations/tenant/atlas.sum @@ -1,4 +1,4 @@ -h1:rOVqify+bmStQ5sanUg4O68NBnTR+imhNdcJHY2YYfs= +h1:WXEFgffupsvOjsoZyUVrgYzOVvKHu7gpaFCM4HJS/Lk= 20260226112249_initial_tenant.sql h1:dIhBg2gzyh+ZjLzPXdHYafd5e62yIEjk1eFlllEyYX0= 20260226233619_add_teams.sql h1:n8MRMUA4BgeXYEnL9HJPc8mnXh8lqIfrCcdYtFFoWqw= 20260227163239.sql h1:ENMZUW7zHK8UjG2TdYlBOZSVPPUCXftIw5U5k2C54oo= @@ -19,4 +19,4 @@ h1:rOVqify+bmStQ5sanUg4O68NBnTR+imhNdcJHY2YYfs= 20260315120000_add_language.sql h1:O1oFQ+aUAI9+uGdIuhjEV9bM8ImXKMMwRQAw3vYhcVM= 20260317120000_drop_refresh_requested_at.sql h1:R4jHtMkCpdY09orFA4RPvtLeUJ2Z7S4WPMmg/bDJuGg= 20260318120000_migrate_excluded_users_to_bot_type.sql h1:QhLNJld3iqMSd/Zi8/jHadFB+0u4ocStzpprBqCaG+M= -20260319120000_add_last_activity_at.sql h1:7NGv54YJVxyvv62zNQNRWILXOcVWRuHQCPMpAkVVCJM= +20260319120000_add_last_activity_at.sql h1:2evW+gCdRyH1OvTH2NDh5cV2xaxOHIldHYrkwS2ThE4= From cd4c18bcd203189427e4c83b4c4dbd152ac08881 Mon Sep 17 00:00:00 2001 From: coji Date: Thu, 19 Mar 2026 09:42:15 +0900 Subject: [PATCH 11/11] fix: align updateSort fallback to desc, simplify trackLatest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - updateSort fallback 'asc' → 'desc' to match SortSchema default - trackLatest: use map.get() result directly instead of redundant ?? '' Co-Authored-By: Claude Opus 4.6 (1M context) --- .../github-users._index/+hooks/use-data-table-state.ts | 2 +- batch/db/mutations.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/routes/$orgSlug/settings/github-users._index/+hooks/use-data-table-state.ts b/app/routes/$orgSlug/settings/github-users._index/+hooks/use-data-table-state.ts index 9f08424d..f4f2a05e 100644 --- a/app/routes/$orgSlug/settings/github-users._index/+hooks/use-data-table-state.ts +++ b/app/routes/$orgSlug/settings/github-users._index/+hooks/use-data-table-state.ts @@ -99,7 +99,7 @@ export function useDataTableState() { (prev) => { if (newSort.sort_by) { prev.set('sort_by', newSort.sort_by) - prev.set('sort_order', newSort.sort_order || 'asc') + prev.set('sort_order', newSort.sort_order || 'desc') } else { prev.delete('sort_by') prev.delete('sort_order') diff --git a/batch/db/mutations.ts b/batch/db/mutations.ts index 413c3474..09f848e8 100644 --- a/batch/db/mutations.ts +++ b/batch/db/mutations.ts @@ -264,7 +264,8 @@ export async function upsertCompanyGithubUsers( function trackLatest(map: Map, login: string, ts: string) { const key = login.toLowerCase() - if (!map.has(key) || ts > (map.get(key) ?? '')) { + const current = map.get(key) + if (!current || ts > current) { map.set(key, ts) } }