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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ and global options (`--token`, `--repository`, `--api-url`).
- **`mergify ci`** — Upload JUnit results, evaluate quarantine, detect git
refs and CI scopes.
[Docs](https://docs.mergify.com/ci-insights/)
- **`mergify tests`** — Inspect test health tracked by Mergify CI Insights
(`mergify tests show NAME...`).
[Docs](https://docs.mergify.com/ci-insights/)
- **`mergify queue`** — Monitor and manage the Mergify merge queue.
[Docs](https://docs.mergify.com/merge-queue/)
- **`mergify freeze`** — Create and manage scheduled merge freezes.
Expand Down
2 changes: 2 additions & 0 deletions crates/mergify-ci/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ description = "Native implementation of `mergify ci` subcommands."
publish = false

[dependencies]
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
mergify-core = { path = "../mergify-core" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml_ng = "0.10"
tokio = { version = "1", default-features = false, features = ["rt"] }
url = "2"
uuid = { version = "1", features = ["v4"] }

Expand Down
96 changes: 90 additions & 6 deletions crates/mergify-ci/src/detector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,57 @@ fn parse_repository_url(url_str: &str) -> Option<String> {

fn validate_owner_repo(path: &str) -> Option<String> {
let (owner, repo) = path.split_once('/')?;
if owner.is_empty() || repo.is_empty() || repo.contains('/') {
if !is_valid_segment(owner) || !is_valid_segment(repo) || repo.contains('/') {
return None;
}
let valid = |s: &str| {
s.chars()
Some(format!("{owner}/{repo}"))
}

/// Allowed character set for an `owner` or `repo` path segment.
///
/// Matches GitHub's allowance (alphanumerics, `_`, `.`, `-`) and the
/// regex used by `parse_repository_url`. Rejects every URL-reserved
/// character (`?`, `#`, `%`, `/`, space) so callers can interpolate
/// the segments straight into a request path without percent-encoding
/// and without enabling path or query injection.
fn is_valid_segment(segment: &str) -> bool {
!segment.is_empty()
&& segment
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '.' || c == '-')
}

/// Clap `value_parser` for `--repository`. Returning `Result<_, String>`
/// makes clap surface a bad value as exit code 2 instead of letting it
/// slip through to runtime as a `Configuration` error.
///
/// # Errors
///
/// Returns the validation message from `split_owner_repo` when the
/// input is not exactly `owner/repo` with allowed characters.
pub fn parse_owner_repo(value: &str) -> Result<String, String> {
split_owner_repo(value)
.map(|_| value.to_string())
.map_err(|e| e.to_string())
}

/// Split a `"owner/repo"` string into its two parts. The
/// Mergify CI Insights endpoints take owner and repository name as
/// separate path segments, while `--repository` accepts the
/// `owner/repo` shorthand. Rejects empty parts and any character
/// outside `is_valid_segment` so the values can be interpolated into
/// URL paths without further escaping.
pub fn split_owner_repo(value: &str) -> Result<(&str, &str), CliError> {
let mismatch = || {
CliError::Configuration(format!(
"invalid repository {value:?}: expected `owner/repo`",
))
};
if !valid(owner) || !valid(repo) {
return None;
let (owner, repo) = value.split_once('/').ok_or_else(mismatch)?;
if !is_valid_segment(owner) || !is_valid_segment(repo) || repo.contains('/') {
return Err(mismatch());
}
Some(format!("{owner}/{repo}"))
Ok((owner, repo))
}

#[must_use]
Expand Down Expand Up @@ -343,6 +383,50 @@ mod tests {
);
}

#[test]
fn split_owner_repo_accepts_owner_repo() {
assert_eq!(
split_owner_repo("Mergifyio/monorepo").unwrap(),
("Mergifyio", "monorepo")
);
assert_eq!(split_owner_repo("a/b").unwrap(), ("a", "b"));
}

#[test]
fn split_owner_repo_rejects_inputs_without_exactly_one_slash() {
for bad in ["", "owner", "owner/", "/repo", "a/b/c", "/", "//"] {
let err = split_owner_repo(bad).unwrap_err();
assert!(
matches!(err, CliError::Configuration(_)),
"input {bad:?} should map to Configuration, got {err:?}",
);
assert!(
err.to_string().contains("owner/repo"),
"error for {bad:?} should mention expected shape, got: {err}",
);
}
}

#[test]
fn split_owner_repo_rejects_url_reserved_characters() {
// These would otherwise inject extra path or query segments
// when interpolated into a request URL.
for bad in [
"owner/repo?x=1",
"owner/repo#frag",
"owner/repo%2e",
"own er/repo",
"owner /repo",
"owner/re po",
] {
let err = split_owner_repo(bad).unwrap_err();
assert!(
matches!(err, CliError::Configuration(_)),
"input {bad:?} should map to Configuration, got {err:?}",
);
}
}

#[test]
fn parse_repository_url_handles_known_shapes() {
let cases = [
Expand Down
1 change: 1 addition & 0 deletions crates/mergify-ci/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ pub mod github_event;
pub mod queue_info;
pub mod queue_metadata;
pub mod scopes_send;
pub mod tests_show;
Loading
Loading