refuse a dependency add that would close a cycle
This commit is contained in:
parent
10508895c4
commit
347b8119b2
1 changed files with 253 additions and 1 deletions
|
|
@ -9,7 +9,7 @@
|
|||
//! action, which is a same-repo-only relationship (there is no
|
||||
//! cross-repo dependency support in this CLI, mirroring the UI).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use clap::{Args as ClapArgs, Subcommand};
|
||||
|
|
@ -97,6 +97,11 @@ fn apply_and_verify(
|
|||
deps: &[u64],
|
||||
adding: bool,
|
||||
) -> Result<()> {
|
||||
if adding {
|
||||
for dep in deps {
|
||||
refuse_cycle(client, owner, name, number, *dep)?;
|
||||
}
|
||||
}
|
||||
let mut call_errors = Vec::new();
|
||||
for dep in deps {
|
||||
let meta = dep_meta(owner, name, *dep)?;
|
||||
|
|
@ -185,6 +190,72 @@ fn apply_and_verify(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse an add of `dep` as a blocker of `number` when `dep` already
|
||||
/// depends on `number`, directly or through other issues.
|
||||
///
|
||||
/// Forgejo answers a direct reverse edge with a bare `500` (its handler
|
||||
/// maps every model error, `ErrCircularDependency` included, to
|
||||
/// `Internal Server Error`) and accepts longer cycles without complaint,
|
||||
/// so the check walks `dependency list` client-side.
|
||||
fn refuse_cycle(client: &Client, owner: &str, name: &str, number: u64, dep: u64) -> Result<()> {
|
||||
let path = find_dependency_path(dep, number, |n| {
|
||||
let deps = dependency_summaries(client, owner, name, n)?;
|
||||
Ok(deps
|
||||
.iter()
|
||||
.filter_map(|d| d.get("number").and_then(Value::as_u64))
|
||||
.collect())
|
||||
})?;
|
||||
let Some(path) = path else { return Ok(()) };
|
||||
let how = match path.as_slice() {
|
||||
[_, _] => "directly".to_owned(),
|
||||
[_, via @ .., _] => format!(
|
||||
"via {}",
|
||||
via.iter()
|
||||
.map(|n| format!("#{n}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" -> ")
|
||||
),
|
||||
_ => String::new(),
|
||||
};
|
||||
bail!(
|
||||
"hive-forge dependency add: #{dep} already depends on #{number} ({how}), so adding \
|
||||
#{dep} as a dependency of #{number} would create a cycle; remove that edge first"
|
||||
);
|
||||
}
|
||||
|
||||
/// Breadth-first walk of the "depends on" relation from `from`, using
|
||||
/// `deps_of` to list an issue's dependencies. Returns the shortest chain
|
||||
/// `from, …, to` when `to` is reachable, else `None`.
|
||||
fn find_dependency_path(
|
||||
from: u64,
|
||||
to: u64,
|
||||
mut deps_of: impl FnMut(u64) -> Result<Vec<u64>>,
|
||||
) -> Result<Option<Vec<u64>>> {
|
||||
let mut parent: HashMap<u64, u64> = HashMap::new();
|
||||
let mut seen = HashSet::from([from]);
|
||||
let mut queue = VecDeque::from([from]);
|
||||
while let Some(cur) = queue.pop_front() {
|
||||
for next in deps_of(cur)? {
|
||||
if !seen.insert(next) {
|
||||
continue;
|
||||
}
|
||||
parent.insert(next, cur);
|
||||
if next == to {
|
||||
let mut path = vec![to];
|
||||
let mut at = to;
|
||||
while let Some(&p) = parent.get(&at) {
|
||||
path.push(p);
|
||||
at = p;
|
||||
}
|
||||
path.reverse();
|
||||
return Ok(Some(path));
|
||||
}
|
||||
queue.push_back(next);
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Build the `IssueMeta` body the create/remove endpoints want.
|
||||
///
|
||||
/// `owner`/`repo` are filled in with the *same* repo the request URL
|
||||
|
|
@ -221,6 +292,36 @@ mod tests {
|
|||
assert_eq!(m.index, Some(3950));
|
||||
}
|
||||
|
||||
fn graph(edges: &[(u64, u64)]) -> impl FnMut(u64) -> Result<Vec<u64>> + '_ {
|
||||
move |n| {
|
||||
Ok(edges
|
||||
.iter()
|
||||
.filter(|(a, _)| *a == n)
|
||||
.map(|(_, b)| *b)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_finds_direct_transitive_and_absent() {
|
||||
let edges = [(1, 2), (2, 3), (4, 5)];
|
||||
assert_eq!(
|
||||
find_dependency_path(1, 2, graph(&edges)).unwrap(),
|
||||
Some(vec![1, 2])
|
||||
);
|
||||
assert_eq!(
|
||||
find_dependency_path(1, 3, graph(&edges)).unwrap(),
|
||||
Some(vec![1, 2, 3])
|
||||
);
|
||||
assert_eq!(find_dependency_path(1, 5, graph(&edges)).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_walk_terminates_on_an_existing_cycle() {
|
||||
let edges = [(1, 2), (2, 1)];
|
||||
assert_eq!(find_dependency_path(1, 9, graph(&edges)).unwrap(), None);
|
||||
}
|
||||
|
||||
/// Out-of-range must be an error, not a wrapped negative index that
|
||||
/// the forge would then look up as some other issue.
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue