Skip to content

Commit 3283c2a

Browse files
committed
feat(github): implement GitHub App installation flow with database support and update integration routes
1 parent 611801a commit 3283c2a

21 files changed

Lines changed: 1308 additions & 112 deletions

.env.example

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@ [email protected]
2020

2121
# --- GitHub App (register once at github.com → Settings → Developer settings → GitHub Apps) ---
2222
#
23+
# Required for “Connect GitHub” (install flow): webhook secret + slug OR full install URL.
24+
# Optional until API/webhooks: GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY
25+
#
2326
# Manual registration checklist (do not commit real secrets):
2427
# - Homepage URL: https://boardtask.app (or your APP_URL)
2528
# - Webhook URL: {APP_URL}/api/integrations/github/webhook
26-
# - Setup URL: {APP_URL}/api/integrations/github/callback
27-
# - Callback URL (OAuth): leave blank until user-to-server OAuth is implemented
29+
# - Setup URL: {APP_URL}/app/integrations/github/callback (must match APP_URL; if GitHub omits installation_id on return, fix this URL or clear the OAuth callback below)
30+
# - Callback URL (OAuth): leave blank until user-to-server OAuth is implemented (install flow uses Setup URL only)
2831
# - Webhook secret: paste the same value as GITHUB_WEBHOOK_SECRET below
2932
# - Permissions: Repository metadata Read; Pull requests Read; Checks Read when needed
3033
# - Events: installation, installation_repositories, pull_request (plus check_run/check_suite later)
@@ -33,7 +36,13 @@ [email protected]
3336
# Generate webhook secret: openssl rand -hex 32
3437
GITHUB_WEBHOOK_SECRET=
3538

36-
# From the GitHub App → General page
39+
# Public slug from https://github.com/apps/<slug> (required unless GITHUB_APP_INSTALL_URL is set)
40+
GITHUB_APP_SLUG=
41+
42+
# Optional: full install URL base, e.g. https://github.com/apps/my-app/installations/new
43+
# GITHUB_APP_INSTALL_URL=
44+
45+
# From the GitHub App → General page (API / JWT; not required for install-only Phase 1)
3746
GITHUB_APP_ID=
3847

3948
# PEM contents in one line; use \n for newlines, e.g. -----BEGIN RSA PRIVATE KEY-----\nMII...

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ async-trait = "0.1"
4343
lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-native-tls", "builder", "smtp-transport"] }
4444
thiserror = "1.0"
4545

46-
# Token generation
46+
# Token generation / encoding
4747
hex = "0.4"
4848
urlencoding = "2"
4949
strum = "0.26"

askama.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
[general]
2-
dirs = ["src", "src/app", "src/app/features/auth", "src/app/features", "src/app/features/projects", "src/app/features/organization", "src/app/features/invites"]
2+
dirs = ["src", "src/app", "src/app/features/auth", "src/app/features", "src/app/features/integrations", "src/app/features/projects", "src/app/features/organization", "src/app/features/invites"]
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- GitHub App installation bound to one organization (Phase 1).
2+
CREATE TABLE IF NOT EXISTS github_app_installations (
3+
organization_id TEXT PRIMARY KEY NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
4+
github_installation_id INTEGER NOT NULL,
5+
account_login TEXT,
6+
account_type TEXT,
7+
created_at INTEGER NOT NULL,
8+
updated_at INTEGER NOT NULL
9+
);
10+
11+
CREATE INDEX IF NOT EXISTS idx_github_app_installations_github_installation_id
12+
ON github_app_installations(github_installation_id);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- Short-lived opaque tokens for GitHub App install flow (avoids huge state= URLs that can be truncated by proxies).
2+
CREATE TABLE IF NOT EXISTS github_install_pending (
3+
id TEXT PRIMARY KEY NOT NULL,
4+
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
5+
expires_at INTEGER NOT NULL
6+
);
7+
8+
CREATE INDEX IF NOT EXISTS idx_github_install_pending_expires_at
9+
ON github_install_pending(expires_at);

src/app/config.rs

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
/// Centralized environment configuration.
22
/// All env vars and defaults are defined here.
3+
4+
/// Load `.env` from, in order: current working directory, crate root (`CARGO_MANIFEST_DIR`), then the
5+
/// executable’s directory. Later files only fill in variables not already set in the environment.
6+
pub fn load_dotenv_from_standard_locations() {
7+
let _ = dotenvy::dotenv();
8+
let repo_env = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".env");
9+
let _ = dotenvy::from_path(&repo_env);
10+
if let Ok(exe) = std::env::current_exe() {
11+
if let Some(dir) = exe.parent() {
12+
let _ = dotenvy::from_path(dir.join(".env"));
13+
}
14+
}
15+
}
16+
317
#[derive(Debug, Clone)]
418
pub struct Config {
519
/// Database connection URL. Required.
@@ -33,14 +47,21 @@ pub struct Config {
3347
/// SMTP encryption: `starttls`, `tls`, or `none`. Unset uses auto (465 → tls, else starttls).
3448
pub smtp_encryption: Option<String>,
3549

36-
/// GitHub App numeric ID from the app’s General settings. Optional until GitHub integration is used.
50+
/// GitHub App numeric ID from the app’s General settings. Optional until GitHub API / JWT is used.
3751
pub github_app_id: Option<String>,
3852

3953
/// PEM private key (`-----BEGIN RSA PRIVATE KEY-----` …). Use `\n` for newlines in `.env`.
4054
pub github_app_private_key: Option<String>,
4155

42-
/// Secret GitHub sends in `X-Hub-Signature-256` for webhooks. Optional until webhooks are enabled.
56+
/// Secret GitHub sends in `X-Hub-Signature-256` for webhooks (Phase 2+).
4357
pub github_webhook_secret: Option<String>,
58+
59+
/// GitHub App public slug (`https://github.com/apps/{slug}/installations/new`). Optional if
60+
/// `github_app_install_url` is set.
61+
pub github_app_slug: Option<String>,
62+
63+
/// Full GitHub App install URL (before `?state=`). Overrides slug-based URL when set.
64+
pub github_app_install_url: Option<String>,
4465
}
4566

4667
impl Config {
@@ -71,6 +92,8 @@ impl Config {
7192
let github_webhook_secret = nonempty_env("GITHUB_WEBHOOK_SECRET");
7293
let github_app_private_key = nonempty_env("GITHUB_APP_PRIVATE_KEY")
7394
.map(|s| s.replace("\\n", "\n"));
95+
let github_app_slug = nonempty_env("GITHUB_APP_SLUG");
96+
let github_app_install_url = nonempty_env("GITHUB_APP_INSTALL_URL");
7497

7598
Ok(Self {
7699
database_url,
@@ -85,6 +108,8 @@ impl Config {
85108
github_app_id,
86109
github_app_private_key,
87110
github_webhook_secret,
111+
github_app_slug,
112+
github_app_install_url,
88113
})
89114
}
90115

@@ -98,7 +123,8 @@ impl Config {
98123
format!("{}{}", self.app_url_base(), Self::github_webhook_path())
99124
}
100125

101-
/// Setup URL for post-install redirect (must match [`Self::github_install_callback_path`]).
126+
/// Setup URL for post-install redirect (register in the GitHub App as “Setup URL”; must match
127+
/// [`Self::github_install_callback_path`]).
102128
pub fn github_install_callback_url(&self) -> String {
103129
format!("{}{}", self.app_url_base(), Self::github_install_callback_path())
104130
}
@@ -108,9 +134,9 @@ impl Config {
108134
"/api/integrations/github/webhook"
109135
}
110136

111-
/// Browser redirect after install/update (`/api/...` per project routing rules).
137+
/// GitHub App **Setup URL** (browser GET with session cookie; app route, not `/api/`).
112138
pub fn github_install_callback_path() -> &'static str {
113-
"/api/integrations/github/callback"
139+
"/app/integrations/github/callback"
114140
}
115141

116142
/// True when App ID and private key are set (JWT / installation tokens).
@@ -123,6 +149,11 @@ impl Config {
123149
self.github_webhook_secret.is_some()
124150
}
125151

152+
/// Explains which env vars are missing when the GitHub install adapter is off (`None` if it would be on).
153+
pub fn github_install_env_hint(&self) -> Option<String> {
154+
crate::app::integrations::github::install_env_hint(self)
155+
}
156+
126157
/// Config for tests. Uses in-memory database URL and console mailer.
127158
pub fn for_tests() -> Self {
128159
Self {
@@ -138,6 +169,8 @@ impl Config {
138169
github_app_id: None,
139170
github_app_private_key: None,
140171
github_webhook_secret: None,
172+
github_app_slug: None,
173+
github_app_install_url: None,
141174
}
142175
}
143176
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
use sqlx::SqliteExecutor;
2+
use time::OffsetDateTime;
3+
use ulid::Ulid;
4+
5+
const PENDING_TTL_SECS: i64 = 900;
6+
7+
/// Insert a pending install token and return the opaque id (URL-safe, short).
8+
pub async fn create<'e, E>(executor: E, organization_id: &str) -> Result<String, sqlx::Error>
9+
where
10+
E: SqliteExecutor<'e>,
11+
{
12+
let id = Ulid::new().to_string();
13+
let exp = OffsetDateTime::now_utc().unix_timestamp() + PENDING_TTL_SECS;
14+
sqlx::query(
15+
r#"INSERT INTO github_install_pending (id, organization_id, expires_at) VALUES (?, ?, ?)"#,
16+
)
17+
.bind(&id)
18+
.bind(organization_id)
19+
.bind(exp)
20+
.execute(executor)
21+
.await?;
22+
Ok(id)
23+
}
24+
25+
/// Remove expired rows (best-effort; keeps the table small).
26+
pub async fn delete_expired<'e, E>(executor: E) -> Result<u64, sqlx::Error>
27+
where
28+
E: SqliteExecutor<'e>,
29+
{
30+
let now = OffsetDateTime::now_utc().unix_timestamp();
31+
let r = sqlx::query(r#"DELETE FROM github_install_pending WHERE expires_at <= ?"#)
32+
.bind(now)
33+
.execute(executor)
34+
.await?;
35+
Ok(r.rows_affected())
36+
}
37+
38+
/// Load the organization id for a valid token without consuming it.
39+
pub async fn find_valid<'e, E>(executor: E, token: &str) -> Result<Option<String>, sqlx::Error>
40+
where
41+
E: SqliteExecutor<'e>,
42+
{
43+
let now = OffsetDateTime::now_utc().unix_timestamp();
44+
sqlx::query_scalar(
45+
r#"SELECT organization_id FROM github_install_pending WHERE id = ? AND expires_at > ?"#,
46+
)
47+
.bind(token.trim())
48+
.bind(now)
49+
.fetch_optional(executor)
50+
.await
51+
}
52+
53+
/// Consume a valid token after the install callback has been handled successfully.
54+
pub async fn consume<'e, E>(executor: E, token: &str) -> Result<bool, sqlx::Error>
55+
where
56+
E: SqliteExecutor<'e>,
57+
{
58+
let now = OffsetDateTime::now_utc().unix_timestamp();
59+
let r = sqlx::query(
60+
r#"DELETE FROM github_install_pending WHERE id = ? AND expires_at > ? RETURNING organization_id"#,
61+
)
62+
.bind(token.trim())
63+
.bind(now)
64+
.execute(executor)
65+
.await?;
66+
Ok(r.rows_affected() > 0)
67+
}

src/app/db/github_installations.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
use sqlx::{FromRow, SqliteExecutor};
2+
use time::OffsetDateTime;
3+
4+
/// One GitHub App installation linked to an organization.
5+
#[derive(Debug, Clone, FromRow)]
6+
pub struct GitHubAppInstallation {
7+
pub organization_id: String,
8+
pub github_installation_id: i64,
9+
pub account_login: Option<String>,
10+
pub account_type: Option<String>,
11+
pub created_at: i64,
12+
pub updated_at: i64,
13+
}
14+
15+
/// Upsert the GitHub installation for an org (one row per org).
16+
pub async fn upsert_for_org<'e, E>(
17+
executor: E,
18+
organization_id: &str,
19+
github_installation_id: i64,
20+
account_login: Option<&str>,
21+
account_type: Option<&str>,
22+
) -> Result<(), sqlx::Error>
23+
where
24+
E: SqliteExecutor<'e>,
25+
{
26+
let now = OffsetDateTime::now_utc().unix_timestamp();
27+
sqlx::query(
28+
r#"INSERT INTO github_app_installations (
29+
organization_id, github_installation_id, account_login, account_type, created_at, updated_at
30+
) VALUES (?, ?, ?, ?, ?, ?)
31+
ON CONFLICT (organization_id) DO UPDATE SET
32+
github_installation_id = excluded.github_installation_id,
33+
account_login = excluded.account_login,
34+
account_type = excluded.account_type,
35+
updated_at = excluded.updated_at"#,
36+
)
37+
.bind(organization_id)
38+
.bind(github_installation_id)
39+
.bind(account_login)
40+
.bind(account_type)
41+
.bind(now)
42+
.bind(now)
43+
.execute(executor)
44+
.await?;
45+
Ok(())
46+
}
47+
48+
/// Load the installation row for an organization, if any.
49+
pub async fn find_by_org<'e, E>(
50+
executor: E,
51+
organization_id: &str,
52+
) -> Result<Option<GitHubAppInstallation>, sqlx::Error>
53+
where
54+
E: SqliteExecutor<'e>,
55+
{
56+
sqlx::query_as::<_, GitHubAppInstallation>(
57+
r#"SELECT organization_id, github_installation_id, account_login, account_type, created_at, updated_at
58+
FROM github_app_installations WHERE organization_id = ?"#,
59+
)
60+
.bind(organization_id)
61+
.fetch_optional(executor)
62+
.await
63+
}

src/app/db/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ pub mod node_edges;
1111
pub mod project_slots;
1212
pub mod task_statuses;
1313
pub mod integrations;
14+
pub mod github_installations;
15+
pub mod github_install_pending;
1416
pub mod teams;
1517
pub mod team_members;
1618
pub mod notifications;

src/app/features/integrations.html

Lines changed: 0 additions & 26 deletions
This file was deleted.

0 commit comments

Comments
 (0)