From 82ca3d93c954926695da4c070d7994a20b8a2b2c Mon Sep 17 00:00:00 2001 From: bobdivx <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:06:40 +0200 Subject: [PATCH] feat(github-app): P6 repo picker (installation repos, project link, lost-access banner) --- apps/server/src/github_app_routes.rs | 4 +- apps/server/src/github_repos.rs | 300 ++++++++++++++++++ apps/server/src/main.rs | 2 + .../src/components/GithubRepoPicker.test.tsx | 32 ++ apps/web/src/components/GithubRepoPicker.tsx | 98 ++++++ .../web/src/components/NewGithubAppWizard.tsx | 31 ++ apps/web/src/components/ProjectGitPanel.tsx | 27 ++ apps/web/src/lib/api.ts | 28 ++ crates/github/src/lib.rs | 1 + crates/github/src/repos.rs | 123 +++++++ 10 files changed, 644 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/github_repos.rs create mode 100644 apps/web/src/components/GithubRepoPicker.test.tsx create mode 100644 apps/web/src/components/GithubRepoPicker.tsx create mode 100644 crates/github/src/repos.rs diff --git a/apps/server/src/github_app_routes.rs b/apps/server/src/github_app_routes.rs index 190edf2feb..0b71f72b49 100644 --- a/apps/server/src/github_app_routes.rs +++ b/apps/server/src/github_app_routes.rs @@ -54,7 +54,7 @@ pub fn router() -> Router { .route("/api/v1/github-app/installed", get(installed)) } -fn api_base() -> String { +pub(crate) fn api_base() -> String { std::env::var("DEVFORGE_GITHUB_API_URL") .ok() .map(|s| s.trim().trim_end_matches('/').to_string()) @@ -62,7 +62,7 @@ fn api_base() -> String { .unwrap_or_else(|| "https://api.github.com".into()) } -fn web_base() -> String { +pub(crate) fn web_base() -> String { std::env::var("DEVFORGE_GITHUB_WEB_URL") .ok() .map(|s| s.trim().trim_end_matches('/').to_string()) diff --git a/apps/server/src/github_repos.rs b/apps/server/src/github_repos.rs new file mode 100644 index 0000000000..30ff3b65e4 --- /dev/null +++ b/apps/server/src/github_repos.rs @@ -0,0 +1,300 @@ +//! P6 — Sélecteur de dépôts GitHub App et liaison d'un projet à un dépôt de l'installation. +//! +//! - `GET /api/v1/github-app/repositories?q=&installation_id=&refresh=1` : cache +//! `github_installation_repos` ; `refresh=1` relit `GET /installation/repositories`. +//! - `GET|PUT /api/v1/projects/{uuid}/github-link` : état de liaison (dont « accès perdu ») +//! et liaison (installation + dépôt ⇒ URL, branche par défaut si vide). + +use axum::{ + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode}, + routing::get, + Json, Router, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sqlx::PgPool; + +use crate::routes::{auth_project, ApiError}; +use crate::state::{now_str, AppState}; + +pub fn router() -> Router { + Router::new() + .route("/api/v1/github-app/repositories", get(repositories)) + .route( + "/api/v1/projects/{uuid}/github-link", + get(get_link).put(put_link), + ) +} + +#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] +pub struct RepoRow { + pub installation_id: i64, + pub repo_id: i64, + pub full_name: String, + pub private: bool, + pub default_branch: Option, +} + +/// Filtre insensible à la casse sur `owner/nom`, tri alphabétique. +pub fn filter_repos(mut rows: Vec, q: &str) -> Vec { + let q = q.trim().to_ascii_lowercase(); + if !q.is_empty() { + rows.retain(|r| r.full_name.to_ascii_lowercase().contains(&q)); + } + rows.sort_by(|a, b| { + a.full_name + .to_ascii_lowercase() + .cmp(&b.full_name.to_ascii_lowercase()) + }); + rows +} + +/// Lien « Configurer l'accès aux dépôts » (compte perso ou organisation). +pub fn configure_url(web: &str, installation_id: i64, account_type: &str, login: &str) -> String { + if account_type.eq_ignore_ascii_case("Organization") { + format!("{web}/organizations/{login}/settings/installations/{installation_id}") + } else { + format!("{web}/settings/installations/{installation_id}") + } +} + +/// Remplace le cache d'une installation par la liste fraîche de GitHub. +pub async fn replace_cache( + pool: &PgPool, + installation_id: i64, + repos: &[devforge_github::repos::InstallationRepo], +) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + sqlx::query("DELETE FROM github_installation_repos WHERE installation_id = $1") + .bind(installation_id) + .execute(&mut *tx) + .await?; + for r in repos { + sqlx::query( + "INSERT INTO github_installation_repos (installation_id, repo_id, full_name, private, default_branch) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING", + ) + .bind(installation_id) + .bind(r.id) + .bind(&r.full_name) + .bind(r.private) + .bind(&r.default_branch) + .execute(&mut *tx) + .await?; + } + tx.commit().await +} + +#[derive(Deserialize)] +struct RepoQuery { + q: Option, + installation_id: Option, + refresh: Option, +} + +async fn require_user(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> { + match crate::auth_routes::user_from_bearer_or_cookie(state, headers).await { + Ok(Some(_)) => Ok(()), + _ => Err(ApiError { + status: StatusCode::UNAUTHORIZED, + message: "connexion requise".into(), + }), + } +} + +async fn repositories( + State(state): State, + headers: HeaderMap, + Query(q): Query, +) -> Result, ApiError> { + require_user(&state, &headers).await?; + let insts: Vec<(i64, String, String, bool)> = sqlx::query_as( + "SELECT installation_id, account_login, account_type, suspended_at IS NOT NULL FROM github_installations ORDER BY created_at", + ) + .fetch_all(&state.pool) + .await + .map_err(ApiError::from)?; + let wanted: Vec = insts + .iter() + .filter(|i| q.installation_id.is_none_or(|w| w == i.0)) + .map(|i| i.0) + .collect(); + let mut refresh_error = None; + if matches!(q.refresh.as_deref(), Some("1") | Some("true")) { + for id in &wanted { + let auth = crate::github_auth::GithubAuth::Installation { + id: *id, + repo_id: None, + }; + let token = crate::github_auth::token(&state, &auth, "").await; + if token.is_empty() { + refresh_error = Some("token d’installation indisponible".to_string()); + continue; + } + match devforge_github::repos::list_installation_repos( + &crate::github_app_routes::api_base(), + &token, + ) + .await + { + Ok(list) => { + replace_cache(&state.pool, *id, &list) + .await + .map_err(ApiError::from)?; + } + Err(e) => refresh_error = Some(e), + } + } + } + let rows: Vec = sqlx::query_as( + "SELECT installation_id, repo_id, full_name, private, default_branch FROM github_installation_repos WHERE installation_id = ANY($1)", + ) + .bind(&wanted) + .fetch_all(&state.pool) + .await + .map_err(ApiError::from)?; + let rows = filter_repos(rows, q.q.as_deref().unwrap_or("")); + let web = crate::github_app_routes::web_base(); + Ok(Json(json!({ + "ok": true, + "installations": insts.iter().filter(|i| wanted.contains(&i.0)).map(|(id, login, ty, susp)| json!({ + "installation_id": id, "account_login": login, "account_type": ty, "suspended": susp, + "configure_url": configure_url(&web, *id, ty, login), + })).collect::>(), + "repositories": rows.iter().map(|r| json!({ + "installation_id": r.installation_id, "repo_id": r.repo_id, "full_name": r.full_name, + "private": r.private, "default_branch": r.default_branch, + "html_url": format!("{web}/{}", r.full_name), + })).collect::>(), + "refresh_error": refresh_error, + }))) +} + +/// `(installation, dépôt)` du projet ; `lost` = lié mais le dépôt n'est plus accessible. +pub async fn link_state(pool: &PgPool, project_uuid: &str) -> Value { + let link: Option<(Option, Option)> = sqlx::query_as( + "SELECT github_installation_id, github_repo_id FROM projects WHERE uuid = $1", + ) + .bind(project_uuid) + .fetch_optional(pool) + .await + .ok() + .flatten(); + let Some((Some(inst), repo)) = link else { + return json!({"linked": false, "lost": false}); + }; + let present: bool = match repo { + Some(r) => sqlx::query_as::<_, (i64,)>( + "SELECT 1::BIGINT FROM github_installation_repos gr JOIN github_installations gi USING (installation_id) WHERE gr.installation_id = $1 AND gr.repo_id = $2 AND gi.suspended_at IS NULL", + ) + .bind(inst) + .bind(r) + .fetch_optional(pool) + .await + .ok() + .flatten() + .is_some(), + None => false, + }; + json!({"linked": true, "installation_id": inst, "repo_id": repo, "lost": !present}) +} + +async fn get_link( + State(state): State, + headers: HeaderMap, + Path(uuid): Path, +) -> Result, ApiError> { + let (_u, _w, project) = auth_project(&state, &headers, &uuid).await?; + let mut v = link_state(&state.pool, &project.uuid).await; + v["ok"] = json!(true); + Ok(Json(v)) +} + +#[derive(Deserialize)] +struct LinkBody { + installation_id: i64, + repo_id: i64, + branch: Option, +} + +async fn put_link( + State(state): State, + headers: HeaderMap, + Path(uuid): Path, + Json(b): Json, +) -> Result, ApiError> { + let (_u, _w, project) = auth_project(&state, &headers, &uuid).await?; + let row: Option = sqlx::query_as( + "SELECT installation_id, repo_id, full_name, private, default_branch FROM github_installation_repos WHERE installation_id = $1 AND repo_id = $2", + ) + .bind(b.installation_id) + .bind(b.repo_id) + .fetch_optional(&state.pool) + .await + .map_err(ApiError::from)?; + let row = row.ok_or_else(|| { + ApiError::message("Dépôt non accessible à l’app GitHub : ajoute-le à l’installation.") + })?; + let branch = b + .branch + .clone() + .filter(|s| !s.trim().is_empty()) + .or_else(|| project.git_branch.clone().filter(|s| !s.trim().is_empty())) + .or(row.default_branch.clone()) + .unwrap_or_else(|| "main".into()); + let url = format!("{}/{}", crate::github_app_routes::web_base(), row.full_name); + sqlx::query( + "UPDATE projects SET github_installation_id = $1, github_repo_id = $2, git_repository = $3, git_branch = $4, updated_at = $5 WHERE uuid = $6", + ) + .bind(row.installation_id) + .bind(row.repo_id) + .bind(&url) + .bind(&branch) + .bind(now_str()) + .bind(&project.uuid) + .execute(&state.pool) + .await + .map_err(ApiError::from)?; + Ok(Json(json!({ + "ok": true, "git_repository": url, "git_branch": branch, + "installation_id": row.installation_id, "repo_id": row.repo_id, + }))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(name: &str) -> RepoRow { + RepoRow { + installation_id: 1, + repo_id: name.len() as i64, + full_name: name.into(), + private: false, + default_branch: Some("main".into()), + } + } + + #[test] + fn filter_is_case_insensitive_and_sorted() { + let out = filter_repos( + vec![r("bobdivx/Zeta"), r("bobdivx/alpha"), r("x/other")], + "BOBDIVX", + ); + let names: Vec<_> = out.iter().map(|r| r.full_name.as_str()).collect(); + assert_eq!(names, vec!["bobdivx/alpha", "bobdivx/Zeta"]); + assert_eq!(filter_repos(vec![r("a/b")], "zzz").len(), 0); + } + + #[test] + fn configure_url_user_vs_org() { + assert_eq!( + configure_url("https://github.com", 7, "User", "bobdivx"), + "https://github.com/settings/installations/7" + ); + assert_eq!( + configure_url("https://github.com", 7, "Organization", "acme"), + "https://github.com/organizations/acme/settings/installations/7" + ); + } +} diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 75d90e5493..05eb93f990 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -31,6 +31,7 @@ mod github_app_events; mod github_app_routes; mod github_auth; mod github_checks; +mod github_repos; mod group_routes; mod infra_routes; mod infra_sqlite; @@ -372,6 +373,7 @@ async fn main() -> Result<(), Box> { .merge(git_routes::router()) .merge(github_app_routes::router()) .merge(pr_previews::router()) + .merge(github_repos::router()) .merge(github_app_events::router()) .merge(draft_routes::router()) .merge(correction::router()) diff --git a/apps/web/src/components/GithubRepoPicker.test.tsx b/apps/web/src/components/GithubRepoPicker.test.tsx new file mode 100644 index 0000000000..d79fe0087b --- /dev/null +++ b/apps/web/src/components/GithubRepoPicker.test.tsx @@ -0,0 +1,32 @@ +import { fireEvent, render, waitFor } from '@testing-library/preact'; +import { describe, expect, it, vi } from 'vitest'; +import type { AppReposResponse } from '../lib/api'; +import { GithubRepoPicker } from './GithubRepoPicker'; + +const inst = { installation_id: 1, account_login: 'bobdivx', account_type: 'User', suspended: false, configure_url: 'https://github.com/settings/installations/1' }; +const repo = (n: string, priv = false) => ({ installation_id: 1, repo_id: n.length, full_name: n, private: priv, default_branch: 'main', html_url: `https://github.com/${n}` }); + +function client(d: AppReposResponse) { + return { githubAppRepositories: vi.fn(async () => d) }; +} + +describe('GithubRepoPicker', () => { + it('vide → bouton installer', async () => { + const r = render( {}} client={client({ ok: true, installations: [], repositories: [] })} />); + expect(await r.findByText('Installer l’app GitHub')).toBeTruthy(); + }); + + it('recherche, badge privé et sélection', async () => { + const onSelect = vi.fn(); + const c = client({ ok: true, installations: [inst], repositories: [repo('bobdivx/alpha', true), repo('bobdivx/beta-long')] }); + const r = render(); + expect(await r.findByText('bobdivx/alpha')).toBeTruthy(); + expect(r.getByText('privé')).toBeTruthy(); + fireEvent.input(r.getByPlaceholderText('Rechercher un dépôt…'), { target: { value: 'beta' } }); + await waitFor(() => expect(r.queryByText('bobdivx/alpha')).toBeNull()); + fireEvent.click(r.getByText('bobdivx/beta-long')); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ full_name: 'bobdivx/beta-long', installation_id: 1 })); + fireEvent.click(r.getByText('Actualiser')); + await waitFor(() => expect(c.githubAppRepositories).toHaveBeenLastCalledWith({ refresh: true })); + }); +}); diff --git a/apps/web/src/components/GithubRepoPicker.tsx b/apps/web/src/components/GithubRepoPicker.tsx new file mode 100644 index 0000000000..5d776e59a6 --- /dev/null +++ b/apps/web/src/components/GithubRepoPicker.tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'preact/hooks'; +import { api, type AppRepo, type AppReposResponse } from '../lib/api'; + +export type RepoPickerClient = { + githubAppRepositories: (p: { q?: string; refresh?: boolean }) => Promise; +}; + +/** Dépôts accessibles à l'app GitHub DevForge (sans PAT). */ +export function GithubRepoPicker({ + onSelect, + selected, + client = api as RepoPickerClient, +}: { + onSelect: (r: AppRepo) => void; + selected?: { installation_id: number; repo_id: number } | null; + client?: RepoPickerClient; +}) { + const [data, setData] = useState(null); + const [q, setQ] = useState(''); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + + const load = async (refresh = false) => { + setBusy(true); + try { + const d = await client.githubAppRepositories({ refresh }); + setData(d); + setErr(d.refresh_error ?? null); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + useEffect(() => { + void load(false); + }, []); + + if (!data) return

{err ?? 'Chargement des dépôts…'}

; + + const needle = q.trim().toLowerCase(); + const repos = data.repositories.filter((r) => !needle || r.full_name.toLowerCase().includes(needle)); + + if (data.installations.length === 0) { + return ( +
+

L’app GitHub DevForge n’est installée sur aucun compte.

+ + Installer l’app GitHub + +
+ ); + } + + return ( +
+
+ setQ((e.target as HTMLInputElement).value)} + /> + +
+ {err &&

{err}

} + {repos.length === 0 ? ( +

Aucun dépôt{needle ? ' ne correspond' : ' accessible'}.

+ ) : ( +
    + {repos.slice(0, 100).map((r) => ( +
  • + +
  • + ))} +
+ )} +

+ {data.installations.map((i) => ( + + Configurer l’accès aux dépôts ({i.account_login}) + + ))} +

+
+ ); +} diff --git a/apps/web/src/components/NewGithubAppWizard.tsx b/apps/web/src/components/NewGithubAppWizard.tsx index 584aee7743..e3665b464b 100644 --- a/apps/web/src/components/NewGithubAppWizard.tsx +++ b/apps/web/src/components/NewGithubAppWizard.tsx @@ -1,4 +1,6 @@ import { useEffect, useState } from 'preact/hooks'; +import { GithubRepoPicker } from './GithubRepoPicker'; +import type { AppRepo } from '../lib/api'; import { api } from '../lib/api'; import type { ClusterNode } from '../lib/api'; import { NodeSelect } from './NodeSelect'; @@ -84,6 +86,7 @@ export function NewGithubAppWizard({ const [repos, setRepos] = useState([]); const [query, setQuery] = useState(''); const [selected, setSelected] = useState(null); + const [appRepo, setAppRepo] = useState(null); const [branches, setBranches] = useState([]); const [branch, setBranch] = useState('main'); const [buildPack, setBuildPack] = useState('nixpacks'); @@ -256,6 +259,14 @@ export function NewGithubAppWizard({ tone: 'ok', }); } + // Dépôt choisi via l'app GitHub : liaison à l'installation (aucun PAT requis). + if (appRepo) { + await api.linkProjectGithub(p.data.uuid, { + installation_id: appRepo.installation_id, + repo_id: appRepo.repo_id, + branch, + }); + } // Lance le premier déploiement automatiquement await api.createDeployment(p.data.uuid, { git_message: 'Initial deploy from GitHub wizard' }); toast.push({ @@ -355,6 +366,25 @@ export function NewGithubAppWizard({ {step === 'repo' && (
+
+ Depuis l’app GitHub DevForge + { + const [owner, repoName] = r.full_name.split('/'); + setAppRepo(r); + setSelected({ + name: repoName, + full_name: r.full_name, + owner, + private: r.private, + default_branch: r.default_branch || 'main', + html_url: r.html_url, + } as GhRepo); + setName(repoName); + }} + /> +
{ + setAppRepo(null); setSelected(r); setName(r.name); }} diff --git a/apps/web/src/components/ProjectGitPanel.tsx b/apps/web/src/components/ProjectGitPanel.tsx index dd752e12dc..ebfcd8c99e 100644 --- a/apps/web/src/components/ProjectGitPanel.tsx +++ b/apps/web/src/components/ProjectGitPanel.tsx @@ -1,4 +1,6 @@ import { useEffect, useState } from 'preact/hooks'; +import { GithubRepoPicker } from './GithubRepoPicker'; +import type { ProjectGithubLink } from '../lib/api'; import { api, type ProjectSync } from '../lib/api'; import { projectSyncMeta } from '../lib/status'; import { DiffViewer, type DiffFile } from './DiffViewer'; @@ -71,6 +73,10 @@ export function ProjectGitPanel({ }) { const toast = useToast(); const [data, setData] = useState(null); + const [ghLink, setGhLink] = useState(null); + useEffect(() => { + api.projectGithubLink(projectUuid).then(setGhLink).catch(() => setGhLink(null)); + }, [projectUuid]); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -242,6 +248,27 @@ export function ProjectGitPanel({ return (
+ {ghLink?.lost && ( + + )} +
+ Choisir le dépôt depuis l’app GitHub + { + try { + await api.linkProjectGithub(projectUuid, { installation_id: r.installation_id, repo_id: r.repo_id }); + setGhLink(await api.projectGithubLink(projectUuid)); + toast.push({ title: 'Dépôt relié', detail: r.full_name, tone: 'ok' }); + } catch (e) { + toast.push({ title: 'Liaison impossible', detail: String((e as Error).message || e), tone: 'danger' }); + } + }} + /> +
setDiffOpen(false)} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index d0f1f4d2e2..70d6c035e8 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1451,6 +1451,19 @@ export const api = { request<{ ok: boolean }>(`/projects/${encodeURIComponent(uuid)}/previews/${pr}/redeploy`, { method: 'POST' }), deletePreview: (uuid: string, pr: number) => request<{ ok: boolean }>(`/projects/${encodeURIComponent(uuid)}/previews/${pr}`, { method: 'DELETE' }), + githubAppRepositories: (p: { q?: string; refresh?: boolean } = {}) => { + const qs = new URLSearchParams(); + if (p.q) qs.set('q', p.q); + if (p.refresh) qs.set('refresh', '1'); + const tail = qs.toString(); + return request(`/github-app/repositories${tail ? `?${tail}` : ''}`); + }, + projectGithubLink: (uuid: string) => request(`/projects/${encodeURIComponent(uuid)}/github-link`), + linkProjectGithub: (uuid: string, body: { installation_id: number; repo_id: number; branch?: string }) => + request<{ ok: boolean; git_repository: string; git_branch: string }>(`/projects/${encodeURIComponent(uuid)}/github-link`, { + method: 'PUT', + body: JSON.stringify(body), + }), githubAppManifest: (body: { org?: string; workflows?: boolean } = {}) => request('/github-app/manifest', { method: 'POST', body: JSON.stringify(body) }), githubAppForget: () => @@ -2758,3 +2771,18 @@ export type PreviewItem = { updated_at: string; }; export type PreviewsResponse = { ok: boolean; enabled: boolean; linked: boolean; previews: PreviewItem[] }; +export type AppRepo = { + installation_id: number; + repo_id: number; + full_name: string; + private: boolean; + default_branch?: string | null; + html_url: string; +}; +export type AppReposResponse = { + ok: boolean; + installations: { installation_id: number; account_login: string; account_type: string; suspended: boolean; configure_url: string }[]; + repositories: AppRepo[]; + refresh_error?: string | null; +}; +export type ProjectGithubLink = { ok: boolean; linked: boolean; lost: boolean; installation_id?: number; repo_id?: number | null }; diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index 3e0e59951e..bf4deae646 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -7,6 +7,7 @@ use std::sync::{Arc, RwLock}; pub mod app_auth; pub mod checks; pub mod http; +pub mod repos; pub use http::HttpGitHubClient; diff --git a/crates/github/src/repos.rs b/crates/github/src/repos.rs new file mode 100644 index 0000000000..5b383d89f4 --- /dev/null +++ b/crates/github/src/repos.rs @@ -0,0 +1,123 @@ +//! P6-01 — Dépôts accessibles à une installation (`GET /installation/repositories`). + +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallationRepo { + pub id: i64, + pub full_name: String, + pub private: bool, + pub default_branch: Option, +} + +pub fn parse_page(v: &Value) -> Vec { + v.get("repositories") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|r| { + Some(InstallationRepo { + id: r.get("id")?.as_i64()?, + full_name: r.get("full_name")?.as_str()?.to_string(), + private: r.get("private").and_then(Value::as_bool).unwrap_or(false), + default_branch: r + .get("default_branch") + .and_then(Value::as_str) + .map(str::to_string), + }) + }) + .collect() +} + +/// Toutes les pages (`per_page=100`, 50 pages max). Aucun token dans les erreurs. +pub async fn list_installation_repos( + api: &str, + token: &str, +) -> Result, String> { + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build() + .unwrap_or_default(); + let base = api.trim_end_matches('/'); + let mut out = Vec::new(); + for page in 1..=50u32 { + let res = http + .get(format!( + "{base}/installation/repositories?per_page=100&page={page}" + )) + .bearer_auth(token) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", "devforge-server/2") + .send() + .await + .map_err(|e| format!("GitHub injoignable : {}", e.without_url()))?; + let status = res.status(); + if !status.is_success() { + return Err(format!("GitHub a répondu {}", status.as_u16())); + } + let v: Value = res + .json() + .await + .map_err(|_| "réponse GitHub illisible".to_string())?; + let batch = parse_page(&v); + let total = v.get("total_count").and_then(Value::as_u64); + let n = batch.len(); + out.extend(batch); + if n < 100 || total.is_some_and(|t| out.len() as u64 >= t) { + break; + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn page(start: i64, n: i64) -> Value { + let repos: Vec = (start..start + n) + .map(|i| json!({"id": i, "full_name": format!("o/r{i}"), "private": i % 2 == 0, "default_branch": "main"})) + .collect(); + json!({"total_count": 130, "repositories": repos}) + } + + #[tokio::test] + async fn paginates_two_pages() { + let gh = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/installation/repositories")) + .and(query_param("page", "1")) + .respond_with(ResponseTemplate::new(200).set_body_json(page(1, 100))) + .expect(1) + .mount(&gh) + .await; + Mock::given(method("GET")) + .and(path("/installation/repositories")) + .and(query_param("page", "2")) + .respond_with(ResponseTemplate::new(200).set_body_json(page(101, 30))) + .expect(1) + .mount(&gh) + .await; + let all = list_installation_repos(&gh.uri(), "ghs_x").await.unwrap(); + assert_eq!(all.len(), 130); + assert_eq!(all[129].full_name, "o/r130"); + assert!(all[1].private); + } + + #[tokio::test] + async fn error_has_no_token() { + let gh = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(401)) + .mount(&gh) + .await; + let e = list_installation_repos(&gh.uri(), "ghs_secret123") + .await + .unwrap_err(); + assert!(!e.contains("ghs_secret123")); + } +}