huskies: merge 866
This commit is contained in:
@@ -230,6 +230,45 @@ pub fn reject_story_from_qa(story_id: &str, notes: &str) -> Result<(), String> {
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Transition a story to the `Blocked` stage via the state machine.
|
||||||
|
///
|
||||||
|
/// Builds a `PipelineEvent::Block { reason }`, validates the transition, and
|
||||||
|
/// writes the resulting `Stage::Blocked` to the CRDT. Returns `Err` on
|
||||||
|
/// `TransitionError` — callers must NOT fall back to direct register writes.
|
||||||
|
pub fn transition_to_blocked(story_id: &str, reason: &str) -> Result<(), String> {
|
||||||
|
let transform = fields_to_clear_transform(&["blocked"]);
|
||||||
|
apply_transition(
|
||||||
|
story_id,
|
||||||
|
PipelineEvent::Block {
|
||||||
|
reason: reason.to_string(),
|
||||||
|
},
|
||||||
|
transform.as_ref().map(|f| f.as_ref()),
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transition a story out of `Blocked` back to `Coding` via the state machine.
|
||||||
|
///
|
||||||
|
/// Builds a `PipelineEvent::Unblock`, validates the transition, writes the
|
||||||
|
/// resulting `Stage::Coding` to the CRDT, and resets `retry_count` to 0.
|
||||||
|
/// Returns `Err` on `TransitionError` — callers must NOT fall back to direct
|
||||||
|
/// register writes.
|
||||||
|
pub fn transition_to_unblocked(story_id: &str) -> Result<(), String> {
|
||||||
|
let transform = fields_to_clear_transform(&["blocked", "merge_failure", "retry_count"]);
|
||||||
|
apply_transition(
|
||||||
|
story_id,
|
||||||
|
PipelineEvent::Unblock,
|
||||||
|
transform.as_ref().map(|f| f.as_ref()),
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Reset the retry counter in the CRDT so the story gets a fresh budget.
|
||||||
|
crate::crdt_state::set_retry_count(story_id, 0);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Map a (current stage, target stage name) pair to the appropriate PipelineEvent.
|
/// Map a (current stage, target stage name) pair to the appropriate PipelineEvent.
|
||||||
fn map_stage_move_to_event(
|
fn map_stage_move_to_event(
|
||||||
from: &Stage,
|
from: &Stage,
|
||||||
@@ -261,6 +300,7 @@ fn map_stage_move_to_event(
|
|||||||
merge_commit: GitSha("manual".to_string()),
|
merge_commit: GitSha("manual".to_string()),
|
||||||
}),
|
}),
|
||||||
(Stage::Coding | Stage::Qa | Stage::Backlog, "done") => Ok(PipelineEvent::Close),
|
(Stage::Coding | Stage::Qa | Stage::Backlog, "done") => Ok(PipelineEvent::Close),
|
||||||
|
(Stage::Blocked { .. }, "current") => Ok(PipelineEvent::Unblock),
|
||||||
(
|
(
|
||||||
Stage::Archived {
|
Stage::Archived {
|
||||||
reason: ArchiveReason::Blocked { .. },
|
reason: ArchiveReason::Blocked { .. },
|
||||||
@@ -345,6 +385,7 @@ fn stage_to_name(s: &Stage) -> &'static str {
|
|||||||
Stage::Upcoming => "upcoming",
|
Stage::Upcoming => "upcoming",
|
||||||
Stage::Backlog => "backlog",
|
Stage::Backlog => "backlog",
|
||||||
Stage::Coding => "current",
|
Stage::Coding => "current",
|
||||||
|
Stage::Blocked { .. } => "blocked",
|
||||||
Stage::Qa => "qa",
|
Stage::Qa => "qa",
|
||||||
Stage::Merge { .. } => "merge",
|
Stage::Merge { .. } => "merge",
|
||||||
Stage::Done { .. } => "done",
|
Stage::Done { .. } => "done",
|
||||||
@@ -452,6 +493,68 @@ mod tests {
|
|||||||
assert_eq!(item_type_from_id("99999"), "story");
|
assert_eq!(item_type_from_id("99999"), "story");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Story 866: block/unblock round-trip regression test ──────────────────
|
||||||
|
|
||||||
|
/// Regression test (story 866): block a story via the new state-machine path,
|
||||||
|
/// verify it lands in `Stage::Blocked`, then unblock and verify it returns
|
||||||
|
/// to `Stage::Coding`.
|
||||||
|
#[test]
|
||||||
|
fn block_unblock_round_trip_via_state_machine() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
|
crate::db::write_item_with_content(
|
||||||
|
"99866_story_block_test",
|
||||||
|
"2_current",
|
||||||
|
"---\nname: Block Round Trip\n---\n# Story\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify starting state is Coding.
|
||||||
|
let item = crate::pipeline_state::read_typed("99866_story_block_test")
|
||||||
|
.expect("read should succeed")
|
||||||
|
.expect("item should exist");
|
||||||
|
assert_eq!(
|
||||||
|
item.stage.dir_name(),
|
||||||
|
"2_current",
|
||||||
|
"should start in 2_current"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Block via the state machine.
|
||||||
|
transition_to_blocked("99866_story_block_test", "retry limit exceeded")
|
||||||
|
.expect("transition_to_blocked should succeed");
|
||||||
|
|
||||||
|
// Verify the CRDT now shows Stage::Blocked.
|
||||||
|
let item = crate::pipeline_state::read_typed("99866_story_block_test")
|
||||||
|
.expect("read should succeed")
|
||||||
|
.expect("item should exist after block");
|
||||||
|
assert_eq!(
|
||||||
|
item.stage.dir_name(),
|
||||||
|
"2_blocked",
|
||||||
|
"should be in 2_blocked after transition_to_blocked"
|
||||||
|
);
|
||||||
|
assert!(item.stage.is_blocked(), "is_blocked() should return true");
|
||||||
|
assert!(
|
||||||
|
matches!(item.stage, Stage::Blocked { .. }),
|
||||||
|
"stage should be Stage::Blocked variant"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unblock via the state machine.
|
||||||
|
transition_to_unblocked("99866_story_block_test")
|
||||||
|
.expect("transition_to_unblocked should succeed");
|
||||||
|
|
||||||
|
// Verify the story returned to Coding.
|
||||||
|
let item = crate::pipeline_state::read_typed("99866_story_block_test")
|
||||||
|
.expect("read should succeed")
|
||||||
|
.expect("item should exist after unblock");
|
||||||
|
assert_eq!(
|
||||||
|
item.stage.dir_name(),
|
||||||
|
"2_current",
|
||||||
|
"should return to 2_current after unblock"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(item.stage, Stage::Coding),
|
||||||
|
"stage should be Stage::Coding after unblock"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── feature_branch_has_unmerged_changes tests ────────────────────────────
|
// ── feature_branch_has_unmerged_changes tests ────────────────────────────
|
||||||
|
|
||||||
fn init_git_repo(repo: &std::path::Path) {
|
fn init_git_repo(repo: &std::path::Path) {
|
||||||
|
|||||||
@@ -119,34 +119,21 @@ impl AgentPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AC6: Detect empty-diff stories before starting the merge pipeline.
|
// AC6: Detect empty-diff stories before starting the merge pipeline.
|
||||||
// If the worktree has no commits on the feature branch, write a
|
// If the worktree has no commits on the feature branch, block the
|
||||||
// merge_failure and block the story immediately — no merge job needed.
|
// story immediately via the state machine — no merge job needed.
|
||||||
if let Some(wt_path) = worktree::find_worktree_path(project_root, story_id)
|
if let Some(wt_path) = worktree::find_worktree_path(project_root, story_id)
|
||||||
&& !crate::agents::gates::worktree_has_committed_work(&wt_path)
|
&& !crate::agents::gates::worktree_has_committed_work(&wt_path)
|
||||||
{
|
{
|
||||||
slog_warn!(
|
|
||||||
"[auto-assign] Story '{story_id}' in 4_merge/ has no commits \
|
|
||||||
on feature branch. Writing merge_failure and blocking."
|
|
||||||
);
|
|
||||||
let empty_diff_reason = "Feature branch has no code changes — the coder agent \
|
let empty_diff_reason = "Feature branch has no code changes — the coder agent \
|
||||||
did not produce any commits.";
|
did not produce any commits.";
|
||||||
if let Some(contents) = crate::db::read_content(story_id) {
|
slog_warn!(
|
||||||
let updated = crate::io::story_metadata::write_merge_failure_in_content(
|
"[auto-assign] Story '{story_id}' in 4_merge/ has no commits \
|
||||||
&contents,
|
on feature branch. Blocking via state machine."
|
||||||
empty_diff_reason,
|
);
|
||||||
);
|
if let Err(e) =
|
||||||
let blocked = crate::io::story_metadata::write_blocked_in_content(&updated);
|
crate::agents::lifecycle::transition_to_blocked(story_id, empty_diff_reason)
|
||||||
crate::db::write_content(story_id, &blocked);
|
{
|
||||||
crate::db::write_item_with_content(story_id, "4_merge", &blocked);
|
slog_error!("[auto-assign] Failed to transition '{story_id}' to Blocked: {e}");
|
||||||
} else {
|
|
||||||
let story_path = project_root
|
|
||||||
.join(".huskies/work/4_merge")
|
|
||||||
.join(format!("{story_id}.md"));
|
|
||||||
let _ = crate::io::story_metadata::write_merge_failure(
|
|
||||||
&story_path,
|
|
||||||
empty_diff_reason,
|
|
||||||
);
|
|
||||||
let _ = crate::io::story_metadata::write_blocked(&story_path);
|
|
||||||
}
|
}
|
||||||
let _ = self
|
let _ = self
|
||||||
.watcher_tx
|
.watcher_tx
|
||||||
|
|||||||
@@ -34,8 +34,16 @@ pub(super) fn has_review_hold(project_root: &Path, _stage_dir: &str, story_id: &
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return `true` if the story file has `blocked: true` in its front matter.
|
/// Return `true` if the story is blocked — either via the typed `Stage::Blocked`
|
||||||
|
/// variant or the legacy `blocked: true` front-matter field.
|
||||||
pub(super) fn is_story_blocked(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
pub(super) fn is_story_blocked(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
||||||
|
// Check the typed stage first (authoritative after story 866).
|
||||||
|
if let Ok(Some(item)) = crate::pipeline_state::read_typed(story_id)
|
||||||
|
&& item.stage.is_blocked()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Legacy fallback: check front-matter field for backward compatibility.
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::io::story_metadata::parse_front_matter;
|
||||||
let contents = match read_story_contents(project_root, story_id) {
|
let contents = match read_story_contents(project_root, story_id) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
|
|||||||
@@ -267,12 +267,13 @@ max_turns = 10
|
|||||||
let found = pool.run_watchdog_pass(Some(root));
|
let found = pool.run_watchdog_pass(Some(root));
|
||||||
assert!(found >= 1, "watchdog should detect the over-limit agent");
|
assert!(found >= 1, "watchdog should detect the over-limit agent");
|
||||||
|
|
||||||
// With max_retries=1, the first violation blocks immediately.
|
// With max_retries=1, the first violation blocks immediately via the state machine.
|
||||||
let updated = crate::db::read_content(story_id)
|
let item = crate::crdt_state::read_item(story_id)
|
||||||
.expect("story content must still exist after watchdog termination");
|
.expect("story must be in CRDT after watchdog termination");
|
||||||
assert!(
|
assert_eq!(
|
||||||
updated.contains("blocked: true"),
|
item.stage, "2_blocked",
|
||||||
"story must be marked `blocked: true` after limit termination with max_retries=1 — got:\n{updated}"
|
"story stage must be 2_blocked after limit termination with max_retries=1 — got: {}",
|
||||||
|
item.stage
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sanity: the agent itself is also Failed with the right reason.
|
// Sanity: the agent itself is also Failed with the right reason.
|
||||||
@@ -407,11 +408,12 @@ max_turns = 10
|
|||||||
let event = rx.try_recv().expect("watchdog must emit an Error event");
|
let event = rx.try_recv().expect("watchdog must emit an Error event");
|
||||||
assert!(matches!(event, AgentEvent::Error { .. }));
|
assert!(matches!(event, AgentEvent::Error { .. }));
|
||||||
|
|
||||||
// With max_retries=1, the story is blocked.
|
// With max_retries=1, the story is blocked via the state machine.
|
||||||
let updated = crate::db::read_content(story_id).unwrap();
|
let item = crate::crdt_state::read_item(story_id)
|
||||||
assert!(
|
.expect("story must be in CRDT after per-session overrun");
|
||||||
updated.contains("blocked: true"),
|
assert_eq!(
|
||||||
"story must be blocked after per-session overrun with max_retries=1"
|
item.stage, "2_blocked",
|
||||||
|
"story stage must be 2_blocked after per-session overrun with max_retries=1"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,9 +472,9 @@ max_turns = 10
|
|||||||
Some(1),
|
Some(1),
|
||||||
"after session 1, retry_count should be 1 in CRDT"
|
"after session 1, retry_count should be 1 in CRDT"
|
||||||
);
|
);
|
||||||
let content = crate::db::read_content(story_id).unwrap();
|
assert_ne!(
|
||||||
assert!(
|
item.stage.as_str(),
|
||||||
!content.contains("blocked: true"),
|
"2_blocked",
|
||||||
"story should NOT be blocked after session 1"
|
"story should NOT be blocked after session 1"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -490,9 +492,9 @@ max_turns = 10
|
|||||||
Some(2),
|
Some(2),
|
||||||
"after session 2, retry_count should be 2 in CRDT"
|
"after session 2, retry_count should be 2 in CRDT"
|
||||||
);
|
);
|
||||||
let content = crate::db::read_content(story_id).unwrap();
|
assert_ne!(
|
||||||
assert!(
|
item.stage.as_str(),
|
||||||
!content.contains("blocked: true"),
|
"2_blocked",
|
||||||
"story should NOT be blocked after session 2"
|
"story should NOT be blocked after session 2"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -504,16 +506,18 @@ max_turns = 10
|
|||||||
pool.inject_test_agent_with_session(story_id, "coder-1", AgentStatus::Running, "session-3");
|
pool.inject_test_agent_with_session(story_id, "coder-1", AgentStatus::Running, "session-3");
|
||||||
pool.run_watchdog_pass(Some(root));
|
pool.run_watchdog_pass(Some(root));
|
||||||
|
|
||||||
let content = crate::db::read_content(story_id).unwrap();
|
|
||||||
assert!(
|
|
||||||
content.contains("blocked: true"),
|
|
||||||
"story must be blocked after session 3 (retry_count=3 >= max_retries=3) — got:\n{content}"
|
|
||||||
);
|
|
||||||
let item = crate::crdt_state::read_item(story_id).expect("story must be in CRDT");
|
let item = crate::crdt_state::read_item(story_id).expect("story must be in CRDT");
|
||||||
|
assert_eq!(
|
||||||
|
item.stage, "2_blocked",
|
||||||
|
"story must be blocked after session 3 (retry_count=3 >= max_retries=3) — got: {}",
|
||||||
|
item.stage
|
||||||
|
);
|
||||||
|
// retry_count resets to 0 on stage transition (Bug 780) — the fact
|
||||||
|
// that the story reached 2_blocked proves the retry limit was hit.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
item.retry_count,
|
item.retry_count,
|
||||||
Some(3),
|
Some(0),
|
||||||
"retry_count should be 3 in CRDT after session 3"
|
"retry_count should reset to 0 after stage transition to blocked"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,8 +104,6 @@ pub(crate) fn should_block_story(
|
|||||||
max_retries: u32,
|
max_retries: u32,
|
||||||
stage_label: &str,
|
stage_label: &str,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
use crate::io::story_metadata::write_blocked_in_content;
|
|
||||||
|
|
||||||
if max_retries == 0 {
|
if max_retries == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -119,23 +117,16 @@ pub(crate) fn should_block_story(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if new_count >= max_retries {
|
if new_count >= max_retries {
|
||||||
|
let reason =
|
||||||
|
format!("Retry limit exceeded ({new_count}/{max_retries}) at {stage_label} stage");
|
||||||
slog_warn!(
|
slog_warn!(
|
||||||
"[pipeline] Story '{story_id}' reached retry limit ({new_count}/{max_retries}) \
|
"[pipeline] Story '{story_id}' reached retry limit ({new_count}/{max_retries}) \
|
||||||
at {stage_label} stage. Marking as blocked."
|
at {stage_label} stage. Marking as blocked."
|
||||||
);
|
);
|
||||||
if let Some(contents) = crate::db::read_content(story_id) {
|
if let Err(e) = crate::agents::lifecycle::transition_to_blocked(story_id, &reason) {
|
||||||
let blocked = write_blocked_in_content(&contents);
|
slog_error!("[pipeline] Failed to transition '{story_id}' to Blocked: {e}");
|
||||||
crate::db::write_content(story_id, &blocked);
|
|
||||||
let stage = crate::pipeline_state::read_typed(story_id)
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.map(|i| i.stage.dir_name().to_string())
|
|
||||||
.unwrap_or_else(|| "2_current".to_string());
|
|
||||||
crate::db::write_item_with_content(story_id, &stage, &blocked);
|
|
||||||
}
|
}
|
||||||
Some(format!(
|
Some(reason)
|
||||||
"Retry limit exceeded ({new_count}/{max_retries}) at {stage_label} stage"
|
|
||||||
))
|
|
||||||
} else {
|
} else {
|
||||||
slog!(
|
slog!(
|
||||||
"[pipeline] Story '{story_id}' retry {new_count}/{max_retries} at {stage_label} stage."
|
"[pipeline] Story '{story_id}' retry {new_count}/{max_retries} at {stage_label} stage."
|
||||||
|
|||||||
@@ -114,19 +114,20 @@ impl AgentPool {
|
|||||||
Err(e) => e.clone(),
|
Err(e) => e.clone(),
|
||||||
};
|
};
|
||||||
let is_no_commits = reason.contains("no commits to merge");
|
let is_no_commits = reason.contains("no commits to merge");
|
||||||
if let Some(contents) = crate::db::read_content(&sid) {
|
if !is_no_commits {
|
||||||
let with_failure = crate::io::story_metadata::write_merge_failure_in_content(
|
// Write merge_failure to content for non-blocking failures.
|
||||||
&contents, &reason,
|
if let Some(contents) = crate::db::read_content(&sid) {
|
||||||
);
|
let updated = crate::io::story_metadata::write_merge_failure_in_content(
|
||||||
let updated = if is_no_commits {
|
&contents, &reason,
|
||||||
crate::io::story_metadata::write_blocked_in_content(&with_failure)
|
);
|
||||||
} else {
|
crate::db::write_content(&sid, &updated);
|
||||||
with_failure
|
crate::db::write_item_with_content(&sid, "4_merge", &updated);
|
||||||
};
|
}
|
||||||
crate::db::write_content(&sid, &updated);
|
|
||||||
crate::db::write_item_with_content(&sid, "4_merge", &updated);
|
|
||||||
}
|
}
|
||||||
if is_no_commits {
|
if is_no_commits {
|
||||||
|
if let Err(e) = crate::agents::lifecycle::transition_to_blocked(&sid, &reason) {
|
||||||
|
crate::slog_error!("[merge] Failed to transition '{sid}' to Blocked: {e}");
|
||||||
|
}
|
||||||
let _ = pool
|
let _ = pool
|
||||||
.watcher_tx
|
.watcher_tx
|
||||||
.send(crate::io::watcher::WatcherEvent::StoryBlocked {
|
.send(crate::io::watcher::WatcherEvent::StoryBlocked {
|
||||||
|
|||||||
@@ -44,8 +44,13 @@ pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> Stri
|
|||||||
unblock_by_story_id(&story_id)
|
unblock_by_story_id(&story_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unblock a story using the content store (DB-backed).
|
/// Unblock a story via the typed state machine.
|
||||||
|
///
|
||||||
|
/// Checks whether the story is in `Stage::Blocked` (new path) or has legacy
|
||||||
|
/// `blocked: true` / `merge_failure` front-matter, then routes through
|
||||||
|
/// [`crate::agents::lifecycle::transition_to_unblocked`].
|
||||||
fn unblock_by_story_id(story_id: &str) -> String {
|
fn unblock_by_story_id(story_id: &str) -> String {
|
||||||
|
// Read content for the story name and legacy field checks.
|
||||||
let contents = match crate::db::read_content(story_id) {
|
let contents = match crate::db::read_content(story_id) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return format!("Failed to read story content for **{story_id}**"),
|
None => return format!("Failed to read story content for **{story_id}**"),
|
||||||
@@ -57,34 +62,51 @@ fn unblock_by_story_id(story_id: &str) -> String {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
|
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
|
||||||
|
|
||||||
|
// Check if the story is blocked via the typed stage or legacy front-matter.
|
||||||
|
let typed_blocked = crate::pipeline_state::read_typed(story_id)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some_and(|item| item.stage.is_blocked());
|
||||||
let has_blocked = meta.blocked == Some(true);
|
let has_blocked = meta.blocked == Some(true);
|
||||||
let has_merge_failure = meta.merge_failure.is_some();
|
let has_merge_failure = meta.merge_failure.is_some();
|
||||||
|
|
||||||
if !has_blocked && !has_merge_failure {
|
if !typed_blocked && !has_blocked && !has_merge_failure {
|
||||||
return format!("**{story_name}** ({story_id}) is not blocked. Nothing to unblock.");
|
return format!("**{story_name}** ({story_id}) is not blocked. Nothing to unblock.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut updated = contents;
|
// Route through the state machine.
|
||||||
if has_blocked {
|
match crate::agents::lifecycle::transition_to_unblocked(story_id) {
|
||||||
updated = clear_front_matter_field_in_content(&updated, "blocked");
|
Ok(()) => {}
|
||||||
}
|
Err(e) => {
|
||||||
if has_merge_failure {
|
// If the typed transition fails (e.g. legacy Archived item),
|
||||||
updated = clear_front_matter_field_in_content(&updated, "merge_failure");
|
// fall back to clearing front-matter fields directly.
|
||||||
}
|
crate::slog_warn!(
|
||||||
// retry_count lives in the CRDT; clear any stale copy from front-matter.
|
"[unblock] State-machine transition failed for '{story_id}': {e}. \
|
||||||
updated = clear_front_matter_field_in_content(&updated, "retry_count");
|
Falling back to front-matter cleanup."
|
||||||
|
);
|
||||||
|
let mut updated = contents;
|
||||||
|
if has_blocked {
|
||||||
|
updated = clear_front_matter_field_in_content(&updated, "blocked");
|
||||||
|
}
|
||||||
|
if has_merge_failure {
|
||||||
|
updated = clear_front_matter_field_in_content(&updated, "merge_failure");
|
||||||
|
}
|
||||||
|
updated = clear_front_matter_field_in_content(&updated, "retry_count");
|
||||||
|
|
||||||
crate::db::write_content(story_id, &updated);
|
crate::db::write_content(story_id, &updated);
|
||||||
let stage = crate::pipeline_state::read_typed(story_id)
|
let stage = crate::pipeline_state::read_typed(story_id)
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|i| i.stage.dir_name().to_string())
|
.map(|i| i.stage.dir_name().to_string())
|
||||||
.unwrap_or_else(|| "2_current".to_string());
|
.unwrap_or_else(|| "2_current".to_string());
|
||||||
crate::db::write_item_with_content(story_id, &stage, &updated);
|
crate::db::write_item_with_content(story_id, &stage, &updated);
|
||||||
crate::crdt_state::set_retry_count(story_id, 0);
|
crate::crdt_state::set_retry_count(story_id, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut cleared = Vec::new();
|
let mut cleared = Vec::new();
|
||||||
if has_blocked {
|
if typed_blocked || has_blocked {
|
||||||
cleared.push("blocked");
|
cleared.push("blocked");
|
||||||
}
|
}
|
||||||
if has_merge_failure {
|
if has_merge_failure {
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ fn stage_display_name(stage: &str) -> &str {
|
|||||||
Some(Stage::Upcoming) => "upcoming",
|
Some(Stage::Upcoming) => "upcoming",
|
||||||
Some(Stage::Backlog) => "backlog",
|
Some(Stage::Backlog) => "backlog",
|
||||||
Some(Stage::Coding) => "in-progress",
|
Some(Stage::Coding) => "in-progress",
|
||||||
|
Some(Stage::Blocked { .. }) => "blocked",
|
||||||
Some(Stage::Qa) => "QA",
|
Some(Stage::Qa) => "QA",
|
||||||
Some(Stage::Merge { .. }) => "merge",
|
Some(Stage::Merge { .. }) => "merge",
|
||||||
Some(Stage::Done { .. }) => "done",
|
Some(Stage::Done { .. }) => "done",
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
|
|||||||
Stage::Done { .. } => "done",
|
Stage::Done { .. } => "done",
|
||||||
Stage::Archived { .. } => "archived",
|
Stage::Archived { .. } => "archived",
|
||||||
Stage::Frozen { .. } => "frozen",
|
Stage::Frozen { .. } => "frozen",
|
||||||
|
Stage::Blocked { .. } => "blocked",
|
||||||
};
|
};
|
||||||
member_items.push(json!({
|
member_items.push(json!({
|
||||||
"story_id": sid,
|
"story_id": sid,
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
|||||||
Stage::Upcoming => state.backlog.push(story), // upcoming shown with backlog
|
Stage::Upcoming => state.backlog.push(story), // upcoming shown with backlog
|
||||||
Stage::Backlog => state.backlog.push(story),
|
Stage::Backlog => state.backlog.push(story),
|
||||||
Stage::Coding => state.current.push(story),
|
Stage::Coding => state.current.push(story),
|
||||||
|
Stage::Blocked { .. } => state.current.push(story), // blocked shown with current
|
||||||
Stage::Qa => state.qa.push(story),
|
Stage::Qa => state.qa.push(story),
|
||||||
Stage::Merge { .. } => state.merge.push(story),
|
Stage::Merge { .. } => state.merge.push(story),
|
||||||
Stage::Done { .. } => state.done.push(story),
|
Stage::Done { .. } => state.done.push(story),
|
||||||
|
|||||||
@@ -115,18 +115,6 @@ pub fn write_review_hold(path: &Path) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write `blocked: true` to the YAML front matter of a story file.
|
|
||||||
///
|
|
||||||
/// Used to mark stories that have exceeded the retry limit and should not
|
|
||||||
/// be auto-assigned again.
|
|
||||||
pub fn write_blocked(path: &Path) -> Result<(), String> {
|
|
||||||
let contents =
|
|
||||||
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
|
|
||||||
let updated = set_front_matter_field(&contents, "blocked", "true");
|
|
||||||
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write or update `depends_on:` field in the YAML front matter of a story file.
|
/// Write or update `depends_on:` field in the YAML front matter of a story file.
|
||||||
///
|
///
|
||||||
/// Serialises `deps` as an inline YAML sequence, e.g. `[477, 478]`.
|
/// Serialises `deps` as an inline YAML sequence, e.g. `[477, 478]`.
|
||||||
@@ -162,11 +150,6 @@ pub fn write_rejection_notes_to_content(contents: &str, notes: &str) -> String {
|
|||||||
format!("{contents}{section}")
|
format!("{contents}{section}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write `blocked: true` to story content (pure function).
|
|
||||||
pub fn write_blocked_in_content(contents: &str) -> String {
|
|
||||||
set_front_matter_field(contents, "blocked", "true")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write or update `merge_failure` in story content (pure function).
|
/// Write or update `merge_failure` in story content (pure function).
|
||||||
pub fn write_merge_failure_in_content(contents: &str, reason: &str) -> String {
|
pub fn write_merge_failure_in_content(contents: &str, reason: &str) -> String {
|
||||||
let escaped = reason
|
let escaped = reason
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ mod types;
|
|||||||
pub use deps::{check_archived_deps, check_archived_deps_from_list, check_unmet_deps};
|
pub use deps::{check_archived_deps, check_archived_deps_from_list, check_unmet_deps};
|
||||||
pub use fields::{
|
pub use fields::{
|
||||||
clear_front_matter_field, clear_front_matter_field_in_content, set_front_matter_field,
|
clear_front_matter_field, clear_front_matter_field_in_content, set_front_matter_field,
|
||||||
write_blocked, write_blocked_in_content, write_depends_on, write_depends_on_in_content,
|
write_depends_on, write_depends_on_in_content, write_merge_failure,
|
||||||
write_merge_failure, write_merge_failure_in_content, write_mergemaster_attempted_in_content,
|
write_merge_failure_in_content, write_mergemaster_attempted_in_content,
|
||||||
write_rejection_notes_to_content, write_review_hold, write_review_hold_in_content,
|
write_rejection_notes_to_content, write_review_hold, write_review_hold_in_content,
|
||||||
};
|
};
|
||||||
pub use parser::{
|
pub use parser::{
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pub fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, Strin
|
|||||||
Stage::Upcoming => ("create", format!("huskies: triage {item_id}")),
|
Stage::Upcoming => ("create", format!("huskies: triage {item_id}")),
|
||||||
Stage::Backlog => ("create", format!("huskies: create {item_id}")),
|
Stage::Backlog => ("create", format!("huskies: create {item_id}")),
|
||||||
Stage::Coding => ("start", format!("huskies: start {item_id}")),
|
Stage::Coding => ("start", format!("huskies: start {item_id}")),
|
||||||
|
Stage::Blocked { .. } => ("block", format!("huskies: block {item_id}")),
|
||||||
Stage::Qa => ("qa", format!("huskies: queue {item_id} for QA")),
|
Stage::Qa => ("qa", format!("huskies: queue {item_id} for QA")),
|
||||||
Stage::Merge { .. } => ("merge", format!("huskies: queue {item_id} for merge")),
|
Stage::Merge { .. } => ("merge", format!("huskies: queue {item_id} for merge")),
|
||||||
Stage::Done { .. } => ("done", format!("huskies: done {item_id}")),
|
Stage::Done { .. } => ("done", format!("huskies: done {item_id}")),
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
|
|||||||
match view.stage.as_str() {
|
match view.stage.as_str() {
|
||||||
"0_upcoming" => Ok(Stage::Upcoming),
|
"0_upcoming" => Ok(Stage::Upcoming),
|
||||||
"1_backlog" => Ok(Stage::Backlog),
|
"1_backlog" => Ok(Stage::Backlog),
|
||||||
|
"2_blocked" => Ok(Stage::Blocked {
|
||||||
|
reason: String::new(),
|
||||||
|
}),
|
||||||
"2_current" => Ok(Stage::Coding),
|
"2_current" => Ok(Stage::Coding),
|
||||||
"3_qa" => Ok(Stage::Qa),
|
"3_qa" => Ok(Stage::Qa),
|
||||||
"4_merge" => {
|
"4_merge" => {
|
||||||
@@ -147,10 +150,11 @@ impl PipelineItem {
|
|||||||
let dir = stage_dir_name(&self.stage);
|
let dir = stage_dir_name(&self.stage);
|
||||||
let blocked = matches!(
|
let blocked = matches!(
|
||||||
self.stage,
|
self.stage,
|
||||||
Stage::Archived {
|
Stage::Blocked { .. }
|
||||||
reason: ArchiveReason::Blocked { .. },
|
| Stage::Archived {
|
||||||
..
|
reason: ArchiveReason::Blocked { .. },
|
||||||
}
|
..
|
||||||
|
}
|
||||||
);
|
);
|
||||||
// Frozen stories map to "7_frozen"; they are not "blocked" in the CRDT sense.
|
// Frozen stories map to "7_frozen"; they are not "blocked" in the CRDT sense.
|
||||||
(dir, blocked)
|
(dir, blocked)
|
||||||
@@ -302,6 +306,26 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_blocked_item() {
|
||||||
|
let view = PipelineItemView {
|
||||||
|
story_id: "42_story_test".to_string(),
|
||||||
|
stage: "2_blocked".to_string(),
|
||||||
|
name: Some("Test".to_string()),
|
||||||
|
agent: None,
|
||||||
|
retry_count: None,
|
||||||
|
blocked: None,
|
||||||
|
depends_on: None,
|
||||||
|
claimed_by: None,
|
||||||
|
claimed_at: None,
|
||||||
|
merged_at: None,
|
||||||
|
qa_mode: None,
|
||||||
|
mergemaster_attempted: None,
|
||||||
|
};
|
||||||
|
let item = PipelineItem::try_from(&view).unwrap();
|
||||||
|
assert!(matches!(item.stage, Stage::Blocked { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_archived_blocked_item() {
|
fn project_archived_blocked_item() {
|
||||||
let view = PipelineItemView {
|
let view = PipelineItemView {
|
||||||
@@ -385,6 +409,13 @@ mod tests {
|
|||||||
(Stage::Upcoming, "0_upcoming", false),
|
(Stage::Upcoming, "0_upcoming", false),
|
||||||
(Stage::Backlog, "1_backlog", false),
|
(Stage::Backlog, "1_backlog", false),
|
||||||
(Stage::Coding, "2_current", false),
|
(Stage::Coding, "2_current", false),
|
||||||
|
(
|
||||||
|
Stage::Blocked {
|
||||||
|
reason: "stuck".into(),
|
||||||
|
},
|
||||||
|
"2_blocked",
|
||||||
|
true,
|
||||||
|
),
|
||||||
(Stage::Qa, "3_qa", false),
|
(Stage::Qa, "3_qa", false),
|
||||||
(
|
(
|
||||||
Stage::Merge {
|
Stage::Merge {
|
||||||
|
|||||||
@@ -172,13 +172,7 @@ fn block_from_any_active_stage() {
|
|||||||
reason: "stuck".into(),
|
reason: "stuck".into(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(result, Ok(Stage::Blocked { .. })));
|
||||||
result,
|
|
||||||
Ok(Stage::Archived {
|
|
||||||
reason: ArchiveReason::Blocked { .. },
|
|
||||||
..
|
|
||||||
})
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let m = Stage::Merge {
|
let m = Stage::Merge {
|
||||||
@@ -191,17 +185,20 @@ fn block_from_any_active_stage() {
|
|||||||
reason: "stuck".into(),
|
reason: "stuck".into(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(result, Ok(Stage::Blocked { .. })));
|
||||||
result,
|
|
||||||
Ok(Stage::Archived {
|
|
||||||
reason: ArchiveReason::Blocked { .. },
|
|
||||||
..
|
|
||||||
})
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unblock_returns_to_backlog() {
|
fn unblock_returns_to_coding() {
|
||||||
|
let s = Stage::Blocked {
|
||||||
|
reason: "test".into(),
|
||||||
|
};
|
||||||
|
let result = transition(s, PipelineEvent::Unblock).unwrap();
|
||||||
|
assert!(matches!(result, Stage::Coding));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_unblock_archived_blocked_returns_to_backlog() {
|
||||||
let s = Stage::Archived {
|
let s = Stage::Archived {
|
||||||
archived_at: chrono::Utc::now(),
|
archived_at: chrono::Utc::now(),
|
||||||
reason: ArchiveReason::Blocked {
|
reason: ArchiveReason::Blocked {
|
||||||
|
|||||||
@@ -160,14 +160,11 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
|
|||||||
reason: ArchiveReason::Completed,
|
reason: ArchiveReason::Completed,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ── Stuck states (any active → Archived) ───────────────────────
|
// ── Block: any active → Blocked ──────────────────────────────
|
||||||
(Backlog, Block { reason })
|
(Backlog, Block { reason })
|
||||||
| (Coding, Block { reason })
|
| (Coding, Block { reason })
|
||||||
| (Qa, Block { reason })
|
| (Qa, Block { reason })
|
||||||
| (Merge { .. }, Block { reason }) => Ok(Archived {
|
| (Merge { .. }, Block { reason }) => Ok(Blocked { reason }),
|
||||||
archived_at: now,
|
|
||||||
reason: ArchiveReason::Blocked { reason },
|
|
||||||
}),
|
|
||||||
|
|
||||||
(Backlog, ReviewHold { reason })
|
(Backlog, ReviewHold { reason })
|
||||||
| (Coding, ReviewHold { reason })
|
| (Coding, ReviewHold { reason })
|
||||||
@@ -221,7 +218,10 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
|
|||||||
merge_commit: GitSha("closed".to_string()),
|
merge_commit: GitSha("closed".to_string()),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ── Unblock: from Archived(Blocked) or Archived(MergeFailed) → Backlog
|
// ── Unblock: Blocked → Coding ─────────────────────────────────
|
||||||
|
(Blocked { .. }, Unblock) => Ok(Coding),
|
||||||
|
|
||||||
|
// ── Legacy unblock: Archived(Blocked|MergeFailed) → Backlog ──
|
||||||
(
|
(
|
||||||
Archived {
|
Archived {
|
||||||
reason: ArchiveReason::Blocked { .. },
|
reason: ArchiveReason::Blocked { .. },
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ impl fmt::Display for AgentName {
|
|||||||
/// after CRDT convergence. Notice what is NOT a field:
|
/// after CRDT convergence. Notice what is NOT a field:
|
||||||
/// - `agent` — local execution state, not pipeline state
|
/// - `agent` — local execution state, not pipeline state
|
||||||
/// - `retry_count` — also local
|
/// - `retry_count` — also local
|
||||||
/// - `blocked` — folded into `Archived { reason: Blocked { .. } }`
|
/// - `blocked` — now a first-class `Blocked { reason }` stage
|
||||||
///
|
///
|
||||||
/// ## Canonical state machine (story 857)
|
/// ## Canonical state machine (story 857)
|
||||||
///
|
///
|
||||||
@@ -61,7 +61,7 @@ impl fmt::Display for AgentName {
|
|||||||
/// | qa_pending | `Qa` |
|
/// | qa_pending | `Qa` |
|
||||||
/// | merge_pending | `Merge { .. }` |
|
/// | merge_pending | `Merge { .. }` |
|
||||||
/// | done | `Done { .. }` |
|
/// | done | `Done { .. }` |
|
||||||
/// | blocked | `Archived { Blocked { .. } }` |
|
/// | blocked | `Blocked { .. }` |
|
||||||
/// | merge_failure | `Archived { MergeFailed { .. } }` |
|
/// | merge_failure | `Archived { MergeFailed { .. } }` |
|
||||||
/// | archived | `Archived { Completed }` |
|
/// | archived | `Archived { Completed }` |
|
||||||
/// | superseded | `Archived { Superseded { .. } }` |
|
/// | superseded | `Archived { Superseded { .. } }` |
|
||||||
@@ -95,6 +95,11 @@ pub enum Stage {
|
|||||||
merge_commit: GitSha,
|
merge_commit: GitSha,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Story is blocked, awaiting human resolution or retry-limit review.
|
||||||
|
/// Unlike `Archived`, a blocked story is expected to return to active work
|
||||||
|
/// (via `Unblock → Coding`).
|
||||||
|
Blocked { reason: String },
|
||||||
|
|
||||||
/// Out of the active flow. The reason explains why.
|
/// Out of the active flow. The reason explains why.
|
||||||
Archived {
|
Archived {
|
||||||
archived_at: DateTime<Utc>,
|
archived_at: DateTime<Utc>,
|
||||||
@@ -149,14 +154,16 @@ impl Stage {
|
|||||||
stage_dir_name(self)
|
stage_dir_name(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if this is the Archived(Blocked) variant.
|
/// Returns true if this is the `Blocked` variant (or the legacy
|
||||||
|
/// `Archived(Blocked)` for backward-compatible reads).
|
||||||
pub fn is_blocked(&self) -> bool {
|
pub fn is_blocked(&self) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
self,
|
self,
|
||||||
Stage::Archived {
|
Stage::Blocked { .. }
|
||||||
reason: ArchiveReason::Blocked { .. },
|
| Stage::Archived {
|
||||||
..
|
reason: ArchiveReason::Blocked { .. },
|
||||||
}
|
..
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +179,9 @@ impl Stage {
|
|||||||
match s {
|
match s {
|
||||||
"0_upcoming" => Some(Stage::Upcoming),
|
"0_upcoming" => Some(Stage::Upcoming),
|
||||||
"1_backlog" => Some(Stage::Backlog),
|
"1_backlog" => Some(Stage::Backlog),
|
||||||
|
"2_blocked" => Some(Stage::Blocked {
|
||||||
|
reason: String::new(),
|
||||||
|
}),
|
||||||
"2_current" => Some(Stage::Coding),
|
"2_current" => Some(Stage::Coding),
|
||||||
"3_qa" => Some(Stage::Qa),
|
"3_qa" => Some(Stage::Qa),
|
||||||
"4_merge" => Some(Stage::Merge {
|
"4_merge" => Some(Stage::Merge {
|
||||||
@@ -269,6 +279,7 @@ pub fn stage_label(s: &Stage) -> &'static str {
|
|||||||
Stage::Qa => "Qa",
|
Stage::Qa => "Qa",
|
||||||
Stage::Merge { .. } => "Merge",
|
Stage::Merge { .. } => "Merge",
|
||||||
Stage::Done { .. } => "Done",
|
Stage::Done { .. } => "Done",
|
||||||
|
Stage::Blocked { .. } => "Blocked",
|
||||||
Stage::Archived { .. } => "Archived",
|
Stage::Archived { .. } => "Archived",
|
||||||
Stage::Frozen { .. } => "Frozen",
|
Stage::Frozen { .. } => "Frozen",
|
||||||
}
|
}
|
||||||
@@ -280,6 +291,7 @@ pub fn stage_dir_name(s: &Stage) -> &'static str {
|
|||||||
Stage::Upcoming => "0_upcoming",
|
Stage::Upcoming => "0_upcoming",
|
||||||
Stage::Backlog => "1_backlog",
|
Stage::Backlog => "1_backlog",
|
||||||
Stage::Coding => "2_current",
|
Stage::Coding => "2_current",
|
||||||
|
Stage::Blocked { .. } => "2_blocked",
|
||||||
Stage::Qa => "3_qa",
|
Stage::Qa => "3_qa",
|
||||||
Stage::Merge { .. } => "4_merge",
|
Stage::Merge { .. } => "4_merge",
|
||||||
Stage::Done { .. } => "5_done",
|
Stage::Done { .. } => "5_done",
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ pub fn get_work_item_content(
|
|||||||
crate::pipeline_state::Stage::Upcoming => "upcoming",
|
crate::pipeline_state::Stage::Upcoming => "upcoming",
|
||||||
crate::pipeline_state::Stage::Backlog => "backlog",
|
crate::pipeline_state::Stage::Backlog => "backlog",
|
||||||
crate::pipeline_state::Stage::Coding => "current",
|
crate::pipeline_state::Stage::Coding => "current",
|
||||||
|
crate::pipeline_state::Stage::Blocked { .. } => "blocked",
|
||||||
crate::pipeline_state::Stage::Qa => "qa",
|
crate::pipeline_state::Stage::Qa => "qa",
|
||||||
crate::pipeline_state::Stage::Merge { .. } => "merge",
|
crate::pipeline_state::Stage::Merge { .. } => "merge",
|
||||||
crate::pipeline_state::Stage::Done { .. } => "done",
|
crate::pipeline_state::Stage::Done { .. } => "done",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub fn stage_display_name(stage: &str) -> &'static str {
|
|||||||
Some(Stage::Upcoming) => "Upcoming",
|
Some(Stage::Upcoming) => "Upcoming",
|
||||||
Some(Stage::Backlog) => "Backlog",
|
Some(Stage::Backlog) => "Backlog",
|
||||||
Some(Stage::Coding) => "Current",
|
Some(Stage::Coding) => "Current",
|
||||||
|
Some(Stage::Blocked { .. }) => "Blocked",
|
||||||
Some(Stage::Qa) => "QA",
|
Some(Stage::Qa) => "QA",
|
||||||
Some(Stage::Merge { .. }) => "Merge",
|
Some(Stage::Merge { .. }) => "Merge",
|
||||||
Some(Stage::Done { .. }) => "Done",
|
Some(Stage::Done { .. }) => "Done",
|
||||||
|
|||||||
Reference in New Issue
Block a user