mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
`branch_head_sha` existed to keep callers compiling while the branch-head lookup moved onto the repository reader. Its last caller now opens a reader directly, so the wrapper only made the typed API worse: it joined an already-validated owner and repo into a slug so the reader could split them apart again, invented an "invalid repository coordinate" error for a value validated upstream, opened a fresh credential session per call, and flattened `RepositoryReadError` into `anyhow` while keeping one variant — leaving callers unable to tell a rate limit from a rejected token. Its integration test pinned the wrapper rather than the behavior. Replace it with one that asserts the same 404-means-not-observable semantics through `resolve_commit`, which is where that contract actually lives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
522 lines
16 KiB
Rust
522 lines
16 KiB
Rust
use std::error::Error as _;
|
|
|
|
use fabro_github::{
|
|
GitHubAppCredentials, GitHubContext, GitHubCredentials, GitHubRepositoryReader,
|
|
InstallationToken, RepositoryReadError, close_pull_request,
|
|
create_installation_access_token_for_pr, create_pull_request, enable_auto_merge,
|
|
get_pull_request, merge_pull_request, resolve_authenticated_url, sign_app_jwt,
|
|
};
|
|
use fabro_test::{GitHubAppOptions, GitHubAppState, TwinGitHub};
|
|
use fabro_types::GitHubRepositorySlug;
|
|
use fabro_types::settings::run::MergeStrategy;
|
|
|
|
const TEST_RSA_KEY: &str = include_str!("../src/testdata/rsa_private.pem");
|
|
const HEAD_SHA: &str = "1111111111111111111111111111111111111111";
|
|
const TAG_SHA: &str = "2222222222222222222222222222222222222222";
|
|
const UPPER_SHA: &str = "ABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD";
|
|
|
|
fn github_credentials() -> GitHubCredentials {
|
|
GitHubCredentials::App(GitHubAppCredentials {
|
|
app_id: "42".to_string(),
|
|
private_key_pem: TEST_RSA_KEY.to_string(),
|
|
slug: Some("test-app".to_string()),
|
|
})
|
|
}
|
|
|
|
fn standard_app_state() -> GitHubAppState {
|
|
let mut state = GitHubAppState::new();
|
|
state.register_app(GitHubAppOptions {
|
|
app_id: "42".into(),
|
|
slug: "test-app".into(),
|
|
owner_login: "acme".into(),
|
|
public: true,
|
|
private_key_pem: TEST_RSA_KEY.into(),
|
|
webhook_secret: None,
|
|
});
|
|
state.add_installation("42", "acme", vec!["widgets".into()], false);
|
|
state.add_repository(
|
|
"acme",
|
|
"widgets",
|
|
vec!["main".into(), "feature".into()],
|
|
false,
|
|
);
|
|
state.set_repository_ref("acme", "widgets", "heads/main", HEAD_SHA);
|
|
state.set_repository_ref("acme", "widgets", "heads/release", HEAD_SHA);
|
|
state.set_repository_ref("acme", "widgets", "tags/release", TAG_SHA);
|
|
state.set_repository_ref("acme", "widgets", "heads/fabro/run/123", HEAD_SHA);
|
|
state.set_repository_ref("acme", "widgets", "heads/uppercase", UPPER_SHA);
|
|
for (path, contents) in [
|
|
(
|
|
".fabro/workflows/build/workflow.toml",
|
|
b"name = \"build\"\n".to_vec(),
|
|
),
|
|
("dir/hello world#.txt", b"hello\n".to_vec()),
|
|
("invalid.txt", vec![0xff, 0xfe]),
|
|
("empty.txt", Vec::new()),
|
|
("five-bytes.txt", b"12345".to_vec()),
|
|
("nested/a b/%/é.toml", "unicode: 🦀\n".as_bytes().to_vec()),
|
|
] {
|
|
state.add_repository_file("acme", "widgets", HEAD_SHA, path, contents);
|
|
}
|
|
state
|
|
}
|
|
|
|
fn repository() -> GitHubRepositorySlug {
|
|
GitHubRepositorySlug::try_new("acme/widgets").expect("test repository slug should be valid")
|
|
}
|
|
|
|
/// Open a reader for the standard repository against `base_url`.
|
|
async fn open_reader(
|
|
base_url: &str,
|
|
credentials: &GitHubCredentials,
|
|
) -> Result<GitHubRepositoryReader, RepositoryReadError> {
|
|
GitHubRepositoryReader::open(
|
|
&GitHubContext::with_http_client(credentials, base_url, fabro_test::test_http_client()),
|
|
&repository(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(twin)]
|
|
async fn create_and_get_pull_request() {
|
|
let twin = TwinGitHub::start(standard_app_state()).await;
|
|
let creds = github_credentials();
|
|
let ctx = &GitHubContext::new(&creds, &twin.base_url);
|
|
|
|
let created = create_pull_request(
|
|
ctx,
|
|
"acme",
|
|
"widgets",
|
|
"main",
|
|
"feature",
|
|
"Add widgets",
|
|
"PR body",
|
|
false,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let pr = get_pull_request(ctx, "acme", "widgets", created.number)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(pr.title, "Add widgets");
|
|
assert_eq!(pr.state, "open");
|
|
assert_eq!(pr.head.ref_name, "feature");
|
|
assert_eq!(pr.base.ref_name, "main");
|
|
|
|
twin.shutdown().await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(twin)]
|
|
async fn create_merge_and_verify_state() {
|
|
let twin = TwinGitHub::start(standard_app_state()).await;
|
|
let creds = github_credentials();
|
|
let ctx = &GitHubContext::new(&creds, &twin.base_url);
|
|
|
|
let created = create_pull_request(
|
|
ctx, "acme", "widgets", "main", "feature", "Merge me", "PR body", false,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
merge_pull_request(
|
|
ctx,
|
|
"acme",
|
|
"widgets",
|
|
created.number,
|
|
MergeStrategy::Squash,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let pr = get_pull_request(ctx, "acme", "widgets", created.number)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(pr.state, "closed");
|
|
assert_eq!(pr.mergeable, Some(false));
|
|
|
|
twin.shutdown().await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(twin)]
|
|
async fn create_close_and_verify_state() {
|
|
let twin = TwinGitHub::start(standard_app_state()).await;
|
|
let creds = github_credentials();
|
|
|
|
let ctx = &GitHubContext::new(&creds, &twin.base_url);
|
|
|
|
let created = create_pull_request(
|
|
ctx, "acme", "widgets", "main", "feature", "Close me", "PR body", false,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
close_pull_request(ctx, "acme", "widgets", created.number)
|
|
.await
|
|
.unwrap();
|
|
|
|
let pr = get_pull_request(ctx, "acme", "widgets", created.number)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(pr.state, "closed");
|
|
|
|
twin.shutdown().await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(twin)]
|
|
async fn enable_auto_merge_persists() {
|
|
let twin = TwinGitHub::start(standard_app_state()).await;
|
|
let creds = github_credentials();
|
|
|
|
let ctx = &GitHubContext::new(&creds, &twin.base_url);
|
|
|
|
let created = create_pull_request(
|
|
ctx,
|
|
"acme",
|
|
"widgets",
|
|
"main",
|
|
"feature",
|
|
"Auto merge me",
|
|
"PR body",
|
|
false,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
enable_auto_merge(
|
|
ctx,
|
|
"acme",
|
|
"widgets",
|
|
&created.node_id,
|
|
MergeStrategy::Squash,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let GitHubCredentials::App(app_creds) = &creds else {
|
|
panic!("expected app credentials");
|
|
};
|
|
let jwt = sign_app_jwt(&app_creds.app_id, &app_creds.private_key_pem).unwrap();
|
|
let client = fabro_test::test_http_client();
|
|
let token =
|
|
create_installation_access_token_for_pr(&client, &jwt, "acme", "widgets", &twin.base_url)
|
|
.await
|
|
.unwrap();
|
|
|
|
let detail: serde_json::Value = fabro_test::test_http_client()
|
|
.get(format!(
|
|
"{}/repos/acme/widgets/pulls/{}",
|
|
twin.base_url, created.number
|
|
))
|
|
.header("Authorization", format!("Bearer {token}"))
|
|
.header("Accept", "application/vnd.github+json")
|
|
.header("User-Agent", "fabro")
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
detail["auto_merge"]["merge_method"].as_str(),
|
|
Some("SQUASH")
|
|
);
|
|
|
|
twin.shutdown().await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(twin)]
|
|
async fn resolve_authenticated_url_embeds_token() {
|
|
let twin = TwinGitHub::start(standard_app_state()).await;
|
|
let creds = github_credentials();
|
|
|
|
let url = resolve_authenticated_url(
|
|
&GitHubContext::new(&creds, &twin.base_url),
|
|
"https://github.com/acme/widgets.git",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(url.raw_string().starts_with("https://x-access-token:ghs_"));
|
|
assert!(url.raw_string().contains("github.com/acme/widgets.git"));
|
|
assert!(
|
|
url.redacted_string()
|
|
.starts_with("https://x-access-token:****@")
|
|
);
|
|
|
|
twin.shutdown().await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(twin)]
|
|
async fn resolve_authenticated_url_errors_on_non_github_url() {
|
|
let twin = TwinGitHub::start(standard_app_state()).await;
|
|
let creds = github_credentials();
|
|
|
|
let error = resolve_authenticated_url(
|
|
&GitHubContext::new(&creds, &twin.base_url),
|
|
"https://gitlab.com/foo/bar",
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(error.to_string().contains("Not a GitHub HTTPS URL"));
|
|
|
|
twin.shutdown().await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(twin)]
|
|
async fn repository_reader_reuses_one_app_token_for_ref_and_file_reads() {
|
|
let twin = TwinGitHub::start(standard_app_state()).await;
|
|
assert_eq!(twin.active_token_count().await, 0);
|
|
let creds = github_credentials();
|
|
let base_url = format!("{}/", twin.base_url);
|
|
let reader = open_reader(&base_url, &creds).await.unwrap();
|
|
assert_eq!(twin.active_token_count().await, 1);
|
|
|
|
assert_eq!(reader.resolve_commit("heads/main").await.unwrap(), HEAD_SHA);
|
|
assert_eq!(
|
|
reader
|
|
.read_utf8_file_at(HEAD_SHA, ".fabro/workflows/build/workflow.toml", 1024)
|
|
.await
|
|
.unwrap(),
|
|
"name = \"build\"\n"
|
|
);
|
|
assert_eq!(
|
|
reader
|
|
.read_utf8_file_at(HEAD_SHA, "dir/hello world#.txt", 1024)
|
|
.await
|
|
.unwrap(),
|
|
"hello\n"
|
|
);
|
|
let unicode_contents = "unicode: 🦀\n";
|
|
assert_eq!(
|
|
reader
|
|
.read_utf8_file_at(HEAD_SHA, "nested/a b/%/é.toml", unicode_contents.len(),)
|
|
.await
|
|
.unwrap(),
|
|
unicode_contents
|
|
);
|
|
assert!(matches!(
|
|
reader
|
|
.read_utf8_file_at(HEAD_SHA, "nested/a b/%/é.toml", unicode_contents.len() - 1,)
|
|
.await,
|
|
Err(RepositoryReadError::BodyTooLarge { .. })
|
|
));
|
|
assert_eq!(twin.active_token_count().await, 1);
|
|
|
|
twin.shutdown().await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(twin)]
|
|
async fn repository_reader_preserves_exact_ref_namespaces() {
|
|
let twin = TwinGitHub::start(standard_app_state()).await;
|
|
let creds = github_credentials();
|
|
let reader = open_reader(&twin.base_url, &creds).await.unwrap();
|
|
|
|
assert_eq!(
|
|
reader.resolve_commit("heads/release").await.unwrap(),
|
|
HEAD_SHA
|
|
);
|
|
assert_eq!(
|
|
reader.resolve_commit("tags/release").await.unwrap(),
|
|
TAG_SHA
|
|
);
|
|
assert_eq!(
|
|
reader.resolve_commit("heads/fabro/run/123").await.unwrap(),
|
|
HEAD_SHA
|
|
);
|
|
assert_eq!(reader.resolve_commit(HEAD_SHA).await.unwrap(), HEAD_SHA);
|
|
assert_eq!(
|
|
reader.resolve_commit("heads/uppercase").await.unwrap(),
|
|
UPPER_SHA.to_ascii_lowercase()
|
|
);
|
|
assert!(matches!(
|
|
reader.resolve_commit("heads/missing").await,
|
|
Err(RepositoryReadError::NotFound {
|
|
operation: fabro_github::Operation::Revision,
|
|
})
|
|
));
|
|
|
|
twin.shutdown().await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(twin)]
|
|
async fn repository_reader_classifies_file_failures_without_retaining_bytes() {
|
|
let twin = TwinGitHub::start(standard_app_state()).await;
|
|
let creds = github_credentials();
|
|
let reader = open_reader(&twin.base_url, &creds).await.unwrap();
|
|
|
|
assert!(matches!(
|
|
reader
|
|
.read_utf8_file_at(HEAD_SHA, ".fabro/workflows/build/workflow.toml", 4)
|
|
.await,
|
|
Err(RepositoryReadError::BodyTooLarge { max_bytes: 4 })
|
|
));
|
|
let error = reader
|
|
.read_utf8_file_at(HEAD_SHA, "invalid.txt", 16)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(error, RepositoryReadError::InvalidUtf8 { .. }));
|
|
assert!(
|
|
error
|
|
.source()
|
|
.and_then(|source| source.downcast_ref::<std::str::Utf8Error>())
|
|
.is_some()
|
|
);
|
|
assert!(!format!("{error:?}").contains("255"));
|
|
let directory_error = reader
|
|
.read_utf8_file_at(HEAD_SHA, "dir", 1024)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(
|
|
directory_error,
|
|
RepositoryReadError::ContentNotFile
|
|
));
|
|
assert!(!format!("{directory_error:?}").contains("[]"));
|
|
assert!(matches!(
|
|
reader
|
|
.read_utf8_file_at(HEAD_SHA, "missing.txt", 1024)
|
|
.await,
|
|
Err(RepositoryReadError::NotFound {
|
|
operation: fabro_github::Operation::Content,
|
|
})
|
|
));
|
|
assert_eq!(
|
|
reader
|
|
.read_utf8_file_at(HEAD_SHA, "five-bytes.txt", 5)
|
|
.await
|
|
.unwrap(),
|
|
"12345"
|
|
);
|
|
assert!(matches!(
|
|
reader
|
|
.read_utf8_file_at(HEAD_SHA, "five-bytes.txt", 4)
|
|
.await,
|
|
Err(RepositoryReadError::BodyTooLarge { max_bytes: 4 })
|
|
));
|
|
assert_eq!(
|
|
reader
|
|
.read_utf8_file_at(HEAD_SHA, "empty.txt", 0)
|
|
.await
|
|
.unwrap(),
|
|
""
|
|
);
|
|
|
|
twin.shutdown().await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(twin)]
|
|
async fn static_repository_credentials_do_not_mint_tokens() {
|
|
let mut state = standard_app_state();
|
|
let static_token = state.generate_access_token(
|
|
"42",
|
|
1,
|
|
vec!["widgets".to_string()],
|
|
serde_json::json!({ "contents": "read" }),
|
|
);
|
|
let twin = TwinGitHub::start(state).await;
|
|
let initial_tokens = twin.active_token_count().await;
|
|
|
|
for credentials in [
|
|
GitHubCredentials::Pat(static_token.clone()),
|
|
GitHubCredentials::Installation(InstallationToken {
|
|
token: static_token.clone(),
|
|
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
|
|
}),
|
|
] {
|
|
let reader = open_reader(&twin.base_url, &credentials).await.unwrap();
|
|
assert_eq!(reader.resolve_commit("heads/main").await.unwrap(), HEAD_SHA);
|
|
assert_eq!(
|
|
reader
|
|
.read_utf8_file_at(HEAD_SHA, "five-bytes.txt", 5)
|
|
.await
|
|
.unwrap(),
|
|
"12345"
|
|
);
|
|
}
|
|
|
|
assert_eq!(twin.active_token_count().await, initial_tokens);
|
|
twin.shutdown().await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(twin)]
|
|
async fn expired_installation_token_keeps_credential_error_source() {
|
|
let twin = TwinGitHub::start(standard_app_state()).await;
|
|
let credentials = GitHubCredentials::Installation(InstallationToken {
|
|
token: "ghs_expired".to_string(),
|
|
expires_at: chrono::Utc::now() - chrono::Duration::minutes(1),
|
|
});
|
|
|
|
let error = open_reader(&twin.base_url, &credentials)
|
|
.await
|
|
.err()
|
|
.expect("expired installation token should fail");
|
|
let RepositoryReadError::CredentialResolution { source } = error else {
|
|
panic!("expected credential resolution error");
|
|
};
|
|
assert!(source.to_string().contains("expired"));
|
|
|
|
twin.shutdown().await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(twin)]
|
|
async fn branch_head_resolution_distinguishes_missing_from_present() {
|
|
let twin = TwinGitHub::start(standard_app_state()).await;
|
|
let credentials = github_credentials();
|
|
let reader = open_reader(&twin.base_url, &credentials).await.unwrap();
|
|
|
|
assert_eq!(
|
|
reader.resolve_commit("heads/release").await.unwrap(),
|
|
HEAD_SHA
|
|
);
|
|
// GitHub answers an absent branch with 404, which the reader reports as
|
|
// "not observable" rather than proving the branch does not exist.
|
|
assert!(matches!(
|
|
reader.resolve_commit("heads/missing").await,
|
|
Err(RepositoryReadError::NotFound {
|
|
operation: fabro_github::Operation::Revision,
|
|
})
|
|
));
|
|
|
|
twin.shutdown().await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(twin)]
|
|
async fn repository_reader_keeps_auth_and_malformed_sha_failures_distinct() {
|
|
let mut state = standard_app_state();
|
|
state.set_repository_ref("acme", "widgets", "heads/malformed", "short-sha");
|
|
let denied_token =
|
|
state.generate_access_token("42", 1, vec!["widgets".to_string()], serde_json::json!({}));
|
|
let twin = TwinGitHub::start(state).await;
|
|
|
|
let invalid_credentials = GitHubCredentials::Pat("invalid-token".to_string());
|
|
let invalid_reader = open_reader(&twin.base_url, &invalid_credentials)
|
|
.await
|
|
.unwrap();
|
|
assert!(matches!(
|
|
invalid_reader.resolve_commit("heads/main").await,
|
|
Err(RepositoryReadError::AuthenticationRejected)
|
|
));
|
|
|
|
let denied_credentials = GitHubCredentials::Pat(denied_token);
|
|
let denied_reader = open_reader(&twin.base_url, &denied_credentials)
|
|
.await
|
|
.unwrap();
|
|
assert!(matches!(
|
|
denied_reader.resolve_commit("heads/main").await,
|
|
Err(RepositoryReadError::PermissionDenied)
|
|
));
|
|
|
|
let app_credentials = github_credentials();
|
|
let app_reader = open_reader(&twin.base_url, &app_credentials).await.unwrap();
|
|
assert!(matches!(
|
|
app_reader.resolve_commit("heads/malformed").await,
|
|
Err(RepositoryReadError::MalformedCommitSha)
|
|
));
|
|
|
|
twin.shutdown().await;
|
|
}
|