use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{anyhow, Result};
use clap::{ArgMatches, Args, Command, FromArgMatches, Subcommand, ValueEnum};
use serde::Serialize;
use serde_json::Value;
use crate::commands::describe::CommandDescriptor;
use crate::content_trust::ContentTrust;
use crate::extensions::backlog_commands;
use crate::extensions::backlog_state;
use crate::extensions::dispatch;
use crate::extensions::dispatch_state;
use crate::extensions::{Extension, StartupContext, WorkflowExtension};
use crate::mcp::protocol::Tool;
use crate::mcp::tools::{build_tool, ToolDef};
use crate::output::{self, OutputFormat};
use crate::paths;
use crate::paths::state::StateLayout;
#[derive(Subcommand)]
enum BacklogCommand {
Pull(BacklogPullArgs),
Scope(BacklogScopeArgs),
Next(BacklogNextArgs),
Claim(BacklogClaimArgs),
SetStatus(BacklogSetStatusArgs),
Complete(BacklogCompleteArgs),
Adapters(BacklogAdaptersArgs),
PromoteNext(BacklogPromoteNextArgs),
BootstrapGithub(BacklogBootstrapGithubArgs),
PullGithub(BacklogPullGithubArgs),
Lint(BacklogLintArgs),
Groom(BacklogGroomArgs),
}
#[derive(Args)]
struct BacklogBootstrapGithubArgs {
#[arg(long, default_value = ".")]
path: PathBuf,
#[arg(long)]
profile: Option<String>,
#[arg(long = "repo")]
github_repo: String,
#[arg(long)]
adopt_existing: bool,
}
#[derive(Args)]
struct BacklogPullGithubArgs {
#[arg(long, default_value = ".")]
path: PathBuf,
#[arg(long)]
profile: Option<String>,
#[arg(long = "repo")]
github_repo: String,
}
#[derive(Args)]
struct BacklogLintArgs {
#[arg(long, default_value = ".")]
path: PathBuf,
#[arg(long)]
profile: Option<String>,
}
#[derive(Args)]
struct BacklogGroomArgs {
#[arg(long, default_value = ".")]
path: PathBuf,
#[arg(long)]
profile: Option<String>,
}
#[derive(Args)]
struct BacklogPullArgs {
#[arg(long, default_value = ".")]
path: PathBuf,
#[arg(long)]
profile: Option<String>,
#[arg(long = "repo")]
repo_override: Option<String>,
}
#[derive(Clone, Copy, ValueEnum)]
enum BacklogScopePriorityArg {
ActiveNow,
Next,
Later,
Parked,
}
impl BacklogScopePriorityArg {
fn as_str(self) -> &'static str {
match self {
Self::ActiveNow => "active-now",
Self::Next => "next",
Self::Later => "later",
Self::Parked => "parked",
}
}
}
#[derive(Args)]
struct BacklogScopeArgs {
#[arg(long, default_value = ".")]
path: PathBuf,
#[arg(long)]
profile: Option<String>,
#[arg(long = "repo")]
repo_override: Option<String>,
#[arg(long)]
issues: String,
#[arg(long, value_enum)]
priority: BacklogScopePriorityArg,
}
#[derive(Args)]
struct BacklogNextArgs {
#[arg(long, default_value = ".")]
path: PathBuf,
#[arg(long)]
profile: Option<String>,
}
#[derive(Args)]
struct BacklogClaimArgs {
#[arg(long, default_value = ".")]
path: PathBuf,
#[arg(long)]
profile: Option<String>,
#[arg(long = "ccd-id")]
ccd_id: u64,
#[arg(long)]
write: bool,
}
#[derive(Clone, Copy, ValueEnum)]
enum BacklogMutationStatusArg {
Ready,
#[value(name = "in-progress")]
InProgress,
Blocked,
Parked,
Done,
}
impl BacklogMutationStatusArg {
fn as_str(self) -> &'static str {
match self {
Self::Ready => "ready",
Self::InProgress => "in-progress",
Self::Blocked => "blocked",
Self::Parked => "parked",
Self::Done => "done",
}
}
}
#[derive(Args)]
struct BacklogSetStatusArgs {
#[arg(long, default_value = ".")]
path: PathBuf,
#[arg(long)]
profile: Option<String>,
#[arg(long = "ccd-id")]
ccd_id: u64,
#[arg(long, value_enum)]
status: BacklogMutationStatusArg,
#[arg(long)]
write: bool,
}
#[derive(Args)]
struct BacklogCompleteArgs {
#[arg(long, default_value = ".")]
path: PathBuf,
#[arg(long)]
profile: Option<String>,
#[arg(long = "ccd-id")]
ccd_id: u64,
#[arg(long)]
write: bool,
}
#[derive(Args)]
struct BacklogAdaptersArgs {
#[arg(long, default_value = ".")]
path: PathBuf,
#[arg(long)]
profile: Option<String>,
}
#[derive(Args)]
struct BacklogPromoteNextArgs {
#[arg(long, default_value = ".")]
path: PathBuf,
#[arg(long)]
profile: Option<String>,
#[arg(long, default_value_t = 3)]
batch_size: usize,
#[arg(long)]
write: bool,
}
const COMMAND_GROUPS: &[&str] = &["backlog"];
const MCP_TOOLS: &[ToolDef] = &[ToolDef {
name: "ccd_backlog",
description: "Work queue: pull lint groom bootstrap",
commands: &[
("backlog-bootstrap-github", &["backlog", "bootstrap-github"]),
("backlog-pull-github", &["backlog", "pull-github"]),
("backlog-lint", &["backlog", "lint"]),
("backlog-groom", &["backlog", "groom"]),
],
renames: &[],
exclude: &[],
extra_props: &[],
}];
pub(crate) struct BacklogExtension;
pub(crate) static BACKLOG_EXTENSION: BacklogExtension = BacklogExtension;
impl Extension for BacklogExtension {
fn name(&self) -> &'static str {
"backlog"
}
fn command_groups(&self) -> &'static [&'static str] {
COMMAND_GROUPS
}
fn cli_command(&self) -> Option<Command> {
Some(backlog_cli_command())
}
fn dispatch_cli(
&self,
subcommand_name: &str,
matches: &ArgMatches,
output: OutputFormat,
) -> Option<Result<ExitCode>> {
if subcommand_name != "backlog" {
return None;
}
Some(run_cli_from_matches(matches, output))
}
fn mcp_tools(&self, commands: &[CommandDescriptor]) -> Vec<Tool> {
MCP_TOOLS
.iter()
.map(|tool| build_tool(tool, commands))
.collect()
}
fn dispatch_mcp(&self, tool_name: &str, args: &Value) -> Option<Result<Value>> {
if tool_name != "ccd_backlog" {
return None;
}
let command = match get_required_string(args, "command") {
Ok(command) => command,
Err(error) => return Some(Err(error)),
};
let report = (|| -> Result<Value> {
match command.as_str() {
"backlog-bootstrap-github" => {
let path = resolve_path(args)?;
let github_repo = get_required_string(args, "github_repo")?;
let report = backlog_commands::bootstrap_github(
&path,
get_opt_str(args, "profile").as_deref(),
&github_repo,
get_bool(args, "adopt_existing"),
)?;
to_value(&report)
}
"backlog-pull-github" => {
let path = resolve_path(args)?;
let github_repo = get_required_string(args, "github_repo")?;
let report = backlog_commands::pull_github(
&path,
get_opt_str(args, "profile").as_deref(),
&github_repo,
)?;
to_value(&report)
}
"backlog-lint" => {
let path = resolve_path(args)?;
let report =
backlog_commands::lint(&path, get_opt_str(args, "profile").as_deref())?;
to_value(&report)
}
"backlog-groom" => {
let path = resolve_path(args)?;
let report =
backlog_commands::groom(&path, get_opt_str(args, "profile").as_deref())?;
to_value(&report)
}
other => Err(anyhow!("unknown backlog MCP command: {other}")),
}
})();
Some(report)
}
fn health_diagnostics(
&self,
layout: &StateLayout,
repo_root: &Path,
locality_id: &str,
) -> Result<Vec<super::HealthDiagnostic>> {
let mut diagnostics = Vec::new();
if let Some(diagnostic) =
super::adapter::diagnose_github_backlog_fallback(layout, repo_root, locality_id)?
{
diagnostics.push(super::HealthDiagnostic {
check: "backlog_adapter",
severity: "warning",
file: diagnostic.repo_overlay_config_path.display().to_string(),
message: diagnostic.message(),
details: None,
});
}
Ok(diagnostics)
}
fn enrich_pod_status(
&self,
_pod_name: &str,
_locality_id: &str,
_profile: &str,
shared_root: &std::path::Path,
) -> Option<Vec<(String, String)>> {
let db_path = shared_root
.join("extensions")
.join("backlog")
.join("dispatch-state.db");
if !db_path.exists() {
return None;
}
let conn = match rusqlite::Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
) {
Ok(c) => c,
Err(_) => return None,
};
let mut columns = Vec::new();
if let Ok(mut stmt) = conn.prepare("SELECT branch, backlog_id FROM assignments LIMIT 3") {
if let Ok(rows) = stmt.query_map([], |row| {
Ok((
row.get::<_, Option<String>>(0)
.unwrap_or_default()
.unwrap_or_default(),
row.get::<_, String>(1).unwrap_or_default(),
))
}) {
for row in rows.flatten().take(1) {
if !row.0.is_empty() {
columns.push(("Branch".to_string(), row.0));
}
if !row.1.is_empty() {
columns.push(("Assignment".to_string(), row.1));
}
}
}
}
if columns.is_empty() {
None
} else {
Some(columns)
}
}
}
impl WorkflowExtension for BacklogExtension {
fn load_work_queue_snapshot(
&self,
layout: &StateLayout,
) -> Result<Option<super::WorkQueueSnapshot>> {
let cache = backlog_state::load_cache(layout)?;
match cache {
Some(cache) => Ok(Some(work_queue_snapshot_from_cache(layout, &cache)?)),
None => Ok(None),
}
}
fn load_session_assignment(
&self,
ctx: &StartupContext<'_>,
session_id: &str,
) -> Result<Option<dispatch::AssignmentView>> {
let cache = backlog_state::load_cache(ctx.layout)?;
let view = dispatch_state::resolve_session_assignment(
ctx.layout,
ctx.locality_id,
session_id,
cache.as_ref(),
)?;
Ok(view.map(|v| local_assignment_to_dispatch_view(&v)))
}
fn load_branch_assignment(
&self,
ctx: &StartupContext<'_>,
branch: &str,
) -> Result<Option<dispatch::AssignmentView>> {
let cache = backlog_state::load_cache(ctx.layout)?;
let view = dispatch_state::resolve_branch_assignment(
ctx.layout,
ctx.locality_id,
branch,
cache.as_ref(),
)?;
Ok(view.map(|v| local_assignment_to_dispatch_view(&v)))
}
fn ensure_assignment(
&self,
ctx: &StartupContext<'_>,
owner: dispatch::AssignmentOwner<'_>,
) -> Result<dispatch::AssignmentOutcome> {
let cache = backlog_state::load_cache(ctx.layout)?;
let cache_status = if ctx.allow_cached_work {
"loaded"
} else {
"stale"
};
let local_view = match owner {
dispatch::AssignmentOwner::Session { session_id, branch } => {
dispatch_state::ensure_local_assignment(
ctx.repo_root,
ctx.layout,
ctx.locality_id,
dispatch_state::LocalAssignmentRequest {
session_id,
branch,
cache: cache.as_ref(),
cache_status,
},
)?
}
dispatch::AssignmentOwner::PreSessionBranch { branch } => {
dispatch_state::ensure_local_assignment_by_branch(
ctx.repo_root,
ctx.layout,
ctx.locality_id,
branch,
cache.as_ref(),
cache_status,
)?
}
};
Ok(local_dispatch_to_outcome(&local_view))
}
fn observe_next_step(
&self,
ctx: &StartupContext<'_>,
) -> Result<Option<dispatch::NextStepObservation>> {
let cache = backlog_state::load_cache(ctx.layout)?;
let dispatch_paths =
dispatch_state::extension_dispatch_state_paths(ctx.layout, ctx.locality_id)?;
let claimed_refs = dispatch_state::load_claimed_refs(&dispatch_paths)?;
let claimed_ids = dispatch_state::load_claimed_ids(&dispatch_paths)?;
if let Some(item) = cache.as_ref().and_then(|cache| {
cache.items.iter().find(|item| {
(!item.backlog_ref.is_empty() && claimed_refs.contains(&item.backlog_ref.key()))
|| (item.has_ccd_id() && claimed_ids.contains(&item.ccd_id))
})
}) {
return Ok(Some(dispatch::NextStepObservation {
item: dispatch::NextStepItem {
backlog_ref: item.backlog_ref.clone(),
ccd_id: item.ccd_id,
github_issue_number: item.github_issue_number,
content_trust: ContentTrust::ExternalAdapterOutput,
title: backlog_state::display_title(&item.title),
branch: None,
},
confidence: if cache.is_some() && ctx.allow_cached_work {
dispatch::NextStepConfidence::Cached
} else {
dispatch::NextStepConfidence::Unverified
},
}));
}
if let Some(&ccd_id) = claimed_ids.iter().next() {
return Ok(Some(dispatch::NextStepObservation {
item: dispatch::NextStepItem {
backlog_ref: backlog_state::BacklogRef::default(),
ccd_id,
github_issue_number: 0,
content_trust: ContentTrust::ExternalAdapterOutput,
title: format!("ccd#{ccd_id}"),
branch: None,
},
confidence: if cache.is_some() && ctx.allow_cached_work {
dispatch::NextStepConfidence::Cached
} else {
dispatch::NextStepConfidence::Unverified
},
}));
}
Ok(None)
}
fn on_session_started(&self, ctx: &dispatch::SessionBoundaryContext<'_>) -> Result<()> {
dispatch_state::adopt_unsessioned_entries(ctx.layout, ctx.locality_id, ctx.session_id)?;
Ok(())
}
fn on_session_cleared(&self, ctx: &dispatch::SessionBoundaryContext<'_>) -> Result<()> {
dispatch_state::remove_session_entries(ctx.layout, ctx.locality_id, ctx.session_id)?;
Ok(())
}
fn resolve_assignment_references(
&self,
ctx: &StartupContext<'_>,
assignment: &dispatch::AssignmentView,
) -> Result<Vec<dispatch::StartupAlert>> {
let cache = backlog_state::load_cache(ctx.layout)?;
let Some(cache) = cache else {
return Ok(Vec::new());
};
let item = cache.items.iter().find(|item| {
(!assignment.backlog_ref.is_empty()
&& !item.backlog_ref.is_empty()
&& item.backlog_ref.key() == assignment.backlog_ref.key())
|| (assignment.github_issue_number != 0
&& is_native_issue_provider(item.backlog_ref.provider.as_str())
&& item.github_issue_number == assignment.github_issue_number)
|| (assignment.ccd_id != 0 && item.ccd_id == assignment.ccd_id)
});
let Some(item) = item else {
return Ok(Vec::new());
};
if item.is_active() && item.is_queue_scoped() {
return Ok(Vec::new());
}
if !item.is_active() {
let identifier = if let Some(native_ref) = native_issue_ref(item) {
let display = item.display_ref();
if display == native_ref {
display
} else {
format!("{display} / {native_ref}")
}
} else {
item.display_ref()
};
return Ok(vec![dispatch::StartupAlert {
check: "closed_issue_reference",
severity: dispatch::StartupAlertSeverity::Warning,
message: format!(
"local assignment references closed work item `{}` `{}`; \
retarget the session before relying on it",
identifier,
backlog_state::display_title(&item.title),
),
}]);
}
Ok(vec![dispatch::StartupAlert {
check: "out_of_queue_continuity",
severity: dispatch::StartupAlertSeverity::Warning,
message: format!(
"local assignment still points at {} but it is no longer queue-scoped by CCD priority labels; keep continuity if intentional or relabel/retarget it before relying on backlog auto-selection",
out_of_queue_target(item)
),
}])
}
}
fn local_assignment_to_dispatch_view(
v: &dispatch_state::LocalAssignmentView,
) -> dispatch::AssignmentView {
let owner = if v.session_id.is_empty() {
dispatch::AssignmentOwnerView::PreSessionBranch {
branch: v.branch.clone().unwrap_or_default(),
}
} else {
dispatch::AssignmentOwnerView::Session {
session_id: v.session_id.clone(),
}
};
dispatch::AssignmentView {
backlog_ref: v.backlog_ref.clone(),
ccd_id: v.ccd_id,
github_issue_number: v.github_issue_number,
content_trust: ContentTrust::ExternalAdapterOutput,
title: v.title.clone(),
owner,
branch: v.branch.clone(),
worktree: v.worktree.clone(),
}
}
fn work_queue_snapshot_from_cache(
layout: &StateLayout,
cache: &backlog_state::GitHubBacklogCache,
) -> Result<super::WorkQueueSnapshot> {
let cache_view =
backlog_state::load_cache_view_from_ref(layout, Some(cache), cache.items.len())?;
Ok(super::WorkQueueSnapshot {
content_trust: ContentTrust::ExternalAdapterOutput,
provider: cache.provider.clone(),
repo: cache.repo.clone(),
fetched_at_epoch_s: cache.fetched_at_epoch_s,
stale_after_s: super::work_queue::DEFAULT_STALE_AFTER_SECS,
revalidate_on_refresh: backlog_state::should_revalidate_on_refresh(cache),
queue_summary: work_queue_summary_from_view(&cache_view.queue_summary),
dispatch: cache_view
.dispatch
.as_ref()
.map(work_queue_dispatch_from_view),
active_items: cache_view
.active_items
.iter()
.map(work_queue_summary_item_from_view)
.collect(),
items: cache
.items
.iter()
.map(work_queue_snapshot_item_from_cache)
.collect(),
})
}
fn work_queue_summary_from_view(
summary: &backlog_state::GitHubQueueSummary,
) -> super::work_queue::WorkQueueSummary {
super::work_queue::WorkQueueSummary {
open_issues: summary.open_issues,
queue_scoped: summary.queue_scoped,
queue_candidates: summary.queue_candidates,
policy_conflicts: summary.policy_conflicts,
metadata_invalid: summary.metadata_invalid,
upstream_claimed: summary.upstream_claimed,
auto_selectable: summary.auto_selectable,
}
}
fn work_queue_dispatch_from_view(
dispatch: &backlog_state::GitHubBacklogDispatchView,
) -> super::work_queue::WorkQueueDispatchView {
super::work_queue::WorkQueueDispatchView {
status: dispatch.status,
priority_label: dispatch.priority_label.map(|label| label.short_name()),
reason: dispatch.reason.clone(),
selected: dispatch
.selected
.as_ref()
.map(work_queue_summary_item_from_view),
}
}
fn work_queue_summary_item_from_view(
item: &backlog_state::GitHubBacklogSummaryItem,
) -> super::WorkQueueSummaryItem {
super::WorkQueueSummaryItem {
ccd_id: item.ccd_id,
github_issue_number: item.github_issue_number,
backlog_ref: item.backlog_ref.clone(),
content_trust: item.content_trust,
title: item.title.clone(),
url: item.url.clone(),
section: item.section.clone(),
status: item.status.clone(),
queue_state: item.queue_state.as_str(),
dispatch_state: item.dispatch_state.as_str(),
metadata_status: match item.metadata_status {
backlog_state::MetadataStatus::Enriched => "enriched",
backlog_state::MetadataStatus::Absent => "absent",
backlog_state::MetadataStatus::Partial => "partial",
backlog_state::MetadataStatus::Invalid => "invalid",
},
upstream_claim: match item.upstream_claim {
backlog_state::UpstreamClaimState::Unclaimed => "unclaimed",
backlog_state::UpstreamClaimState::Claimed => "claimed",
},
priority_label: item.priority_label.map(|label| label.short_name()),
claimed_by: item.claimed_by.clone(),
priority_rank: item.priority_rank,
}
}
fn work_queue_snapshot_item_from_cache(
item: &backlog_state::GitHubBacklogItem,
) -> super::WorkQueueSnapshotItem {
super::WorkQueueSnapshotItem {
ccd_id: item.ccd_id,
github_issue_number: item.github_issue_number,
backlog_ref: item.backlog_ref.clone(),
content_trust: ContentTrust::ExternalAdapterOutput,
title: backlog_state::display_title(&item.title),
url: item.url.clone(),
section: item.section.clone(),
status: item.status.clone(),
queue_state: item.queue_state.as_str(),
dispatch_state: item.dispatch_state.as_str(),
metadata_status: match item.metadata_status {
backlog_state::MetadataStatus::Enriched => "enriched",
backlog_state::MetadataStatus::Absent => "absent",
backlog_state::MetadataStatus::Partial => "partial",
backlog_state::MetadataStatus::Invalid => "invalid",
},
upstream_claim: match item.upstream_claim {
backlog_state::UpstreamClaimState::Unclaimed => "unclaimed",
backlog_state::UpstreamClaimState::Claimed => "claimed",
},
priority_label: item.priority_label.map(|label| label.short_name()),
claimed_by: item.claimed_by.clone(),
priority_rank: item.priority_rank,
closed: !item.is_active(),
}
}
fn local_dispatch_to_outcome(
view: &dispatch_state::LocalDispatchView,
) -> dispatch::AssignmentOutcome {
let status = match view.status {
"existing" => dispatch::AssignmentStatus::Existing,
"assigned" => dispatch::AssignmentStatus::Assigned,
_ => dispatch::AssignmentStatus::Skipped,
};
dispatch::AssignmentOutcome {
status,
reason: view.reason.clone(),
next_step: view
.dispatch
.as_ref()
.map(|dispatch_view| dispatch::ExtensionNextStepView {
status: dispatch::NextStepStatus::NeedsInput,
source: dispatch::NextStepSource::BacklogAdapter,
reason: dispatch_view.reason.clone().or_else(|| {
Some(format!(
"backlog adapter reported `{}` and requires explicit actor input",
dispatch_view.status
))
}),
observation: None,
}),
assignment: view
.assignment
.as_ref()
.map(local_assignment_to_dispatch_view),
}
}
fn run_cli(command: BacklogCommand, output: OutputFormat) -> Result<ExitCode> {
match command {
BacklogCommand::Pull(args) => {
let repo_path = paths::cli::resolve(&args.path)?;
let report = backlog_commands::pull(
&repo_path,
args.profile.as_deref(),
args.repo_override.as_deref(),
)?;
output::render_report(output, &report)
}
BacklogCommand::Scope(args) => {
let repo_path = paths::cli::resolve(&args.path)?;
let report = backlog_commands::scope(
&repo_path,
args.profile.as_deref(),
args.repo_override.as_deref(),
&args.issues,
args.priority.as_str(),
)?;
output::render_report(output, &report)
}
BacklogCommand::Next(args) => {
let repo_path = paths::cli::resolve(&args.path)?;
let report = backlog_commands::next(&repo_path, args.profile.as_deref())?;
output::render_report(output, &report)
}
BacklogCommand::Claim(args) => {
let repo_path = paths::cli::resolve(&args.path)?;
let report = backlog_commands::claim(
&repo_path,
args.profile.as_deref(),
args.ccd_id,
!args.write,
)?;
output::render_report(output, &report)
}
BacklogCommand::SetStatus(args) => {
let repo_path = paths::cli::resolve(&args.path)?;
let report = backlog_commands::set_status(
&repo_path,
args.profile.as_deref(),
args.ccd_id,
args.status.as_str(),
!args.write,
)?;
output::render_report(output, &report)
}
BacklogCommand::Complete(args) => {
let repo_path = paths::cli::resolve(&args.path)?;
let report = backlog_commands::complete(
&repo_path,
args.profile.as_deref(),
args.ccd_id,
!args.write,
)?;
output::render_report(output, &report)
}
BacklogCommand::Adapters(args) => {
let repo_path = paths::cli::resolve(&args.path)?;
let report = backlog_commands::adapters(&repo_path, args.profile.as_deref())?;
output::render_report(output, &report)
}
BacklogCommand::PromoteNext(args) => {
let repo_path = paths::cli::resolve(&args.path)?;
let report = backlog_commands::promote_next(
&repo_path,
args.profile.as_deref(),
args.batch_size,
!args.write,
)?;
output::render_report(output, &report)
}
BacklogCommand::BootstrapGithub(args) => {
let repo_path = paths::cli::resolve(&args.path)?;
let report = backlog_commands::bootstrap_github(
&repo_path,
args.profile.as_deref(),
&args.github_repo,
args.adopt_existing,
)?;
output::render_report(output, &report)
}
BacklogCommand::PullGithub(args) => {
let repo_path = paths::cli::resolve(&args.path)?;
let report = backlog_commands::pull_github(
&repo_path,
args.profile.as_deref(),
&args.github_repo,
)?;
output::render_report(output, &report)
}
BacklogCommand::Lint(args) => {
let repo_path = paths::cli::resolve(&args.path)?;
let report = backlog_commands::lint(&repo_path, args.profile.as_deref())?;
output::render_report(output, &report)
}
BacklogCommand::Groom(args) => {
let repo_path = paths::cli::resolve(&args.path)?;
let report = backlog_commands::groom(&repo_path, args.profile.as_deref())?;
output::render_report(output, &report)
}
}
}
fn backlog_cli_command() -> Command {
BacklogCommand::augment_subcommands(
Command::new("backlog")
.about("Work queue commands")
.subcommand_required(true)
.arg_required_else_help(true),
)
}
fn run_cli_from_matches(matches: &ArgMatches, output: OutputFormat) -> Result<ExitCode> {
let command = BacklogCommand::from_arg_matches(matches)?;
run_cli(command, output)
}
fn is_native_issue_provider(provider: &str) -> bool {
provider.eq_ignore_ascii_case("github-issues") || provider.eq_ignore_ascii_case("gitlab-issues")
}
fn native_issue_ref(item: &backlog_state::GitHubBacklogItem) -> Option<String> {
(item.github_issue_number != 0 && is_native_issue_provider(item.backlog_ref.provider.as_str()))
.then(|| {
backlog_state::provider_native_issue_ref(
item.backlog_ref.provider.as_str(),
item.github_issue_number,
)
})
}
fn out_of_queue_target(item: &backlog_state::GitHubBacklogItem) -> String {
let title = backlog_state::display_title(&item.title);
if let Some(native_ref) = native_issue_ref(item) {
if item
.backlog_ref
.provider
.eq_ignore_ascii_case("gitlab-issues")
{
format!("open GitLab work item `{native_ref}` `{title}`")
} else {
format!("open GitHub issue `{native_ref}` `{title}`")
}
} else {
format!("open work item `{}` `{title}`", item.display_ref())
}
}
fn resolve_path(args: &Value) -> Result<PathBuf> {
let path = get_opt_str(args, "path").unwrap_or_else(|| ".".to_owned());
paths::cli::resolve(&PathBuf::from(path))
}
fn get_required_string(args: &Value, key: &str) -> Result<String> {
get_opt_str(args, key).ok_or_else(|| anyhow!("missing required argument `{key}`"))
}
fn get_opt_str(args: &Value, key: &str) -> Option<String> {
args.get(key).and_then(Value::as_str).map(str::to_owned)
}
fn get_bool(args: &Value, key: &str) -> bool {
args.get(key).and_then(Value::as_bool).unwrap_or(false)
}
fn to_value<T: Serialize>(report: &T) -> Result<Value> {
Ok(serde_json::to_value(report)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backlog_extension_reports_metadata() {
let extension: &dyn Extension = &BACKLOG_EXTENSION;
assert_eq!(extension.name(), "backlog");
assert_eq!(extension.command_groups(), &["backlog"]);
}
}