diff --git a/README.md b/README.md index 6e9d3815..7f98b221 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # UpFlow -Development productivity dashboard that tracks pull request cycle times from GitHub. Calculates coding time, pickup time, review time, and deploy time to help teams understand their development workflow. +Tracks pull request cycle times from GitHub — coding, pickup, review, and deploy — so teams can see where time is spent and ship faster. Data is stored in SQLite with a multi-tenant (database-per-org) architecture. @@ -17,35 +17,91 @@ Data is stored in SQLite with a multi-tenant (database-per-org) architecture. pnpm install ``` -### 2. Configure environment variables +### 2. Create a GitHub App + +UpFlow uses a GitHub App for OAuth login. This is required regardless of which integration method you choose below. + +Create a GitHub App at https://github.com/settings/apps/new: + +- **Callback URL**: `http://localhost:5173/api/auth/callback/github` (dev) / `https://your-domain/api/auth/callback/github` (prod) +- **Expire user authorization tokens**: ON +- **Request user authorization (OAuth) during installation**: ON +- **Permissions > Account permissions > Email addresses**: Read-only + +Then copy `.env.example` and fill in the values: ```bash cp .env.example .env ``` -Edit `.env` with your values: +| Variable | Description | +| ---------------------- | -------------------------------------- | +| `UPFLOW_DATA_DIR` | Data directory path (e.g. `./data`) | +| `BETTER_AUTH_SECRET` | Secret for better-auth (min 32 chars) | +| `BETTER_AUTH_URL` | App URL (e.g. `http://localhost:5173`) | +| `GITHUB_CLIENT_ID` | GitHub App client ID | +| `GITHUB_CLIENT_SECRET` | GitHub App client secret | -| Variable | Description | Required | -| --------------------------- | -------------------------------------- | -------- | -| `UPFLOW_DATA_DIR` | Data directory path (e.g. `./data`) | Yes | -| `BETTER_AUTH_SECRET` | Secret for better-auth (min 32 chars) | Yes | -| `BETTER_AUTH_URL` | App URL (e.g. `http://localhost:5173`) | Yes | -| `GITHUB_CLIENT_ID` | GitHub App client ID | Yes | -| `GITHUB_CLIENT_SECRET` | GitHub App client secret | Yes | -| `INTEGRATION_PRIVATE_TOKEN` | GitHub PAT for PR data fetching | Yes | -| `GEMINI_API_KEY` | Gemini API key for PR classification | No | +### 3. Choose integration method -### 3. Set up GitHub App +UpFlow supports two ways to fetch PR data from GitHub. -Create a GitHub App at https://github.com/settings/apps/new: +#### Method A: PAT (Personal Access Token) — simple self-hosted setup -- **Callback URL**: `http://localhost:5173/api/auth/callback/github` (dev) / `https://your-domain/api/auth/callback/github` (prod) -- **Expire user authorization tokens**: ON -- **Request user authorization (OAuth) during installation**: ON -- **Webhook**: Active OFF -- **Permissions > Account permissions > Email addresses**: Read-only +The quickest way to get started on a single server. Just add a GitHub PAT to `.env`: + +| Variable | Description | +| --------------------------- | ------------------------------- | +| `INTEGRATION_PRIVATE_TOKEN` | GitHub PAT for PR data fetching | + +PR data is fetched by an hourly crawl job. For the initial fetch, see [Fetching PR Data](#fetching-pr-data) below. + +#### Method B: GitHub App — organization-wide with realtime updates + +For multi-team rollouts where PR data should update instantly on events. This uses the same GitHub App created in Step 2, with additional permissions and configuration. + +##### Add repository permissions + +In your GitHub App settings, add the following repository permissions: + +- **Contents**: Read-only (commits) +- **Pull requests**: Read-only (PRs, reviews, comments) +- **Deployments**: Read-only (deploy events for cycle time calculation) +- **Metadata**: Read-only (automatically granted) + +##### Additional environment variables + +Add to `.env`: + +| Variable | Description | +| ------------------------ | --------------------------------------- | +| `GITHUB_APP_ID` | GitHub App ID | +| `GITHUB_APP_PRIVATE_KEY` | GitHub App private key (base64 encoded) | +| `GITHUB_WEBHOOK_SECRET` | Webhook signature verification secret | + +The private key should be base64-encoded from the PEM file (required for platforms like Fly.io where newlines in env vars are problematic). + +##### Install the App to your Organization + +1. Log in to UpFlow and go to **Settings > Integration** +2. Switch the integration method to **GitHub App** +3. Click **Install GitHub App** — you'll be redirected to GitHub's installation page +4. Select the target Organization and configure repository access (All / Selected) +5. You'll be redirected back to UpFlow once installation is complete + +You can verify the connection status in Settings > Integration. + +##### Configure Webhook + +Set up the webhook to enable realtime PR data updates. -Use the **Client ID** and generate a **Client secret** for your `.env`. +1. In your GitHub App settings, set **Webhook** to Active +2. **Webhook URL**: `https://your-domain/api/github/webhook` +3. Generate a **Webhook Secret** and set the same value in both GitHub App settings and `GITHUB_WEBHOOK_SECRET` in `.env` +4. Under **Subscribe to events**, enable: + - **Pull request** — triggers realtime crawl on PR changes + - **Pull request review** — captures review events + - **Pull request review comment** — captures review comments ### 4. Initialize database @@ -61,20 +117,20 @@ pnpm dev ## Fetching PR Data -UpFlow needs to fetch PR data from GitHub to display metrics. After setting up a repository in the dashboard, run: +UpFlow needs to fetch PR data from GitHub to display metrics. After adding a repository in the dashboard, run: ```bash pnpm tsx batch/cli.ts crawl ``` -In production, `crawl` runs automatically every hour. +In production, `crawl` runs automatically every hour. With Method B (GitHub App + Webhook), data also updates in realtime on PR events. ## Authentication -- **GitHub OAuth only**: Login requires the user's GitHub login to be registered in the org's GitHub Users list with Active status -- **First-user bootstrap**: On a fresh database with no users, the first GitHub login is allowed unconditionally and promoted to super admin -- **Auto-registration**: PR authors and reviewers are automatically added as inactive GitHub users during crawl. An admin enables them via Settings > GitHub Users +- **GitHub OAuth only**: login requires the user's GitHub login to be registered in the org's GitHub Users list with Active status +- **First-user bootstrap**: on a fresh database with no users, the first GitHub login is allowed unconditionally and promoted to super admin +- **Auto-registration**: PR authors and reviewers are automatically added as inactive GitHub users during crawl — an admin enables them via Settings > GitHub Users ## License -[O'Saasy License](./LICENSE) — 自由に使用・改変・配布できますが、本ソフトウェアの機能そのものを主たる価値とする競合 SaaS の提供は禁止されています。 +[O'Saasy License](./LICENSE) diff --git a/app/libs/auth-client.ts b/app/libs/auth-client.ts index bcd8601a..7816df8f 100644 --- a/app/libs/auth-client.ts +++ b/app/libs/auth-client.ts @@ -1,11 +1,6 @@ import { adminClient, organizationClient } from 'better-auth/client/plugins' import { createAuthClient } from 'better-auth/react' -const baseURL = import.meta.env.DEV - ? 'http://localhost:5173' - : 'https://upflow.team' - export const authClient = createAuthClient({ - baseURL, plugins: [adminClient(), organizationClient()], }) diff --git a/app/libs/dotenv.server.ts b/app/libs/dotenv.server.ts index 4cdf492e..1919ca0d 100644 --- a/app/libs/dotenv.server.ts +++ b/app/libs/dotenv.server.ts @@ -4,8 +4,8 @@ const envSchema = z.object({ UPFLOW_DATA_DIR: z.string(), BETTER_AUTH_URL: z.string(), BETTER_AUTH_SECRET: z.string().min(32), - GOOGLE_CLIENT_ID: z.string(), - GOOGLE_CLIENT_SECRET: z.string(), + GITHUB_CLIENT_ID: z.string(), + GITHUB_CLIENT_SECRET: z.string(), }) envSchema.parse(process.env) diff --git a/app/routes/$orgSlug/settings/_index/+forms/integration-settings.tsx b/app/routes/$orgSlug/settings/_index/+forms/integration-settings.tsx index 33429ef3..307039df 100644 --- a/app/routes/$orgSlug/settings/_index/+forms/integration-settings.tsx +++ b/app/routes/$orgSlug/settings/_index/+forms/integration-settings.tsx @@ -79,7 +79,7 @@ function GitHubAppSection({ integration?.method === 'github_app' && githubAppLink == null const githubInstallationsSettingsUrl = githubAppLink - ? `https://github.com/orgs/${encodeURIComponent(githubAppLink.githubOrg)}/settings/installations` + ? `https://github.com/organizations/${encodeURIComponent(githubAppLink.githubOrg)}/settings/installations` : null const tokenNote = !integration?.hasToken ? ( diff --git a/app/routes/$orgSlug/settings/integration/index.tsx b/app/routes/$orgSlug/settings/integration/index.tsx index 56ae11fc..3abae568 100644 --- a/app/routes/$orgSlug/settings/integration/index.tsx +++ b/app/routes/$orgSlug/settings/integration/index.tsx @@ -10,15 +10,13 @@ import { getGithubAppLink, getIntegration, } from '~/app/services/github-integration-queries.server' +import { getGithubAppSlug } from '~/app/services/github-octokit.server' import ContentSection from '../+components/content-section' import { IntegrationSettings } from '../_index/+forms/integration-settings' import { upsertIntegration } from '../_index/+functions/mutations.server' import { INTENTS, integrationSettingsSchema as schema } from '../_index/+schema' import type { Route } from './+types/index' -const GITHUB_APP_INSTALL_NEW_URL = - 'https://github.com/apps/upflow-team/installations/new' - export const handle = { breadcrumb: (_data: unknown, params: { orgSlug: string }) => ({ label: 'Integration', @@ -28,9 +26,10 @@ export const handle = { export const loader = async ({ context }: Route.LoaderArgs) => { const { organization } = context.get(orgContext) - const [integration, githubAppLink] = await Promise.all([ + const [integration, githubAppLink, githubAppSlug] = await Promise.all([ getIntegration(organization.id), getGithubAppLink(organization.id), + getGithubAppSlug(), ]) const safeIntegration = integration ? { @@ -46,7 +45,11 @@ export const loader = async ({ context }: Route.LoaderArgs) => { appRepositorySelection: githubAppLink.appRepositorySelection, } : null - return { integration: safeIntegration, githubAppLink: safeGithubAppLink } + return { + integration: safeIntegration, + githubAppLink: safeGithubAppLink, + githubAppSlug, + } } export const action = async ({ request, context }: Route.ActionArgs) => { @@ -116,15 +119,27 @@ export const action = async ({ request, context }: Route.ActionArgs) => { ) } - if (intent === INTENTS.installGithubApp) { + if ( + intent === INTENTS.installGithubApp || + intent === INTENTS.copyInstallUrl + ) { + const slug = await getGithubAppSlug() + if (!slug) { + return dataWithError( + { intent: intent as string }, + { + message: + 'GitHub App is not configured (GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY missing)', + }, + ) + } + const baseUrl = `https://github.com/apps/${slug}/installations/new` const nonce = await generateInstallState(organization.id) - const installUrl = `${GITHUB_APP_INSTALL_NEW_URL}?state=${encodeURIComponent(nonce)}` - throw redirect(installUrl) - } + const installUrl = `${baseUrl}?state=${encodeURIComponent(nonce)}` - if (intent === INTENTS.copyInstallUrl) { - const nonce = await generateInstallState(organization.id) - const installUrl = `${GITHUB_APP_INSTALL_NEW_URL}?state=${encodeURIComponent(nonce)}` + if (intent === INTENTS.installGithubApp) { + throw redirect(installUrl) + } return data({ intent: INTENTS.copyInstallUrl, installUrl, diff --git a/app/services/github-octokit.server.ts b/app/services/github-octokit.server.ts index 112797f4..4054306a 100644 --- a/app/services/github-octokit.server.ts +++ b/app/services/github-octokit.server.ts @@ -25,6 +25,24 @@ export function createAppOctokit(): Octokit { }) } +let cachedAppSlug: string | null = null + +/** + * Get the GitHub App slug (e.g. "upflow-team") via GET /app. + * Cached in memory after first successful call. + */ +export async function getGithubAppSlug(): Promise { + if (cachedAppSlug) return cachedAppSlug + try { + const octokit = createAppOctokit() + const { data } = await octokit.rest.apps.getAuthenticated() + cachedAppSlug = data?.slug ?? null + return cachedAppSlug + } catch { + return null + } +} + export type IntegrationAuth = | { method: 'token'; privateToken: string } | { method: 'github_app'; installationId: number } diff --git a/app/services/jobs/process.server.ts b/app/services/jobs/process.server.ts index 6a2590a0..9c4b3600 100644 --- a/app/services/jobs/process.server.ts +++ b/app/services/jobs/process.server.ts @@ -30,6 +30,7 @@ export const processJob = defineJob({ run: async (step, input) => { const orgId = input.organizationId as OrganizationId + // scopes: undefined → full-org processing, scopes: [] → no-op if (input.scopes !== undefined && input.scopes.length === 0) { return { pullCount: 0 } } diff --git a/vite.config.ts b/vite.config.ts index 4414886c..dce167b5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -34,6 +34,10 @@ export default defineConfig(async (configEnv) => { : []), ], + server: { + allowedHosts: ['.ngrok-free.app'], + }, + optimizeDeps: { exclude: ['@sentry/react-router'], },