mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
refactor(install): tag GithubAppOwner with discriminated object shape
The install GitHub App manifest shape encoded owner as `"personal"` or
`"org:<slug>"` - a magic string parsed in install-app.tsx, built by
install-api.ts, and reparsed server-side. Replace with a tagged object
`{ kind: "personal" } | { kind: "org", slug }` in the OpenAPI spec, the
progenitor-generated Rust types, and the frontend.
Server-side, the internal `GitHubAppOwner` enum keeps its semantic
shape but gains a `TryFrom<GithubAppOwnerInput>` conversion and emits
the tagged JSON via `as_session_value`.
Frontend drops `buildGithubOwnerValue` in favor of
`buildInstallGithubAppOwner`, and the ready-screen renders the owner
through a small helper instead of string concatenation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ad7fdc8d13
commit
acb6b3f9d6
6 changed files with 93 additions and 46 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { buildGithubOwnerValue, readInstallError } from "./install-api";
|
||||
import { buildInstallGithubAppOwner, readInstallError } from "./install-api";
|
||||
|
||||
describe("readInstallError", () => {
|
||||
test("prefers the structured install error payload", async () => {
|
||||
|
|
@ -31,12 +31,15 @@ describe("readInstallError", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("buildGithubOwnerValue", () => {
|
||||
test("uses personal for personal app installs", () => {
|
||||
expect(buildGithubOwnerValue("personal", "")).toBe("personal");
|
||||
describe("buildInstallGithubAppOwner", () => {
|
||||
test("uses the personal tag for personal app installs", () => {
|
||||
expect(buildInstallGithubAppOwner("personal", "")).toEqual({ kind: "personal" });
|
||||
});
|
||||
|
||||
test("formats organization owners with the expected prefix", () => {
|
||||
expect(buildGithubOwnerValue("org", " acme ")).toBe("org:acme");
|
||||
test("trims organization slugs and tags them with `org`", () => {
|
||||
expect(buildInstallGithubAppOwner("org", " acme ")).toEqual({
|
||||
kind: "org",
|
||||
slug: "acme",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
export type InstallGithubAppOwner =
|
||||
| { kind: "personal" }
|
||||
| { kind: "org"; slug: string };
|
||||
|
||||
export interface InstallSessionResponse {
|
||||
completed_steps: string[];
|
||||
llm:
|
||||
|
|
@ -13,7 +17,7 @@ export interface InstallSessionResponse {
|
|||
| {
|
||||
strategy: string;
|
||||
username?: string;
|
||||
owner?: string;
|
||||
owner?: InstallGithubAppOwner;
|
||||
app_name?: string;
|
||||
slug?: string;
|
||||
allowed_username?: string;
|
||||
|
|
@ -34,7 +38,7 @@ export interface InstallLlmProviderInput {
|
|||
}
|
||||
|
||||
export interface InstallGithubAppManifestInput {
|
||||
owner: string;
|
||||
owner: InstallGithubAppOwner;
|
||||
app_name: string;
|
||||
allowed_username: string;
|
||||
}
|
||||
|
|
@ -92,13 +96,13 @@ export async function readInstallError(
|
|||
return `${fallback} (${response.status})`;
|
||||
}
|
||||
|
||||
export function buildGithubOwnerValue(
|
||||
export function buildInstallGithubAppOwner(
|
||||
ownerKind: "personal" | "org",
|
||||
organizationSlug: string,
|
||||
): string {
|
||||
): InstallGithubAppOwner {
|
||||
return ownerKind === "org"
|
||||
? `org:${organizationSlug.trim()}`
|
||||
: "personal";
|
||||
? { kind: "org", slug: organizationSlug.trim() }
|
||||
: { kind: "personal" };
|
||||
}
|
||||
|
||||
export async function getInstallSession(token: string): Promise<InstallSessionResponse> {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ import { Link, Navigate, useLocation, useNavigate } from "react-router";
|
|||
|
||||
import {
|
||||
type InstallFinishResponse,
|
||||
type InstallGithubAppOwner,
|
||||
type InstallLlmProviderInput,
|
||||
type InstallSessionResponse,
|
||||
buildGithubOwnerValue,
|
||||
buildInstallGithubAppOwner,
|
||||
createInstallGithubAppManifest,
|
||||
finishInstall,
|
||||
getInstallSession,
|
||||
|
|
@ -116,10 +117,10 @@ export default function InstallApp() {
|
|||
);
|
||||
if (nextSession.github?.strategy === "app") {
|
||||
setGithubStrategy("app");
|
||||
const owner = nextSession.github.owner ?? "personal";
|
||||
if (owner.startsWith("org:")) {
|
||||
const owner = nextSession.github.owner ?? { kind: "personal" };
|
||||
if (owner.kind === "org") {
|
||||
setGithubOwnerKind("org");
|
||||
setGithubOrganization(owner.slice(4));
|
||||
setGithubOrganization(owner.slug);
|
||||
} else {
|
||||
setGithubOwnerKind("personal");
|
||||
setGithubOrganization("");
|
||||
|
|
@ -389,7 +390,7 @@ export default function InstallApp() {
|
|||
}
|
||||
|
||||
const manifest = await createInstallGithubAppManifest(installToken, {
|
||||
owner: buildGithubOwnerValue(githubOwnerKind, githubOrganization),
|
||||
owner: buildInstallGithubAppOwner(githubOwnerKind, githubOrganization),
|
||||
app_name: githubAppName.trim(),
|
||||
allowed_username: githubAllowedUsername.trim(),
|
||||
});
|
||||
|
|
@ -970,7 +971,7 @@ function GithubAppDoneScreen({
|
|||
</p>
|
||||
</header>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<SummaryCard title="Owner" body={github.owner || "personal"} />
|
||||
<SummaryCard title="Owner" body={describeGithubAppOwner(github.owner)} />
|
||||
<SummaryCard title="App" body={github.slug || github.app_name || "GitHub App"} />
|
||||
<SummaryCard
|
||||
title="Allowed user"
|
||||
|
|
@ -1071,6 +1072,13 @@ function describeGithubSummary(github: InstallSessionResponse["github"]): string
|
|||
return github.username ? `Token for ${github.username}` : "Token configured";
|
||||
}
|
||||
|
||||
function describeGithubAppOwner(
|
||||
owner: InstallGithubAppOwner | undefined,
|
||||
): string {
|
||||
if (!owner || owner.kind === "personal") return "personal";
|
||||
return owner.slug ? `org:${owner.slug}` : "org";
|
||||
}
|
||||
|
||||
function submitGithubManifest(
|
||||
formAction: string,
|
||||
manifest: Record<string, unknown>,
|
||||
|
|
|
|||
|
|
@ -2111,14 +2111,25 @@ components:
|
|||
- allowed_username
|
||||
properties:
|
||||
owner:
|
||||
type: string
|
||||
description: >
|
||||
Either `personal` or `org:<slug>`.
|
||||
$ref: "#/components/schemas/InstallGithubAppOwner"
|
||||
app_name:
|
||||
type: string
|
||||
allowed_username:
|
||||
type: string
|
||||
|
||||
InstallGithubAppOwner:
|
||||
description: Owner of the GitHub App being created during browser install.
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [personal, org]
|
||||
slug:
|
||||
type: string
|
||||
description: Required when `kind` is `org`; the organization slug.
|
||||
|
||||
InstallGithubAppManifestResponse:
|
||||
description: Browser handoff payload for the GitHub App creation flow.
|
||||
type: object
|
||||
|
|
@ -2145,7 +2156,7 @@ components:
|
|||
username:
|
||||
type: string
|
||||
owner:
|
||||
type: string
|
||||
$ref: "#/components/schemas/InstallGithubAppOwner"
|
||||
app_name:
|
||||
type: string
|
||||
slug:
|
||||
|
|
|
|||
|
|
@ -227,11 +227,24 @@ struct GithubTokenTestInput {
|
|||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct GithubAppManifestInput {
|
||||
owner: String,
|
||||
owner: GithubAppOwnerInput,
|
||||
app_name: String,
|
||||
allowed_username: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct GithubAppOwnerInput {
|
||||
kind: GithubAppOwnerKind,
|
||||
slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum GithubAppOwnerKind {
|
||||
Personal,
|
||||
Org,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct GithubAppRedirectQuery {
|
||||
code: Option<String>,
|
||||
|
|
@ -260,18 +273,6 @@ enum GitHubAppOwner {
|
|||
}
|
||||
|
||||
impl GitHubAppOwner {
|
||||
fn parse(raw: &str) -> anyhow::Result<Self> {
|
||||
let value = raw.trim();
|
||||
if value.eq_ignore_ascii_case("personal") {
|
||||
return Ok(Self::Personal);
|
||||
}
|
||||
if let Some(org) = value.strip_prefix("org:") {
|
||||
anyhow::ensure!(!org.trim().is_empty(), "organization owner cannot be empty");
|
||||
return Ok(Self::Organization(org.trim().to_string()));
|
||||
}
|
||||
anyhow::bail!("owner must be 'personal' or 'org:<slug>'");
|
||||
}
|
||||
|
||||
fn manifest_form_action(&self) -> String {
|
||||
match self {
|
||||
Self::Personal => "https://github.com/settings/apps/new".to_string(),
|
||||
|
|
@ -281,10 +282,28 @@ impl GitHubAppOwner {
|
|||
}
|
||||
}
|
||||
|
||||
fn as_session_value(&self) -> String {
|
||||
fn as_session_value(&self) -> serde_json::Value {
|
||||
match self {
|
||||
Self::Personal => "personal".to_string(),
|
||||
Self::Organization(org) => format!("org:{org}"),
|
||||
Self::Personal => serde_json::json!({ "kind": "personal" }),
|
||||
Self::Organization(org) => serde_json::json!({ "kind": "org", "slug": org }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<GithubAppOwnerInput> for GitHubAppOwner {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(value: GithubAppOwnerInput) -> Result<Self, Self::Error> {
|
||||
match value.kind {
|
||||
GithubAppOwnerKind::Personal => Ok(Self::Personal),
|
||||
GithubAppOwnerKind::Org => {
|
||||
let slug = value.slug.unwrap_or_default();
|
||||
let trimmed = slug.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("organization owner requires a non-empty slug".to_string());
|
||||
}
|
||||
Ok(Self::Organization(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -605,10 +624,10 @@ async fn post_install_github_app_manifest(
|
|||
}
|
||||
observe_operator(&state, &headers);
|
||||
|
||||
let owner = match GitHubAppOwner::parse(&input.owner) {
|
||||
let owner = match GitHubAppOwner::try_from(input.owner) {
|
||||
Ok(owner) => owner,
|
||||
Err(err) => {
|
||||
return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err.to_string());
|
||||
return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err);
|
||||
}
|
||||
};
|
||||
if input.app_name.trim().is_empty() {
|
||||
|
|
|
|||
|
|
@ -197,7 +197,9 @@ async fn install_endpoints_reject_missing_and_wrong_tokens() {
|
|||
(
|
||||
"POST",
|
||||
"/install/github/app/manifest",
|
||||
Some(r#"{"owner":"personal","app_name":"Fabro","allowed_username":"octocat"}"#),
|
||||
Some(
|
||||
r#"{"owner":{"kind":"personal"},"app_name":"Fabro","allowed_username":"octocat"}"#,
|
||||
),
|
||||
),
|
||||
("POST", "/install/finish", None),
|
||||
];
|
||||
|
|
@ -539,7 +541,7 @@ async fn github_app_manifest_round_trip_updates_install_session() {
|
|||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"owner":"personal","app_name":"Fabro Test","allowed_username":"octocat"}"#,
|
||||
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
|
|
@ -661,7 +663,7 @@ async fn github_app_manifest_rejects_retry_while_pending_and_preserves_prior_tok
|
|||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"owner":"personal","app_name":"Fabro Test","allowed_username":"octocat"}"#,
|
||||
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
|
|
@ -701,7 +703,7 @@ async fn github_app_manifest_rejects_retry_while_pending_and_preserves_prior_tok
|
|||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"owner":"personal","app_name":"Fabro Retry","allowed_username":"octocat"}"#,
|
||||
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Retry","allowed_username":"octocat"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
|
|
@ -767,7 +769,7 @@ async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_s
|
|||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"owner":"personal","app_name":"Fabro Test","allowed_username":"octocat"}"#,
|
||||
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
|
|
@ -907,7 +909,7 @@ async fn github_app_redirect_exchange_failure_returns_to_wizard_and_keeps_pendin
|
|||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"owner":"personal","app_name":"Fabro Test","allowed_username":"octocat"}"#,
|
||||
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue