Skip to content

Commit 7febb50

Browse files
Remaining refinements: tests, pre-release support, PR label strategy (#40)
* #4 Add tests for config, releases, and semver error cases - error.rs: derive Clone + PartialEq to support test assertions - models.rs: derive Clone on TagName and CreateReleaseRequest - github_client.rs: extract GithubApi trait; move HTTP status/JSON handling from releases.rs into GithubClient's impl - releases.rs: make Releases<C: GithubApi> generic; add 5 tests via MockGithubApi (request fields, error propagation, None on 404) - config.rs: add 6 tests covering required fields, missing vars, invalid strategy, custom prefix/version, dry-run flag - semver.rs: add 7 from_str error cases (empty, malformed, leading zeros, pre-release suffix) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * #2 Pre-release support (prerelease flag + identifier + counter) - semver.rs: add PreRelease struct; update regex to parse -id.N suffix; add base_version(), with_pre_release(), bump_pre_release(), pre_release_matches(); increment() strips pre-release on all arms; get_version() includes pre-release suffix; 5 new tests - config.rs: add prerelease (bool) and prerelease_identifier (String) fields read from INPUT_PRERELEASE / INPUT_PRERELEASE-IDENTIFIER - releases.rs: pass config.prerelease into CreateReleaseRequest - main.rs: NoRelease early-exit skipped when prerelease=true; version computation branches on prerelease — bumps counter if same identifier, otherwise applies strategy to base and starts at .1 - action.yaml: add prerelease and prerelease-identifier inputs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * #1 Derive increment strategy from merged PR labels - semver.rs: derive Clone on VersionIncrementStrategy - models.rs: add Label and PullRequest models - github_client.rs: add get_pr_for_commit to GithubApi trait; implement via GET /repos/{owner}/{repo}/commits/{sha}/pulls - config.rs: add use_label_strategy, label_major, label_minor, label_patch, label_skip fields (all optional with sensible defaults) - releases.rs: expose get_pr_for_commit; update MockGithubApi with pr_response field and with_pr_labels builder; add 2 new tests - main.rs: add resolve_label_strategy (major > minor > skip > patch > fallback); run() fetches PR and resolves strategy when use_label_strategy=true; 6 new tests for label resolution - action.yaml: add use-label-strategy, label-major, label-minor, label-patch, label-skip inputs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 660116a commit 7febb50

8 files changed

Lines changed: 650 additions & 83 deletions

File tree

action.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,34 @@ inputs:
3030
description: 'GitHub API host. Override for GitHub Enterprise (e.g. https://github.example.com/api/v3). Defaults to https://api.github.com.'
3131
required: false
3232
default: 'https://api.github.com'
33+
prerelease:
34+
description: 'Create a pre-release instead of a stable release.'
35+
required: false
36+
default: false
37+
prerelease-identifier:
38+
description: 'Pre-release identifier appended to the version (e.g. "beta" produces v1.2.0-beta.1). Defaults to rc.'
39+
required: false
40+
default: 'rc'
41+
use-label-strategy:
42+
description: 'Derive the increment strategy from PR labels instead of the version-increment-strategy input. Falls back to version-increment-strategy if no matching label is found.'
43+
required: false
44+
default: false
45+
label-major:
46+
description: 'PR label that triggers a major version bump.'
47+
required: false
48+
default: 'release:major'
49+
label-minor:
50+
description: 'PR label that triggers a minor version bump.'
51+
required: false
52+
default: 'release:minor'
53+
label-patch:
54+
description: 'PR label that triggers a patch version bump.'
55+
required: false
56+
default: 'release:patch'
57+
label-skip:
58+
description: 'PR label that skips release creation.'
59+
required: false
60+
default: 'release:skip'
3361
outputs:
3462
version:
3563
description: 'The version number that will be created'

src/config.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ const BODY: &str = "INPUT_BODY";
1414
const GENERATE_RELEASE_NOTES: &str = "INPUT_GENERATE-RELEASE-NOTES";
1515
const DRY_RUN: &str = "INPUT_DRY-RUN";
1616
const INCREMENT_STRATEGY: &str = "INPUT_VERSION-INCREMENT-STRATEGY";
17+
const PRERELEASE: &str = "INPUT_PRERELEASE";
18+
const PRERELEASE_IDENTIFIER: &str = "INPUT_PRERELEASE-IDENTIFIER";
19+
const USE_LABEL_STRATEGY: &str = "INPUT_USE-LABEL-STRATEGY";
20+
const LABEL_MAJOR: &str = "INPUT_LABEL-MAJOR";
21+
const LABEL_MINOR: &str = "INPUT_LABEL-MINOR";
22+
const LABEL_PATCH: &str = "INPUT_LABEL-PATCH";
23+
const LABEL_SKIP: &str = "INPUT_LABEL-SKIP";
1724

1825
#[derive(Debug)]
1926
pub struct Config {
@@ -29,6 +36,13 @@ pub struct Config {
2936
pub body: String,
3037
pub generate_release_notes: bool,
3138
pub dry_run: bool,
39+
pub prerelease: bool,
40+
pub prerelease_identifier: String,
41+
pub use_label_strategy: bool,
42+
pub label_major: String,
43+
pub label_minor: String,
44+
pub label_patch: String,
45+
pub label_skip: String,
3246
}
3347

3448
pub struct Token(pub String);
@@ -55,6 +69,13 @@ impl Config {
5569
body: get_body(),
5670
generate_release_notes: get_generate_release_notes()?,
5771
dry_run: is_dry_run()?,
72+
prerelease: get_prerelease(),
73+
prerelease_identifier: get_prerelease_identifier(),
74+
use_label_strategy: get_use_label_strategy(),
75+
label_major: get_label(LABEL_MAJOR, "release:major"),
76+
label_minor: get_label(LABEL_MINOR, "release:minor"),
77+
label_patch: get_label(LABEL_PATCH, "release:patch"),
78+
label_skip: get_label(LABEL_SKIP, "release:skip"),
5879
})
5980
}
6081

@@ -118,3 +139,123 @@ fn is_dry_run() -> Result<bool, ActionError> {
118139
let v = require_env(DRY_RUN)?;
119140
Ok(matches!(v.to_ascii_lowercase().as_str(), "true"))
120141
}
142+
143+
fn get_prerelease() -> bool {
144+
env::var(PRERELEASE).map_or(false, |v| matches!(v.to_ascii_lowercase().as_str(), "true"))
145+
}
146+
147+
fn get_prerelease_identifier() -> String {
148+
env::var(PRERELEASE_IDENTIFIER).unwrap_or_else(|_| "rc".to_string())
149+
}
150+
151+
fn get_use_label_strategy() -> bool {
152+
env::var(USE_LABEL_STRATEGY).map_or(false, |v| matches!(v.to_ascii_lowercase().as_str(), "true"))
153+
}
154+
155+
fn get_label(var: &'static str, default: &str) -> String {
156+
env::var(var).unwrap_or_else(|_| default.to_string())
157+
}
158+
159+
#[cfg(test)]
160+
mod tests {
161+
use super::*;
162+
use std::sync::Mutex;
163+
164+
static ENV_LOCK: Mutex<()> = Mutex::new(());
165+
166+
fn set_required_env() {
167+
std::env::set_var("GITHUB_REPOSITORY", "owner/repo");
168+
std::env::set_var("GITHUB_OUTPUT", "/tmp/output");
169+
std::env::set_var("GITHUB_TOKEN", "test-token");
170+
std::env::set_var("GITHUB_SHA", "abc123");
171+
std::env::set_var("INPUT_VERSION-INCREMENT-STRATEGY", "patch");
172+
std::env::set_var("INPUT_GENERATE-RELEASE-NOTES", "true");
173+
std::env::set_var("INPUT_DRY-RUN", "false");
174+
}
175+
176+
fn clear_env() {
177+
for var in &[
178+
"GITHUB_REPOSITORY",
179+
"GITHUB_OUTPUT",
180+
"GITHUB_TOKEN",
181+
"GITHUB_SHA",
182+
"INPUT_VERSION-INCREMENT-STRATEGY",
183+
"INPUT_GENERATE-RELEASE-NOTES",
184+
"INPUT_DRY-RUN",
185+
"INPUT_INITIAL-VERSION",
186+
"INPUT_TAG-PREFIX",
187+
"INPUT_BODY",
188+
"INPUT_GITHUB-HOST",
189+
] {
190+
std::env::remove_var(var);
191+
}
192+
}
193+
194+
#[test]
195+
fn config_new_parses_required_fields() {
196+
let _lock = ENV_LOCK.lock().unwrap();
197+
set_required_env();
198+
let config = Config::new().unwrap();
199+
assert_eq!(config.owner, "owner");
200+
assert_eq!(config.repo, "repo");
201+
assert_eq!(config.commitish, "abc123");
202+
assert_eq!(config.github_output_path, "/tmp/output");
203+
assert_eq!(config.tag_prefix, "v");
204+
assert_eq!(config.default_version, "0.1.0");
205+
assert_eq!(config.github_host, "https://api.github.com");
206+
assert!(!config.dry_run);
207+
assert!(config.generate_release_notes);
208+
clear_env();
209+
}
210+
211+
#[test]
212+
fn config_missing_token_returns_error() {
213+
let _lock = ENV_LOCK.lock().unwrap();
214+
set_required_env();
215+
std::env::remove_var("GITHUB_TOKEN");
216+
let err = Config::new().unwrap_err();
217+
assert_eq!(err, ActionError::MissingEnv("GITHUB_TOKEN"));
218+
clear_env();
219+
}
220+
221+
#[test]
222+
fn config_missing_repository_returns_error() {
223+
let _lock = ENV_LOCK.lock().unwrap();
224+
set_required_env();
225+
std::env::remove_var("GITHUB_REPOSITORY");
226+
let err = Config::new().unwrap_err();
227+
assert_eq!(err, ActionError::MissingEnv("GITHUB_REPOSITORY"));
228+
clear_env();
229+
}
230+
231+
#[test]
232+
fn config_invalid_strategy_returns_error() {
233+
let _lock = ENV_LOCK.lock().unwrap();
234+
set_required_env();
235+
std::env::set_var("INPUT_VERSION-INCREMENT-STRATEGY", "bogus");
236+
let err = Config::new().unwrap_err();
237+
assert!(matches!(err, ActionError::InvalidStrategy(_)));
238+
clear_env();
239+
}
240+
241+
#[test]
242+
fn config_custom_prefix_and_initial_version() {
243+
let _lock = ENV_LOCK.lock().unwrap();
244+
set_required_env();
245+
std::env::set_var("INPUT_TAG-PREFIX", "release-");
246+
std::env::set_var("INPUT_INITIAL-VERSION", "2.0.0");
247+
let config = Config::new().unwrap();
248+
assert_eq!(config.get_default_tag(), "release-2.0.0");
249+
clear_env();
250+
}
251+
252+
#[test]
253+
fn config_dry_run_parsed_correctly() {
254+
let _lock = ENV_LOCK.lock().unwrap();
255+
set_required_env();
256+
std::env::set_var("INPUT_DRY-RUN", "true");
257+
let config = Config::new().unwrap();
258+
assert!(config.dry_run);
259+
clear_env();
260+
}
261+
}

src/error.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::fmt;
22

3-
#[derive(Debug)]
3+
#[derive(Debug, Clone, PartialEq)]
44
pub enum ActionError {
55
MissingEnv(&'static str),
66
InvalidStrategy(String),

src/github_client.rs

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1-
use self::models::CreateReleaseRequest;
1+
use self::models::{CreateReleaseRequest, PullRequest, TagName};
2+
use crate::error::ActionError;
23
use crate::Config;
34

45
use reqwest::blocking::Client;
5-
use reqwest::blocking::Response;
66
use reqwest::header;
7-
use reqwest::Error;
7+
use reqwest::StatusCode;
8+
9+
pub mod models;
10+
11+
pub trait GithubApi {
12+
fn get_latest_release(&self) -> Result<Option<TagName>, ActionError>;
13+
fn create_release(&self, request: &CreateReleaseRequest) -> Result<Option<TagName>, ActionError>;
14+
fn get_pr_for_commit(&self, sha: &str) -> Result<Option<PullRequest>, ActionError>;
15+
}
816

917
pub struct GithubClient {
1018
client: Client,
@@ -13,8 +21,6 @@ pub struct GithubClient {
1321
github_host: String,
1422
}
1523

16-
pub mod models;
17-
1824
impl GithubClient {
1925
pub fn new(config: &Config) -> GithubClient {
2026
let mut headers = header::HeaderMap::new();
@@ -41,25 +47,72 @@ impl GithubClient {
4147
github_host: config.github_host.clone(),
4248
}
4349
}
50+
}
4451

52+
impl GithubApi for GithubClient {
4553
// GET /repos/{owner}/{repo}/releases/latest
46-
pub fn get_latest_release(&self) -> Result<Response, Error> {
47-
self.client
54+
fn get_latest_release(&self) -> Result<Option<TagName>, ActionError> {
55+
let response = self
56+
.client
4857
.get(format!(
4958
"{}/repos/{}/{}/releases/latest",
5059
self.github_host, self.owner, self.repo
5160
))
5261
.send()
62+
.map_err(|e| ActionError::ApiError(e.to_string()))?;
63+
64+
match response.status() {
65+
StatusCode::OK => response
66+
.json()
67+
.map_err(|e| ActionError::ApiError(e.to_string())),
68+
StatusCode::NOT_FOUND => Ok(None),
69+
StatusCode::UNAUTHORIZED => Err(ActionError::Unauthorized),
70+
s => Err(ActionError::UnexpectedStatus(s.as_u16())),
71+
}
5372
}
5473

5574
// POST /repos/{owner}/{repo}/releases
56-
pub fn create_release(&self, request: &CreateReleaseRequest) -> Result<Response, Error> {
57-
self.client
75+
fn create_release(&self, request: &CreateReleaseRequest) -> Result<Option<TagName>, ActionError> {
76+
let response = self
77+
.client
5878
.post(format!(
5979
"{}/repos/{}/{}/releases",
6080
self.github_host, self.owner, self.repo
6181
))
6282
.json(request)
6383
.send()
84+
.map_err(|e| ActionError::ApiError(e.to_string()))?;
85+
86+
match response.status() {
87+
StatusCode::CREATED => response
88+
.json()
89+
.map_err(|e| ActionError::ApiError(e.to_string())),
90+
StatusCode::UNAUTHORIZED => Err(ActionError::Unauthorized),
91+
s => Err(ActionError::UnexpectedStatus(s.as_u16())),
92+
}
93+
}
94+
95+
// GET /repos/{owner}/{repo}/commits/{sha}/pulls
96+
fn get_pr_for_commit(&self, sha: &str) -> Result<Option<PullRequest>, ActionError> {
97+
let response = self
98+
.client
99+
.get(format!(
100+
"{}/repos/{}/{}/commits/{}/pulls",
101+
self.github_host, self.owner, self.repo, sha
102+
))
103+
.send()
104+
.map_err(|e| ActionError::ApiError(e.to_string()))?;
105+
106+
match response.status() {
107+
StatusCode::OK => {
108+
let prs: Vec<PullRequest> = response
109+
.json()
110+
.map_err(|e| ActionError::ApiError(e.to_string()))?;
111+
Ok(prs.into_iter().next())
112+
}
113+
StatusCode::NOT_FOUND => Ok(None),
114+
StatusCode::UNAUTHORIZED => Err(ActionError::Unauthorized),
115+
s => Err(ActionError::UnexpectedStatus(s.as_u16())),
116+
}
64117
}
65118
}

src/github_client/models.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
use serde::Deserialize;
22
use serde::Serialize;
33

4-
#[derive(Deserialize, Debug)]
4+
#[derive(Deserialize, Debug, Clone)]
55
pub struct TagName {
66
pub tag_name: String,
77
}
88

9-
#[derive(Serialize, Debug)]
9+
#[derive(Serialize, Debug, Clone)]
1010
pub struct CreateReleaseRequest {
1111
pub tag_name: String,
1212
pub target_commitish: String,
@@ -16,3 +16,13 @@ pub struct CreateReleaseRequest {
1616
pub prerelease: bool,
1717
pub generate_release_notes: bool,
1818
}
19+
20+
#[derive(Deserialize, Debug, Clone)]
21+
pub struct Label {
22+
pub name: String,
23+
}
24+
25+
#[derive(Deserialize, Debug, Clone)]
26+
pub struct PullRequest {
27+
pub labels: Vec<Label>,
28+
}

0 commit comments

Comments
 (0)