Skip to content

Commit d12a5ce

Browse files
Expose cq track over http
This server is also where webhooks would go Change-Id: I0a52f469aa9c04302a33c4b4298e43f968c2d786
1 parent af5ba49 commit d12a5ce

7 files changed

Lines changed: 271 additions & 151 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

forges/josh-github-changes/src/repo.rs

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,60 @@
22
/// Supports https://github.com/owner/repo[.git] and git@github.com:owner/repo[.git].
33
pub fn parse_owner_repo(url: &str) -> anyhow::Result<(String, String)> {
44
let url = url.trim();
5-
if let Some(stripped) = url.strip_prefix("git@github.com:") {
6-
let path = stripped.strip_suffix(".git").unwrap_or(stripped);
7-
let mut parts = path.splitn(2, '/');
8-
let owner = parts
9-
.next()
10-
.ok_or_else(|| anyhow::anyhow!("Invalid GitHub SSH URL: {}", url))?;
11-
let repo = parts
12-
.next()
13-
.ok_or_else(|| anyhow::anyhow!("Invalid GitHub SSH URL: {}", url))?;
14-
return Ok((owner.to_string(), repo.to_string()));
15-
}
165

17-
let parsed = url::Url::parse(url).map_err(|e| anyhow::anyhow!("Invalid URL: {}", e))?;
18-
if parsed
19-
.host_str()
20-
.map_or(true, |h| h != "github.com" && !h.ends_with(".github.com"))
21-
{
22-
return Err(anyhow::anyhow!("Not a GitHub URL: {}", url));
23-
}
6+
let path = if let Some(stripped) = url.strip_prefix("git@github.com:") {
7+
stripped.to_string()
8+
} else {
9+
let parsed = url::Url::parse(url).map_err(|e| anyhow::anyhow!("Invalid URL: {}", e))?;
10+
if parsed
11+
.host_str()
12+
.map_or(true, |h| h != "github.com" && !h.ends_with(".github.com"))
13+
{
14+
return Err(anyhow::anyhow!("Not a GitHub URL: {}", url));
15+
}
16+
17+
parsed.path().trim_start_matches('/').to_string()
18+
};
2419

25-
let path = parsed.path().trim_start_matches('/');
26-
let path = path.strip_suffix(".git").unwrap_or(path);
27-
let mut segments = path.splitn(2, '/');
28-
let owner = segments
29-
.next()
30-
.ok_or_else(|| anyhow::anyhow!("Invalid GitHub URL: {}", url))?;
31-
let repo = segments
32-
.next()
33-
.ok_or_else(|| anyhow::anyhow!("Invalid GitHub URL: {}", url))?;
20+
let path = path.strip_suffix(".git").unwrap_or(&path);
21+
let (owner, repo) = path
22+
.split_once('/')
23+
.ok_or_else(|| anyhow::anyhow!("Invalid GitHub URL (missing owner/repo): {}", url))?;
3424

3525
Ok((owner.to_string(), repo.to_string()))
3626
}
27+
28+
#[cfg(test)]
29+
mod tests {
30+
use super::*;
31+
32+
#[test]
33+
fn valid_urls() {
34+
let cases = [
35+
"https://github.com/octocat/hello-world",
36+
"https://github.com/octocat/hello-world.git",
37+
"git@github.com:octocat/hello-world",
38+
"git@github.com:octocat/hello-world.git",
39+
" https://github.com/octocat/hello-world ",
40+
];
41+
for url in cases {
42+
let (owner, repo) = parse_owner_repo(url).unwrap_or_else(|e| panic!("{url}: {e}"));
43+
44+
assert_eq!(owner, "octocat", "{url}");
45+
assert_eq!(repo, "hello-world", "{url}");
46+
}
47+
}
48+
49+
#[test]
50+
fn invalid_urls() {
51+
let cases = [
52+
"https://gitlab.com/octocat/hello-world",
53+
"https://github.com/octocat",
54+
"not a url at all",
55+
];
56+
57+
for url in cases {
58+
assert!(parse_owner_repo(url).is_err(), "{url} should be rejected");
59+
}
60+
}
61+
}

josh-cq/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ edition = "2024"
77
tokio.workspace = true
88
clap.workspace = true
99
anyhow.workspace = true
10+
serde.workspace = true
1011
serde_json.workspace = true
1112
git2.workspace = true
13+
axum.workspace = true
1214

1315
josh-core.workspace = true
1416
josh-link.workspace = true

josh-cq/src/bin/josh-cq.rs

Lines changed: 56 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
use anyhow::Context;
22
use clap::Parser;
33

4-
use josh_core::filter::tree;
5-
use josh_core::git::{normalize_repo_path, spawn_git_command};
6-
use josh_link::make_signature;
7-
8-
use std::collections::BTreeMap;
4+
use josh_core::git::normalize_repo_path;
95

106
#[derive(Parser)]
117
#[command(about = "Josh Commit Queue")]
@@ -18,6 +14,8 @@ struct Cli {
1814
enum Commands {
1915
/// Initialize metarepo
2016
Init,
17+
/// Start HTTP server
18+
Serve(ServeArgs),
2119
#[command(flatten)]
2220
Action(ActionCommands),
2321
}
@@ -45,119 +43,18 @@ struct TrackArgs {
4543
mode: String,
4644
}
4745

48-
fn handle_track(
49-
args: &TrackArgs,
50-
transaction: &josh_core::cache::Transaction,
51-
) -> anyhow::Result<()> {
52-
let repo = transaction.repo();
53-
54-
// Fetch refs from remote
55-
let refs = josh_cq::remote::list_refs(&args.url)?;
56-
57-
// Fetch HEAD from remote
58-
spawn_git_command(repo.path(), &["fetch", &args.url, "HEAD"], &[])?;
59-
60-
// Get commit from FETCH_HEAD
61-
let fetch_head_ref = repo
62-
.find_reference("FETCH_HEAD")
63-
.context("Failed to find FETCH_HEAD")?;
64-
let fetched_commit = fetch_head_ref
65-
.peel_to_commit()
66-
.context("Failed to peel FETCH_HEAD to commit")?
67-
.id();
68-
69-
// Get HEAD commit
70-
let head_ref = repo.head().context("Failed to get HEAD")?;
71-
let head_commit = head_ref
72-
.peel_to_commit()
73-
.context("Failed to peel HEAD to commit")?;
74-
let head_tree = head_commit.tree().context("Failed to get HEAD tree")?;
75-
76-
let signature = make_signature(repo)?;
77-
78-
let mode = josh_core::filter::LinkMode::parse(&args.mode)
79-
.with_context(|| format!("Invalid link mode: '{}'", args.mode))?;
80-
81-
let link_path = std::path::Path::new("remotes").join(&args.id).join("link");
82-
let tree_with_link_oid = josh_link::prepare_link_add(
83-
&transaction,
84-
&link_path,
85-
&args.url,
86-
None, // filter (default :/)
87-
"HEAD", // target
88-
fetched_commit,
89-
&head_tree,
90-
mode,
91-
)?
92-
.into_tree_oid();
93-
94-
let tree_with_link = repo
95-
.find_tree(tree_with_link_oid)
96-
.context("Failed to find tree with link")?;
97-
98-
// Create refs.json blob
99-
let refs_blob = {
100-
let refs_map: BTreeMap<String, String> = refs
101-
.iter()
102-
.map(|(k, v)| (k.clone(), v.to_string()))
103-
.collect();
104-
105-
let refs_json =
106-
serde_json::to_string_pretty(&refs_map).context("Failed to serialize refs to JSON")?;
107-
108-
repo.blob(refs_json.as_bytes())
109-
.context("Failed to create refs.json blob")?
110-
};
111-
112-
// Insert refs.json into the tree
113-
let refs_path = std::path::Path::new("remotes")
114-
.join(&args.id)
115-
.join("refs.json");
116-
117-
let final_tree = tree::insert(
118-
repo,
119-
&tree_with_link,
120-
&refs_path,
121-
refs_blob,
122-
git2::FileMode::Blob.into(),
123-
)
124-
.context("Failed to insert refs.json into tree")?;
125-
126-
// Create final commit with both files
127-
let final_commit = repo
128-
.commit(
129-
None,
130-
&signature,
131-
&signature,
132-
&format!("Track remote: {}", args.id),
133-
&final_tree,
134-
&[&head_commit],
135-
)
136-
.context("Failed to create final commit")?;
137-
138-
// Update HEAD to point to the new commit
139-
repo.head()?
140-
.set_target(final_commit, "josh-cq track")
141-
.context("Failed to update HEAD")?;
142-
143-
println!("Tracked remote '{}' at {}", args.id, args.url);
144-
println!("Found {} refs", refs.len());
145-
146-
Ok(())
46+
#[derive(clap::Parser)]
47+
struct ServeArgs {
48+
/// Port to listen on
49+
#[arg(long, default_value = "8080")]
50+
port: u16,
14751
}
14852

149-
#[tokio::main]
150-
async fn main() -> anyhow::Result<()> {
151-
let cli = Cli::parse();
152-
153-
let action = match cli.command {
154-
Commands::Init => {
155-
// TODO
156-
return Ok(());
157-
}
158-
Commands::Action(action) => action,
159-
};
160-
53+
fn open_repo() -> anyhow::Result<(
54+
std::path::PathBuf,
55+
std::sync::Arc<josh_core::cache::CacheStack>,
56+
josh_core::cache::Transaction,
57+
)> {
16158
let repo = git2::Repository::open_from_env().context("Not in a git repository")?;
16259
let repo_path = normalize_repo_path(repo.path());
16360

@@ -172,16 +69,51 @@ async fn main() -> anyhow::Result<()> {
17269
.open(None)
17370
.context("Failed TransactionContext::open")?;
17471

175-
match action {
176-
ActionCommands::Track(ref args) => handle_track(args, &transaction),
177-
ActionCommands::Fetch => {
178-
todo!()
72+
Ok((repo_path, cache, transaction))
73+
}
74+
75+
#[tokio::main]
76+
async fn main() -> anyhow::Result<()> {
77+
let cli = Cli::parse();
78+
79+
match cli.command {
80+
Commands::Init => {
81+
// TODO
82+
return Ok(());
17983
}
180-
ActionCommands::Step => {
181-
todo!()
84+
Commands::Serve(args) => {
85+
let (repo_path, cache, _transaction) = open_repo()?;
86+
87+
let state = josh_cq::cq::AppState { repo_path, cache };
88+
let app = josh_cq::cq::make_router(state);
89+
90+
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], args.port));
91+
println!("Listening on {}", addr);
92+
93+
let listener = tokio::net::TcpListener::bind(addr).await?;
94+
axum::serve(listener, app).await?;
18295
}
183-
ActionCommands::Push => {
184-
todo!()
96+
Commands::Action(action) => {
97+
let (_repo_path, _cache, transaction) = open_repo()?;
98+
99+
match action {
100+
ActionCommands::Track(ref args) => {
101+
let msg =
102+
josh_cq::cq::handle_track(&args.url, &args.id, &args.mode, &transaction)?;
103+
println!("{}", msg);
104+
}
105+
ActionCommands::Fetch => {
106+
todo!()
107+
}
108+
ActionCommands::Step => {
109+
todo!()
110+
}
111+
ActionCommands::Push => {
112+
todo!()
113+
}
114+
}
185115
}
186116
}
117+
118+
Ok(())
187119
}

0 commit comments

Comments
 (0)