Merge pull request #51 from mongodb/development

v1.26.0
This commit is contained in:
Mick Grove 2025-07-25 23:18:23 -07:00 committed by GitHub
commit 2127dc56d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1609 additions and 38 deletions

1
.gitignore vendored
View file

@ -68,6 +68,7 @@ Cargo.lock
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
.vscode/launch.json
# Local History for Visual Studio Code
.history/

View file

@ -2,6 +2,10 @@
All notable changes to this project will be documented in this file.
## [1.26.0]
- Added rule for ElevenLabs
- Added support for scanning Jira issues via a given JQL (Jira Query Language)
## [1.25.0]
- Fixed GitLab authentication bug
- Added pre-commit and pre-receive installation hooks

View file

@ -10,7 +10,7 @@ publish = false
[package]
name = "kingfisher"
version = "1.25.0"
version = "1.26.0"
description = "MongoDB's blazingly fast secret scanning and validation tool"
edition.workspace = true
rust-version.workspace = true
@ -185,6 +185,7 @@ semver = "1.0.26"
globset = "0.4.16"
jsonwebtoken = "9.3.1"
ipnet = "2.11.0"
jira_query = "1.6.0"
[dependencies.tikv-jemallocator]
version = "0.6"
@ -223,6 +224,7 @@ codegen-units = 256
[patch.crates-io]
vectorscan-rs = { path = "vendor/vectorscan-rs/vectorscan-rs" }
vectorscan-rs-sys = { path = "vendor/vectorscan-rs/vectorscan-rs-sys" }
jira_query = { path = "vendor/jira_query" }
[profile.profiling]
inherits = "release"

17
NOTICE
View file

@ -1,5 +1,14 @@
NOTICE file corresponding to Section 4 (d) of the Apache License, Version 2.0
--------------------------------------------------------------------
Notices for Kingfisher
--------------------------------------------------------------------
Copyright 2025 MongoDB, Inc.
https://www.mongodb.com
Source repository: https://github.com/mongodb/kingfisher
--------------------------------------------------------------------
Upstream notices
--------------------------------------------------------------------
@ -21,11 +30,3 @@ distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
--------------------------------------------------------------------
Additional notices for Kingfisher
--------------------------------------------------------------------
Copyright 2025 MongoDB, Inc.
https://www.mongodb.com
Source repository: https://github.com/mongodb/kingfisher

View file

@ -21,6 +21,7 @@ Kingfisher extends Nosey Parker with live secret validation via cloud-provider A
- **LanguageAware Accuracy**: AST parsing in 20+ languages via TreeSitter reduces contextless regex matches. see [docs/PARSING.md](/docs/PARSING.md)
- **Built-In Validation**: Hundreds of built-in detection rules, many with live-credential validators that call the relevant service APIs (AWS, Azure, GCP, Stripe, etc.) to confirm a secret is active. You can extend or override the library by adding YAML-defined rules on the command line—see [docs/RULES.md](/docs/RULES.md) for details
- **Git History Scanning**: Scan local repos, remote GitHub/GitLab orgs/users, or arbitrary GitHub/GitLab repos
- **Jira Scanning**: Scan issues returned from a JQL search using `--jira-url` and `--jql`
- **Baseline Support:** Generate and manage baseline files to ignore known secrets and report only newly introduced ones. See ([docs/BASELINE.md](docs/BASELINE.md)) for details.
# Getting Started
@ -285,14 +286,34 @@ kingfisher scan --git-url https://gitlab.com/group/project.git
kingfisher gitlab repos list --group my-group
```
## Scanning Jira
### Scan Jira issues matching a JQL query
```bash
KF_JIRA_TOKEN="token" kingfisher scan \
--jira-url https://jira.company.com \
--jql "project = TEST AND status = Open" \
--max-results 500
```
### Scan the last 1,000 Jira issues:
```bash
KF_JIRA_TOKEN="token" kingfisher scan \
--jira-url https://jira.mongodb.org \
--jql 'ORDER BY created DESC' \
--max-results 1000
```
---
## Environment Variables for Tokens
| Variable | Purpose |
| ----------------- | ---------------------------- |
| `KF_GITHUB_TOKEN` | GitHub Personal Access Token |
| `KF_GITLAB_TOKEN` | GitLab Personal Access Token |
| `KF_JIRA_TOKEN` | Jira API token |
Set them temporarily per command:
@ -306,6 +327,11 @@ Or export for the session:
export KF_GITLAB_TOKEN="glpat-…"
```
To authenticate Jira requests:
```bash
export KF_JIRA_TOKEN="token"
```
_If no token is provided Kingfisher still works for public repositories._
---

36
data/rules/elevenlabs.yml Normal file
View file

@ -0,0 +1,36 @@
rules:
- name: ElevenLabs API Key
id: kingfisher.elevenlabs.1
pattern: |
(?xi)
\b
(
sk_
[0-9a-f]{48}
)
\b
min_entropy: 3.5
confidence: medium
examples:
- sk_2a30e5a0d39d5f2c5f6a9d2f95cd016049a6323985479bfd
- sk_da9c0613fdeecfab10b302d6f39a3e371f774feb9eafed56
- sk_82a331629e2128ef70396600809b6a2ff4e433154fa27e1b
references:
- https://elevenlabs.io/docs/api-reference/authentication
- https://elevenlabs.io/docs/api-reference/user/subscription/get
validation:
type: Http
content:
request:
method: GET
url: https://api.elevenlabs.io/v1/user/subscription
headers:
xi-api-key: '{{ TOKEN }}'
response_matcher:
- report_response: true
- type: WordMatch
match_all_words: false
words:
- '"tier"'
- '"missing_permissions"'

View file

@ -12,7 +12,7 @@ rules:
confidence: medium
examples:
- example-jira.atlassian.net
- jira.sprintUri= https://leakyday.atlassian.net/rest
- jira.sprintUri= https://example.atlassian.net/rest
- name: Jira Token
id: kingfisher.jira.2

View file

@ -25,7 +25,8 @@ pub struct InputSpecifierArgs {
"gitlab_group",
"git_url",
"all_github_organizations",
"all_gitlab_groups"
"all_gitlab_groups",
"jira_url"
]),
value_hint = ValueHint::AnyPath
)]
@ -84,6 +85,18 @@ pub struct InputSpecifierArgs {
#[arg(long, default_value_t = GitLabRepoType::Owner)]
pub gitlab_repo_type: GitLabRepoType,
/// Jira base URL (e.g. https://jira.example.com)
#[arg(long, value_hint = ValueHint::Url, requires = "jql")]
pub jira_url: Option<Url>,
/// JQL query to select Jira issues
#[arg(long, requires = "jira_url")]
pub jql: Option<String>,
/// Maximum number of Jira results to fetch
#[arg(long, default_value_t = 100)]
pub max_results: usize,
/// Select how to clone Git repositories
#[arg(long, default_value_t=GitCloneMode::Bare, alias="git-clone-mode")]
pub git_clone: GitCloneMode,

View file

@ -280,6 +280,13 @@ impl FindingsStore {
self.clone_dir.join(repo_identifier)
}
/// Return the directory used to store cloned repositories and other
/// temporary artifacts.
pub fn clone_root(&self) -> PathBuf {
self.clone_dir.clone()
}
pub fn get_finding_data_iter(
&self,
) -> impl Iterator<Item = finding_data::FindingMetadata> + '_ {

52
src/jira.rs Normal file
View file

@ -0,0 +1,52 @@
use anyhow::{Context, Result};
use jira_query::{Auth, JiraInstance, Pagination};
use reqwest::Client;
use url::Url;
// Re-export the Issue type from jira_query so callers don't depend on the crate.
pub use jira_query::Issue as JiraIssue;
pub async fn fetch_issues(
jira_url: Url,
jql: &str,
max_results: usize,
ignore_certs: bool,
) -> Result<Vec<JiraIssue>> {
// build a &str without any trailing `/`
let base = jira_url.as_str().trim_end_matches('/');
let client = Client::builder()
.danger_accept_invalid_certs(ignore_certs)
.build()
.context("Failed to build HTTP client")?;
let mut jira = JiraInstance::at(base.to_string())? // no trailing slash here
.with_client(client)
.paginate(Pagination::MaxResults(max_results as u32));
if let Ok(token) = std::env::var("KF_JIRA_TOKEN") {
jira = jira.authenticate(Auth::ApiKey(token));
}
let issues = jira.search(jql).await?;
Ok(issues)
}
use std::path::PathBuf;
pub async fn download_issues_to_dir(
jira_url: Url,
jql: &str,
max_results: usize,
ignore_certs: bool,
output_dir: &PathBuf,
) -> Result<Vec<PathBuf>> {
std::fs::create_dir_all(output_dir)?;
let issues = fetch_issues(jira_url, jql, max_results, ignore_certs).await?;
let mut paths = Vec::new();
for issue in issues {
let file = output_dir.join(format!("{}.json", issue.key));
std::fs::write(&file, serde_json::to_vec(&issue)?)?;
paths.push(file);
}
Ok(paths)
}

View file

@ -18,6 +18,7 @@ pub mod git_url;
pub mod github;
pub mod gitlab;
pub mod guesser;
pub mod jira;
pub mod liquid_filters;
pub mod location;
pub mod matcher;

View file

@ -279,6 +279,10 @@ fn create_default_scan_args() -> cli::commands::scan::ScanArgs {
gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(),
gitlab_repo_type: GitLabRepoType::Owner,
jira_url: None,
jql: None,
max_results: 50,
// git clone / history options
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,

View file

@ -112,6 +112,34 @@ impl DetailsReporter {
None
}
}
/// If the given file path corresponds to a Jira issue downloaded to disk,
/// return the online Jira URL for that issue.
fn jira_issue_url(
&self,
path: &std::path::Path,
args: &cli::commands::scan::ScanArgs,
) -> Option<String> {
// drop any trailing slash so we dont end up with “//browse/…”
let jira_url = args
.input_specifier_args
.jira_url
.as_ref()?
.as_str()
.trim_end_matches('/');
let ds = self.datastore.lock().ok()?;
let root = ds.clone_root();
let jira_dir = root.join("jira_issues");
if path.starts_with(&jira_dir) {
let key = path.file_stem()?.to_string_lossy();
Some(format!("{}/browse/{}", jira_url, key))
} else {
None
}
}
fn gather_findings(&self) -> Result<Vec<Finding>> {
let metadata_list = self.get_finding_data()?;
let all_matches = self.get_filtered_matches()?;
@ -288,7 +316,7 @@ impl Reportable for DetailsReporter {
ReportOutputFormat::Json => self.json_format(writer, args),
ReportOutputFormat::Jsonl => self.jsonl_format(writer, args),
ReportOutputFormat::Bson => self.bson_format(writer, args),
ReportOutputFormat::Sarif => self.sarif_format(writer, args.no_dedup),
ReportOutputFormat::Sarif => self.sarif_format(writer, args.no_dedup, args),
}
}
}

View file

@ -39,14 +39,14 @@ impl DetailsReporter {
};
// Process to JSON first, then convert to BSON
let json_finding = self.process_match_to_json(&single_origin_rm)?;
let json_finding = self.process_match_to_json(&single_origin_rm, args)?;
if let Ok(bson_doc) = json_to_bson_document(&json_finding) {
bson_findings.push(bson_doc);
}
}
} else {
// Process normally for deduped matches or matches with only one origin
let json_finding = self.process_match_to_json(&rm)?;
let json_finding = self.process_match_to_json(&rm, args)?;
if let Ok(bson_doc) = json_to_bson_document(&json_finding) {
bson_findings.push(bson_doc);
}

View file

@ -101,7 +101,11 @@ impl DetailsReporter {
.iter()
.find_map(|origin| {
if let Origin::File(e) = origin {
Some(e.path.display().to_string())
if let Some(url) = self.jira_issue_url(&e.path, args) {
Some(url)
} else {
Some(e.path.display().to_string())
}
} else {
None
}
@ -173,12 +177,12 @@ impl DetailsReporter {
};
// Process this single-origin match into a JSON finding
let json_finding = self.process_match_to_json(&single_origin_rm)?;
let json_finding = self.process_match_to_json(&single_origin_rm, args)?;
findings.push(json_finding);
}
} else {
// Process normally for deduped matches or matches with only one origin
let json_finding = self.process_match_to_json(&rm)?;
let json_finding = self.process_match_to_json(&rm, args)?;
findings.push(json_finding);
}
}
@ -192,7 +196,11 @@ impl DetailsReporter {
}
// Add a helper method to convert a ReportMatch to a JSON finding
pub fn process_match_to_json(&self, rm: &ReportMatch) -> Result<serde_json::Value> {
pub fn process_match_to_json(
&self,
rm: &ReportMatch,
args: &cli::commands::scan::ScanArgs,
) -> Result<serde_json::Value> {
// Extract the relevant data from the match as you already do in your current implementation
let source_span = &rm.m.location.source_span;
let line_num = source_span.start.line;
@ -242,7 +250,11 @@ impl DetailsReporter {
.iter()
.find_map(|origin| {
if let Origin::File(e) = origin {
Some(e.path.display().to_string())
if let Some(url) = self.jira_issue_url(&e.path, args) {
Some(url)
} else {
Some(e.path.display().to_string())
}
} else {
None
}
@ -325,13 +337,13 @@ impl DetailsReporter {
};
// Process this single-origin match into a JSON finding and write it
let json_finding = self.process_match_to_json(&single_origin_rm)?;
let json_finding = self.process_match_to_json(&single_origin_rm, args)?;
serde_json::to_writer(&mut writer, &json_finding)?;
writeln!(writer)?;
}
} else {
// Process normally for deduped matches or matches with only one origin
let json_finding = self.process_match_to_json(&rm)?;
let json_finding = self.process_match_to_json(&rm, args)?;
serde_json::to_writer(&mut writer, &json_finding)?;
writeln!(writer)?;
}
@ -413,7 +425,10 @@ mod tests {
all_gitlab_groups: false,
gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(),
gitlab_repo_type: GitLabRepoType::Owner,
// Jira options
jira_url: None,
jql: None,
max_results: 50,
// clone / history options
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,

View file

@ -214,14 +214,18 @@ impl<'a> Display for PrettyFinding<'a> {
for p in rm.origin.iter() {
match p {
Origin::File(e) => {
let display_path = if let Some(url) = reporter.jira_issue_url(&e.path, args) {
url
} else {
e.path.display().to_string()
};
writeln!(
f,
" |Path..........: {}",
if rm.validation_success {
reporter.style_active_creds(e.path.display()).to_string().to_string()
// Convert StyledObject to String
reporter.style_active_creds(&display_path).to_string()
} else {
e.path.display().to_string()
display_path
}
)?;
}
@ -337,7 +341,10 @@ fn test_pretty_format_with_nan_entropy_panics() {
all_gitlab_groups: false,
gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(),
gitlab_repo_type: GitLabRepoType::Owner,
// Jira options
jira_url: None,
jql: None,
max_results: 50,
// git clone / history options
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,

View file

@ -14,7 +14,12 @@ struct LocationKey {
text: String,
}
impl DetailsReporter {
fn make_sarif_result(&self, finding: &Finding, no_dedup: bool) -> Result<sarif::Result> {
fn make_sarif_result(
&self,
finding: &Finding,
no_dedup: bool,
args: &cli::commands::scan::ScanArgs,
) -> Result<sarif::Result> {
// Deduplicate exactly as in the JSON reporter
// let matches = self.deduplicate_matches(finding.matches.clone(), no_dedup);
// Deduplicate exactly as in the JSON reporter - but only if no_dedup is false
@ -66,11 +71,13 @@ impl DetailsReporter {
for p in prov.iter() {
match p {
Origin::File(e) => {
let uri = if let Some(url) = self.jira_issue_url(&e.path, args) {
url
} else {
e.path.display().to_string()
};
artifact_locations.push(
sarif::ArtifactLocationBuilder::default()
.uri(e.path.display().to_string())
.build()
.ok()?,
sarif::ArtifactLocationBuilder::default().uri(uri).build().ok()?,
);
}
Origin::GitRepo(e) => {
@ -199,7 +206,13 @@ impl DetailsReporter {
let p = first_match.origin.first();
match p {
Origin::File(e) => {
msg.push_str(&format!("Location: {}\n", e.path.display()));
let uri = if let Some(url) = self.jira_issue_url(&e.path, args) {
url
} else {
e.path.display().to_string()
};
msg.push_str(&format!("Location: {}\n", uri));
}
Origin::GitRepo(e) => {
if let Some(cs) = &e.first_commit {
@ -242,7 +255,12 @@ impl DetailsReporter {
Ok(result)
}
pub fn sarif_format<W: std::io::Write>(&self, mut writer: W, no_dedup: bool) -> Result<()> {
pub fn sarif_format<W: std::io::Write>(
&self,
mut writer: W,
no_dedup: bool,
args: &cli::commands::scan::ScanArgs,
) -> Result<()> {
// Gather findings first
let mut findings = self.gather_findings()?;
@ -329,8 +347,11 @@ impl DetailsReporter {
.build()?,
)
.build()?;
let sarif_results: Vec<sarif::Result> =
findings.par_iter().filter_map(|f| self.make_sarif_result(f, no_dedup).ok()).collect();
let sarif_results: Vec<sarif::Result> = findings
.par_iter()
.filter_map(|f| self.make_sarif_result(f, no_dedup, args).ok())
.collect();
let run = sarif::RunBuilder::default().tool(tool).results(sarif_results).build()?;
let sarif = sarif::SarifBuilder::default()
.version(sarif::Version::V2_1_0.to_string())

View file

@ -20,7 +20,7 @@ use crate::{
findings_store,
git_binary::{CloneMode, Git},
git_url::GitUrl,
github, gitlab,
github, gitlab, jira,
matcher::Match,
origin::OriginSet,
PathBuf,
@ -224,3 +224,32 @@ pub async fn enumerate_gitlab_repos(
repo_urls.dedup();
Ok(repo_urls)
}
pub async fn fetch_jira_issues(
args: &scan::ScanArgs,
global_args: &global::GlobalArgs,
datastore: &Arc<Mutex<findings_store::FindingsStore>>,
) -> Result<Vec<PathBuf>> {
let Some(jira_url) = args.input_specifier_args.jira_url.clone() else {
return Ok(Vec::new());
};
let Some(jql) = args.input_specifier_args.jql.as_deref() else {
return Ok(Vec::new());
};
let max_results = args.input_specifier_args.max_results;
let output_dir = {
let ds = datastore.lock().unwrap();
ds.clone_root()
};
let output_dir = output_dir.join("jira_issues");
let _paths = jira::download_issues_to_dir(
jira_url,
jql,
max_results,
global_args.ignore_certs,
&output_dir,
)
.await?;
Ok(vec![output_dir])
}

View file

@ -18,7 +18,9 @@ use crate::{
rules_database::RulesDatabase,
scanner::{
clone_or_update_git_repos, enumerate_filesystem_inputs, enumerate_github_repos,
repos::enumerate_gitlab_repos, run_secret_validation, summary::print_scan_summary,
repos::{enumerate_gitlab_repos, fetch_jira_issues},
run_secret_validation,
summary::print_scan_summary,
},
};
@ -61,7 +63,11 @@ pub async fn run_async_scan(
repo_urls.sort();
repo_urls.dedup();
let input_roots = clone_or_update_git_repos(args, global_args, &repo_urls, &datastore)?;
let mut input_roots = clone_or_update_git_repos(args, global_args, &repo_urls, &datastore)?;
// Fetch Jira issues if requested
let jira_dirs = fetch_jira_issues(args, global_args, &datastore).await?;
input_roots.extend(jira_dirs);
if input_roots.is_empty() {
bail!("No inputs to scan");
}

View file

@ -56,6 +56,11 @@ rules:
request:
method: BREW
url: "https://example.com/"
response_matcher:
- report_response: true
- status:
- 200
type: StatusMatch
"#,
)
.unwrap();

View file

@ -79,6 +79,9 @@ rules:
gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(),
gitlab_repo_type: GitLabRepoType::Owner,
jira_url: None,
jql: None,
max_results: 50,
// git clone / history options
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,

View file

@ -66,6 +66,9 @@ fn test_github_remote_scan() -> Result<()> {
gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(),
gitlab_repo_type: GitLabRepoType::Owner,
jira_url: None,
jql: None,
max_results: 50,
// git clone / history options
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,

View file

@ -64,6 +64,10 @@ fn test_gitlab_remote_scan() -> Result<()> {
all_gitlab_groups: false,
gitlab_api_url: Url::parse("https://gitlab.com/")?,
gitlab_repo_type: GitLabRepoType::Owner,
jira_url: None,
jql: None,
max_results: 50,
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,
scan_nested_repos: true,

View file

@ -122,6 +122,9 @@ async fn test_validation_cache_and_depvars() -> Result<()> {
gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(),
gitlab_repo_type: GitLabRepoType::Owner,
jira_url: None,
jql: None,
max_results: 50,
// git clone / history options
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,

View file

@ -65,6 +65,9 @@ impl TestContext {
gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(),
gitlab_repo_type: GitLabRepoType::Owner,
jira_url: None,
jql: None,
max_results: 50,
// git clone / history options
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,
@ -130,6 +133,9 @@ impl TestContext {
gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(),
gitlab_repo_type: GitLabRepoType::Owner,
jira_url: None,
jql: None,
max_results: 50,
// git clone / history options
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,

2
vendor/jira_query/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
Cargo.lock

4
vendor/jira_query/CONTRIBUTING.md vendored Normal file
View file

@ -0,0 +1,4 @@
## Certificate of Origin
By contributing to this project you agree to the Developer Certificate of Origin (DCO). This document was created by the Linux Kernel community and is a simple statement that you, as a contributor, have the legal right to make the contribution. See the [DCO.md](DCO.md) file for details.

30
vendor/jira_query/Cargo.toml vendored Normal file
View file

@ -0,0 +1,30 @@
[package]
name = "jira_query"
authors = ["Marek Suchánek <marek.suchanek@protonmail.com>"]
version = "1.6.0"
edition = "2021"
# Check the Rust version using `cargo msrv verify`.
rust-version = "1.81"
license = "Apache-2.0"
description = "Access tickets on a remote Jira instance."
readme = "README.md"
documentation = "https://docs.rs/jira_query/"
homepage = "https://github.com/msuchane/jira_query"
repository = "https://github.com/msuchane/jira_query"
keywords = ["jira", "atlassian", "rest"]
categories = ["api-bindings"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
log = "0.4"
thiserror = "2.0"
reqwest = { version = "0.12", default-features = false, features = ["json","rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Version with a security patch:
chrono = { version = ">=0.4.20", features = ["serde"] }
[dev-dependencies]
tokio = { version = ">=1.45", features = ["full"] }

14
vendor/jira_query/DCO.md vendored Normal file
View file

@ -0,0 +1,14 @@
## What is the DCO?
The DCO is a certification normally associated with every contribution to a project made by every contributor. It signifies that the contributor has the right to submit the contribution under the applicable open source license of the project. The entire certification text (maintained by the Linux Foundation as version 1.1 at https://developercertificate.org/) is the following:
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or
(b) The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or
(c) The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it.
(d) I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved.

201
vendor/jira_query/LICENSE vendored Normal file
View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2022 Marek Suchánek
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

72
vendor/jira_query/README.md vendored Normal file
View file

@ -0,0 +1,72 @@
# jira_query
[![Crates.io](https://img.shields.io/crates/v/jira_query.svg)](https://crates.io/crates/jira_query)
[![Apache-2.0 license](https://img.shields.io/crates/l/jira_query)](https://crates.io/crates/jira_query)
[![Documentation](https://docs.rs/jira_query/badge.svg)](https://docs.rs/jira_query)
[![CI tests](https://github.com/msuchane/jira_query/actions/workflows/rust-tests.yml/badge.svg)](https://github.com/msuchane/jira_query/actions/workflows/rust-tests.yml)
[![Dependency status](https://deps.rs/repo/github/msuchane/jira_query/status.svg)](https://deps.rs/repo/github/msuchane/jira_query)
Access issues on a remote Jira instance.
## Description
The `jira_query` crate is a Rust library that can query a Jira instance using its REST API. It returns a strongly typed representation of the requested issues.
This library provides no functionality to create or modify issues. The access is read-only.
## Usage
### Basic anonymous query
Without logging in, search for a single ticket and check for its priority:
```rust
use tokio;
use jira_query::JiraInstance;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let jira = JiraInstance::at("https://issues.redhat.com".to_string())?;
let issue = jira.issue("CS-1113").await?;
assert_eq!(issue.fields.priority.name, "Normal");
Ok(())
}
```
### Advanced query
Use an API key to log into Jira. Search for all CentOS Stream tickets that are of the Blocker priority. Check that there is more than one ticket:
```rust
use tokio;
use jira_query::{Auth, JiraInstance, Pagination};
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let jira = JiraInstance::at("https://bugzilla.redhat.com".to_string())?
.authenticate(Auth::ApiKey("My API Key".to_string()))
.paginate(Pagination::ChunkSize(32));
let query = r#"project="CentOS Stream" AND priority=Blocker"#;
let issues = jira.search(query).await?;
assert!(issues.len() > 1);
Ok(())
}
```
## A note on semantic versioning
This crate reserves the right to make limited breaking changes to the Jira structs in minor versions (`X.Y`).
The reason is that the official Jira documentation does not specify which fields in the JSON body are optional (`Option<T>`) and which are mandatory (`T`). Rather than exposing all fields as optional, this crate tries to process fields as mandatory until proven otherwise in testing. As a consequence, minor releases must occasionally turn a mandatory field to an optional field.
## See also
* [`bugzilla_query`](https://crates.io/crates/bugzilla_query), a similar interface to Bugzilla

297
vendor/jira_query/src/access.rs vendored Normal file
View file

@ -0,0 +1,297 @@
/*
Copyright 2022 Marek Suchánek <msuchane@redhat.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Jira API documentation:
// * https://docs.atlassian.com/software/jira/docs/api/REST/latest/
// * https://docs.atlassian.com/jira-software/REST/latest/
use crate::errors::JiraQueryError;
use crate::issue_model::{Issue, JqlResults};
// The prefix of every subsequent REST request.
// This string comes directly after the host in the URL.
const REST_PREFIX: &str = "rest/api/2";
/// Configuration and credentials to access a Jira instance.
pub struct JiraInstance {
pub host: String,
pub auth: Auth,
pub pagination: Pagination,
client: reqwest::Client,
}
/// The authentication method used to contact Jira.
pub enum Auth {
Anonymous,
ApiKey(String),
Basic { user: String, password: String },
}
// We could set a default enum variant and derive, but that raises the MSRV to 1.62.
impl Default for Auth {
fn default() -> Self {
Self::Anonymous
}
}
/// Controls the upper limit of how many tickets the response from Jira can contain:
///
/// * `Default`: Use the default settings of this instance, which sets an arbitrary limit on the number of tickets.
/// * `MaxResults`: Set the upper limit to this value. Note that each instance has a maximum allowed value,
/// and if you set `MaxResults` higher than that, the instance uses its own maximum allowed value.
/// * `ChunkSize`: Access the tickets in a series of requests, each accessing the number of tickets equal to the chunk size.
/// This enables you to access an unlimited number of tickets, as long as the chunk size is smaller
/// than the maximum allowed results size for the instance.
pub enum Pagination {
Default,
MaxResults(u32),
ChunkSize(u32),
}
// We could set a default enum variant and derive, but that raises the MSRV to 1.62.
impl Default for Pagination {
fn default() -> Self {
Self::Default
}
}
/// The method of the request to Jira. Either request specific IDs,
/// or use a free-form JQL search query.
enum Method<'a> {
Key(&'a str),
Keys(&'a [&'a str]),
Search(&'a str),
}
impl<'a> Method<'a> {
fn url_fragment(&self) -> String {
match self {
Self::Key(id) => format!("issue/{id}"),
Self::Keys(ids) => format!("search?jql=id%20in%20({})", ids.join(",")),
Self::Search(query) => format!("search?jql={query}"),
}
}
}
impl JiraInstance {
/// Create a new `BzInstance` struct using a host URL, with default values
/// for all options.
pub fn at(host: String) -> Result<Self, JiraQueryError> {
// TODO: This function takes host as a String, even though client is happy with &str.
// The String is only used in the host struct attribute.
let client = reqwest::Client::new();
Ok(Self {
host,
client,
auth: Auth::default(),
pagination: Pagination::default(),
})
}
/// Set the authentication method of this `JiraInstance`.
#[must_use]
pub fn authenticate(mut self, auth: Auth) -> Self {
self.auth = auth;
self
}
/// Set the http client of this `JiraInstance`.
#[must_use]
pub fn with_client(mut self, client: reqwest::Client) -> Self {
self.client = client;
self
}
/// Set the pagination method of this `JiraInstance`.
#[must_use]
pub const fn paginate(mut self, pagination: Pagination) -> Self {
self.pagination = pagination;
self
}
/// Based on the request method, form a complete, absolute URL
/// to download the tickets from the REST API.
#[must_use]
fn path(&self, method: &Method, start_at: u32) -> String {
let max_results = match self.pagination {
Pagination::Default => String::new(),
// For both MaxResults and ChunkSIze, set the maxResults size to the value set in the variant.
// The maxResults size is relevant for ChunkSize in that each chunk requires its own results
// to be at least this large.
Pagination::MaxResults(n) | Pagination::ChunkSize(n) => format!("&maxResults={n}"),
};
// The `startAt` option is only valid with JQL. With a URL by key, it breaks the REST query.
let start_at = match method {
Method::Key(_) => String::new(),
Method::Keys(_) | Method::Search(_) => format!("&startAt={start_at}"),
};
format!(
"{}/{}/{}{}{}",
self.host,
REST_PREFIX,
method.url_fragment(),
max_results,
start_at,
)
}
/// Download the specified URL using the configured authentication.
async fn authenticated_get(&self, url: &str) -> Result<reqwest::Response, reqwest::Error> {
let request_builder = self.client.get(url);
let authenticated = match &self.auth {
Auth::Anonymous => request_builder,
Auth::ApiKey(key) => request_builder.header("Authorization", &format!("Bearer {key}")),
Auth::Basic { user, password } => request_builder.basic_auth(user, Some(password)),
};
authenticated.send().await
}
// This method uses a separate implementation from `issues` because Jira provides a way
// to request a single ticket specifically. That conveniently handles error cases
// where no tickets might match, or more than one might.
/// Access a single issue by its key.
pub async fn issue(&self, key: &str) -> Result<Issue, JiraQueryError> {
let url = self.path(&Method::Key(key), 0);
// Gets an issue by ID and deserializes the JSON to data variable
let issue = self.authenticated_get(&url).await?.json::<Issue>().await?;
log::debug!("{:#?}", issue);
Ok(issue)
}
/// Access several issues by their keys.
///
/// If the list of keys is empty, returns an empty list back with no errors.
pub async fn issues(&self, keys: &[&str]) -> Result<Vec<Issue>, JiraQueryError> {
// If the user specifies no keys, skip network requests and return no bugs.
// Returning an error could also be valid, but I believe that this behavior
// is less surprising and more practical.
if keys.is_empty() {
return Ok(Vec::new());
}
let method = Method::Keys(keys);
// If Pagination is set to ChunkSize, split the issue keys into chunk by chunk size
// and request each chunk separately.
if let Pagination::ChunkSize(chunk_size) = self.pagination {
self.paginated_issues(&method, chunk_size).await
// If Pagination is not set to ChunkSize, use a single chunk request for all issues.
} else {
let issues = self.chunk_of_issues(&method, 0).await?;
// If the resulting list is empty, return an error.
// TODO: The REST parsing above already results in an error if the results are empty.
// Try to catch the error there.
if issues.is_empty() {
Err(JiraQueryError::NoIssues)
} else {
Ok(issues)
}
}
}
/// Download all issues specified in the request as a series of chunks or pages.
/// The request controls whether the download works with IDs or JQL.
/// This function only processes the resulting pages coming back from Jira
/// and stops the iteration at the last page.
///
/// See the Jira documentation:
/// <https://confluence.atlassian.com/jirakb/changing-maxresults-parameter-for-jira-rest-api-779160706.html>.
async fn paginated_issues(
&self,
method: &Method<'_>,
chunk_size: u32,
) -> Result<Vec<Issue>, JiraQueryError> {
let mut all_issues = Vec::new();
let mut start_at = 0;
loop {
let mut chunk_issues = self.chunk_of_issues(method, start_at).await?;
// Calculate the length now before the content moves to `all_issues`.
let page_size = chunk_issues.len();
all_issues.append(&mut chunk_issues);
// If this page contains fewer issues than the chunk size,
// it's the last page. Stop the loop.
if page_size < chunk_size as usize {
break;
}
start_at += chunk_size;
}
Ok(all_issues)
}
/// Download a specific list (chunk) of issues.
/// Reused elsewhere as a building block of different pagination methods.
async fn chunk_of_issues(
&self,
method: &Method<'_>,
start_at: u32,
) -> Result<Vec<Issue>, JiraQueryError> {
let url = self.path(method, start_at);
let results = self
.authenticated_get(&url)
.await?
.json::<JqlResults>()
.await?;
log::debug!("{:#?}", results);
Ok(results.issues)
}
/// Access issues using a free-form JQL search.
///
/// An example of a query: `project="CentOS Stream" AND priority = High`.
pub async fn search(&self, query: &str) -> Result<Vec<Issue>, JiraQueryError> {
let method = Method::Search(query);
// If Pagination is set to ChunkSize, split the issue keys into chunk by chunk size
// and request each chunk separately.
if let Pagination::ChunkSize(chunk_size) = self.pagination {
self.paginated_issues(&method, chunk_size).await
// If Pagination is not set to ChunkSize, use a single chunk request for all issues.
} else {
let issues = self.chunk_of_issues(&method, 0).await?;
Ok(issues)
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
let result = 2 + 2;
assert_eq!(result, 4);
}
// #[test]
// fn issues() {
// let results = crate::issues("todo", &["todo"], "todo");
// eprintln!("{:#?}", results);
// assert_eq!(results.issues.len(), todo);
// }
}

28
vendor/jira_query/src/errors.rs vendored Normal file
View file

@ -0,0 +1,28 @@
/*
Copyright 2022 Marek Suchánek <msuchane@redhat.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
use thiserror::Error;
/// All errors that might occur in this crate.
#[derive(Error, Debug)]
pub enum JiraQueryError {
#[error("Required issues are missing in the Jira response: {}.", .0.join(", "))]
MissingIssues(Vec<String>),
#[error("The Jira query returned no issues.")]
NoIssues,
#[error("Error in accessing the Jira REST API.")]
Request(#[from] reqwest::Error),
}

440
vendor/jira_query/src/issue_model.rs vendored Normal file
View file

@ -0,0 +1,440 @@
/*
Copyright 2022 Marek Suchánek <msuchane@redhat.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
use chrono::{DateTime, NaiveDate, Utc};
/// This module replicates the fields in a Jira issue as strongly typed structs.
/// Any extra fields that come from a custom Jira configuration are captured
/// in the `extra` hash map in the parent struct.
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// The response from Jira to a JQL query,
/// which includes the list of requested issues and additional metadata.
#[derive(Clone, Debug, Deserialize)]
pub struct JqlResults {
pub issues: Vec<Issue>,
#[serde(flatten)]
#[allow(dead_code)]
pub extra: Value,
}
/// A single Jira issue with all its fields.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Issue {
pub id: String,
pub key: String,
pub expand: String,
pub fields: Fields,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}
/// A container for most fields of a Jira issue.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Fields {
#[serde(rename = "lastViewed")]
pub last_viewed: Option<DateTime<Utc>>,
pub labels: Vec<String>,
#[serde(default)]
pub assignee: Option<User>,
pub description: Option<String>,
pub duedate: Option<NaiveDate>,
// Both `versions` and `fixVersions` are optional fields and they might
// either be missing or set to an empty list.
// I'm consolidating both cases as an empty list, because I don't believe
// that there's a meaningful semantic difference between them here.
#[serde(default)]
pub versions: Vec<Version>,
#[serde(default)]
#[serde(rename = "fixVersions")]
pub fix_versions: Vec<Version>,
#[serde(default)]
pub reporter: Option<User>,
pub status: Status,
pub created: DateTime<Utc>,
pub updated: DateTime<Utc>,
pub issuetype: IssueType,
pub timeestimate: Option<i32>,
pub aggregatetimeestimate: Option<i32>,
pub timeoriginalestimate: Option<i32>,
pub timespent: Option<i32>,
pub aggregatetimespent: Option<i32>,
pub aggregatetimeoriginalestimate: Option<i32>,
pub progress: Option<Progress>,
pub aggregateprogress: Option<Progress>,
pub workratio: i64,
pub summary: String,
#[serde(default)]
pub creator: Option<User>,
pub project: Project,
pub priority: Option<Priority>,
#[serde(default)]
pub components: Vec<Component>,
pub watches: Watches,
pub archiveddate: Option<DateTime<Utc>>,
pub archivedby: Option<DateTime<Utc>>,
pub resolution: Option<Resolution>,
pub resolutiondate: Option<DateTime<Utc>>,
pub comment: Option<Comments>,
pub issuelinks: Vec<IssueLink>,
pub votes: Votes,
pub parent: Option<CondensedIssue>,
pub subtasks: Vec<CondensedIssue>,
pub environment: Option<String>,
pub security: Option<Security>,
#[serde(flatten)]
pub extra: Value,
}
/// The representation of a Jira user account.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct User {
pub active: bool,
#[serde(rename = "displayName")]
pub display_name: String,
#[serde(rename = "emailAddress")]
pub email_address: Option<String>,
pub key: Option<String>,
pub name: Option<String>,
#[serde(rename = "timeZone")]
pub time_zone: String,
#[serde(rename = "avatarUrls")]
pub avatar_urls: AvatarUrls,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
#[serde(rename = "accountId")]
pub account_id: Option<String>,
}
/// The representation of a Jira product version.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Version {
pub id: String,
pub description: Option<String>,
pub name: String,
pub archived: bool,
pub released: bool,
/// Jira stores `releaseDate` only as `YYYY-MM-DD`, so it can't Serialize, Deserialize to full `DateTime`.
#[serde(rename = "releaseDate")]
pub release_date: Option<NaiveDate>,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}
/// The Jira issue status.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Status {
pub description: String,
#[serde(rename = "iconUrl")]
pub icon_url: String,
pub id: String,
pub name: String,
#[serde(rename = "statusCategory")]
pub status_category: StatusCategory,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}
/// The category of a Jira issue status.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct StatusCategory {
#[serde(rename = "colorName")]
pub color_name: String,
pub id: i32,
pub key: String,
pub name: String,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}
/// The resolution of a Jira issue when it's closed.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Resolution {
pub description: String,
pub id: String,
pub name: String,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}
/// The type of a Jira issue.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct IssueType {
#[serde(rename = "avatarId")]
pub avatar_id: Option<i32>,
pub description: String,
#[serde(rename = "iconUrl")]
pub icon_url: String,
pub id: String,
pub name: String,
pub subtask: bool,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}
/// A project namespace that groups Jira issues.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Project {
pub id: String,
pub key: String,
pub name: String,
#[serde(rename = "projectTypeKey")]
pub project_type_key: String,
#[serde(rename = "projectCategory")]
pub project_category: Option<ProjectCategory>,
#[serde(rename = "avatarUrls")]
pub avatar_urls: AvatarUrls,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}
/// The category of a Jira project.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProjectCategory {
pub description: String,
pub id: String,
pub name: String,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}
/// The priority of a Jira issue.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Priority {
#[serde(rename = "iconUrl")]
pub icon_url: String,
pub id: String,
pub name: String,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}
/// The component of a Jira issue.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Component {
pub description: Option<String>,
pub id: String,
pub name: String,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}
/// Users watching a Jira issue.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Watches {
#[serde(rename = "isWatching")]
pub is_watching: bool,
#[serde(rename = "watchCount")]
pub watch_count: i32,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}
/// The progress of a Jira issue.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Progress {
pub progress: i32,
pub total: i32,
#[serde(flatten)]
pub extra: Value,
}
/// A comment below a Jira issue.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Comment {
#[serde(default)]
pub author: Option<User>,
pub body: String,
pub created: DateTime<Utc>,
pub id: String,
#[serde(rename = "updateAuthor")]
pub update_author: Option<User>,
pub updated: DateTime<Utc>,
pub visibility: Option<Visibility>,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}
/// A container for all comments below a Jira issue.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Comments {
pub comments: Vec<Comment>,
#[serde(rename = "maxResults")]
pub max_results: i32,
#[serde(rename = "startAt")]
pub start_at: i32,
pub total: i32,
#[serde(flatten)]
pub extra: Value,
}
/// A link from one Jira issue to another.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct IssueLink {
pub id: String,
#[serde(rename = "outwardIssue")]
pub outward_issue: Option<LinkedIssue>,
#[serde(rename = "inwardIssue")]
pub inward_issue: Option<LinkedIssue>,
#[serde(rename = "type")]
pub link_type: IssueLinkType,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}
/// A Jira issue linked from another one.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct LinkedIssue {
pub id: String,
pub key: String,
pub fields: LinkedIssueFields,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}
/// The reduced fields of a linked Jira issue.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct LinkedIssueFields {
pub issuetype: IssueType,
pub priority: Option<Priority>,
pub status: Status,
pub summary: String,
#[serde(flatten)]
pub extra: Value,
}
/// The direction of a link to a Jira issue.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct IssueLinkType {
pub id: String,
pub inward: String,
pub name: String,
pub outward: String,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}
/// The votes for a Jira issue.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Votes {
#[serde(rename = "hasVoted")]
pub has_voted: bool,
pub votes: i32,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}
/// A Jira avatar in several different sizes:
///
/// * `xsmall` = 16x16 px
/// * `small` = 24x24 px
/// * `medium` = 48x48 px
/// * `full` = maximum
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct AvatarUrls {
#[serde(rename = "16x16")]
pub xsmall: String,
#[serde(rename = "24x24")]
pub small: String,
#[serde(rename = "32x32")]
pub medium: String,
#[serde(rename = "48x48")]
pub full: String,
#[serde(flatten)]
pub extra: Value,
}
/// A minimal, reduced representation of a Jira issue.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct CondensedIssue {
pub fields: CondensedFields,
pub id: String,
pub key: String,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}
/// A minimal, reduced listing of the fields of a Jira issue.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct CondensedFields {
pub issuetype: IssueType,
pub priority: Option<Priority>,
pub status: Status,
pub summary: String,
#[serde(flatten)]
pub extra: Value,
}
/// The visibility of a Jira issue.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Visibility {
pub r#type: String,
pub value: String,
#[serde(flatten)]
pub extra: Value,
}
/// The security level of a Jira issue.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
// TODO: This seems to be a generic container, similar to several other structs.
// In a future major release, try to consolidate them into one generic struct with:
// description, id, name.
// Also see if Serde can convert id to a number after all, somehow.
pub struct Security {
pub description: String,
pub id: String,
pub name: String,
#[serde(rename = "self")]
pub self_link: String,
#[serde(flatten)]
pub extra: Value,
}

41
vendor/jira_query/src/lib.rs vendored Normal file
View file

@ -0,0 +1,41 @@
/*
Copyright 2022 Marek Suchánek <msuchane@redhat.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Enable additional clippy lints by default.
#![warn(
clippy::pedantic,
clippy::unwrap_used,
clippy::expect_used,
clippy::clone_on_ref_ptr,
clippy::todo
)]
// Forbid unsafe code in this program.
#![forbid(unsafe_code)]
mod access;
mod errors;
mod issue_model;
pub use access::{Auth, JiraInstance, Pagination};
pub use errors::JiraQueryError;
pub use issue_model::{
AvatarUrls, Comment, Comments, Component, CondensedFields, CondensedIssue, Fields, Issue,
IssueLink, IssueLinkType, IssueType, LinkedIssue, LinkedIssueFields, Priority, Progress,
Project, ProjectCategory, Resolution, Status, StatusCategory, User, Version, Visibility, Votes,
Watches,
};
// Re-export JSON Value because it's an integral part of the issue model.
pub use serde_json::Value;

165
vendor/jira_query/tests/integration.rs vendored Normal file
View file

@ -0,0 +1,165 @@
use tokio;
use jira_query::*;
/// A common convenience function to get anonymous access
/// to the Red Hat Jira instance.
fn rh_jira() -> JiraInstance {
JiraInstance::at("https://issues.redhat.com".to_string()).unwrap()
}
/// A common convenience function to get anonymous access
/// to the Atlassian Jira instance.
fn atlassian_jira() -> JiraInstance {
JiraInstance::at("https://jira.atlassian.com/".to_string()).unwrap()
}
/// A common convenience function to get anonymous access
/// to the Apache Jira instance.
fn apache_jira() -> JiraInstance {
JiraInstance::at("https://issues.apache.org/jira/".to_string()).unwrap()
}
/// A common convenience function to get anonymous access
/// to the Whamcloud Jira instance.
fn whamcloud_jira() -> JiraInstance {
JiraInstance::at("https://jira.whamcloud.com".to_string()).unwrap()
}
/// Try accessing several public issues separately
/// to test the client and the deserialization.
#[tokio::test]
async fn access_issue() {
let instance = rh_jira();
let _issue1 = instance.issue("CS-1113").await.unwrap();
let _issue2 = instance.issue("CS-1111").await.unwrap();
}
/// Try accessing several public issues at once
/// to test the client and the deserialization.
#[tokio::test]
async fn access_issues() {
let instance = rh_jira();
let issues = instance.issues(&["CS-1086", "CS-1084"]).await.unwrap();
assert_eq!(issues.len(), 2);
}
/// Try accessing several public issues at once
/// to test the client and the deserialization.
#[tokio::test]
async fn access_missing_issue() {
let instance = rh_jira();
let issues = instance.issues(&["CS-11111111111111111111"]).await;
assert!(issues.is_err());
// TODO: This case should actually match JiraQueryError::NoIssues, not JiraQueryError::Rest. Fix it.
assert!(matches!(issues.unwrap_err(), JiraQueryError::Request(_)));
}
/// Check that the issue fields contain the expected values.
/// Work with fields that are standard in Jira, rather than custom extensions.
#[tokio::test]
async fn check_standard_fields() {
let instance = rh_jira();
let issue = instance.issue("CS-1113").await.unwrap();
assert_eq!(issue.id, "14658916");
assert_eq!(issue.key, "CS-1113");
assert_eq!(
issue.fields.summary,
"Set gitlab.com/redhat/centos-stream/tests to public"
);
assert_eq!(issue.fields.assignee.unwrap().display_name, "aoife moloney");
assert_eq!(issue.fields.reporter.display_name, "Donald Zickus");
assert_eq!(issue.fields.issuetype.name, "Task");
assert_eq!(issue.fields.project.key, "CS");
assert_eq!(issue.fields.project.name, "CentOS Stream Pipeline");
assert_eq!(issue.fields.priority.unwrap().name, "Normal");
}
/// Check that the issue was created at the expected date, and that time deserialization
/// works as expected.
#[tokio::test]
async fn check_time() {
let instance = rh_jira();
let issue = instance.issue("CS-1113").await.unwrap();
let date_created = chrono::NaiveDate::from_ymd_opt(2022, 5, 24).unwrap();
assert_eq!(issue.fields.created.date_naive(), date_created);
}
/// Try accessing issues that match a JQL search.
#[tokio::test]
async fn search_for_issues() {
let instance = rh_jira();
let query = r#"project="CentOS Stream Pipeline" AND priority=Blocker"#;
let issues = instance.search(query).await.unwrap();
// There should be at least a couple of blocker tickets for CentOS Stream.
assert!(issues.len() > 1);
}
/// Make sure that no IDs on the input result in no bugs, without errors.
#[tokio::test]
async fn check_no_issues() {
let instance = rh_jira();
let issues = instance.issues(&[]).await;
assert_eq!(issues.ok(), Some(vec![]));
}
/// Try accessing issues that match a JQL search.
/// Check that their number isn't limited by a page size.
#[tokio::test]
async fn search_for_issues_start_at() {
let instance = rh_jira().paginate(Pagination::ChunkSize(30));
let query = r#"project="CentOS Stream Pipeline""#;
let issues = instance.search(query).await.unwrap();
// The query should result in at least 1,000 issues.
assert!(issues.len() > 1000);
}
/// Try accessing several public Atlassian issues
/// to test the client and the deserialization.
#[tokio::test]
async fn access_atlassian_issues() {
let instance = atlassian_jira();
let _issues = instance
.issues(&["ACCESS-1427", "ACCESS-1364", "CLOUD-11546", "CLOUD-11236"])
.await
.unwrap();
}
/// Try accessing Atlassian issues created between two dates,
/// using a JQL search.
#[tokio::test]
async fn search_for_atlassian_issues() {
let instance = atlassian_jira();
// Search for all closed CONFCLOUD issues created between 2022-11-12 and 2023-02-12.
let query = r#"project = CONFCLOUD AND status = Closed AND created >= 2022-11-12 AND created <= 2023-02-12"#;
let issues = instance.search(query).await.unwrap();
// There should be at least 39 such issues.
assert!(issues.len() >= 39);
}
/// Try accessing several public Apache issues
/// to test the client and the deserialization.
#[tokio::test]
async fn access_apache_issues() {
let instance = apache_jira();
let _issues = instance
.issues(&["SVN-748", "SVN-750", "SPARK-41075", "SLING-10585"])
.await
.unwrap();
}
#[tokio::test]
async fn access_whamcloud_issues() {
let instance = whamcloud_jira();
let _issues = instance
.issues(&["LU-10647", "LU-13009", "LU-8002", "LU-8874"])
.await
.unwrap();
}