Skip to content

Commit c42ab4d

Browse files
authored
Merge pull request #2 from trollefson/feat/b2b_v2
feat/b2b_v2 to main
2 parents 79bcac6 + f420d48 commit c42ab4d

File tree

9 files changed

+276
-74
lines changed

9 files changed

+276
-74
lines changed

Cargo.lock

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

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "shipit"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
edition = "2024"
55
license = "MIT"
66
description = "A CLI for managing git releases"

README.md

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -74,41 +74,53 @@ shipit b2b develop main --dryrun
7474
### `b2b` — Branch to Branch
7575

7676
```
77-
shipit b2b <source> <target> [--ai] [--dryrun] [--dir <path>] [--id <project-id>]
77+
shipit b2b <source> <target> [--ai] [--dryrun] [--dir <path>] [--id <identifier>] [--platform <github|gitlab>] [--remote <name>] [--prompt <text>]
7878
```
7979

80-
| Argument / Flag | Description |
81-
|----------------------|-------------|
82-
| `source` | Branch with new commits (e.g. `develop`) |
83-
| `target` | Destination branch (e.g. `main`) |
84-
| `--ai` | Enable Ollama LLM to generate categorized release notes |
85-
| `--dryrun` | Preview the merge request description without creating it |
86-
| `--dir <path>` | Path to the git repository (defaults to current directory) |
87-
| `--id <project-id>` | Project identifier — `owner/repo` for GitHub, numeric ID for GitLab |
80+
| Argument / Flag | Description |
81+
|-----------------------------------|-------------|
82+
| `source` | Branch with new commits (e.g. `develop`) |
83+
| `target` | Destination branch (e.g. `main`) |
84+
| `--ai` | Enable Ollama LLM to generate categorized release notes |
85+
| `--dryrun` | Preview the merge request description without creating it |
86+
| `--dir <path>` | Path to the git repository (defaults to current directory) |
87+
| `--id <identifier>` | Project identifier — `owner/repo` for GitHub, numeric ID for GitLab (auto-detected from remote URL if omitted) |
88+
| `--platform <github\|gitlab>` | Force a specific platform (overrides auto-detection from the remote URL) |
89+
| `--remote <name>` | Git remote to detect platform from (defaults to `origin`) |
90+
| `--prompt <text>` | Prompt prefix sent to Ollama when `--ai` is set (overrides the `ollama.prompt` config value) |
8891

8992
**What happens:**
9093

9194
1. Finds all commits on `source` that aren't on `target`
92-
2. If `--ai` is set, sends the commit log to a local LLM running with Ollama and generate categorized release notes (features, fixes, infra, docs)
93-
3. Opens a merge request on GitLab or Github with the description
95+
2. If `--ai` is set, sends the commit log to a local LLM running with Ollama and generates categorized release notes (features, fixes, infra, docs)
96+
3. If the local `source` branch is ahead of the remote, prompts you to push it before continuing
97+
4. Opens a pull/merge request on GitHub or GitLab with the description
9498

9599
**Examples:**
96100

97101
```bash
98-
# GitHub — --id uses owner/repo format
99-
shipit b2b develop main --id owner/repo
102+
# Auto-detect platform and project from the origin remote URL
103+
shipit b2b develop main
104+
105+
# With llm generated release notes
106+
shipit b2b develop main --ai
100107

101-
# GitLab — --id uses a numeric project ID
108+
# Preview the description without creating the request
109+
shipit b2b develop main --ai --dryrun
110+
111+
# Explicitly specify the project identifier
112+
shipit b2b develop main --id owner/repo
102113
shipit b2b develop main --id 12345678
103114

104-
# With AI-generated release notes
105-
shipit b2b develop main --id owner/repo --ai
115+
# Override the detected platform
116+
shipit b2b develop main --platform github
117+
shipit b2b develop main --platform gitlab
106118

107-
# Dry run — see the description without creating the MR
108-
shipit b2b develop main --id owner/repo --ai --dryrun
119+
# Use a different remote
120+
shipit b2b develop main --remote upstream
109121

110122
# Point at a repo outside the current directory
111-
shipit b2b develop main --id owner/repo --ai --dir /path/to/repo
123+
shipit b2b develop main --dir /path/to/repo
112124
```
113125

114126
---

src/cli.rs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
use clap::{Parser, Subcommand};
1+
use clap::{Parser, Subcommand, ValueEnum};
2+
3+
#[derive(Debug, Clone, ValueEnum)]
4+
pub enum Platform {
5+
Github,
6+
Gitlab,
7+
}
28

39
#[derive(Parser)]
410
pub struct Cli {
@@ -11,14 +17,20 @@ pub enum Commands {
1117
B2b {
1218
source: String,
1319
target: String,
14-
#[arg(long)]
20+
#[arg(long, help = "Use AI to generate the merge/pull request title and description")]
1521
ai: bool,
16-
#[arg(long)]
22+
#[arg(long, help = "Print the merge/pull request details without creating it")]
1723
dryrun: bool,
18-
#[arg(long)]
24+
#[arg(long, help = "Path to the git repository (defaults to current directory)")]
1925
dir: Option<String>,
20-
#[arg(long, required_unless_present = "dryrun", help = "GitLab project ID or GitHub 'owner/repo'")]
26+
#[arg(long, help = "GitLab project ID or GitHub 'owner/repo' (auto-detected from remote URL if not provided)")]
2127
id: Option<String>,
28+
#[arg(long, value_enum, help = "Platform to open the merge/pull request on (overrides auto-detection)")]
29+
platform: Option<Platform>,
30+
#[arg(long, default_value = "origin", help = "Name of the git remote to use")]
31+
remote: String,
32+
#[arg(long, help = "Prompt prefix to send to Ollama (overrides the config file value)")]
33+
prompt: Option<String>,
2234
},
2335
Config {
2436
#[command(subcommand)]

src/commands/b2b.rs

Lines changed: 82 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@ use std::env;
22

33
use git2::Repository;
44

5+
use crate::cli::Platform;
56
use crate::context::Context;
67
use crate::error::ShipItError;
7-
use crate::common::{open_github_pr, open_gitlab_mr, summarize_with_ollama};
8+
use crate::common::{lookup_github_identifier, lookup_gitlab_project_id, open_github_pr, open_gitlab_mr, summarize_with_ollama};
89

910
pub async fn branch_to_branch(
1011
ctx: &Context,
1112
args_source: String,
1213
args_target: String,
1314
args_dir: Option<String>,
1415
args_id: Option<String>,
16+
args_platform: Option<Platform>,
17+
args_remote: String,
18+
args_prompt: Option<String>,
1519
) -> Result<(), ShipItError> {
1620
let dir = match args_dir {
1721
Some(path) => std::path::PathBuf::from(path),
@@ -80,52 +84,112 @@ pub async fn branch_to_branch(
8084

8185
// ask a local llm to summarize these commit messages
8286
let mut summary = if ctx.settings.shipit.ai {
87+
let mut ollama = ctx.settings.ollama.clone();
88+
if let Some(prompt) = args_prompt {
89+
ollama.prompt = prompt;
90+
}
8391
let result = summarize_with_ollama(
84-
&description, &ctx.settings.ollama
92+
&description, &ollama
8593
).await.or_else(|_e| Err(ShipItError::Error("Failed to summarize with Ollama!".to_string())))?;
8694
println!("The merge request description is:\n\n{}", result);
8795
result
8896
} else {
8997
description
9098
};
91-
summary += "\n\n\n*This request was opened by Shipit* 🚢";
99+
summary += "\n\n\n*This request was generated by [Shipit](https://gitshipit.net)* 🚢";
92100

93101
if ctx.settings.shipit.dryrun {
94102
println!("\n\nDry run complete! Re-run without the dry-run flag to open a request.");
95103
return Ok(());
96104
}
97105

98-
// handle opening a github pr or gitlab mr
99-
// defaults to github if both are configured
100-
let id = args_id.as_deref()
101-
.ok_or_else(|| ShipItError::Error("A project identifier is required via '--id'.".to_string()))?;
106+
// always fetch the remote URL — needed both for platform detection and ID auto-lookup
107+
let remote_url = {
108+
let remote = repo.find_remote(&args_remote).map_err(|e| ShipItError::Git(e))?;
109+
remote.url()
110+
.ok_or_else(|| ShipItError::Error(format!("The '{}' remote has no URL.", args_remote)))?
111+
.to_string()
112+
};
113+
114+
// determine the platform to use:
115+
// use --platform flag if provided, otherwise detect from origin remote url
116+
let (is_github, is_gitlab) = match args_platform {
117+
Some(Platform::Github) => (true, false),
118+
Some(Platform::Gitlab) => (false, true),
119+
None => (remote_url.contains("github"), remote_url.contains("gitlab")),
120+
};
102121

103-
let use_github = ctx.settings.github.token.is_some();
104-
let use_gitlab = ctx.settings.gitlab.token.is_some() && !use_github;
122+
// resolve the project identifier:
123+
// use --id if provided, otherwise look it up from the remote url via the platform api
124+
let resolved_id: String = match args_id {
125+
Some(id) => id,
126+
None => {
127+
if is_github {
128+
lookup_github_identifier(&remote_url)
129+
.map_err(|e| ShipItError::Error(format!("Failed to detect GitHub owner/repo from remote URL: {}", e)))?
130+
} else if is_gitlab {
131+
let token = ctx.settings.gitlab.token.as_deref()
132+
.ok_or_else(|| ShipItError::Error("GitLab token is required to look up the project ID.".to_string()))?;
133+
let id = lookup_gitlab_project_id(&remote_url, &ctx.settings.gitlab.domain, token).await
134+
.map_err(|e| ShipItError::Error(format!("Failed to look up GitLab project ID from remote URL: {}", e)))?;
135+
println!("Auto-detected GitLab project ID: {}", id);
136+
id.to_string()
137+
} else {
138+
return Err(ShipItError::Error("Could not determine platform. Use '--platform github' or '--platform gitlab' to specify it explicitly.".to_string()));
139+
}
140+
}
141+
};
142+
143+
// check if the local source branch is ahead of its remote tracking branch
144+
let needs_push = {
145+
let local_oid = source.get().target()
146+
.ok_or_else(|| ShipItError::Git(git2::Error::from_str("Failed to get source branch OID")))?;
147+
let remote_tracking_ref = format!("refs/remotes/{}/{}", args_remote, args_source);
148+
match repo.find_reference(&remote_tracking_ref) {
149+
Ok(remote_ref) => match remote_ref.target() {
150+
Some(remote_oid) => {
151+
let (ahead, _) = repo.graph_ahead_behind(local_oid, remote_oid)
152+
.map_err(|e| ShipItError::Git(e))?;
153+
ahead > 0
154+
}
155+
None => true,
156+
},
157+
Err(_) => true, // no remote tracking branch yet
158+
}
159+
};
160+
161+
if needs_push {
162+
println!(
163+
"\n\nYour local source branch is ahead of the remote. Please push it, then press Enter to continue:\n\n git push {} {}\n",
164+
args_remote, args_source
165+
);
166+
let mut input = String::new();
167+
std::io::stdin().read_line(&mut input).map_err(|e| ShipItError::Error(format!("Failed to read input: {}", e)))?;
168+
}
105169

106-
if use_github {
107-
let parts: Vec<&str> = id.splitn(2, '/').collect();
170+
if is_github {
171+
let parts: Vec<&str> = resolved_id.splitn(2, '/').collect();
108172
if parts.len() != 2 {
109-
return Err(ShipItError::Error("'--id' must be in 'owner/repo' format for GitHub.".to_string()));
173+
return Err(ShipItError::Error(format!("GitHub project identifier '{}' must be in 'owner/repo' format.", resolved_id)));
110174
}
111-
let (owner, repo) = (parts[0], parts[1]);
112175
let token = ctx.settings.github.token.as_deref().unwrap();
176+
let (owner, gh_repo) = (parts[0], parts[1]);
113177
let pr_url = open_github_pr(
114178
&args_source, &args_target, &ctx.settings.github.domain,
115-
token, owner, repo, &summary,
179+
token, owner, gh_repo, &summary,
116180
).await.map_err(|e| ShipItError::Error(format!("Failed to open a GitHub PR: {}", e)))?;
117181
println!("\n\nThe pull request is available at:\n\n{}", pr_url);
118-
} else if use_gitlab {
119-
let project_id: u64 = id.parse()
120-
.map_err(|_| ShipItError::Error("'--id' must be a numeric project ID for GitLab.".to_string()))?;
182+
} else if is_gitlab {
183+
let project_id: u64 = resolved_id.parse()
184+
.map_err(|_| ShipItError::Error(format!("GitLab project identifier '{}' must be a numeric project ID.", resolved_id)))?;
121185
let token = ctx.settings.gitlab.token.as_deref().unwrap();
122186
let mr_url = open_gitlab_mr(
123187
&args_source, &args_target, &ctx.settings.gitlab.domain,
124188
token, &project_id, &summary,
125189
).await.map_err(|e| ShipItError::Error(format!("Failed to open a GitLab MR: {}", e)))?;
126190
println!("\n\nThe merge request is available at:\n\n{}", mr_url["web_url"]);
127191
} else {
128-
return Err(ShipItError::Error("No platform token configured. Set github.token or gitlab.token in your shipit config.".to_string()));
192+
return Err(ShipItError::Error("Could not determine platform. Use '--platform github' or '--platform gitlab' to specify it explicitly.".to_string()));
129193
}
130194

131195
Ok(())

0 commit comments

Comments
 (0)