Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use std::mem::discriminant;

use anyhow::{Result, bail};
use anyhow::{Context, Result, bail};

use crate::issue::IssueUrl;
use crate::notification::NotificationAsk;
Expand All @@ -19,6 +19,12 @@ pub enum Command {
Run(RunArgs),
}

/// The hidden argument a Spec run starts each Ticket's Run with, followed by
/// the Spec branch: it makes the Run a Merge run into the Spec branch that
/// sends no Run notification and leaves the Launch directory alone. Not in
/// help.
pub const SPEC_BRANCH: &str = "--spec-branch";

/// A Run's arguments.
pub struct RunArgs {
pub issue: IssueUrl,
Expand All @@ -28,6 +34,8 @@ pub struct RunArgs {
/// What `email` or `no-email` asked for, if either was given; without
/// one, the User config decides.
pub email: Option<NotificationAsk>,
/// The Spec branch, given with [`SPEC_BRANCH`] to a Ticket's Run.
pub spec_branch: Option<String>,
}

/// Parse the arguments after the program name. `help`, `version`, `update`,
Expand Down Expand Up @@ -59,6 +67,7 @@ pub fn parse(args: &[String]) -> Result<Command> {
let mut issue = None;
let mut goal = None;
let mut email = None;
let mut spec_branch = None;
let mut args = args.iter().peekable();
while let Some(arg) = args.next() {
match arg.as_str() {
Expand All @@ -73,6 +82,12 @@ pub fn parse(args: &[String]) -> Result<Command> {
"no-email" | "--no-email" => {
ask_once(&mut email, NotificationAsk::Skip, arg, EMAIL_FLAGS)?
}
SPEC_BRANCH => {
if spec_branch.is_some() {
bail!("repeated argument: {arg}");
}
spec_branch = Some(args.next().context("missing Spec branch")?.clone());
}
_ => {
if issue.is_some() {
bail!("unexpected argument after the Issue URL: {arg}");
Expand All @@ -84,7 +99,12 @@ pub fn parse(args: &[String]) -> Result<Command> {
let Some(issue) = issue else {
bail!("missing Issue URL");
};
Ok(Command::Run(RunArgs { issue, goal, email }))
Ok(Command::Run(RunArgs {
issue,
goal,
email,
spec_branch,
}))
}

const MERGE_FLAGS: &str = "merge and no-merge";
Expand Down
124 changes: 123 additions & 1 deletion src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,97 @@ pub fn issue_title(issue: &IssueUrl) -> Result<String> {
.to_string())
}

/// A Spec's sub-issue, as a Spec run reads it.
pub struct Ticket {
pub number: u64,
pub is_open: bool,
pub labels: Vec<String>,
/// It has sub-issues of its own.
pub has_sub_issues: bool,
/// The numbers of the open issues it is blocked by, in the Spec or not.
pub open_blockers: Vec<u64>,
}

/// Every sub-issue of `issue`, open or closed, with its labels, whether it
/// has sub-issues of its own and the issues it is blocked by: its GitHub
/// "blocked by" links, never the text of its body.
const TICKETS_QUERY: &str = "\
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $number) {
subIssues(first: 100) {
nodes {
number
state
labels(first: 100) { nodes { name } }
subIssues { totalCount }
blockedBy(first: 100) { nodes { number state } }
}
}
}
}
}";

/// The sub-issues of `issue`, its Tickets if it has any, in one query.
pub fn tickets(issue: &IssueUrl) -> Result<Vec<Ticket>> {
let json = gh_json(&[
"api",
"graphql",
"-f",
&format!("query={TICKETS_QUERY}"),
"-f",
&format!("owner={}", issue.owner),
"-f",
&format!("repo={}", issue.repo),
"-F",
&format!("number={}", issue.number),
])?;
let nodes = |value: &Value, what: &str| -> Result<Vec<Value>> {
value["nodes"]
.as_array()
.cloned()
.with_context(|| format!("gh api graphql output has no {what}"))
};
let is_open = |node: &Value| -> Result<bool> {
Ok(node["state"]
.as_str()
.context("gh api graphql output has an issue with no state")?
== "OPEN")
};
let number = |node: &Value| {
node["number"]
.as_u64()
.context("gh api graphql output has an issue with no number")
};
nodes(
&json["data"]["repository"]["issue"]["subIssues"],
"subIssues",
)?
.iter()
.map(|node| {
let mut open_blockers = Vec::new();
for blocker in nodes(&node["blockedBy"], "blockedBy")? {
if is_open(&blocker)? {
open_blockers.push(number(&blocker)?);
}
}
Ok(Ticket {
number: number(node)?,
is_open: is_open(node)?,
labels: nodes(&node["labels"], "labels")?
.iter()
.filter_map(|label| label["name"].as_str().map(String::from))
.collect(),
has_sub_issues: node["subIssues"]["totalCount"]
.as_u64()
.context("gh api graphql output has an issue with no subIssues count")?
> 0,
open_blockers,
})
})
.collect()
}

/// Close `issue` with `comment`.
pub fn close_issue(issue: &IssueUrl, comment: &str) -> Result<()> {
gh(&[
Expand Down Expand Up @@ -169,6 +260,31 @@ pub fn pull_requests_with_head_prefix(issue: &IssueUrl, prefix: &str) -> Result<
.collect()
}

/// Open a pull request, ready for review, from `head` into `base`, and
/// return its URL.
pub fn create_pr(
issue: &IssueUrl,
head: &str,
base: &str,
title: &str,
body: &str,
) -> Result<String> {
gh_stdout(&[
"pr",
"create",
"--repo",
&issue.repo_slug(),
"--head",
head,
"--base",
base,
"--title",
title,
"--body",
body,
])
}

/// Mark the pull request whose head is `branch` ready for review.
pub fn mark_ready(issue: &IssueUrl, branch: &str) -> Result<()> {
gh(&["pr", "ready", branch, "--repo", &issue.repo_slug()])
Expand Down Expand Up @@ -329,6 +445,12 @@ pub fn checks_on(issue: &IssueUrl, sha: &str) -> Result<Vec<Check>> {

/// Run `gh <args>`, failing with its stderr if it exits non-zero.
fn gh(args: &[&str]) -> Result<()> {
gh_stdout(args).map(drop)
}

/// Run `gh <args>` and return its trimmed stdout, failing with its stderr if
/// it exits non-zero.
fn gh_stdout(args: &[&str]) -> Result<String> {
let output = Command::new("gh")
.args(args)
.output()
Expand All @@ -340,7 +462,7 @@ fn gh(args: &[&str]) -> Result<()> {
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

/// Every item of the `field` list of the GitHub API's `path`, across all its
Expand Down
10 changes: 10 additions & 0 deletions src/issue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ impl IssueUrl {
}
}

/// Issue `number` in the same repository.
pub fn sibling(&self, number: u64) -> IssueUrl {
IssueUrl {
url: format!("https://github.com/{}/issues/{number}", self.repo_slug()),
owner: self.owner.clone(),
repo: self.repo.clone(),
number,
}
}

/// `owner/repo`, as `gh --repo` takes it.
pub fn repo_slug(&self) -> String {
format!("{}/{}", self.owner, self.repo)
Expand Down
38 changes: 32 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ mod prompt;
mod prompts_page;
mod run;
mod session;
mod spec_run;
mod update;
mod worktree;

Expand All @@ -28,6 +29,7 @@ use std::process::ExitCode;
use args::{Command, RunArgs};
use config::UserConfig;
use notification::{NotificationAsk, RunNotification};
use run::Goal;

const HELP: &str = "\
thirdshift turns a GitHub issue into a ready-for-review pull request, or a merged one, unattended.
Expand Down Expand Up @@ -78,7 +80,12 @@ The Resend API key comes only from the RESEND_API_KEY environment variable:

fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let RunArgs { issue, goal, email } = match args::parse(&args) {
let RunArgs {
issue,
goal,
email,
spec_branch,
} = match args::parse(&args) {
Ok(Command::Help) => {
print!("{HELP}");
return ExitCode::SUCCESS;
Expand All @@ -104,13 +111,22 @@ fn main() -> ExitCode {
return ExitCode::FAILURE;
}
};
let goal = goal.unwrap_or(config.default_goal());
// A Ticket's Run in a Spec run is always a Merge run, and leaves the Run
// notification and the Launch directory to the Spec run.
let (goal, email, launch_pull) = match spec_branch {
Some(_) => (Goal::Merged, NotificationAsk::Skip, false),
None => (
goal.unwrap_or(config.default_goal()),
email.unwrap_or(config.email.default_ask()),
config.launch_pull,
),
};
// First, so no interrupt can end the Run once its notification is checked.
if let Err(error) = interrupt::install() {
progress::step(format_args!("{error:#}"));
return ExitCode::FAILURE;
}
let notification = match email.unwrap_or(config.email.default_ask()) {
let notification = match email {
NotificationAsk::Send(to) => RunNotification::new(to, &config.email, &issue).map(Some),
NotificationAsk::Skip => Ok(None),
};
Expand All @@ -121,11 +137,21 @@ fn main() -> ExitCode {
return ExitCode::FAILURE;
}
};
let ended = run::run(&issue, goal, &config.logs_dir, config.launch_pull);
let ended = run::run(
&issue,
goal,
&config.logs_dir,
launch_pull,
spec_branch.as_deref(),
);
let code = match &ended {
Ok(reached) => {
// Also on stderr, so the outcome shows even when stdout is captured.
progress::step(format_args!("PR {} is {}", reached.pr_url, goal.outcome()));
progress::step(format_args!(
"PR {} is {}",
reached.pr_url,
reached.goal.outcome()
));
print_pr_url(&reached.pr_url);
ExitCode::SUCCESS
}
Expand All @@ -141,7 +167,7 @@ fn main() -> ExitCode {
}
};
if let Some(notification) = notification {
notification.send(&ended, goal);
notification.send(&ended);
}
code
}
Expand Down
12 changes: 6 additions & 6 deletions src/notification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::github;
use crate::host;
use crate::issue::IssueUrl;
use crate::progress;
use crate::run::{Goal, Reached};
use crate::run::Reached;

/// What a Run asks about its Run notification, by its command or, without
/// `email` or `no-email`, by the User config.
Expand Down Expand Up @@ -54,15 +54,15 @@ impl RunNotification {
})
}

/// Send the notification for the Run that `ended`, whose goal was `goal`.
/// A failed send is only a warning: it never changes the Run's outcome.
pub fn send(self, ended: &Result<Reached, FailedRun>, goal: Goal) {
/// Send the notification for the Run that `ended`. A failed send is only
/// a warning: it never changes the Run's outcome.
pub fn send(self, ended: &Result<Reached, FailedRun>) {
let (outcome, pr_url, cause, log) = match ended {
Ok(reached) => (
goal.outcome(),
reached.goal.outcome(),
Some(reached.pr_url.as_str()),
None,
Some(reached.log.as_path()),
reached.log.as_deref(),
),
Err(failed) => {
let (outcome, cause) = if failed.interrupted {
Expand Down
Loading
Loading