use serde::Deserialize;
use crate::commands::ticket::runner::CommandRunner;
pub(crate) const PUBLISH_REF: &str = "origin/main";
const TRACKER_LIST_LIMIT_N: usize = 200;
const TRACKER_LIST_LIMIT: &str = "200";
#[derive(Debug, Clone)]
pub(crate) struct NewIssue {
pub(crate) title: String,
pub(crate) body: String,
pub(crate) labels: Vec<String>,
pub(crate) milestone: String,
pub(crate) parent: Option<u64>,
}
#[derive(Debug, Clone)]
pub(crate) struct ChildIssue {
pub(crate) number: u64,
pub(crate) title: String,
pub(crate) state: String,
pub(crate) labels: Vec<String>,
pub(crate) body: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PhaseCandidate {
pub(crate) number: u64,
pub(crate) title: String,
}
pub(crate) trait EpicBackend {
fn repo_slug(&self) -> anyhow::Result<String>;
fn repo_relative_path(&self, path: &str) -> anyhow::Result<Option<String>>;
fn publish_sha(&self, path: &str) -> anyhow::Result<Option<String>>;
fn local_sha(&self, path: &str) -> anyhow::Result<Option<String>>;
fn create_issue(&self, spec: &NewIssue) -> anyhow::Result<u64>;
fn set_title(&self, issue: u64, title: &str) -> anyhow::Result<()>;
fn body(&self, issue: u64) -> anyhow::Result<String>;
fn set_body(&self, issue: u64, body: &str) -> anyhow::Result<()>;
fn children(&self, tracker: u64) -> anyhow::Result<Vec<ChildIssue>>;
fn find_tracker(
&self,
outcome: &str,
labels: &[String],
) -> anyhow::Result<Option<FoundTracker>>;
fn attach_project(&self, repo: &str, issue: u64, number: u64) -> anyhow::Result<()>;
fn comments(&self, issue: u64) -> anyhow::Result<Vec<String>>;
fn comment(&self, issue: u64, body: &str) -> anyhow::Result<()>;
fn parent(&self, issue: u64) -> anyhow::Result<Option<u64>>;
fn close_issue(&self, issue: u64) -> anyhow::Result<()>;
fn phase_titled_issues(&self, epic: u64) -> anyhow::Result<Vec<PhaseCandidate>>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct FoundTracker {
pub(crate) number: u64,
pub(crate) placeholder: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SubIssueEnvelope {
#[serde(default)]
sub_issues: SubIssueConnection,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SubIssueConnection {
#[serde(default)]
nodes: Vec<SubIssueNode>,
#[serde(default)]
total_count: u64,
}
#[derive(Debug, Deserialize)]
struct SubIssueNode {
#[serde(default)]
number: u64,
#[serde(default)]
title: String,
#[serde(default)]
state: String,
}
#[derive(Debug, Deserialize)]
struct IssueRow {
#[serde(default)]
number: u64,
#[serde(default)]
title: String,
}
#[derive(Debug, Deserialize)]
struct BodyOnly {
#[serde(default)]
body: String,
}
#[derive(Debug, Deserialize)]
struct BodyAndLabels {
#[serde(default)]
body: String,
#[serde(default)]
labels: Vec<LabelName>,
}
#[derive(Debug, Deserialize)]
struct LabelName {
#[serde(default)]
name: String,
}
#[derive(Debug, Deserialize)]
struct ParentOnly {
#[serde(deserialize_with = "required_nullable")]
parent: Option<ParentRef>,
}
fn required_nullable<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<T>::deserialize(deserializer)
}
#[derive(Debug, Deserialize)]
struct ParentRef {
#[serde(default)]
number: u64,
}
#[derive(Debug, Deserialize)]
struct CommentList {
#[serde(default)]
comments: Vec<BodyOnly>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RepoSlug {
#[serde(default)]
name_with_owner: String,
}
pub(crate) struct GhEpicBackend<R: CommandRunner> {
runner: R,
}
impl<R: CommandRunner> GhEpicBackend<R> {
pub(crate) fn new(runner: R) -> Self {
Self { runner }
}
#[cfg(test)]
pub(crate) fn runner(&self) -> &R {
&self.runner
}
fn sub_issues_paginated(&self, tracker: u64) -> anyhow::Result<Vec<SubIssueNode>> {
let endpoint = format!("repos/{{owner}}/{{repo}}/issues/{tracker}/sub_issues");
let out = self.runner.run("gh", &["api", &endpoint, "--paginate"])?;
let text = out.ok_or_stderr("gh api …/sub_issues --paginate")?;
let mut nodes = Vec::new();
for page in serde_json::Deserializer::from_str(&text).into_iter::<Vec<SubIssueNode>>() {
let page = page.map_err(|e| {
anyhow::anyhow!("failed to parse a sub-issue page for #{tracker}: {e}")
})?;
nodes.extend(page);
}
Ok(nodes)
}
fn git_first_line(&self, args: &[&str]) -> anyhow::Result<Option<String>> {
let out = self.runner.run("git", args)?;
let text = out.ok_or_stderr("git")?;
Ok(text
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(str::to_string))
}
}
impl<R: CommandRunner> EpicBackend for GhEpicBackend<R> {
fn repo_slug(&self) -> anyhow::Result<String> {
let out = self
.runner
.run("gh", &["repo", "view", "--json", "nameWithOwner"])?;
let text = out.ok_or_stderr("gh repo view")?;
let parsed: RepoSlug = serde_json::from_str(&text)
.map_err(|e| anyhow::anyhow!("failed to parse `gh repo view` JSON: {e}"))?;
if parsed.name_with_owner.is_empty() {
anyhow::bail!("`gh repo view` returned no nameWithOwner — is this a GitHub checkout?");
}
Ok(parsed.name_with_owner)
}
fn repo_relative_path(&self, path: &str) -> anyhow::Result<Option<String>> {
self.git_first_line(&["ls-files", "--full-name", "--", path])
}
fn publish_sha(&self, path: &str) -> anyhow::Result<Option<String>> {
self.git_first_line(&["log", "-1", "--format=%H", PUBLISH_REF, "--", path])
}
fn local_sha(&self, path: &str) -> anyhow::Result<Option<String>> {
self.git_first_line(&["log", "-1", "--format=%H", "HEAD", "--", path])
}
fn create_issue(&self, spec: &NewIssue) -> anyhow::Result<u64> {
let number = spec.parent.map(|p| p.to_string());
let mut args: Vec<&str> = vec![
"issue",
"create",
"--title",
&spec.title,
"--body",
&spec.body,
"--milestone",
&spec.milestone,
];
for label in &spec.labels {
args.push("--label");
args.push(label);
}
if let Some(parent) = number.as_deref() {
args.push("--parent");
args.push(parent);
}
let out = self.runner.run("gh", &args)?;
let text = out.ok_or_stderr("gh issue create")?;
issue_number_from_url(&text)
}
fn set_title(&self, issue: u64, title: &str) -> anyhow::Result<()> {
let n = issue.to_string();
self.runner
.run("gh", &["issue", "edit", &n, "--title", title])?
.ok_or_stderr("gh issue edit --title")?;
Ok(())
}
fn body(&self, issue: u64) -> anyhow::Result<String> {
let n = issue.to_string();
let out = self
.runner
.run("gh", &["issue", "view", &n, "--json", "body"])?;
let text = out.ok_or_stderr("gh issue view --json body")?;
let parsed: BodyOnly = serde_json::from_str(&text).map_err(|e| {
anyhow::anyhow!("failed to parse `gh issue view` body for #{issue}: {e}")
})?;
Ok(parsed.body)
}
fn set_body(&self, issue: u64, body: &str) -> anyhow::Result<()> {
if body.trim().is_empty() {
anyhow::bail!("refusing to write an empty body to #{issue}");
}
let n = issue.to_string();
self.runner
.run("gh", &["issue", "edit", &n, "--body", body])?
.ok_or_stderr("gh issue edit --body")?;
Ok(())
}
fn children(&self, tracker: u64) -> anyhow::Result<Vec<ChildIssue>> {
let n = tracker.to_string();
let out = self
.runner
.run("gh", &["issue", "view", &n, "--json", "subIssues"])?;
let text = out.ok_or_stderr("gh issue view --json subIssues")?;
let parsed: SubIssueEnvelope = serde_json::from_str(&text).map_err(|e| {
anyhow::anyhow!("failed to parse the sub-issue list for #{tracker}: {e}")
})?;
let nodes = if parsed.sub_issues.nodes.len() < parsed.sub_issues.total_count as usize {
self.sub_issues_paginated(tracker)?
} else {
parsed.sub_issues.nodes
};
let mut children = Vec::new();
for node in nodes {
let n = node.number.to_string();
let out = self
.runner
.run("gh", &["issue", "view", &n, "--json", "body,labels"])?;
let text = out.ok_or_stderr("gh issue view --json body,labels")?;
let detail: BodyAndLabels = serde_json::from_str(&text).map_err(|e| {
anyhow::anyhow!("failed to parse `gh issue view` for child #{n}: {e}")
})?;
children.push(ChildIssue {
number: node.number,
title: node.title,
state: node.state,
labels: detail.labels.into_iter().map(|l| l.name).collect(),
body: detail.body,
});
}
Ok(children)
}
fn find_tracker(
&self,
outcome: &str,
labels: &[String],
) -> anyhow::Result<Option<FoundTracker>> {
let mut args: Vec<&str> = vec![
"issue",
"list",
"--state",
"all",
"--limit",
TRACKER_LIST_LIMIT,
"--json",
"number,title",
];
for label in labels {
args.push("--label");
args.push(label);
}
let out = self.runner.run("gh", &args)?;
let text = out.ok_or_stderr("gh issue list --label")?;
let rows: Vec<IssueRow> = serde_json::from_str(&text)
.map_err(|e| anyhow::anyhow!("failed to parse `gh issue list` JSON: {e}"))?;
if rows.len() >= TRACKER_LIST_LIMIT_N {
anyhow::bail!(
"`gh issue list` returned a full page of {TRACKER_LIST_LIMIT_N} issues for \
labels [{}], so an existing tracker may have been cut off — refusing to file \
one that might be a duplicate. Pass `--tracker <number>` to resume a known \
tracker",
labels.join(", ")
);
}
let placeholder = super::render::placeholder_tracker_title(outcome);
let matches: Vec<FoundTracker> = rows
.iter()
.filter_map(|r| {
if super::render::is_tracker_title(&r.title, r.number, outcome) {
Some(FoundTracker {
number: r.number,
placeholder: false,
})
} else if r.title.trim() == placeholder {
Some(FoundTracker {
number: r.number,
placeholder: true,
})
} else {
None
}
})
.collect();
match matches.as_slice() {
[] => Ok(None),
[one] => Ok(Some(*one)),
many => anyhow::bail!(
"{} issues already carry this outcome as a tracker title ({}) — \
pass `--tracker <number>` to say which one to resume",
many.len(),
many.iter()
.map(|m| format!("#{}", m.number))
.collect::<Vec<_>>()
.join(", ")
),
}
}
fn attach_project(&self, repo: &str, issue: u64, number: u64) -> anyhow::Result<()> {
let slug = repo.to_string();
let owner = slug.split('/').next().unwrap_or(&slug).to_string();
let project = number.to_string();
let url = format!("https://github.com/{slug}/issues/{issue}");
self.runner
.run(
"gh",
&[
"project", "item-add", &project, "--owner", &owner, "--url", &url,
],
)?
.ok_or_stderr("gh project item-add")?;
Ok(())
}
fn comments(&self, issue: u64) -> anyhow::Result<Vec<String>> {
let n = issue.to_string();
let out = self
.runner
.run("gh", &["issue", "view", &n, "--json", "comments"])?;
let text = out.ok_or_stderr("gh issue view --json comments")?;
let parsed: CommentList = serde_json::from_str(&text)
.map_err(|e| anyhow::anyhow!("failed to parse the comment list for #{issue}: {e}"))?;
Ok(parsed.comments.into_iter().map(|c| c.body).collect())
}
fn comment(&self, issue: u64, body: &str) -> anyhow::Result<()> {
let n = issue.to_string();
self.runner
.run("gh", &["issue", "comment", &n, "--body", body])?
.ok_or_stderr("gh issue comment")?;
Ok(())
}
fn parent(&self, issue: u64) -> anyhow::Result<Option<u64>> {
let n = issue.to_string();
let out = self
.runner
.run("gh", &["issue", "view", &n, "--json", "parent"])?;
let text = out.ok_or_stderr("gh issue view --json parent")?;
let parsed: ParentOnly = serde_json::from_str(&text)
.map_err(|e| anyhow::anyhow!("failed to parse the parent of #{issue}: {e}"))?;
Ok(parsed.parent.map(|p| p.number).filter(|n| *n != 0))
}
fn close_issue(&self, issue: u64) -> anyhow::Result<()> {
let n = issue.to_string();
self.runner
.run("gh", &["issue", "close", &n])?
.ok_or_stderr("gh issue close")?;
Ok(())
}
fn phase_titled_issues(&self, epic: u64) -> anyhow::Result<Vec<PhaseCandidate>> {
let query = format!("in:title \"EPIC_{epic} PHASE_\"");
let out = self.runner.run(
"gh",
&[
"issue",
"list",
"--state",
"all",
"--limit",
TRACKER_LIST_LIMIT,
"--search",
&query,
"--json",
"number,title",
],
)?;
let text = out.ok_or_stderr("gh issue list --search")?;
let rows: Vec<IssueRow> = serde_json::from_str(&text)
.map_err(|e| anyhow::anyhow!("failed to parse `gh issue list` JSON: {e}"))?;
if rows.len() >= TRACKER_LIST_LIMIT_N {
anyhow::bail!(
"`gh issue list` returned a full page of {TRACKER_LIST_LIMIT_N} issues titled \
for epic #{epic}, so a phase may have been cut off — cannot prove every \
phase-titled issue is linked"
);
}
Ok(rows
.into_iter()
.map(|r| PhaseCandidate {
number: r.number,
title: r.title,
})
.collect())
}
}
pub(crate) fn issue_number_from_url(text: &str) -> anyhow::Result<u64> {
let line = text
.lines()
.map(str::trim)
.rfind(|l| !l.is_empty())
.unwrap_or("");
line.rsplit('/')
.next()
.and_then(|n| n.parse::<u64>().ok())
.ok_or_else(|| {
anyhow::anyhow!("`gh issue create` printed no issue URL — got {line:?}; nothing filed")
})
}