feat(iso): Create generate-iso command (#192)
## Tasks - [x] Add ctrl-c handler to kill spawned children - [x] add more args to support all variables - [x] Add integration test
This commit is contained in:
parent
4634f40840
commit
e6cce3d542
25 changed files with 737 additions and 201 deletions
|
|
@ -373,7 +373,7 @@ impl RunDriver for DockerDriver {
|
|||
}
|
||||
|
||||
fn docker_run(opts: &RunOpts, cid_file: &Path) -> Command {
|
||||
cmd!(
|
||||
let command = cmd!(
|
||||
"docker",
|
||||
"run",
|
||||
format!("--cidfile={}", cid_file.display()),
|
||||
|
|
@ -397,5 +397,8 @@ fn docker_run(opts: &RunOpts, cid_file: &Path) -> Command {
|
|||
},
|
||||
&*opts.image,
|
||||
for opts.args,
|
||||
)
|
||||
);
|
||||
trace!("{command:?}");
|
||||
|
||||
command
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use blue_build_utils::get_env_var;
|
|||
#[cfg(test)]
|
||||
use blue_build_utils::test_utils::get_env_var;
|
||||
|
||||
use super::{CiDriver, Driver};
|
||||
use super::{opts::GenerateTagsOpts, CiDriver, Driver};
|
||||
|
||||
mod event;
|
||||
|
||||
|
|
@ -34,10 +34,11 @@ impl CiDriver for GithubDriver {
|
|||
Ok(GITHUB_TOKEN_ISSUER_URL.to_string())
|
||||
}
|
||||
|
||||
fn generate_tags(recipe: &blue_build_recipe::Recipe) -> miette::Result<Vec<String>> {
|
||||
fn generate_tags(opts: &GenerateTagsOpts) -> miette::Result<Vec<String>> {
|
||||
const PR_EVENT: &str = "pull_request";
|
||||
let timestamp = blue_build_utils::get_tag_timestamp();
|
||||
let os_version = Driver::get_os_version(recipe).inspect(|v| trace!("os_version={v}"))?;
|
||||
let os_version =
|
||||
Driver::get_os_version(opts.oci_ref).inspect(|v| trace!("os_version={v}"))?;
|
||||
let ref_name = get_env_var(GITHUB_REF_NAME).inspect(|v| trace!("{GITHUB_REF_NAME}={v}"))?;
|
||||
let short_sha = {
|
||||
let mut short_sha = get_env_var(GITHUB_SHA).inspect(|v| trace!("{GITHUB_SHA}={v}"))?;
|
||||
|
|
@ -47,7 +48,7 @@ impl CiDriver for GithubDriver {
|
|||
|
||||
let tags = match (
|
||||
Self::on_default_branch(),
|
||||
recipe.alt_tags.as_ref(),
|
||||
opts.alt_tags.as_ref(),
|
||||
get_env_var(GITHUB_EVENT_NAME).inspect(|v| trace!("{GITHUB_EVENT_NAME}={v}")),
|
||||
get_env_var(PR_EVENT_NUMBER).inspect(|v| trace!("{PR_EVENT_NUMBER}={v}")),
|
||||
) {
|
||||
|
|
@ -128,21 +129,21 @@ impl CiDriver for GithubDriver {
|
|||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use blue_build_recipe::Recipe;
|
||||
use std::borrow::Cow;
|
||||
|
||||
use blue_build_utils::{
|
||||
constants::{
|
||||
GITHUB_EVENT_NAME, GITHUB_EVENT_PATH, GITHUB_REF_NAME, GITHUB_SHA, PR_EVENT_NUMBER,
|
||||
},
|
||||
string_vec,
|
||||
cowstr_vec, string_vec,
|
||||
test_utils::set_env_var,
|
||||
};
|
||||
use oci_distribution::Reference;
|
||||
use rstest::rstest;
|
||||
|
||||
use crate::{
|
||||
drivers::CiDriver,
|
||||
test::{
|
||||
create_test_recipe, create_test_recipe_alt_tags, TEST_TAG_1, TEST_TAG_2, TIMESTAMP,
|
||||
},
|
||||
drivers::{opts::GenerateTagsOpts, CiDriver},
|
||||
test::{TEST_TAG_1, TEST_TAG_2, TIMESTAMP},
|
||||
};
|
||||
|
||||
use super::GithubDriver;
|
||||
|
|
@ -216,7 +217,7 @@ mod test {
|
|||
#[rstest]
|
||||
#[case::default_branch(
|
||||
setup_default_branch,
|
||||
create_test_recipe,
|
||||
None,
|
||||
string_vec![
|
||||
format!("{}-40", &*TIMESTAMP),
|
||||
"latest",
|
||||
|
|
@ -227,7 +228,7 @@ mod test {
|
|||
)]
|
||||
#[case::default_branch_alt_tags(
|
||||
setup_default_branch,
|
||||
create_test_recipe_alt_tags,
|
||||
Some(cowstr_vec![TEST_TAG_1, TEST_TAG_2]),
|
||||
string_vec![
|
||||
TEST_TAG_1,
|
||||
format!("{TEST_TAG_1}-40"),
|
||||
|
|
@ -241,12 +242,12 @@ mod test {
|
|||
)]
|
||||
#[case::pr_branch(
|
||||
setup_pr_branch,
|
||||
create_test_recipe,
|
||||
None,
|
||||
string_vec!["pr-12-40", format!("{COMMIT_SHA}-40")],
|
||||
)]
|
||||
#[case::pr_branch_alt_tags(
|
||||
setup_pr_branch,
|
||||
create_test_recipe_alt_tags,
|
||||
Some(cowstr_vec![TEST_TAG_1, TEST_TAG_2]),
|
||||
string_vec![
|
||||
format!("pr-12-{TEST_TAG_1}-40"),
|
||||
format!("{COMMIT_SHA}-{TEST_TAG_1}-40"),
|
||||
|
|
@ -256,12 +257,12 @@ mod test {
|
|||
)]
|
||||
#[case::branch(
|
||||
setup_branch,
|
||||
create_test_recipe,
|
||||
None,
|
||||
string_vec![format!("{COMMIT_SHA}-40"), "br-test-40"],
|
||||
)]
|
||||
#[case::branch_alt_tags(
|
||||
setup_branch,
|
||||
create_test_recipe_alt_tags,
|
||||
Some(cowstr_vec![TEST_TAG_1, TEST_TAG_2]),
|
||||
string_vec![
|
||||
format!("br-{BR_REF_NAME}-{TEST_TAG_1}-40"),
|
||||
format!("{COMMIT_SHA}-{TEST_TAG_1}-40"),
|
||||
|
|
@ -271,14 +272,20 @@ mod test {
|
|||
)]
|
||||
fn generate_tags(
|
||||
#[case] setup: impl FnOnce(),
|
||||
#[case] recipe_fn: impl Fn() -> Recipe<'static>,
|
||||
#[case] alt_tags: Option<Vec<Cow<'_, str>>>,
|
||||
#[case] mut expected: Vec<String>,
|
||||
) {
|
||||
setup();
|
||||
expected.sort();
|
||||
let recipe = recipe_fn();
|
||||
let oci_ref: Reference = "ghcr.io/ublue-os/silverblue-main".parse().unwrap();
|
||||
|
||||
let mut tags = GithubDriver::generate_tags(&recipe).unwrap();
|
||||
let mut tags = GithubDriver::generate_tags(
|
||||
&GenerateTagsOpts::builder()
|
||||
.oci_ref(&oci_ref)
|
||||
.alt_tags(alt_tags)
|
||||
.build(),
|
||||
)
|
||||
.unwrap();
|
||||
tags.sort();
|
||||
|
||||
assert_eq!(tags, expected);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ use blue_build_utils::test_utils::get_env_var;
|
|||
|
||||
use crate::drivers::Driver;
|
||||
|
||||
use super::CiDriver;
|
||||
use super::{opts::GenerateTagsOpts, CiDriver};
|
||||
|
||||
pub struct GitlabDriver;
|
||||
|
||||
|
|
@ -43,9 +43,9 @@ impl CiDriver for GitlabDriver {
|
|||
))
|
||||
}
|
||||
|
||||
fn generate_tags(recipe: &blue_build_recipe::Recipe) -> miette::Result<Vec<String>> {
|
||||
fn generate_tags(opts: &GenerateTagsOpts) -> miette::Result<Vec<String>> {
|
||||
const MR_EVENT: &str = "merge_request_event";
|
||||
let os_version = Driver::get_os_version(recipe)?;
|
||||
let os_version = Driver::get_os_version(opts.oci_ref)?;
|
||||
let timestamp = blue_build_utils::get_tag_timestamp();
|
||||
let short_sha =
|
||||
get_env_var(CI_COMMIT_SHORT_SHA).inspect(|v| trace!("{CI_COMMIT_SHORT_SHA}={v}"))?;
|
||||
|
|
@ -54,7 +54,7 @@ impl CiDriver for GitlabDriver {
|
|||
|
||||
let tags = match (
|
||||
Self::on_default_branch(),
|
||||
recipe.alt_tags.as_ref(),
|
||||
opts.alt_tags.as_ref(),
|
||||
get_env_var(CI_MERGE_REQUEST_IID).inspect(|v| trace!("{CI_MERGE_REQUEST_IID}={v}")),
|
||||
get_env_var(CI_PIPELINE_SOURCE).inspect(|v| trace!("{CI_PIPELINE_SOURCE}={v}")),
|
||||
) {
|
||||
|
|
@ -141,23 +141,23 @@ impl CiDriver for GitlabDriver {
|
|||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use blue_build_recipe::Recipe;
|
||||
use std::borrow::Cow;
|
||||
|
||||
use blue_build_utils::{
|
||||
constants::{
|
||||
CI_COMMIT_REF_NAME, CI_COMMIT_SHORT_SHA, CI_DEFAULT_BRANCH, CI_MERGE_REQUEST_IID,
|
||||
CI_PIPELINE_SOURCE, CI_PROJECT_NAME, CI_PROJECT_NAMESPACE, CI_REGISTRY, CI_SERVER_HOST,
|
||||
CI_SERVER_PROTOCOL,
|
||||
},
|
||||
string_vec,
|
||||
cowstr_vec, string_vec,
|
||||
test_utils::set_env_var,
|
||||
};
|
||||
use oci_distribution::Reference;
|
||||
use rstest::rstest;
|
||||
|
||||
use crate::{
|
||||
drivers::CiDriver,
|
||||
test::{
|
||||
create_test_recipe, create_test_recipe_alt_tags, TEST_TAG_1, TEST_TAG_2, TIMESTAMP,
|
||||
},
|
||||
drivers::{opts::GenerateTagsOpts, CiDriver},
|
||||
test::{TEST_TAG_1, TEST_TAG_2, TIMESTAMP},
|
||||
};
|
||||
|
||||
use super::GitlabDriver;
|
||||
|
|
@ -227,7 +227,7 @@ mod test {
|
|||
#[rstest]
|
||||
#[case::default_branch(
|
||||
setup_default_branch,
|
||||
create_test_recipe,
|
||||
None,
|
||||
string_vec![
|
||||
format!("{}-40", &*TIMESTAMP),
|
||||
"latest",
|
||||
|
|
@ -238,7 +238,7 @@ mod test {
|
|||
)]
|
||||
#[case::default_branch_alt_tags(
|
||||
setup_default_branch,
|
||||
create_test_recipe_alt_tags,
|
||||
Some(cowstr_vec![TEST_TAG_1, TEST_TAG_2]),
|
||||
string_vec![
|
||||
TEST_TAG_1,
|
||||
format!("{TEST_TAG_1}-40"),
|
||||
|
|
@ -252,12 +252,12 @@ mod test {
|
|||
)]
|
||||
#[case::pr_branch(
|
||||
setup_mr_branch,
|
||||
create_test_recipe,
|
||||
None,
|
||||
string_vec!["mr-12-40", format!("{COMMIT_SHA}-40")],
|
||||
)]
|
||||
#[case::pr_branch_alt_tags(
|
||||
setup_mr_branch,
|
||||
create_test_recipe_alt_tags,
|
||||
Some(cowstr_vec![TEST_TAG_1, TEST_TAG_2]),
|
||||
string_vec![
|
||||
format!("mr-12-{TEST_TAG_1}-40"),
|
||||
format!("{COMMIT_SHA}-{TEST_TAG_1}-40"),
|
||||
|
|
@ -267,12 +267,12 @@ mod test {
|
|||
)]
|
||||
#[case::branch(
|
||||
setup_branch,
|
||||
create_test_recipe,
|
||||
None,
|
||||
string_vec![format!("{COMMIT_SHA}-40"), "br-test-40"],
|
||||
)]
|
||||
#[case::branch_alt_tags(
|
||||
setup_branch,
|
||||
create_test_recipe_alt_tags,
|
||||
Some(cowstr_vec![TEST_TAG_1, TEST_TAG_2]),
|
||||
string_vec![
|
||||
format!("br-{BR_REF_NAME}-{TEST_TAG_1}-40"),
|
||||
format!("{COMMIT_SHA}-{TEST_TAG_1}-40"),
|
||||
|
|
@ -282,14 +282,20 @@ mod test {
|
|||
)]
|
||||
fn generate_tags(
|
||||
#[case] setup: impl FnOnce(),
|
||||
#[case] recipe_fn: impl Fn() -> Recipe<'static>,
|
||||
#[case] alt_tags: Option<Vec<Cow<'_, str>>>,
|
||||
#[case] mut expected: Vec<String>,
|
||||
) {
|
||||
setup();
|
||||
expected.sort();
|
||||
let recipe = recipe_fn();
|
||||
let oci_ref: Reference = "ghcr.io/ublue-os/silverblue-main".parse().unwrap();
|
||||
|
||||
let mut tags = GitlabDriver::generate_tags(&recipe).unwrap();
|
||||
let mut tags = GitlabDriver::generate_tags(
|
||||
&GenerateTagsOpts::builder()
|
||||
.oci_ref(&oci_ref)
|
||||
.alt_tags(alt_tags)
|
||||
.build(),
|
||||
)
|
||||
.unwrap();
|
||||
tags.sort();
|
||||
|
||||
assert_eq!(tags, expected);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
use blue_build_utils::string_vec;
|
||||
use log::trace;
|
||||
use miette::bail;
|
||||
use miette::{bail, Context, IntoDiagnostic};
|
||||
use oci_distribution::Reference;
|
||||
|
||||
use super::{CiDriver, Driver};
|
||||
use super::{opts::GenerateTagsOpts, CiDriver, Driver};
|
||||
|
||||
pub struct LocalDriver;
|
||||
|
||||
|
|
@ -21,14 +23,31 @@ impl CiDriver for LocalDriver {
|
|||
bail!("Keyless not supported");
|
||||
}
|
||||
|
||||
fn generate_tags(recipe: &blue_build_recipe::Recipe) -> miette::Result<Vec<String>> {
|
||||
trace!("LocalDriver::generate_tags({recipe:?})");
|
||||
Ok(vec![format!("local-{}", Driver::get_os_version(recipe)?)])
|
||||
fn generate_tags(opts: &GenerateTagsOpts) -> miette::Result<Vec<String>> {
|
||||
trace!("LocalDriver::generate_tags({opts:?})");
|
||||
let os_version = Driver::get_os_version(opts.oci_ref)?;
|
||||
Ok(opts.alt_tags.as_ref().map_or_else(
|
||||
|| string_vec![format!("local-{os_version}")],
|
||||
|alt_tags| {
|
||||
alt_tags
|
||||
.iter()
|
||||
.flat_map(|alt| string_vec![format!("local-{alt}-{os_version}")])
|
||||
.collect()
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn generate_image_name(recipe: &blue_build_recipe::Recipe) -> miette::Result<String> {
|
||||
trace!("LocalDriver::generate_image_name({recipe:?})");
|
||||
Ok(recipe.name.trim().to_lowercase())
|
||||
fn generate_image_name<S>(name: S) -> miette::Result<Reference>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
fn inner(name: &str) -> miette::Result<Reference> {
|
||||
trace!("LocalDriver::generate_image_name({name})");
|
||||
name.parse()
|
||||
.into_diagnostic()
|
||||
.with_context(|| format!("Unable to parse {name}"))
|
||||
}
|
||||
inner(&name.as_ref().trim().to_lowercase())
|
||||
}
|
||||
|
||||
fn get_repo_url() -> miette::Result<String> {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
use clap::ValueEnum;
|
||||
|
||||
pub use build::*;
|
||||
pub use ci::*;
|
||||
pub use inspect::*;
|
||||
pub use run::*;
|
||||
pub use signing::*;
|
||||
|
||||
mod build;
|
||||
mod ci;
|
||||
mod inspect;
|
||||
mod run;
|
||||
mod signing;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ pub struct PushOpts<'a> {
|
|||
pub struct BuildTagPushOpts<'a> {
|
||||
/// The base image name.
|
||||
///
|
||||
/// NOTE: This SHOULD NOT contain the tag of the image.
|
||||
///
|
||||
/// NOTE: You cannot have this set with `archive_path` set.
|
||||
#[builder(default, setter(into, strip_option))]
|
||||
pub image: Option<Cow<'a, str>>,
|
||||
|
|
|
|||
12
process/drivers/opts/ci.rs
Normal file
12
process/drivers/opts/ci.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use oci_distribution::Reference;
|
||||
use typed_builder::TypedBuilder;
|
||||
|
||||
#[derive(Debug, Clone, TypedBuilder)]
|
||||
pub struct GenerateTagsOpts<'scope> {
|
||||
pub oci_ref: &'scope Reference,
|
||||
|
||||
#[builder(default, setter(into))]
|
||||
pub alt_tags: Option<Vec<Cow<'scope, str>>>,
|
||||
}
|
||||
|
|
@ -11,10 +11,10 @@ pub struct RunOpts<'scope> {
|
|||
pub args: Cow<'scope, [String]>,
|
||||
|
||||
#[builder(default, setter(into))]
|
||||
pub env_vars: Cow<'scope, [RunOptsEnv<'scope>]>,
|
||||
pub env_vars: Vec<RunOptsEnv<'scope>>,
|
||||
|
||||
#[builder(default, setter(into))]
|
||||
pub volumes: Cow<'scope, [RunOptsVolume<'scope>]>,
|
||||
pub volumes: Vec<RunOptsVolume<'scope>>,
|
||||
|
||||
#[builder(default, setter(strip_option))]
|
||||
pub uid: Option<u32>,
|
||||
|
|
@ -48,7 +48,7 @@ pub struct RunOptsVolume<'scope> {
|
|||
macro_rules! run_volumes {
|
||||
($($host:expr => $container:expr),+ $(,)?) => {
|
||||
{
|
||||
[
|
||||
vec![
|
||||
$($crate::drivers::opts::RunOptsVolume::builder()
|
||||
.path_or_vol_name($host)
|
||||
.container_path($container)
|
||||
|
|
@ -71,7 +71,7 @@ pub struct RunOptsEnv<'scope> {
|
|||
macro_rules! run_envs {
|
||||
($($key:expr => $value:expr),+ $(,)?) => {
|
||||
{
|
||||
[
|
||||
vec![
|
||||
$($crate::drivers::opts::RunOptsEnv::builder()
|
||||
.key($key)
|
||||
.value($value)
|
||||
|
|
|
|||
|
|
@ -227,8 +227,12 @@ impl RunDriver for PodmanDriver {
|
|||
|
||||
add_cid(&cid);
|
||||
|
||||
let status = podman_run(opts, &cid_file)
|
||||
.status_image_ref_progress(&*opts.image, "Running container")?;
|
||||
let status = if opts.privileged {
|
||||
podman_run(opts, &cid_file).status()?
|
||||
} else {
|
||||
podman_run(opts, &cid_file)
|
||||
.status_image_ref_progress(&*opts.image, "Running container")?
|
||||
};
|
||||
|
||||
remove_cid(&cid);
|
||||
|
||||
|
|
@ -254,7 +258,7 @@ impl RunDriver for PodmanDriver {
|
|||
}
|
||||
|
||||
fn podman_run(opts: &RunOpts, cid_file: &Path) -> Command {
|
||||
cmd!(
|
||||
let command = cmd!(
|
||||
if opts.privileged {
|
||||
warn!(
|
||||
"Running 'podman' in privileged mode requires '{}'",
|
||||
|
|
@ -267,7 +271,10 @@ fn podman_run(opts: &RunOpts, cid_file: &Path) -> Command {
|
|||
if opts.privileged => "podman",
|
||||
"run",
|
||||
format!("--cidfile={}", cid_file.display()),
|
||||
if opts.privileged => "--privileged",
|
||||
if opts.privileged => [
|
||||
"--privileged",
|
||||
"--network=host",
|
||||
],
|
||||
if opts.remove => "--rm",
|
||||
if opts.pull => "--pull=always",
|
||||
for volume in opts.volumes => [
|
||||
|
|
@ -280,5 +287,8 @@ fn podman_run(opts: &RunOpts, cid_file: &Path) -> Command {
|
|||
],
|
||||
&*opts.image,
|
||||
for opts.args,
|
||||
)
|
||||
);
|
||||
trace!("{command:?}");
|
||||
|
||||
command
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ use std::{
|
|||
process::{ExitStatus, Output},
|
||||
};
|
||||
|
||||
use blue_build_recipe::Recipe;
|
||||
use blue_build_utils::{constants::COSIGN_PUB_PATH, retry};
|
||||
use log::{debug, info, trace};
|
||||
use miette::{bail, miette, Result};
|
||||
use miette::{bail, miette, Context, IntoDiagnostic, Result};
|
||||
use oci_distribution::Reference;
|
||||
use semver::{Version, VersionReq};
|
||||
|
||||
use crate::drivers::{functions::get_private_key, types::CiDriverType, Driver};
|
||||
|
|
@ -14,8 +14,9 @@ use crate::drivers::{functions::get_private_key, types::CiDriverType, Driver};
|
|||
use super::{
|
||||
image_metadata::ImageMetadata,
|
||||
opts::{
|
||||
BuildOpts, BuildTagPushOpts, CheckKeyPairOpts, GenerateKeyPairOpts, GetMetadataOpts,
|
||||
PushOpts, RunOpts, SignOpts, SignVerifyOpts, TagOpts, VerifyOpts, VerifyType,
|
||||
BuildOpts, BuildTagPushOpts, CheckKeyPairOpts, GenerateKeyPairOpts, GenerateTagsOpts,
|
||||
GetMetadataOpts, PushOpts, RunOpts, SignOpts, SignVerifyOpts, TagOpts, VerifyOpts,
|
||||
VerifyType,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -307,18 +308,27 @@ pub trait CiDriver {
|
|||
///
|
||||
/// # Errors
|
||||
/// Will error if the environment variables aren't set.
|
||||
fn generate_tags(recipe: &Recipe) -> Result<Vec<String>>;
|
||||
fn generate_tags(oci_ref: &GenerateTagsOpts) -> Result<Vec<String>>;
|
||||
|
||||
/// Generates the image name based on CI.
|
||||
///
|
||||
/// # Errors
|
||||
/// Will error if the environment variables aren't set.
|
||||
fn generate_image_name(recipe: &Recipe) -> Result<String> {
|
||||
Ok(format!(
|
||||
"{}/{}",
|
||||
Self::get_registry()?,
|
||||
recipe.name.trim().to_lowercase()
|
||||
))
|
||||
fn generate_image_name<S>(name: S) -> Result<Reference>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
fn inner(name: &str, registry: &str) -> Result<Reference> {
|
||||
let image = format!("{registry}/{name}");
|
||||
image
|
||||
.parse()
|
||||
.into_diagnostic()
|
||||
.with_context(|| format!("Unable to parse image {image}"))
|
||||
}
|
||||
inner(
|
||||
&name.as_ref().trim().to_lowercase(),
|
||||
&Self::get_registry()?.to_lowercase(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Get the URL for the repository.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue