Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 82 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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

Expand All @@ -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 <org-id>
```

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)
5 changes: 0 additions & 5 deletions app/libs/auth-client.ts
Original file line number Diff line number Diff line change
@@ -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()],
})
4 changes: 2 additions & 2 deletions app/libs/dotenv.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
Expand Down
39 changes: 27 additions & 12 deletions app/routes/$orgSlug/settings/integration/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
? {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions app/services/github-octokit.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
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 }
Expand Down
1 change: 1 addition & 0 deletions app/services/jobs/process.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down
4 changes: 4 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export default defineConfig(async (configEnv) => {
: []),
],

server: {
allowedHosts: ['.ngrok-free.app'],
},

optimizeDeps: {
exclude: ['@sentry/react-router'],
},
Expand Down
Loading