From 689d1c89160896f70ee50b0af0ec5fba7e26a77a Mon Sep 17 00:00:00 2001 From: codcod Date: Tue, 11 Nov 2025 21:57:14 +0100 Subject: [PATCH 1/2] feat: add repos validate command --- .github/workflows/release.yml | 7 +- Cargo.toml | 3 + README.md | 1 + common/repos-github/Cargo.toml | 10 + common/repos-github/README.md | 74 +++++ common/repos-github/src/client.rs | 24 ++ common/repos-github/src/lib.rs | 22 ++ common/repos-github/src/pull_requests.rs | 122 ++++++++ common/repos-github/src/repositories.rs | 47 ++++ common/repos-github/src/util.rs | 87 ++++++ docs/plugins.md | 1 + justfile | 1 + plugins/repos-validate/Cargo.toml | 19 ++ plugins/repos-validate/README.md | 155 ++++++++++ plugins/repos-validate/src/main.rs | 282 +++++++++++++++++++ src/commands/pr.rs | 7 +- src/github/api.rs | 52 ++-- src/github/auth.rs | 28 -- src/github/client.rs | 214 -------------- src/github/mod.rs | 51 +--- src/github/pull_requests.rs | 300 -------------------- src/github/repositories.rs | 342 ----------------------- src/github/types.rs | 104 +------ src/main.rs | 12 +- src/plugins.rs | 25 ++ tests/github_tests.rs | 228 +-------------- 26 files changed, 943 insertions(+), 1275 deletions(-) create mode 100644 common/repos-github/Cargo.toml create mode 100644 common/repos-github/README.md create mode 100644 common/repos-github/src/client.rs create mode 100644 common/repos-github/src/lib.rs create mode 100644 common/repos-github/src/pull_requests.rs create mode 100644 common/repos-github/src/repositories.rs create mode 100644 common/repos-github/src/util.rs create mode 100644 plugins/repos-validate/Cargo.toml create mode 100644 plugins/repos-validate/README.md create mode 100644 plugins/repos-validate/src/main.rs delete mode 100644 src/github/auth.rs delete mode 100644 src/github/client.rs delete mode 100644 src/github/pull_requests.rs delete mode 100644 src/github/repositories.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75f7563..79d3c92 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,6 +81,7 @@ jobs: path: | target/${{ matrix.target }}/release/repos target/${{ matrix.target }}/release/repos-health + target/${{ matrix.target }}/release/repos-validate # 3. Create the universal macOS binary from the previously built artifacts. # This job is very fast as it does not re-compile anything. @@ -104,7 +105,8 @@ jobs: run: | lipo -create -output repos arm64/repos x86_64/repos lipo -create -output repos-health arm64/repos-health x86_64/repos-health - strip -x repos repos-health + lipo -create -output repos-validate arm64/repos-validate x86_64/repos-validate + strip -x repos repos-health repos-validate - name: Upload universal artifact uses: actions/upload-artifact@v5 @@ -113,6 +115,7 @@ jobs: path: | repos repos-health + repos-validate # 4. Determine version, create the GitHub Release, and upload all artifacts. # This is the final publishing step. @@ -161,6 +164,7 @@ jobs: suffix=$(basename "$dir" | sed 's/build-artifacts-//') tar -czf "repos-${VERSION}-${suffix}.tar.gz" -C "$dir" repos tar -czf "repos-health-${VERSION}-${suffix}.tar.gz" -C "$dir" repos-health + tar -czf "repos-validate-${VERSION}-${suffix}.tar.gz" -C "$dir" repos-validate fi done @@ -168,6 +172,7 @@ jobs: if [ -d "dist/build-artifacts-macos-universal" ]; then tar -czf "repos-${VERSION}-macos-universal.tar.gz" -C "dist/build-artifacts-macos-universal" repos tar -czf "repos-health-${VERSION}-macos-universal.tar.gz" -C "dist/build-artifacts-macos-universal" repos-health + tar -czf "repos-validate-${VERSION}-macos-universal.tar.gz" -C "dist/build-artifacts-macos-universal" repos-validate fi # List final assets diff --git a/Cargo.toml b/Cargo.toml index 555bc1e..b5e77fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,11 +10,14 @@ path = "src/lib.rs" [workspace] members = [ ".", + "common/repos-github", "plugins/repos-health", + "plugins/repos-validate", ] [dependencies] async-trait = "0.1" +repos-github = { path = "common/repos-github" } clap = { version = "4.4", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.9" diff --git a/README.md b/README.md index f69631b..2e8c9ca 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ overview. Click on a command to see its detailed documentation. | [**`pr`**](./docs/commands/pr.md) | Creates pull requests for repositories with changes. | | [**`rm`**](./docs/commands/rm.md) | Removes cloned repositories from your local disk. | | [**`init`**](./docs/commands/init.md) | Generates a `config.yaml` file from local Git repositories. | +| [**`validate`**](./plugins/repos-validate/README.md) | Validates config file and repository connectivity (via plugin). | For a full list of options for any command, run `repos --help`. diff --git a/common/repos-github/Cargo.toml b/common/repos-github/Cargo.toml new file mode 100644 index 0000000..4ee7b31 --- /dev/null +++ b/common/repos-github/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "repos-github" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +reqwest = { version = "0.12", features = ["json"] } +serde = { version = "1.0", features = ["derive"] } +tokio = { version = "1.0", features = ["full"] } diff --git a/common/repos-github/README.md b/common/repos-github/README.md new file mode 100644 index 0000000..563465c --- /dev/null +++ b/common/repos-github/README.md @@ -0,0 +1,74 @@ +# repos-github + +A shared library for GitHub API interactions used by the `repos` CLI tool and its plugins. + +## Purpose + +This library centralizes all GitHub API communication logic, providing a consistent and reusable interface for: + +- Repository information retrieval +- Topic fetching +- Authentication handling +- Error management + +## Usage + +```rust +use repos_github::{GitHubClient, PullRequestParams}; +use anyhow::Result; + +#[tokio::main] +async fn main() -> Result<()> { + // Create a new GitHub client (automatically reads GITHUB_TOKEN from env) + let client = GitHubClient::new(None); + + // Get repository details including topics + let repo_details = client.get_repository_details("owner", "repo-name").await?; + println!("Topics: {:?}", repo_details.topics); + + // Create a pull request + let pr_params = PullRequestParams::new( + "owner", + "repo-name", + "My Test PR", + "feature-branch", + "main", + "This is the body of the PR.", + true, // draft + ); + let pr = client.create_pull_request(pr_params).await?; + println!("Created PR: {}", pr.html_url); + + Ok(()) +} +``` + +## Authentication + +The library automatically reads the `GITHUB_TOKEN` environment variable for authentication. This is required for: + +- Private repositories +- Avoiding API rate limits +- Accessing organization repositories + +## Error Handling + +The library provides detailed error messages for common scenarios: + +- **403 Forbidden**: Indicates missing or insufficient permissions +- **404 Not Found**: Repository doesn't exist or isn't accessible +- Network errors and timeouts + +## Integration + +This library is used by: + +- `repos-validate` plugin: For connectivity checks and topic supplementation +- Future plugins that need GitHub API access + +## Benefits + +- **DRY Principle**: Single source of truth for GitHub API logic +- **Consistency**: All components use the same authentication and error handling +- **Maintainability**: Changes to GitHub API interactions are made in one place +- **Testability**: Centralized logic is easier to unit test diff --git a/common/repos-github/src/client.rs b/common/repos-github/src/client.rs new file mode 100644 index 0000000..f0a4aea --- /dev/null +++ b/common/repos-github/src/client.rs @@ -0,0 +1,24 @@ +//! GitHub client implementation + +/// GitHub API client for making authenticated requests +pub struct GitHubClient { + pub(crate) client: reqwest::Client, + pub(crate) token: Option, +} + +impl GitHubClient { + /// Create a new GitHub client with an optional token + /// If no token is provided, will try to read from GITHUB_TOKEN environment variable + pub fn new(token: Option) -> Self { + Self { + client: reqwest::Client::new(), + token: token.or_else(|| std::env::var("GITHUB_TOKEN").ok()), + } + } +} + +impl Default for GitHubClient { + fn default() -> Self { + Self::new(None) + } +} diff --git a/common/repos-github/src/lib.rs b/common/repos-github/src/lib.rs new file mode 100644 index 0000000..4a1a5b6 --- /dev/null +++ b/common/repos-github/src/lib.rs @@ -0,0 +1,22 @@ +//! GitHub API client library +//! +//! This library provides a centralized interface for GitHub API operations +//! including repository management, pull request creation, and authentication. +//! +//! ## Modules +//! +//! - [`client`]: Core GitHub client implementation +//! - [`pull_requests`]: Pull request creation and management +//! - [`repositories`]: Repository information retrieval +//! - [`util`]: Utility functions for GitHub operations + +mod client; +mod pull_requests; +mod repositories; +mod util; + +// Re-export public API +pub use client::GitHubClient; +pub use pull_requests::{PullRequest, PullRequestParams}; +pub use repositories::GitHubRepo; +pub use util::parse_github_url; diff --git a/common/repos-github/src/pull_requests.rs b/common/repos-github/src/pull_requests.rs new file mode 100644 index 0000000..094aec3 --- /dev/null +++ b/common/repos-github/src/pull_requests.rs @@ -0,0 +1,122 @@ +//! Pull request operations + +use crate::client::GitHubClient; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +pub(crate) struct CreatePullRequestPayload<'a> { + title: &'a str, + head: &'a str, + base: &'a str, + body: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + draft: Option, +} + +#[derive(Deserialize, Debug)] +pub struct PullRequest { + pub html_url: String, + pub number: u64, + pub id: u64, + pub title: String, + pub state: String, +} + +/// Parameters for creating a pull request +#[derive(Debug, Clone)] +pub struct PullRequestParams<'a> { + pub owner: &'a str, + pub repo: &'a str, + pub title: &'a str, + pub head: &'a str, + pub base: &'a str, + pub body: &'a str, + pub draft: bool, +} + +impl<'a> PullRequestParams<'a> { + pub fn new( + owner: &'a str, + repo: &'a str, + title: &'a str, + head: &'a str, + base: &'a str, + body: &'a str, + draft: bool, + ) -> Self { + Self { + owner, + repo, + title, + head, + base, + body, + draft, + } + } +} + +impl GitHubClient { + /// Create a pull request on GitHub + /// + /// # Arguments + /// * `params` - Pull request parameters including owner, repo, title, head, base, body, and draft status + /// + /// # Returns + /// A PullRequest struct containing the created PR information + /// + /// # Errors + /// Returns an error if: + /// - No authentication token is configured + /// - The API request fails + /// - The response cannot be parsed + pub async fn create_pull_request(&self, params: PullRequestParams<'_>) -> Result { + if self.token.is_none() { + anyhow::bail!( + "GitHub token is required for creating pull requests. Set GITHUB_TOKEN environment variable." + ); + } + + let url = format!( + "https://api.github.com/repos/{}/{}/pulls", + params.owner, params.repo + ); + + let payload = CreatePullRequestPayload { + title: params.title, + head: params.head, + base: params.base, + body: params.body, + draft: if params.draft { Some(true) } else { None }, + }; + + let mut request = self.client.post(&url).header("User-Agent", "repos-cli"); + + if let Some(token) = &self.token { + request = request.header("Authorization", format!("token {}", token)); + } + + let response = request.json(&payload).send().await?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(anyhow::anyhow!( + "Failed to create pull request ({} {}): {}", + status.as_u16(), + status.canonical_reason().unwrap_or("Unknown"), + error_text + )); + } + + let pr: PullRequest = response + .json() + .await + .context("Failed to parse PR creation response")?; + Ok(pr) + } +} diff --git a/common/repos-github/src/repositories.rs b/common/repos-github/src/repositories.rs new file mode 100644 index 0000000..09719d8 --- /dev/null +++ b/common/repos-github/src/repositories.rs @@ -0,0 +1,47 @@ +//! Repository-related operations + +use crate::client::GitHubClient; +use anyhow::{Context, Result, anyhow}; +use serde::Deserialize; + +#[derive(Deserialize, Debug, Clone)] +pub struct GitHubRepo { + pub topics: Vec, +} + +impl GitHubClient { + pub async fn get_repository_details(&self, owner: &str, repo: &str) -> Result { + let url = format!("https://api.github.com/repos/{}/{}", owner, repo); + let mut request = self.client.get(&url).header("User-Agent", "repos-cli"); + + if let Some(token) = &self.token { + request = request.header("Authorization", format!("token {}", token)); + } + + let response = request.send().await?; + + if !response.status().is_success() { + let status = response.status(); + let error_msg = if status.as_u16() == 403 { + if self.token.is_none() { + "Access forbidden. This may be a private repository. Set GITHUB_TOKEN environment variable." + } else { + "Access forbidden. Check your GITHUB_TOKEN permissions or repository access." + } + } else { + status.canonical_reason().unwrap_or("Unknown error") + }; + return Err(anyhow!( + "Failed to connect ({} {})", + status.as_u16(), + error_msg + )); + } + + let repo_data: GitHubRepo = response + .json() + .await + .context("Failed to parse GitHub API response")?; + Ok(repo_data) + } +} diff --git a/common/repos-github/src/util.rs b/common/repos-github/src/util.rs new file mode 100644 index 0000000..095cde7 --- /dev/null +++ b/common/repos-github/src/util.rs @@ -0,0 +1,87 @@ +//! Utility functions for GitHub operations + +use anyhow::{Result, anyhow}; + +/// Parse GitHub URL to extract owner and repository name +/// +/// Supports various GitHub URL formats: +/// - SSH: `git@github.com:owner/repo.git` +/// - HTTPS: `https://github.com/owner/repo.git` +/// - Legacy: `github.com/owner/repo` +/// +/// # Arguments +/// * `url` - The GitHub repository URL to parse +/// +/// # Returns +/// A tuple containing (owner, repository_name) +/// +/// # Errors +/// Returns an error if the URL format is not recognized +pub fn parse_github_url(url: &str) -> Result<(String, String)> { + let url = url.trim_end_matches('/').trim_end_matches(".git"); + + // Handle SSH URLs: git@github.com:owner/repo or git@github-enterprise:owner/repo + if url.starts_with("git@") + && let Some(colon_pos) = url.find(':') + { + let after_colon = &url[colon_pos + 1..]; + let parts: Vec<&str> = after_colon.split('/').collect(); + if parts.len() == 2 { + return Ok((parts[0].to_string(), parts[1].to_string())); + } + } + + // Handle HTTPS URLs: https://github.com/owner/repo or https://github-enterprise/owner/repo + if url.starts_with("https://") || url.starts_with("http://") { + let without_protocol = url + .trim_start_matches("https://") + .trim_start_matches("http://"); + + let parts: Vec<&str> = without_protocol.split('/').collect(); + if parts.len() >= 3 { + return Ok((parts[1].to_string(), parts[2].to_string())); + } + } + + // Legacy support: github.com/owner/repo + if url.contains("github.com") { + let parts: Vec<&str> = url.split('/').collect(); + if parts.len() >= 2 { + let idx = parts.len() - 2; + return Ok((parts[idx].to_string(), parts[idx + 1].to_string())); + } + } + + Err(anyhow!("Invalid GitHub URL format: {}", url)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_ssh_url() { + let (owner, repo) = parse_github_url("git@github.com:owner/repo.git").unwrap(); + assert_eq!(owner, "owner"); + assert_eq!(repo, "repo"); + } + + #[test] + fn test_parse_https_url() { + let (owner, repo) = parse_github_url("https://github.com/owner/repo.git").unwrap(); + assert_eq!(owner, "owner"); + assert_eq!(repo, "repo"); + } + + #[test] + fn test_parse_legacy_url() { + let (owner, repo) = parse_github_url("github.com/owner/repo").unwrap(); + assert_eq!(owner, "owner"); + assert_eq!(repo, "repo"); + } + + #[test] + fn test_parse_invalid_url() { + assert!(parse_github_url("invalid-url").is_err()); + } +} diff --git a/docs/plugins.md b/docs/plugins.md index 768fcf7..2cafd42 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -39,6 +39,7 @@ The core CLI: - `REPOS_DEBUG=1` (if --debug flag was passed) - `REPOS_TOTAL_REPOS=28` (total repos in config) - `REPOS_FILTERED_COUNT=5` (repos after filtering) + - `REPOS_CONFIG_FILE=/path/to/your/config.yaml` (path to config file) 6. Executes `repos-health prs` with only plugin-specific args ### Using Context Injection in Your Plugin diff --git a/justfile b/justfile index 223df66..66b04b6 100644 --- a/justfile +++ b/justfile @@ -12,6 +12,7 @@ build: [group('lifecycle')] build-plugins: cargo build --release -p repos-health + cargo build --release -p repos-validate # Run tests [group('qa')] diff --git a/plugins/repos-validate/Cargo.toml b/plugins/repos-validate/Cargo.toml new file mode 100644 index 0000000..195044d --- /dev/null +++ b/plugins/repos-validate/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "repos-validate" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "repos-validate" +path = "src/main.rs" + +[dependencies] +repos = { path = "../.." } +repos-github = { path = "../../common/repos-github" } +anyhow = "1.0" +tokio = { version = "1.0", features = ["full"] } +clap = { version = "4.4", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_yaml = "0.9" +colored = "3.0" +chrono = "0.4" diff --git a/plugins/repos-validate/README.md b/plugins/repos-validate/README.md new file mode 100644 index 0000000..a91c4fe --- /dev/null +++ b/plugins/repos-validate/README.md @@ -0,0 +1,155 @@ +# repos-validate + +A plugin for the `repos` CLI tool that validates your `config.yaml` file and checks connectivity to all configured repositories. + +## Features + +- **Configuration Syntax Validation**: Confirms that `config.yaml` is properly formatted and parseable +- **Repository Connectivity Check**: Optionally verifies that each repository exists and is accessible via the GitHub API (with `--connect`) +- **Topic Synchronization**: Synchronizes GitHub topics with config tags - adds missing topics and removes outdated gh: tags (with `--sync-topics`) +- **Automatic Backup**: Creates timestamped backups before modifying `config.yaml` + +## Installation + +Build and install the plugin as part of the `repos` workspace: + +```bash +cargo build --release +sudo cp target/release/repos-validate /usr/local/bin/ +``` + +Or install it directly: + +```bash +cd plugins/repos-validate +cargo install --path . +``` + +## Usage + +### Basic Validation + +Validate your configuration syntax: + +```bash +repos validate +``` + +Example output: + +```console +✅ config.yaml syntax is valid. + +Validation finished successfully. +``` + +### Check Repository Connectivity + +Validate configuration and check that all repositories are accessible: + +```bash +repos validate --connect +``` + +Example output: + +```console +✅ config.yaml syntax is valid. + +Validating repository connectivity... +✅ codcod/repos: Accessible. +✅ another/project: Accessible. + +Validation finished successfully. +``` + +### Synchronize GitHub Topics + +Preview which GitHub topics would be synchronized (added/removed) with config tags: + +```bash +repos validate --connect --sync-topics +``` + +Example output: + +```console +✅ config.yaml syntax is valid. + +Validating repository connectivity... +✅ codcod/repos: Accessible. + - Would add: ["gh:cli", "gh:rust", "gh:automation"] + - Would remove: ["gh:deprecated-topic"] +✅ another/project: Accessible. + - Topics already synchronized + +Validation finished successfully. +``` + +### Apply Topic Synchronization + +To actually update your `config.yaml` with synchronized GitHub topics, use the `--apply` flag: + +```bash +repos validate --connect --sync-topics --apply +``` + +This will: + +1. Create a timestamped backup of your `config.yaml` (e.g., `config.yaml.backup.20251111_143022`) +2. Fetch topics from GitHub for each repository +3. Add missing topics as tags (prefixed with `gh:`) +4. Remove outdated `gh:` tags that no longer exist in GitHub topics + +Example output: + +```console +✅ config.yaml syntax is valid. + +Validating repository connectivity... +✅ codcod/repos: Accessible. + - Topics to add: ["gh:cli", "gh:rust", "gh:automation"] + - Topics to remove: ["gh:deprecated-topic"] +✅ another/project: Accessible. + - Topics already synchronized + +Validation finished successfully. + +Applying topic synchronization to config.yaml... +✅ Created backup: "config.yaml.backup.20251111_143022" +✅ Successfully updated config.yaml + 1 repositories were synchronized +``` + +## Backup Files + +When using `--apply`, the plugin automatically creates a backup before modifying your configuration. Backup files are named with a timestamp pattern: + +```console +config.yaml.backup.YYYYMMDD_HHMMSS +``` + +You can restore from a backup at any time: + +```bash +cp config.yaml.backup.20251111_143022 config.yaml +``` + +## Authentication + +For private repositories or to avoid rate limiting, set your GitHub token: + +```bash +export GITHUB_TOKEN=your_github_personal_access_token +repos validate +``` + +## Exit Codes + +- `0`: All repositories are accessible +- `1`: One or more repositories failed connectivity check + +## Supported Repository URL Formats + +- SSH: `git@github.com:owner/repo.git` +- HTTPS: `https://github.com/owner/repo.git` diff --git a/plugins/repos-validate/src/main.rs b/plugins/repos-validate/src/main.rs new file mode 100644 index 0000000..b9290be --- /dev/null +++ b/plugins/repos-validate/src/main.rs @@ -0,0 +1,282 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use colored::Colorize; +use repos::{Repository, is_debug_mode, load_plugin_context}; +use repos_github::GitHubClient; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(name = "repos-validate")] +#[command(about = "Validate config.yaml syntax and repository connectivity")] +struct Args { + /// Validate connectivity to repositories + #[arg(long)] + connect: bool, + + /// Synchronize tags with GitHub topics for each repository (requires --connect) + #[arg(long, requires = "connect")] + sync_topics: bool, + + /// Apply the topic synchronization to config.yaml (requires --sync-topics) + #[arg(long, requires = "sync_topics")] + apply: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + let debug = is_debug_mode(); + + // Load repositories from injected context or fail + let repos = load_plugin_context()? + .context("Failed to load plugin context. Make sure to run this via 'repos validate'")?; + + if debug { + eprintln!("Loaded {} repositories from context", repos.len()); + } + + println!("{}", "✅ config.yaml syntax is valid.".green()); + println!(); + + if !args.connect { + println!("{}", "Validation finished successfully.".green()); + return Ok(()); + } + + println!("Validating repository connectivity..."); + + let gh_client = GitHubClient::new(None); + let mut errors = 0; + let mut sync_map: HashMap = HashMap::new(); + + for repo in repos { + match validate_repository(&gh_client, &repo, args.sync_topics).await { + Ok(topics) => { + println!("{} {}: Accessible.", "✅".green(), repo.name); + if args.sync_topics && !topics.is_empty() { + let existing_tags: HashSet<_> = repo.tags.iter().cloned().collect(); + + // GitHub topics with gh: prefix + let gh_topics: HashSet = + topics.iter().map(|t| format!("gh:{}", t)).collect(); + + // Find existing gh: tags in config + let existing_gh_tags: HashSet = existing_tags + .iter() + .filter(|t| t.starts_with("gh:")) + .cloned() + .collect(); + + // Topics to add (in GitHub but not in tags) + let to_add: Vec = + gh_topics.difference(&existing_tags).cloned().collect(); + + // Topics to remove (gh: tags in config but not in GitHub topics) + let to_remove: Vec = + existing_gh_tags.difference(&gh_topics).cloned().collect(); + + if !to_add.is_empty() || !to_remove.is_empty() { + if args.apply { + sync_map.insert( + repo.name.clone(), + TopicSync { + add: to_add.clone(), + remove: to_remove.clone(), + }, + ); + if !to_add.is_empty() { + println!(" - Topics to add: {:?}", to_add); + } + if !to_remove.is_empty() { + println!(" - Topics to remove: {:?}", to_remove); + } + } else { + if !to_add.is_empty() { + println!(" - Would add: {:?}", to_add); + } + if !to_remove.is_empty() { + println!(" - Would remove: {:?}", to_remove); + } + } + } else { + println!(" - Topics already synchronized"); + } + } + } + Err(e) => { + println!("{} {}: {}", "❌".red(), repo.name, e); + errors += 1; + } + } + } + + println!(); + if errors > 0 { + println!( + "{}", + format!("Validation finished with {} error(s).", errors).red() + ); + std::process::exit(1); + } else { + println!("{}", "Validation finished successfully.".green()); + } + + // Apply supplementation if requested + if args.apply && !sync_map.is_empty() { + println!(); + let config_path = get_config_path()?; + apply_sync(&config_path, &sync_map)?; + } + + Ok(()) +} + +#[derive(Debug)] +struct TopicSync { + add: Vec, + remove: Vec, +} + +async fn validate_repository( + gh_client: &GitHubClient, + repo: &Repository, + fetch_topics: bool, +) -> Result> { + // Parse owner/repo from the URL + let (owner, repo_name) = parse_github_url(&repo.url)?; + + // Get repository details from GitHub + let repo_data = gh_client.get_repository_details(&owner, &repo_name).await?; + + // Return topics if requested, otherwise empty vector + if fetch_topics { + Ok(repo_data.topics) + } else { + Ok(vec![]) + } +} + +fn parse_github_url(url: &str) -> Result<(String, String)> { + // Handle SSH URLs: git@github.com:owner/repo.git + if url.starts_with("git@github.com:") { + let path = url + .strip_prefix("git@github.com:") + .context("Invalid GitHub SSH URL")? + .strip_suffix(".git") + .unwrap_or(url.strip_prefix("git@github.com:").unwrap()); + + let parts: Vec<&str> = path.split('/').collect(); + if parts.len() != 2 { + anyhow::bail!("Invalid GitHub repository path: {}", path); + } + return Ok((parts[0].to_string(), parts[1].to_string())); + } + + // Handle HTTPS URLs: https://github.com/owner/repo.git + if url.starts_with("https://github.com/") { + let path = url + .strip_prefix("https://github.com/") + .context("Invalid GitHub HTTPS URL")? + .strip_suffix(".git") + .unwrap_or(url.strip_prefix("https://github.com/").unwrap()); + + let parts: Vec<&str> = path.split('/').collect(); + if parts.len() != 2 { + anyhow::bail!("Invalid GitHub repository path: {}", path); + } + return Ok((parts[0].to_string(), parts[1].to_string())); + } + + anyhow::bail!("Unsupported repository URL format: {}", url) +} + +fn get_config_path() -> Result { + // Try to get config path from environment variable set by repos CLI + if let Ok(config) = std::env::var("REPOS_CONFIG_FILE") { + let debug = is_debug_mode(); + if debug { + eprintln!("Using config file from REPOS_CONFIG_FILE: {}", config); + } + return Ok(PathBuf::from(config)); + } + + // Default to config.yaml in current directory + Ok(PathBuf::from("config.yaml")) +} + +fn create_backup(config_path: &PathBuf) -> Result { + let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); + let backup_path = config_path.with_extension(format!("yaml.backup.{}", timestamp)); + + fs::copy(config_path, &backup_path) + .context(format!("Failed to create backup at {:?}", backup_path))?; + + println!("{} Created backup: {:?}", "✅".green(), backup_path); + Ok(backup_path) +} + +fn apply_sync(config_path: &PathBuf, sync_map: &HashMap) -> Result<()> { + println!("Applying topic synchronization to config.yaml..."); + + // Create backup first + create_backup(config_path)?; + + // Read the config file + let content = fs::read_to_string(config_path) + .context(format!("Failed to read config file: {:?}", config_path))?; + + // Parse as YAML + let mut config: serde_yaml::Value = + serde_yaml::from_str(&content).context("Failed to parse config.yaml")?; + + // Update repositories with synchronized topics + if let Some(repos) = config + .get_mut("repositories") + .and_then(|r| r.as_sequence_mut()) + { + for repo in repos { + // Get the name first + let name = repo.get("name").and_then(|n| n.as_str()).map(String::from); + + if let Some(name) = name + && let Some(sync) = sync_map.get(&name) + { + // Get existing tags or create new array + let tags = repo + .get_mut("tags") + .and_then(|t| t.as_sequence_mut()) + .context(format!("Repository '{}' has invalid tags field", name))?; + + // Remove outdated gh: tags + for topic in &sync.remove { + let topic_value = serde_yaml::Value::String(topic.clone()); + if let Some(pos) = tags.iter().position(|t| t == &topic_value) { + tags.remove(pos); + } + } + + // Add new topics + for topic in &sync.add { + let topic_value = serde_yaml::Value::String(topic.clone()); + if !tags.contains(&topic_value) { + tags.push(topic_value); + } + } + } + } + } + + // Write back to file + let updated_content = + serde_yaml::to_string(&config).context("Failed to serialize updated config")?; + + fs::write(config_path, updated_content) + .context(format!("Failed to write config file: {:?}", config_path))?; + + println!("{} Successfully updated config.yaml", "✅".green()); + println!(" {} repositories were synchronized", sync_map.len()); + + Ok(()) +} diff --git a/src/commands/pr.rs b/src/commands/pr.rs index 0848a26..767f557 100644 --- a/src/commands/pr.rs +++ b/src/commands/pr.rs @@ -1,7 +1,8 @@ //! Pull request command implementation use super::{Command, CommandContext}; -use crate::github::{self, PrOptions}; +use crate::github::PrOptions; +use crate::github::api::create_pr_from_workspace; use anyhow::Result; use async_trait::async_trait; use colored::*; @@ -84,7 +85,7 @@ impl Command for PrCommand { async move { ( repo.name.clone(), - github::create_pr_from_workspace(&repo, &pr_options).await, + create_pr_from_workspace(&repo, &pr_options).await, ) } }) @@ -102,7 +103,7 @@ impl Command for PrCommand { } } else { for repo in repositories { - match github::create_pr_from_workspace(&repo, &pr_options).await { + match create_pr_from_workspace(&repo, &pr_options).await { Ok(_) => successful += 1, Err(e) => { eprintln!( diff --git a/src/github/api.rs b/src/github/api.rs index 3457656..ac5d0f9 100644 --- a/src/github/api.rs +++ b/src/github/api.rs @@ -1,7 +1,6 @@ //! GitHub API operations -use super::client::GitHubClient; -use super::types::{PrOptions, PullRequestParams}; +use super::types::PrOptions; use crate::config::Repository; use crate::constants::github::{DEFAULT_BRANCH_PREFIX, UUID_LENGTH}; use crate::git; @@ -72,10 +71,10 @@ async fn create_github_pr( branch_name: &str, options: &PrOptions, ) -> Result { - let client = GitHubClient::new(Some(options.token.clone())); + let client = repos_github::GitHubClient::new(Some(options.token.clone())); // Extract owner and repo name from URL - let (owner, repo_name) = client.parse_github_url(&repo.url)?; + let (owner, repo_name) = parse_github_url(&repo.url)?; // Determine base branch - get actual default branch if not specified let base_branch = if let Some(ref base) = options.base_branch { @@ -84,23 +83,34 @@ async fn create_github_pr( git::get_default_branch(&repo.get_target_dir())? }; - let result = client - .create_pull_request(PullRequestParams::new( - &owner, - &repo_name, - &options.title, - &options.body, - branch_name, - &base_branch, - options.draft, - )) - .await?; - - let pr_url = result["html_url"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("No html_url in GitHub API response"))?; - - Ok(pr_url.to_string()) + let params = repos_github::PullRequestParams::new( + &owner, + &repo_name, + &options.title, + branch_name, + &base_branch, + &options.body, + options.draft, + ); + + let result = client.create_pull_request(params).await?; + + Ok(result.html_url) +} + +/// Parse a GitHub URL to extract owner and repository name +fn parse_github_url(url: &str) -> Result<(String, String)> { + let url = url.trim_end_matches('/').trim_end_matches(".git"); + + let parts: Vec<&str> = url.split('/').collect(); + if parts.len() < 2 { + anyhow::bail!("Invalid GitHub URL format: {url}"); + } + + let repo_name = parts[parts.len() - 1]; + let owner = parts[parts.len() - 2]; + + Ok((owner.to_string(), repo_name.to_string())) } #[cfg(test)] diff --git a/src/github/auth.rs b/src/github/auth.rs deleted file mode 100644 index 83335f6..0000000 --- a/src/github/auth.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! GitHub authentication utilities - -use anyhow::Result; - -pub struct GitHubAuth { - token: String, -} - -impl GitHubAuth { - pub fn new(token: String) -> Self { - Self { token } - } - - pub fn token(&self) -> &str { - &self.token - } - - pub fn get_auth_header(&self) -> String { - format!("Bearer {}", self.token) - } - - pub fn validate_token(&self) -> Result<()> { - if self.token.is_empty() { - anyhow::bail!("GitHub token is required"); - } - Ok(()) - } -} diff --git a/src/github/client.rs b/src/github/client.rs deleted file mode 100644 index 6f69aae..0000000 --- a/src/github/client.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! GitHub API client implementation -//! -//! This module provides the main `GitHubClient` struct which serves as the entry point -//! for all GitHub API operations. The client encapsulates authentication and HTTP client -//! state, making it easy to perform various GitHub operations. -//! -//! ## Architecture -//! -//! The `GitHubClient` follows a modular design where different API endpoints are organized -//! into separate modules: -//! - `pull_requests.rs` - Pull request operations -//! - `repositories.rs` - Repository information and releases -//! -//! Each module extends the `GitHubClient` with `impl` blocks containing related methods. - -use super::auth::GitHubAuth; -use anyhow::Result; -use reqwest::Client; - -/// GitHub API client for interacting with GitHub's REST API -/// -/// This client provides a unified interface for GitHub API operations, managing -/// authentication and HTTP client state. Different API endpoints are organized -/// into separate modules that extend this client with specific functionality. -/// -/// ## Features -/// -/// - **Authentication Management**: Handles GitHub token authentication -/// - **URL Parsing**: Supports both GitHub.com and GitHub Enterprise URLs -/// - **Modular Design**: API operations are organized by functionality -/// - **Error Handling**: Comprehensive error handling for API responses -/// -/// ## Example -/// -/// ```rust,no_run -/// use repos::github::GitHubClient; -/// -/// # async fn example() -> anyhow::Result<()> { -/// // Create client with authentication -/// let client = GitHubClient::new(Some("your_github_token".to_string())); -/// -/// // Parse repository URL -/// let (owner, repo) = client.parse_github_url("https://github.com/owner/repo")?; -/// -/// // Use client for various operations (see specific modules for examples) -/// // - Pull requests: client.create_pull_request() -/// // - Repositories: client.get_repository() -/// # Ok(()) -/// # } -/// ``` -pub struct GitHubClient { - pub(crate) client: Client, - pub(crate) auth: Option, -} - -impl GitHubClient { - /// Create a new GitHub client - /// - /// # Arguments - /// * `token` - Optional GitHub personal access token for authentication - /// - /// # Returns - /// A new GitHubClient instance - /// - /// # Example - /// ```rust - /// use repos::github::GitHubClient; - /// - /// // Client without authentication (for public repositories) - /// let public_client = GitHubClient::new(None); - /// - /// // Client with authentication (for private repos and higher rate limits) - /// let auth_client = GitHubClient::new(Some("your_token".to_string())); - /// ``` - pub fn new(token: Option) -> Self { - let auth = token.map(GitHubAuth::new); - Self { - client: Client::new(), - auth, - } - } - - /// Parse GitHub URL to extract owner and repository name - /// - /// Supports both github.com and enterprise GitHub instances with various URL formats: - /// - SSH: `git@github.com:owner/repo` or `git@github-enterprise:owner/repo` - /// - HTTPS: `https://github.com/owner/repo` or `https://github-enterprise/owner/repo` - /// - Legacy: `github.com/owner/repo` - /// - /// # Arguments - /// * `url` - The GitHub repository URL to parse - /// - /// # Returns - /// A tuple containing (owner, repository_name) - /// - /// # Errors - /// Returns an error if the URL format is not recognized as a valid GitHub URL - /// - /// # Example - /// ```rust - /// use repos::github::GitHubClient; - /// - /// let client = GitHubClient::new(None); - /// let (owner, repo) = client.parse_github_url("https://github.com/rust-lang/rust").unwrap(); - /// assert_eq!(owner, "rust-lang"); - /// assert_eq!(repo, "rust"); - /// ``` - pub fn parse_github_url(&self, url: &str) -> Result<(String, String)> { - let url = url.trim_end_matches('/').trim_end_matches(".git"); - - // Handle SSH URLs: git@github.com:owner/repo or git@github-enterprise:owner/repo - if let Some(captures) = regex::Regex::new(r"git@([^:]+):([^/]+)/(.+)")?.captures(url) { - let owner = captures.get(2).unwrap().as_str().to_string(); - let repo = captures.get(3).unwrap().as_str().to_string(); - return Ok((owner, repo)); - } - - // Handle HTTPS URLs: https://github.com/owner/repo or https://github-enterprise/owner/repo - if let Some(captures) = regex::Regex::new(r"https://([^/]+)/([^/]+)/(.+)")?.captures(url) { - let owner = captures.get(2).unwrap().as_str().to_string(); - let repo = captures.get(3).unwrap().as_str().to_string(); - return Ok((owner, repo)); - } - - // Legacy support for github.com URLs with [:/] pattern - if let Some(captures) = regex::Regex::new(r"github\.com[:/]([^/]+)/([^/]+)")?.captures(url) - { - let owner = captures.get(1).unwrap().as_str().to_string(); - let repo = captures.get(2).unwrap().as_str().to_string(); - return Ok((owner, repo)); - } - - Err(anyhow::anyhow!("Invalid GitHub URL format: {}", url)) - } - - /// Check if the client has authentication configured - /// - /// # Returns - /// `true` if the client has a GitHub token configured, `false` otherwise - pub fn is_authenticated(&self) -> bool { - self.auth.is_some() - } - - /// Get the authentication token (if available) - /// - /// # Returns - /// `Some(token)` if authenticated, `None` otherwise - pub fn token(&self) -> Option<&str> { - self.auth.as_ref().map(|auth| auth.token()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_github_url_ssh_github_com() { - let client = GitHubClient::new(None); - let (owner, repo) = client - .parse_github_url("git@github.com:owner/repo") - .unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); - } - - #[test] - fn test_parse_github_url_ssh_enterprise() { - let client = GitHubClient::new(None); - let (owner, repo) = client - .parse_github_url("git@github-enterprise:nicos_backbase/journey") - .unwrap(); - assert_eq!(owner, "nicos_backbase"); - assert_eq!(repo, "journey"); - } - - #[test] - fn test_parse_github_url_https_github_com() { - let client = GitHubClient::new(None); - let (owner, repo) = client - .parse_github_url("https://github.com/owner/repo") - .unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); - } - - #[test] - fn test_parse_github_url_https_enterprise() { - let client = GitHubClient::new(None); - let (owner, repo) = client - .parse_github_url("https://github-enterprise/owner/repo") - .unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); - } - - #[test] - fn test_parse_github_url_with_git_suffix() { - let client = GitHubClient::new(None); - let (owner, repo) = client - .parse_github_url("git@github-enterprise:owner/repo.git") - .unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); - } - - #[test] - fn test_parse_github_url_legacy_format() { - let client = GitHubClient::new(None); - let (owner, repo) = client.parse_github_url("github.com/owner/repo").unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); - } -} diff --git a/src/github/mod.rs b/src/github/mod.rs index 4a40a58..389b561 100644 --- a/src/github/mod.rs +++ b/src/github/mod.rs @@ -1,58 +1,21 @@ -//! GitHub API integration module +//! GitHub workflow module //! -//! This module provides a comprehensive interface for interacting with GitHub's REST API. -//! It follows a modular design where different API endpoints are organized into separate -//! sub-modules for better maintainability and organization. +//! This module provides high-level workflow functions for GitHub operations +//! using the shared repos-github library for API interactions. //! //! ## Architecture //! -//! - [`client`]: Core GitHub client with authentication and URL parsing -//! - [`auth`]: Authentication handling and token management -//! - [`pull_requests`]: Pull request creation and management -//! - [`repositories`]: Repository information and releases -//! - [`types`]: Data structures and type definitions -//! - [`api`]: High-level workflow functions +//! - [`api`]: High-level workflow functions (e.g., create PR from workspace) +//! - [`types`]: Workflow-specific types like PrOptions //! -//! ## Features -//! -//! - **Modular Design**: API operations grouped by functionality -//! - **Authentication**: Secure token-based authentication -//! - **Error Handling**: Comprehensive error types and handling -//! - **Enterprise Support**: Works with both GitHub.com and GitHub Enterprise -//! - **Async/Await**: Fully async API with tokio support -//! -//! ## Quick Start -//! -//! ```rust,no_run -//! use repos::github::{GitHubClient, PrOptions}; -//! use repos::config::Repository; -//! -//! # async fn example() -> anyhow::Result<()> { -//! // Create a client -//! let client = GitHubClient::new(Some("your_token".to_string())); -//! -//! // Parse a GitHub URL -//! let (owner, repo) = client.parse_github_url("https://github.com/rust-lang/rust")?; -//! -//! // Get repository information -//! let repo_info = client.get_repository(&owner, &repo).await?; -//! println!("Repository: {}", repo_info.full_name); -//! # Ok(()) -//! # } -//! ``` +//! For low-level GitHub API operations, see the `repos-github` crate. pub mod api; -pub mod auth; -pub mod client; -pub mod pull_requests; -pub mod repositories; pub mod types; // Re-export commonly used items for convenience pub use api::create_pr_from_workspace; -pub use auth::GitHubAuth; -pub use client::GitHubClient; -pub use types::{GitHubRepo, PrOptions, PullRequest, PullRequestParams, User}; +pub use types::PrOptions; // Re-export constants for easy access pub use crate::constants::github::{DEFAULT_BRANCH_PREFIX, DEFAULT_USER_AGENT}; diff --git a/src/github/pull_requests.rs b/src/github/pull_requests.rs deleted file mode 100644 index bcd743d..0000000 --- a/src/github/pull_requests.rs +++ /dev/null @@ -1,300 +0,0 @@ -//! GitHub Pull Request API operations -//! -//! This module contains all functionality related to GitHub pull requests, -//! including creation, management, and querying of pull requests. - -use super::client::GitHubClient; -use super::types::{PullRequest, PullRequestParams}; -use anyhow::Result; -use serde_json::{Value, json}; - -impl GitHubClient { - /// Create a new pull request on GitHub - /// - /// # Arguments - /// * `params` - Pull request parameters including owner, repo, title, body, etc. - /// - /// # Returns - /// A JSON value containing the GitHub API response for the created pull request - /// - /// # Example - /// ```rust,no_run - /// use repos::github::{GitHubClient, PullRequestParams}; - /// - /// # async fn example() -> anyhow::Result<()> { - /// let client = GitHubClient::new(Some("github_token".to_string())); - /// let params = PullRequestParams::new( - /// "owner", - /// "repo", - /// "Fix bug in authentication", - /// "This PR fixes a critical bug in the auth system", - /// "feature-branch", - /// "main", - /// false - /// ); - /// - /// let pr_result = client.create_pull_request(params).await?; - /// println!("Created PR: {}", pr_result["html_url"]); - /// # Ok(()) - /// # } - /// ``` - pub async fn create_pull_request(&self, params: PullRequestParams<'_>) -> Result { - let auth = self.auth.as_ref().ok_or_else(|| { - anyhow::anyhow!("GitHub token is required for creating pull requests") - })?; - - let url = format!( - "{}/repos/{}/{}/pulls", - super::types::constants::GITHUB_API_BASE, - params.owner, - params.repo - ); - - let payload = json!({ - "title": params.title, - "body": params.body, - "head": params.head, - "base": params.base, - "draft": params.draft - }); - - let response = self - .client - .post(&url) - .header("Authorization", auth.get_auth_header()) - .header("User-Agent", super::types::constants::DEFAULT_USER_AGENT) - .header("Accept", "application/vnd.github.v3+json") - .json(&payload) - .send() - .await?; - - if response.status().is_success() { - let result: Value = response.json().await?; - Ok(result) - } else { - let status = response.status(); - let error_text = response.text().await?; - Err(anyhow::anyhow!( - "GitHub API error ({}): {}", - status, - error_text - )) - } - } - - /// Get a specific pull request by number - /// - /// # Arguments - /// * `owner` - Repository owner (username or organization) - /// * `repo` - Repository name - /// * `pr_number` - Pull request number - /// - /// # Returns - /// A PullRequest struct containing the PR information - pub async fn get_pull_request( - &self, - owner: &str, - repo: &str, - pr_number: u64, - ) -> Result { - let url = format!( - "{}/repos/{}/{}/pulls/{}", - super::types::constants::GITHUB_API_BASE, - owner, - repo, - pr_number - ); - - let mut request = self - .client - .get(&url) - .header("User-Agent", super::types::constants::DEFAULT_USER_AGENT) - .header("Accept", "application/vnd.github.v3+json"); - - // Add authorization if available - if let Some(auth) = &self.auth { - request = request.header("Authorization", auth.get_auth_header()); - } - - let response = request.send().await?; - - if response.status().is_success() { - let pr: PullRequest = response.json().await?; - Ok(pr) - } else { - let status = response.status(); - let error_text = response.text().await?; - Err(anyhow::anyhow!( - "Failed to get pull request ({}): {}", - status, - error_text - )) - } - } - - /// List pull requests for a repository - /// - /// # Arguments - /// * `owner` - Repository owner (username or organization) - /// * `repo` - Repository name - /// * `state` - Optional state filter ("open", "closed", "all") - /// * `base` - Optional base branch filter - /// - /// # Returns - /// A vector of PullRequest structs - pub async fn list_pull_requests( - &self, - owner: &str, - repo: &str, - state: Option<&str>, - base: Option<&str>, - ) -> Result> { - let mut url = format!( - "{}/repos/{}/{}/pulls", - super::types::constants::GITHUB_API_BASE, - owner, - repo - ); - - // Add query parameters - let mut params = Vec::new(); - if let Some(state) = state { - params.push(format!("state={}", state)); - } - if let Some(base) = base { - params.push(format!("base={}", base)); - } - - if !params.is_empty() { - url = format!("{}?{}", url, params.join("&")); - } - - let mut request = self - .client - .get(&url) - .header("User-Agent", super::types::constants::DEFAULT_USER_AGENT) - .header("Accept", "application/vnd.github.v3+json"); - - // Add authorization if available - if let Some(auth) = &self.auth { - request = request.header("Authorization", auth.get_auth_header()); - } - - let response = request.send().await?; - - if response.status().is_success() { - let prs: Vec = response.json().await?; - Ok(prs) - } else { - let status = response.status(); - let error_text = response.text().await?; - Err(anyhow::anyhow!( - "Failed to list pull requests ({}): {}", - status, - error_text - )) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn create_test_client_with_auth() -> GitHubClient { - GitHubClient::new(Some("test-token".to_string())) - } - - fn create_test_client_without_auth() -> GitHubClient { - GitHubClient::new(None) - } - - fn create_test_pr_params() -> PullRequestParams<'static> { - PullRequestParams::new( - "test-owner", - "test-repo", - "Test PR Title", - "Test PR body content", - "feature-branch", - "main", - false, - ) - } - - #[tokio::test] - async fn test_create_pull_request_without_auth() { - // Test the auth missing path (line 42-44) - let client = create_test_client_without_auth(); - let params = create_test_pr_params(); - - let result = client.create_pull_request(params).await; - - // Should fail with auth error - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("GitHub token is required") - ); - } - - #[tokio::test] - async fn test_create_pull_request_with_auth() { - // Test the main execution path with auth (lines 46-79) - let client = create_test_client_with_auth(); - let params = create_test_pr_params(); - - let result = client.create_pull_request(params).await; - - // Will fail due to network/API, but exercises the execution path - assert!(result.is_err()); // Expected failure without real GitHub setup - } - - #[tokio::test] - async fn test_get_pull_request_execution() { - // Test get_pull_request execution path - let client = create_test_client_with_auth(); - - let result = client - .get_pull_request("test-owner", "test-repo", 123) - .await; - - // Will fail due to network/API, but exercises the execution path - assert!(result.is_err()); // Expected failure without real GitHub setup - } - - #[tokio::test] - async fn test_list_pull_requests_execution() { - // Test list_pull_requests execution path - let client = create_test_client_with_auth(); - - let result = client - .list_pull_requests("test-owner", "test-repo", None, None) - .await; - - // Will fail due to network/API, but exercises the execution path - assert!(result.is_err()); // Expected failure without real GitHub setup - } - - #[test] - fn test_pull_request_module_exists() { - // This test ensures the module compiles and can be imported - let client = GitHubClient::new(None); - assert!(client.auth.is_none()); - } - - #[test] - fn test_pull_request_params_creation() { - // Test PullRequestParams creation and field access - let params = create_test_pr_params(); - - assert_eq!(params.owner, "test-owner"); - assert_eq!(params.repo, "test-repo"); - assert_eq!(params.title, "Test PR Title"); - assert_eq!(params.body, "Test PR body content"); - assert_eq!(params.head, "feature-branch"); - assert_eq!(params.base, "main"); - assert!(!params.draft); - } -} diff --git a/src/github/repositories.rs b/src/github/repositories.rs deleted file mode 100644 index dc66fb1..0000000 --- a/src/github/repositories.rs +++ /dev/null @@ -1,342 +0,0 @@ -//! GitHub Repository API operations -//! -//! This module contains functionality for interacting with GitHub repositories, -//! including getting repository information, releases, and other repo-level operations. - -use super::client::GitHubClient; -use super::types::GitHubRepo; -use anyhow::Result; -use serde_json::Value; - -impl GitHubClient { - /// Get repository information from GitHub - /// - /// # Arguments - /// * `owner` - Repository owner (username or organization) - /// * `repo` - Repository name - /// - /// # Returns - /// A GitHubRepo struct containing repository information - /// - /// # Example - /// ```rust,no_run - /// use repos::github::GitHubClient; - /// - /// # async fn example() -> anyhow::Result<()> { - /// let client = GitHubClient::new(Some("github_token".to_string())); - /// let repo_info = client.get_repository("octocat", "Hello-World").await?; - /// println!("Repository: {}", repo_info.full_name); - /// # Ok(()) - /// # } - /// ``` - pub async fn get_repository(&self, owner: &str, repo: &str) -> Result { - let url = format!( - "{}/repos/{}/{}", - super::types::constants::GITHUB_API_BASE, - owner, - repo - ); - - let mut request = self - .client - .get(&url) - .header("User-Agent", super::types::constants::DEFAULT_USER_AGENT) - .header("Accept", "application/vnd.github.v3+json"); - - // Add authorization if available - if let Some(auth) = &self.auth { - request = request.header("Authorization", auth.get_auth_header()); - } - - let response = request.send().await?; - - if response.status().is_success() { - let repo_info: GitHubRepo = response.json().await?; - Ok(repo_info) - } else { - let status = response.status(); - let error_text = response.text().await?; - Err(anyhow::anyhow!( - "Failed to get repository information ({}): {}", - status, - error_text - )) - } - } - - /// Get the latest release of a repository - /// - /// # Arguments - /// * `owner` - Repository owner (username or organization) - /// * `repo` - Repository name - /// - /// # Returns - /// A JSON Value containing the latest release information - /// - /// # Example - /// ```rust,no_run - /// use repos::github::GitHubClient; - /// - /// # async fn example() -> anyhow::Result<()> { - /// let client = GitHubClient::new(None); // Public API, no token needed - /// let latest_release = client.get_latest_release("rust-lang", "rust").await?; - /// println!("Latest release: {}", latest_release["tag_name"]); - /// # Ok(()) - /// # } - /// ``` - pub async fn get_latest_release(&self, owner: &str, repo: &str) -> Result { - let url = format!( - "{}/repos/{}/{}/releases/latest", - super::types::constants::GITHUB_API_BASE, - owner, - repo - ); - - let mut request = self - .client - .get(&url) - .header("User-Agent", super::types::constants::DEFAULT_USER_AGENT) - .header("Accept", "application/vnd.github.v3+json"); - - // Add authorization if available - if let Some(auth) = &self.auth { - request = request.header("Authorization", auth.get_auth_header()); - } - - let response = request.send().await?; - - if response.status().is_success() { - let release: Value = response.json().await?; - Ok(release) - } else { - let status = response.status(); - let error_text = response.text().await?; - Err(anyhow::anyhow!( - "Failed to get latest release ({}): {}", - status, - error_text - )) - } - } - - /// List all releases for a repository - /// - /// # Arguments - /// * `owner` - Repository owner (username or organization) - /// * `repo` - Repository name - /// * `per_page` - Optional number of results per page (default: 30, max: 100) - /// * `page` - Optional page number for pagination (default: 1) - /// - /// # Returns - /// A vector of JSON Values containing release information - pub async fn list_releases( - &self, - owner: &str, - repo: &str, - per_page: Option, - page: Option, - ) -> Result> { - let mut url = format!( - "{}/repos/{}/{}/releases", - super::types::constants::GITHUB_API_BASE, - owner, - repo - ); - - // Add query parameters - let mut params = Vec::new(); - if let Some(per_page) = per_page { - params.push(format!("per_page={}", per_page.min(100))); - } - if let Some(page) = page { - params.push(format!("page={}", page)); - } - - if !params.is_empty() { - url = format!("{}?{}", url, params.join("&")); - } - - let mut request = self - .client - .get(&url) - .header("User-Agent", super::types::constants::DEFAULT_USER_AGENT) - .header("Accept", "application/vnd.github.v3+json"); - - // Add authorization if available - if let Some(auth) = &self.auth { - request = request.header("Authorization", auth.get_auth_header()); - } - - let response = request.send().await?; - - if response.status().is_success() { - let releases: Vec = response.json().await?; - Ok(releases) - } else { - let status = response.status(); - let error_text = response.text().await?; - Err(anyhow::anyhow!( - "Failed to list releases ({}): {}", - status, - error_text - )) - } - } - - /// Get repository topics (tags/labels) - /// - /// # Arguments - /// * `owner` - Repository owner (username or organization) - /// * `repo` - Repository name - /// - /// # Returns - /// A vector of topic strings - pub async fn get_repository_topics(&self, owner: &str, repo: &str) -> Result> { - let url = format!( - "{}/repos/{}/{}/topics", - super::types::constants::GITHUB_API_BASE, - owner, - repo - ); - - let mut request = self - .client - .get(&url) - .header("User-Agent", super::types::constants::DEFAULT_USER_AGENT) - .header("Accept", "application/vnd.github.mercy-preview+json"); // Topics API requires this accept header - - // Add authorization if available - if let Some(auth) = &self.auth { - request = request.header("Authorization", auth.get_auth_header()); - } - - let response = request.send().await?; - - if response.status().is_success() { - let result: Value = response.json().await?; - let topics = result["names"] - .as_array() - .unwrap_or(&Vec::new()) - .iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect(); - Ok(topics) - } else { - let status = response.status(); - let error_text = response.text().await?; - Err(anyhow::anyhow!( - "Failed to get repository topics ({}): {}", - status, - error_text - )) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn create_test_client_with_auth() -> GitHubClient { - GitHubClient::new(Some("test-token".to_string())) - } - - fn create_test_client_without_auth() -> GitHubClient { - GitHubClient::new(None) - } - - #[tokio::test] - async fn test_get_repository_with_auth() { - // Test get_repository with auth (lines 32-63) - let client = create_test_client_with_auth(); - - let result = client.get_repository("test-owner", "test-repo").await; - - // Will fail due to network/API, but exercises the execution path - assert!(result.is_err()); // Expected failure without real GitHub setup - } - - #[tokio::test] - async fn test_get_repository_without_auth() { - // Test get_repository without auth (different branch path) - let client = create_test_client_without_auth(); - - let result = client.get_repository("test-owner", "test-repo").await; - - // Will fail due to network/API, but exercises the execution path - assert!(result.is_err()); // Expected failure without real GitHub setup - } - - #[tokio::test] - async fn test_get_latest_release_execution() { - // Test get_latest_release execution path (lines 87-117) - let client = create_test_client_with_auth(); - - let result = client.get_latest_release("test-owner", "test-repo").await; - - // Will fail due to network/API, but exercises the execution path - assert!(result.is_err()); // Expected failure without real GitHub setup - } - - #[tokio::test] - async fn test_list_releases_execution() { - // Test list_releases execution path (lines 132-177) - let client = create_test_client_with_auth(); - - let result = client - .list_releases("test-owner", "test-repo", Some(10), Some(1)) - .await; - - // Will fail due to network/API, but exercises the execution path - assert!(result.is_err()); // Expected failure without real GitHub setup - } - - #[tokio::test] - async fn test_list_releases_default_params() { - // Test list_releases with default parameters (None values) - let client = create_test_client_with_auth(); - - let result = client - .list_releases("test-owner", "test-repo", None, None) - .await; - - // Will fail due to network/API, but exercises the execution path - assert!(result.is_err()); // Expected failure without real GitHub setup - } - - #[tokio::test] - async fn test_get_repository_topics_execution() { - // Test get_repository_topics execution path (lines 194-230) - let client = create_test_client_with_auth(); - - let result = client - .get_repository_topics("test-owner", "test-repo") - .await; - - // Will fail due to network/API, but exercises the execution path - assert!(result.is_err()); // Expected failure without real GitHub setup - } - - #[test] - fn test_repository_module_exists() { - // This test ensures the module compiles and can be imported - let client = GitHubClient::new(None); - assert!(client.auth.is_none()); - } - - #[test] - fn test_client_auth_branching() { - // Test the auth vs no-auth branching logic - let client_with_auth = create_test_client_with_auth(); - let client_without_auth = create_test_client_without_auth(); - - assert!(client_with_auth.auth.is_some()); - assert!(client_without_auth.auth.is_none()); - - // Test auth header generation - if let Some(auth) = &client_with_auth.auth { - let header = auth.get_auth_header(); - assert!(header.starts_with("Bearer ") || header.starts_with("token ")); - } - } -} diff --git a/src/github/types.rs b/src/github/types.rs index 59e5489..0f3bfcc 100644 --- a/src/github/types.rs +++ b/src/github/types.rs @@ -1,44 +1,9 @@ -//! GitHub API types and data structures +//! GitHub workflow types +//! +//! This module contains workflow-specific types for GitHub operations. +//! For low-level GitHub API types, see the `repos-github` crate. -use serde::{Deserialize, Serialize}; -use std::error::Error; -use std::fmt; - -/// Parameters for creating a pull request -#[derive(Debug, Clone)] -pub struct PullRequestParams<'a> { - pub owner: &'a str, - pub repo: &'a str, - pub title: &'a str, - pub body: &'a str, - pub head: &'a str, - pub base: &'a str, - pub draft: bool, -} - -impl<'a> PullRequestParams<'a> { - pub fn new( - owner: &'a str, - repo: &'a str, - title: &'a str, - body: &'a str, - head: &'a str, - base: &'a str, - draft: bool, - ) -> Self { - Self { - owner, - repo, - title, - body, - head, - base, - draft, - } - } -} - -/// Pull request options for creation +/// Pull request options for creation workflow #[derive(Debug, Clone)] pub struct PrOptions { pub title: String, @@ -90,62 +55,3 @@ impl PrOptions { self } } - -/// GitHub API error types -#[derive(Debug)] -pub enum GitHubError { - ApiError(String), - AuthError, - NetworkError(String), - ParseError(String), -} - -impl fmt::Display for GitHubError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - GitHubError::ApiError(msg) => write!(f, "GitHub API error: {msg}"), - GitHubError::AuthError => write!(f, "GitHub authentication error"), - GitHubError::NetworkError(msg) => write!(f, "Network error: {msg}"), - GitHubError::ParseError(msg) => write!(f, "Parse error: {msg}"), - } - } -} - -impl Error for GitHubError {} - -/// GitHub repository information -#[derive(Debug, Serialize, Deserialize)] -pub struct GitHubRepo { - pub id: u64, - pub name: String, - pub full_name: String, - pub html_url: String, - pub clone_url: String, - pub default_branch: String, -} - -/// GitHub user information -#[derive(Debug, Serialize, Deserialize)] -pub struct User { - pub id: u64, - pub login: String, - pub html_url: String, -} - -/// Pull request response from GitHub API -#[derive(Debug, Serialize, Deserialize)] -pub struct PullRequest { - pub id: u64, - pub number: u64, - pub title: String, - pub body: Option, - pub html_url: String, - pub state: String, - pub user: User, -} - -/// Constants for GitHub API -pub mod constants { - // Re-export from centralized constants - pub use crate::constants::github::{API_BASE as GITHUB_API_BASE, DEFAULT_USER_AGENT}; -} diff --git a/src/main.rs b/src/main.rs index 6fe615d..15336b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -293,7 +293,17 @@ async fn main() -> Result<()> { }; // Build plugin context - let context = plugins::PluginContext::new(config, filtered_repos, plugin_args, debug); + let context = if needs_config { + plugins::PluginContext::with_config_path( + config, + filtered_repos, + plugin_args, + debug, + config_path, + ) + } else { + plugins::PluginContext::new(config, filtered_repos, plugin_args, debug) + }; plugins::try_external_plugin(plugin_name, &context)?; } diff --git a/src/plugins.rs b/src/plugins.rs index 9fa8944..4e826be 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -19,6 +19,8 @@ pub struct PluginContext { pub args: Vec, /// Debug mode flag pub debug: bool, + /// Path to the config file + pub config_path: Option, } impl PluginContext { @@ -34,6 +36,24 @@ impl PluginContext { repositories, args, debug, + config_path: None, + } + } + + /// Create a new plugin context with config path + pub fn with_config_path( + config: Config, + repositories: Vec, + args: Vec, + debug: bool, + config_path: String, + ) -> Self { + Self { + config, + repositories, + args, + debug, + config_path: Some(config_path), } } } @@ -65,6 +85,11 @@ pub fn try_external_plugin(plugin_name: &str, context: &PluginContext) -> Result context.repositories.len().to_string(), ); + // Set config file path if available + if let Some(config_path) = &context.config_path { + cmd.env("REPOS_CONFIG_FILE", config_path); + } + let status = cmd.status().map_err(|e| { anyhow::anyhow!( "Plugin '{}' not found or failed to execute: {}", diff --git a/tests/github_tests.rs b/tests/github_tests.rs index 21cc150..665734b 100644 --- a/tests/github_tests.rs +++ b/tests/github_tests.rs @@ -1,76 +1,10 @@ use repos::config::repository::Repository; use repos::github::api::create_pr_from_workspace; -use repos::github::auth::GitHubAuth; -use repos::github::client::GitHubClient; use repos::github::types::PrOptions; +use repos_github::GitHubClient; use std::fs; use tempfile::TempDir; -// ===== GitHub Authentication Tests ===== - -#[test] -fn test_github_auth_new() { - let token = "ghp_test_token_123".to_string(); - let auth = GitHubAuth::new(token.clone()); - assert_eq!(auth.token(), &token); -} - -#[test] -fn test_github_auth_token() { - let token = "ghp_another_token_456".to_string(); - let auth = GitHubAuth::new(token.clone()); - assert_eq!(auth.token(), &token); -} - -#[test] -fn test_github_auth_get_auth_header() { - let token = "ghp_header_token_789".to_string(); - let auth = GitHubAuth::new(token.clone()); - let expected_header = format!("Bearer {}", token); - assert_eq!(auth.get_auth_header(), expected_header); -} - -#[test] -fn test_github_auth_validate_token_success() { - let token = "valid_token".to_string(); - let auth = GitHubAuth::new(token); - let result = auth.validate_token(); - assert!(result.is_ok()); -} - -#[test] -fn test_github_auth_validate_token_empty() { - let auth = GitHubAuth::new(String::new()); - let result = auth.validate_token(); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("GitHub token is required") - ); -} - -#[test] -fn test_github_auth_validate_token_whitespace() { - let auth = GitHubAuth::new(" ".to_string()); - let result = auth.validate_token(); - // Empty string after trim should still pass current validation - // (the validation only checks if completely empty) - assert!(result.is_ok()); -} - -#[test] -fn test_github_auth_comprehensive() { - let token = "ghp_comprehensive_test_token".to_string(); - let auth = GitHubAuth::new(token.clone()); - - // Test all methods work together - assert_eq!(auth.token(), &token); - assert_eq!(auth.get_auth_header(), format!("Bearer {}", token)); - assert!(auth.validate_token().is_ok()); -} - // ===== GitHub Client Tests ===== #[test] @@ -87,144 +21,8 @@ fn test_github_client_new_without_token() { // This tests the constructor } -#[test] -fn test_parse_github_url_ssh_github_com() { - let client = GitHubClient::new(None); - let result = client.parse_github_url("git@github.com:owner/repo"); - assert!(result.is_ok()); - let (owner, repo) = result.unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); -} - -#[test] -fn test_parse_github_url_ssh_enterprise() { - let client = GitHubClient::new(None); - let result = client.parse_github_url("git@github-enterprise:owner/repo"); - assert!(result.is_ok()); - let (owner, repo) = result.unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); -} - -#[test] -fn test_parse_github_url_https_github_com() { - let client = GitHubClient::new(None); - let result = client.parse_github_url("https://github.com/owner/repo"); - assert!(result.is_ok()); - let (owner, repo) = result.unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); -} - -#[test] -fn test_parse_github_url_https_enterprise() { - let client = GitHubClient::new(None); - let result = client.parse_github_url("https://github-enterprise/owner/repo"); - assert!(result.is_ok()); - let (owner, repo) = result.unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); -} - -#[test] -fn test_parse_github_url_legacy_format() { - let client = GitHubClient::new(None); - let result = client.parse_github_url("github.com:owner/repo"); - assert!(result.is_ok()); - let (owner, repo) = result.unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); -} - -#[test] -fn test_parse_github_url_with_git_suffix() { - let client = GitHubClient::new(None); - let result = client.parse_github_url("git@github.com:owner/repo.git"); - assert!(result.is_ok()); - let (owner, repo) = result.unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); -} - -#[test] -fn test_parse_github_url_with_trailing_slash() { - let client = GitHubClient::new(None); - let result = client.parse_github_url("https://github.com/owner/repo/"); - assert!(result.is_ok()); - let (owner, repo) = result.unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); -} - -#[test] -fn test_parse_github_url_invalid_format() { - let client = GitHubClient::new(None); - let result = client.parse_github_url("invalid-url-format"); - assert!(result.is_err()); -} - -#[test] -fn test_parse_github_url_empty_string() { - let client = GitHubClient::new(None); - let result = client.parse_github_url(""); - assert!(result.is_err()); -} - -#[test] -fn test_parse_github_url_only_domain() { - let client = GitHubClient::new(None); - let result = client.parse_github_url("https://github.com"); - assert!(result.is_err()); -} - -#[test] -fn test_parse_github_url_missing_repo() { - let client = GitHubClient::new(None); - let result = client.parse_github_url("https://github.com/owner"); - assert!(result.is_err()); -} - -#[test] -fn test_parse_github_url_complex_repo_name() { - let client = GitHubClient::new(None); - let result = client.parse_github_url("git@github.com:owner/repo-with-dashes"); - assert!(result.is_ok()); - let (owner, repo) = result.unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo-with-dashes"); -} - -#[test] -fn test_parse_github_url_numbers_in_names() { - let client = GitHubClient::new(None); - let result = client.parse_github_url("https://github.com/owner123/repo456"); - assert!(result.is_ok()); - let (owner, repo) = result.unwrap(); - assert_eq!(owner, "owner123"); - assert_eq!(repo, "repo456"); -} - -#[test] -fn test_github_client_comprehensive() { - // Test that all methods work together - let client = GitHubClient::new(Some("test_token".to_string())); - - // Test various URL formats - let urls = vec![ - "git@github.com:owner/repo", - "https://github.com/owner/repo.git", - "github.com/owner/repo", - ]; - - for url in urls { - let result = client.parse_github_url(url); - assert!(result.is_ok()); - let (owner, repo) = result.unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); - } -} +// Note: parse_github_url is now an internal function in api.rs +// URL parsing is tested indirectly through the PR workflow tests // ===== GitHub API Integration Tests ===== @@ -572,23 +370,9 @@ async fn test_create_pr_workspace_custom_branch_and_commit() { #[tokio::test] async fn test_github_integration_auth_client_api() { - // Test complete integration flow with authentication, client, and API + // Test complete integration flow with client and API let token = "ghp_integration_test_token".to_string(); - let auth = GitHubAuth::new(token.clone()); - - // Validate auth - assert!(auth.validate_token().is_ok()); - assert_eq!(auth.get_auth_header(), format!("Bearer {}", token)); - - // Test client with auth - let client = GitHubClient::new(Some(token)); - - // Test URL parsing - let result = client.parse_github_url("git@github.com:owner/repo"); - assert!(result.is_ok()); - let (owner, repo) = result.unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); + let _client = GitHubClient::new(Some(token.clone())); // Setup git repo for API testing let temp_dir = TempDir::new().unwrap(); @@ -625,7 +409,7 @@ async fn test_github_integration_auth_client_api() { let options = PrOptions::new( "Integration Test PR".to_string(), "This PR tests the integration flow".to_string(), - "ghp_integration_test_token".to_string(), + token, ) .create_only(); From ad77b56fdbfe00918324ca4b613ad688ba263d1c Mon Sep 17 00:00:00 2001 From: codcod Date: Tue, 11 Nov 2025 22:02:49 +0100 Subject: [PATCH 2/2] ci: trigger ci after push to feature branch --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c38b7a5..5b78706 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - '[0-9]+-*' # Feature branches like 120-add-repos-validate-command paths-ignore: - '**.md' - 'docs/**'