Skip to content

Commit 4b04fca

Browse files
authored
feat: add repos validate command (#121)
* feat: add repos validate command * ci: trigger ci after push to feature branch
1 parent 5f87781 commit 4b04fca

File tree

27 files changed

+944
-1275
lines changed

27 files changed

+944
-1275
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
push:
55
branches:
66
- main
7+
- '[0-9]+-*' # Feature branches like 120-add-repos-validate-command
78
paths-ignore:
89
- '**.md'
910
- 'docs/**'

.github/workflows/release.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ jobs:
8181
path: |
8282
target/${{ matrix.target }}/release/repos
8383
target/${{ matrix.target }}/release/repos-health
84+
target/${{ matrix.target }}/release/repos-validate
8485
8586
# 3. Create the universal macOS binary from the previously built artifacts.
8687
# This job is very fast as it does not re-compile anything.
@@ -104,7 +105,8 @@ jobs:
104105
run: |
105106
lipo -create -output repos arm64/repos x86_64/repos
106107
lipo -create -output repos-health arm64/repos-health x86_64/repos-health
107-
strip -x repos repos-health
108+
lipo -create -output repos-validate arm64/repos-validate x86_64/repos-validate
109+
strip -x repos repos-health repos-validate
108110
109111
- name: Upload universal artifact
110112
uses: actions/upload-artifact@v5
@@ -113,6 +115,7 @@ jobs:
113115
path: |
114116
repos
115117
repos-health
118+
repos-validate
116119
117120
# 4. Determine version, create the GitHub Release, and upload all artifacts.
118121
# This is the final publishing step.
@@ -161,13 +164,15 @@ jobs:
161164
suffix=$(basename "$dir" | sed 's/build-artifacts-//')
162165
tar -czf "repos-${VERSION}-${suffix}.tar.gz" -C "$dir" repos
163166
tar -czf "repos-health-${VERSION}-${suffix}.tar.gz" -C "$dir" repos-health
167+
tar -czf "repos-validate-${VERSION}-${suffix}.tar.gz" -C "$dir" repos-validate
164168
fi
165169
done
166170
167171
# Package universal macOS artifacts separately
168172
if [ -d "dist/build-artifacts-macos-universal" ]; then
169173
tar -czf "repos-${VERSION}-macos-universal.tar.gz" -C "dist/build-artifacts-macos-universal" repos
170174
tar -czf "repos-health-${VERSION}-macos-universal.tar.gz" -C "dist/build-artifacts-macos-universal" repos-health
175+
tar -czf "repos-validate-${VERSION}-macos-universal.tar.gz" -C "dist/build-artifacts-macos-universal" repos-validate
171176
fi
172177
173178
# List final assets

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ path = "src/lib.rs"
1010
[workspace]
1111
members = [
1212
".",
13+
"common/repos-github",
1314
"plugins/repos-health",
15+
"plugins/repos-validate",
1416
]
1517

1618
[dependencies]
1719
async-trait = "0.1"
20+
repos-github = { path = "common/repos-github" }
1821
clap = { version = "4.4", features = ["derive"] }
1922
serde = { version = "1.0", features = ["derive"] }
2023
serde_yaml = "0.9"

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ overview. Click on a command to see its detailed documentation.
107107
| [**`pr`**](./docs/commands/pr.md) | Creates pull requests for repositories with changes. |
108108
| [**`rm`**](./docs/commands/rm.md) | Removes cloned repositories from your local disk. |
109109
| [**`init`**](./docs/commands/init.md) | Generates a `config.yaml` file from local Git repositories. |
110+
| [**`validate`**](./plugins/repos-validate/README.md) | Validates config file and repository connectivity (via plugin). |
110111

111112
For a full list of options for any command, run `repos <COMMAND> --help`.
112113

common/repos-github/Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[package]
2+
name = "repos-github"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[dependencies]
7+
anyhow = "1.0"
8+
reqwest = { version = "0.12", features = ["json"] }
9+
serde = { version = "1.0", features = ["derive"] }
10+
tokio = { version = "1.0", features = ["full"] }

common/repos-github/README.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# repos-github
2+
3+
A shared library for GitHub API interactions used by the `repos` CLI tool and its plugins.
4+
5+
## Purpose
6+
7+
This library centralizes all GitHub API communication logic, providing a consistent and reusable interface for:
8+
9+
- Repository information retrieval
10+
- Topic fetching
11+
- Authentication handling
12+
- Error management
13+
14+
## Usage
15+
16+
```rust
17+
use repos_github::{GitHubClient, PullRequestParams};
18+
use anyhow::Result;
19+
20+
#[tokio::main]
21+
async fn main() -> Result<()> {
22+
// Create a new GitHub client (automatically reads GITHUB_TOKEN from env)
23+
let client = GitHubClient::new(None);
24+
25+
// Get repository details including topics
26+
let repo_details = client.get_repository_details("owner", "repo-name").await?;
27+
println!("Topics: {:?}", repo_details.topics);
28+
29+
// Create a pull request
30+
let pr_params = PullRequestParams::new(
31+
"owner",
32+
"repo-name",
33+
"My Test PR",
34+
"feature-branch",
35+
"main",
36+
"This is the body of the PR.",
37+
true, // draft
38+
);
39+
let pr = client.create_pull_request(pr_params).await?;
40+
println!("Created PR: {}", pr.html_url);
41+
42+
Ok(())
43+
}
44+
```
45+
46+
## Authentication
47+
48+
The library automatically reads the `GITHUB_TOKEN` environment variable for authentication. This is required for:
49+
50+
- Private repositories
51+
- Avoiding API rate limits
52+
- Accessing organization repositories
53+
54+
## Error Handling
55+
56+
The library provides detailed error messages for common scenarios:
57+
58+
- **403 Forbidden**: Indicates missing or insufficient permissions
59+
- **404 Not Found**: Repository doesn't exist or isn't accessible
60+
- Network errors and timeouts
61+
62+
## Integration
63+
64+
This library is used by:
65+
66+
- `repos-validate` plugin: For connectivity checks and topic supplementation
67+
- Future plugins that need GitHub API access
68+
69+
## Benefits
70+
71+
- **DRY Principle**: Single source of truth for GitHub API logic
72+
- **Consistency**: All components use the same authentication and error handling
73+
- **Maintainability**: Changes to GitHub API interactions are made in one place
74+
- **Testability**: Centralized logic is easier to unit test

common/repos-github/src/client.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//! GitHub client implementation
2+
3+
/// GitHub API client for making authenticated requests
4+
pub struct GitHubClient {
5+
pub(crate) client: reqwest::Client,
6+
pub(crate) token: Option<String>,
7+
}
8+
9+
impl GitHubClient {
10+
/// Create a new GitHub client with an optional token
11+
/// If no token is provided, will try to read from GITHUB_TOKEN environment variable
12+
pub fn new(token: Option<String>) -> Self {
13+
Self {
14+
client: reqwest::Client::new(),
15+
token: token.or_else(|| std::env::var("GITHUB_TOKEN").ok()),
16+
}
17+
}
18+
}
19+
20+
impl Default for GitHubClient {
21+
fn default() -> Self {
22+
Self::new(None)
23+
}
24+
}

common/repos-github/src/lib.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
//! GitHub API client library
2+
//!
3+
//! This library provides a centralized interface for GitHub API operations
4+
//! including repository management, pull request creation, and authentication.
5+
//!
6+
//! ## Modules
7+
//!
8+
//! - [`client`]: Core GitHub client implementation
9+
//! - [`pull_requests`]: Pull request creation and management
10+
//! - [`repositories`]: Repository information retrieval
11+
//! - [`util`]: Utility functions for GitHub operations
12+
13+
mod client;
14+
mod pull_requests;
15+
mod repositories;
16+
mod util;
17+
18+
// Re-export public API
19+
pub use client::GitHubClient;
20+
pub use pull_requests::{PullRequest, PullRequestParams};
21+
pub use repositories::GitHubRepo;
22+
pub use util::parse_github_url;
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//! Pull request operations
2+
3+
use crate::client::GitHubClient;
4+
use anyhow::{Context, Result};
5+
use serde::{Deserialize, Serialize};
6+
7+
#[derive(Serialize)]
8+
pub(crate) struct CreatePullRequestPayload<'a> {
9+
title: &'a str,
10+
head: &'a str,
11+
base: &'a str,
12+
body: &'a str,
13+
#[serde(skip_serializing_if = "Option::is_none")]
14+
draft: Option<bool>,
15+
}
16+
17+
#[derive(Deserialize, Debug)]
18+
pub struct PullRequest {
19+
pub html_url: String,
20+
pub number: u64,
21+
pub id: u64,
22+
pub title: String,
23+
pub state: String,
24+
}
25+
26+
/// Parameters for creating a pull request
27+
#[derive(Debug, Clone)]
28+
pub struct PullRequestParams<'a> {
29+
pub owner: &'a str,
30+
pub repo: &'a str,
31+
pub title: &'a str,
32+
pub head: &'a str,
33+
pub base: &'a str,
34+
pub body: &'a str,
35+
pub draft: bool,
36+
}
37+
38+
impl<'a> PullRequestParams<'a> {
39+
pub fn new(
40+
owner: &'a str,
41+
repo: &'a str,
42+
title: &'a str,
43+
head: &'a str,
44+
base: &'a str,
45+
body: &'a str,
46+
draft: bool,
47+
) -> Self {
48+
Self {
49+
owner,
50+
repo,
51+
title,
52+
head,
53+
base,
54+
body,
55+
draft,
56+
}
57+
}
58+
}
59+
60+
impl GitHubClient {
61+
/// Create a pull request on GitHub
62+
///
63+
/// # Arguments
64+
/// * `params` - Pull request parameters including owner, repo, title, head, base, body, and draft status
65+
///
66+
/// # Returns
67+
/// A PullRequest struct containing the created PR information
68+
///
69+
/// # Errors
70+
/// Returns an error if:
71+
/// - No authentication token is configured
72+
/// - The API request fails
73+
/// - The response cannot be parsed
74+
pub async fn create_pull_request(&self, params: PullRequestParams<'_>) -> Result<PullRequest> {
75+
if self.token.is_none() {
76+
anyhow::bail!(
77+
"GitHub token is required for creating pull requests. Set GITHUB_TOKEN environment variable."
78+
);
79+
}
80+
81+
let url = format!(
82+
"https://api.github.com/repos/{}/{}/pulls",
83+
params.owner, params.repo
84+
);
85+
86+
let payload = CreatePullRequestPayload {
87+
title: params.title,
88+
head: params.head,
89+
base: params.base,
90+
body: params.body,
91+
draft: if params.draft { Some(true) } else { None },
92+
};
93+
94+
let mut request = self.client.post(&url).header("User-Agent", "repos-cli");
95+
96+
if let Some(token) = &self.token {
97+
request = request.header("Authorization", format!("token {}", token));
98+
}
99+
100+
let response = request.json(&payload).send().await?;
101+
102+
if !response.status().is_success() {
103+
let status = response.status();
104+
let error_text = response
105+
.text()
106+
.await
107+
.unwrap_or_else(|_| "Unknown error".to_string());
108+
return Err(anyhow::anyhow!(
109+
"Failed to create pull request ({} {}): {}",
110+
status.as_u16(),
111+
status.canonical_reason().unwrap_or("Unknown"),
112+
error_text
113+
));
114+
}
115+
116+
let pr: PullRequest = response
117+
.json()
118+
.await
119+
.context("Failed to parse PR creation response")?;
120+
Ok(pr)
121+
}
122+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//! Repository-related operations
2+
3+
use crate::client::GitHubClient;
4+
use anyhow::{Context, Result, anyhow};
5+
use serde::Deserialize;
6+
7+
#[derive(Deserialize, Debug, Clone)]
8+
pub struct GitHubRepo {
9+
pub topics: Vec<String>,
10+
}
11+
12+
impl GitHubClient {
13+
pub async fn get_repository_details(&self, owner: &str, repo: &str) -> Result<GitHubRepo> {
14+
let url = format!("https://api.github.com/repos/{}/{}", owner, repo);
15+
let mut request = self.client.get(&url).header("User-Agent", "repos-cli");
16+
17+
if let Some(token) = &self.token {
18+
request = request.header("Authorization", format!("token {}", token));
19+
}
20+
21+
let response = request.send().await?;
22+
23+
if !response.status().is_success() {
24+
let status = response.status();
25+
let error_msg = if status.as_u16() == 403 {
26+
if self.token.is_none() {
27+
"Access forbidden. This may be a private repository. Set GITHUB_TOKEN environment variable."
28+
} else {
29+
"Access forbidden. Check your GITHUB_TOKEN permissions or repository access."
30+
}
31+
} else {
32+
status.canonical_reason().unwrap_or("Unknown error")
33+
};
34+
return Err(anyhow!(
35+
"Failed to connect ({} {})",
36+
status.as_u16(),
37+
error_msg
38+
));
39+
}
40+
41+
let repo_data: GitHubRepo = response
42+
.json()
43+
.await
44+
.context("Failed to parse GitHub API response")?;
45+
Ok(repo_data)
46+
}
47+
}

0 commit comments

Comments
 (0)