use std::path::PathBuf;
use super::backend::{ChildIssue, EpicBackend, NewIssue, PUBLISH_REF};
use super::plan::{self, EpicPlan};
use super::render;
use crate::commands::issue::config::StateModel;
use crate::commands::issue::standard_live::one_line;
pub(crate) const NO_PROJECT_PREFIX: &str = "no-project:";
pub(crate) fn is_scope_refusal(text: &str) -> bool {
let lowered = text.to_lowercase();
lowered.contains("required scopes") || lowered.contains("missing required scope")
}
#[derive(Debug, Clone)]
pub(crate) struct CreateOptions {
pub(crate) plan_path: PathBuf,
pub(crate) milestone: String,
pub(crate) components: Vec<String>,
pub(crate) phase_type: String,
pub(crate) project: Option<u64>,
pub(crate) session: String,
pub(crate) tracker: Option<u64>,
pub(crate) dry_run: bool,
pub(crate) model: StateModel,
}
#[derive(Debug, Clone)]
pub(crate) struct CreateReport {
pub(crate) tracker: Option<u64>,
pub(crate) plan_url: String,
pub(crate) filed: Vec<(u64, u64)>,
pub(crate) skipped: Vec<String>,
pub(crate) waived: Vec<String>,
pub(crate) dry_run: bool,
}
pub(crate) fn create<B: EpicBackend>(
backend: &B,
opts: &CreateOptions,
) -> anyhow::Result<CreateReport> {
let shown = opts.plan_path.display().to_string();
let text = std::fs::read_to_string(&opts.plan_path)
.map_err(|e| anyhow::anyhow!("could not read the plan document {shown}: {e}"))?;
let rel = backend.repo_relative_path(&shown)?.ok_or_else(|| {
anyhow::anyhow!("git does not track {shown} — commit it, then push it to {PUBLISH_REF}")
})?;
let sha = publish_sha(backend, &rel)?;
let plan = plan::parse(&rel, &text)?;
let repo = backend.repo_slug()?;
let plan_url = render::plan_permalink(&repo, &sha, &rel);
if opts.dry_run {
return dry_run_report(backend, opts, &plan, plan_url);
}
let mut report = CreateReport {
tracker: None,
plan_url,
filed: Vec::new(),
skipped: Vec::new(),
waived: Vec::new(),
dry_run: false,
};
let tracker = match opts.tracker {
Some(n) => n,
None => adopt_or_file_tracker(backend, opts, &plan, &mut report)?,
};
report.tracker = Some(tracker);
attach_project_or_waive(backend, &repo, tracker, opts.project, &mut report.waived)?;
let mut children = backend.children(tracker)?;
let total = plan.phases.len();
for (index, phase) in plan.phases.iter().enumerate() {
if let Some(existing) = children
.iter()
.find(|c| render::phase_what(&c.title) == phase.title)
{
let number = existing.number;
attach_project_or_waive(backend, &repo, number, opts.project, &mut report.waived)?;
report.skipped.push(phase.title.clone());
continue;
}
let number = render::next_phase_number(&children);
let body = render::phase_body(phase, tracker, index + 1, total);
let spec = NewIssue {
title: render::phase_title(tracker, number, &phase.title),
body,
labels: phase_labels(opts),
milestone: opts.milestone.clone(),
parent: Some(tracker),
};
let filed = backend.create_issue(&spec)?;
attach_project_or_waive(backend, &repo, filed, opts.project, &mut report.waived)?;
children.push(ChildIssue {
number: filed,
title: spec.title,
state: "OPEN".to_string(),
labels: spec.labels,
body: spec.body,
});
report.filed.push((number, filed));
}
super::sync::sync(backend, tracker, &opts.model)?;
Ok(report)
}
fn publish_sha<B: EpicBackend>(backend: &B, rel: &str) -> anyhow::Result<String> {
if let Some(sha) = backend.publish_sha(rel)? {
return Ok(sha);
}
let local = backend.local_sha(rel)?;
match local {
Some(sha) => anyhow::bail!(
"{rel} is committed locally at {sha} but absent from {PUBLISH_REF} — push it (and \
`git fetch origin`) before filing the epic, so the tracker's plan link resolves for \
everyone"
),
None => anyhow::bail!(
"{rel} is not committed on HEAD and absent from {PUBLISH_REF} — commit and push it \
before filing the epic"
),
}
}
fn adopt_or_file_tracker<B: EpicBackend>(
backend: &B,
opts: &CreateOptions,
plan: &EpicPlan,
report: &mut CreateReport,
) -> anyhow::Result<u64> {
let labels = tracker_labels(opts);
let found = backend.find_tracker(&plan.outcome, &labels).map_err(|e| {
anyhow::anyhow!(
"could not determine whether a tracker for this plan already exists ({}) — refusing \
to file one that might be a duplicate; pass `--tracker <number>` to resume a known \
tracker",
one_line(&e)
)
})?;
if let Some(existing) = found {
if existing.placeholder {
retitle(backend, existing.number, &plan.outcome)?;
}
return Ok(existing.number);
}
let spec = NewIssue {
title: render::placeholder_tracker_title(&plan.outcome),
body: render::tracker_body(plan, &report.plan_url),
labels,
milestone: opts.milestone.clone(),
parent: None,
};
let tracker = backend.create_issue(&spec)?;
retitle(backend, tracker, &plan.outcome)?;
Ok(tracker)
}
fn retitle<B: EpicBackend>(backend: &B, tracker: u64, outcome: &str) -> anyhow::Result<()> {
backend
.set_title(tracker, &render::tracker_title(tracker, outcome))
.map_err(|e| {
anyhow::anyhow!(
"#{tracker} was filed but still carries the placeholder title `{}` ({e}) — \
rename it, then re-run with `--tracker {tracker}`",
render::placeholder_tracker_title(outcome)
)
})
}
fn attach_project_or_waive<B: EpicBackend>(
backend: &B,
repo: &str,
issue: u64,
project: Option<u64>,
waived: &mut Vec<String>,
) -> anyhow::Result<()> {
let Some(number) = project else {
return Ok(());
};
let Err(e) = backend.attach_project(repo, issue, number) else {
return Ok(());
};
let reason = one_line(&e);
if !is_scope_refusal(&reason) {
return Err(e.context(format!(
"could not attach #{issue} to project {number}, and the failure is not the token's \
project scope — refusing to record a retryable outage as a permanent \
`{NO_PROJECT_PREFIX}` comment"
)));
}
let already = backend
.comments(issue)?
.iter()
.any(|c| c.trim_start().starts_with(NO_PROJECT_PREFIX));
if !already {
backend.comment(issue, &format!("{NO_PROJECT_PREFIX} {reason}"))?;
}
waived.push(format!("#{issue}: {reason}"));
Ok(())
}
fn tracker_labels(opts: &CreateOptions) -> Vec<String> {
labels_with_type(opts, "epic")
}
fn phase_labels(opts: &CreateOptions) -> Vec<String> {
labels_with_type(opts, &opts.phase_type)
}
fn labels_with_type(opts: &CreateOptions, type_label: &str) -> Vec<String> {
let mut labels = vec![type_label.to_string(), workstream_label(&opts.session)];
labels.extend(opts.components.iter().cloned());
labels
}
fn workstream_label(session: &str) -> String {
trusty_mpm::core::policy_labels::workstream_label(session)
.map_or_else(|| format!("ws/{session}"), |l| l.name)
}
fn dry_run_report<B: EpicBackend>(
backend: &B,
opts: &CreateOptions,
plan: &EpicPlan,
plan_url: String,
) -> anyhow::Result<CreateReport> {
let children = match opts.tracker {
Some(n) => backend.children(n)?,
None => Vec::new(),
};
let mut skipped = Vec::new();
let mut filed = Vec::new();
let mut next = render::next_phase_number(&children);
for phase in &plan.phases {
if children
.iter()
.any(|c| render::phase_what(&c.title) == phase.title)
{
skipped.push(phase.title.clone());
} else {
filed.push((next, 0));
next += 1;
}
}
Ok(CreateReport {
tracker: opts.tracker,
plan_url,
filed,
skipped,
waived: Vec::new(),
dry_run: true,
})
}