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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- main
- '[0-9]+-*' # Feature branches like 120-add-repos-validate-command
paths-ignore:
- '**.md'
- 'docs/**'
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -161,13 +164,15 @@ 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

# Package universal macOS artifacts separately
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
Expand Down
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <COMMAND> --help`.

Expand Down
10 changes: 10 additions & 0 deletions common/repos-github/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
74 changes: 74 additions & 0 deletions common/repos-github/README.md
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions common/repos-github/src/client.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

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<String>) -> 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)
}
}
22 changes: 22 additions & 0 deletions common/repos-github/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
122 changes: 122 additions & 0 deletions common/repos-github/src/pull_requests.rs
Original file line number Diff line number Diff line change
@@ -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<bool>,
}

#[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<PullRequest> {
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)
}
}
47 changes: 47 additions & 0 deletions common/repos-github/src/repositories.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

impl GitHubClient {
pub async fn get_repository_details(&self, owner: &str, repo: &str) -> Result<GitHubRepo> {
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)
}
}
Loading