Skip to content

Commit

Permalink
Merge pull request #1780 from ehuss/transfer
Browse files Browse the repository at this point in the history
Add an issue transfer command
  • Loading branch information
jackh726 committed May 4, 2024
2 parents d5e4458 + f054b94 commit da0d5ac
Show file tree
Hide file tree
Showing 6 changed files with 216 additions and 4 deletions.
8 changes: 8 additions & 0 deletions parser/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod prioritize;
pub mod relabel;
pub mod second;
pub mod shortcut;
pub mod transfer;

#[derive(Debug, PartialEq)]
pub enum Command<'a> {
Expand All @@ -26,6 +27,7 @@ pub enum Command<'a> {
Shortcut(Result<shortcut::ShortcutCommand, Error<'a>>),
Close(Result<close::CloseCommand, Error<'a>>),
Note(Result<note::NoteCommand, Error<'a>>),
Transfer(Result<transfer::TransferCommand, Error<'a>>),
}

#[derive(Debug)]
Expand Down Expand Up @@ -132,6 +134,11 @@ impl<'a> Input<'a> {
Command::Close,
&original_tokenizer,
));
success.extend(parse_single_command(
transfer::TransferCommand::parse,
Command::Transfer,
&original_tokenizer,
));

if success.len() > 1 {
panic!(
Expand Down Expand Up @@ -207,6 +214,7 @@ impl<'a> Command<'a> {
Command::Shortcut(r) => r.is_ok(),
Command::Close(r) => r.is_ok(),
Command::Note(r) => r.is_ok(),
Command::Transfer(r) => r.is_ok(),
}
}

Expand Down
38 changes: 38 additions & 0 deletions parser/src/command/transfer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//! Parses the `@bot transfer reponame` command.

use crate::error::Error;
use crate::token::{Token, Tokenizer};
use std::fmt;

#[derive(Debug, PartialEq, Eq)]
pub struct TransferCommand(pub String);

#[derive(Debug)]
pub enum ParseError {
MissingRepo,
}

impl std::error::Error for ParseError {}

impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ParseError::MissingRepo => write!(f, "missing repository name"),
}
}
}

impl TransferCommand {
pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result<Option<Self>, Error<'a>> {
if !matches!(input.peek_token()?, Some(Token::Word("transfer"))) {
return Ok(None);
}
input.next_token()?;
let repo = if let Some(Token::Word(repo)) = input.next_token()? {
repo.to_owned()
} else {
return Err(input.error(ParseError::MissingRepo));
};
Ok(Some(TransferCommand(repo)))
}
}
7 changes: 7 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ pub(crate) struct Config {
#[serde(default = "ValidateConfig::default")]
pub(crate) validate_config: Option<ValidateConfig>,
pub(crate) pr_tracking: Option<ReviewPrefsConfig>,
pub(crate) transfer: Option<TransferConfig>,
}

#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
Expand Down Expand Up @@ -344,6 +345,11 @@ pub(crate) struct ReviewPrefsConfig {
_empty: (),
}

#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
#[serde(deny_unknown_fields)]
pub(crate) struct TransferConfig {}

fn get_cached_config(repo: &str) -> Option<Result<Arc<Config>, ConfigurationError>> {
let cache = CONFIG_CACHE.read().unwrap();
cache.get(repo).and_then(|(config, fetch_time)| {
Expand Down Expand Up @@ -520,6 +526,7 @@ mod tests {
no_merges: None,
validate_config: Some(ValidateConfig {}),
pr_tracking: None,
transfer: None,
}
);
}
Expand Down
112 changes: 108 additions & 4 deletions src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,63 @@ impl Issue {
));
Ok(client.json(req).await?)
}

/// Returns the GraphQL ID of this issue.
async fn graphql_issue_id(&self, client: &GithubClient) -> anyhow::Result<String> {
let repo = self.repository();
let mut issue_id = client
.graphql_query(
"query($owner:String!, $repo:String!, $issueNum:Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $issueNum) {
id
}
}
}
",
serde_json::json!({
"owner": repo.organization,
"repo": repo.repository,
"issueNum": self.number,
}),
)
.await?;
let serde_json::Value::String(issue_id) =
issue_id["data"]["repository"]["issue"]["id"].take()
else {
anyhow::bail!("expected issue id, got {issue_id}");
};
Ok(issue_id)
}

/// Transfers this issue to the given repository.
pub async fn transfer(
&self,
client: &GithubClient,
owner: &str,
repo: &str,
) -> anyhow::Result<()> {
let issue_id = self.graphql_issue_id(client).await?;
let repo_id = client.graphql_repo_id(owner, repo).await?;
client
.graphql_query(
"mutation ($issueId: ID!, $repoId: ID!) {
transferIssue(
input: {createLabelsIfMissing: true, issueId: $issueId, repositoryId: $repoId}
) {
issue {
id
}
}
}",
serde_json::json!({
"issueId": issue_id,
"repoId": repo_id,
}),
)
.await?;
Ok(())
}
}

#[derive(Debug, serde::Deserialize)]
Expand Down Expand Up @@ -2203,24 +2260,50 @@ impl GithubClient {
}

/// Issues an ad-hoc GraphQL query.
pub async fn graphql_query<T: serde::de::DeserializeOwned>(
///
/// You are responsible for checking the `errors` array when calling this
/// function to determine if there is an error. Only use this if you are
/// looking for specific error codes, or don't care about errors. Use
/// [`GithubClient::graphql_query`] if you would prefer to have a generic
/// error message.
pub async fn graphql_query_with_errors(
&self,
query: &str,
vars: serde_json::Value,
) -> anyhow::Result<T> {
) -> anyhow::Result<serde_json::Value> {
self.json(self.post(&self.graphql_url).json(&serde_json::json!({
"query": query,
"variables": vars,
})))
.await
}

/// Issues an ad-hoc GraphQL query.
///
/// See [`GithubClient::graphql_query_with_errors`] if you need to check
/// for specific errors.
pub async fn graphql_query(
&self,
query: &str,
vars: serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
let result: serde_json::Value = self.graphql_query_with_errors(query, vars).await?;
if let Some(errors) = result["errors"].as_array() {
let messages: Vec<_> = errors
.iter()
.map(|err| err["message"].as_str().unwrap_or_default())
.collect();
anyhow::bail!("error: {}", messages.join("\n"));
}
Ok(result)
}

/// Returns the object ID of the given user.
///
/// Returns `None` if the user doesn't exist.
pub async fn user_object_id(&self, user: &str) -> anyhow::Result<Option<String>> {
let user_info: serde_json::Value = self
.graphql_query(
.graphql_query_with_errors(
"query($user:String!) {
user(login:$user) {
id
Expand Down Expand Up @@ -2273,7 +2356,7 @@ impl GithubClient {
// work on forks. This GraphQL query seems to work fairly reliably,
// and seems to cost only 1 point.
match self
.graphql_query::<serde_json::Value>(
.graphql_query_with_errors(
"query($repository_owner:String!, $repository_name:String!, $user_id:ID!) {
repository(owner: $repository_owner, name: $repository_name) {
defaultBranchRef {
Expand Down Expand Up @@ -2398,6 +2481,27 @@ impl GithubClient {
.with_context(|| format!("failed to set milestone for {url} to milestone {milestone:?}"))?;
Ok(())
}

/// Returns the GraphQL ID of the given repository.
async fn graphql_repo_id(&self, owner: &str, repo: &str) -> anyhow::Result<String> {
let mut repo_id = self
.graphql_query(
"query($owner:String!, $repo:String!) {
repository(owner: $owner, name: $repo) {
id
}
}",
serde_json::json!({
"owner": owner,
"repo": repo,
}),
)
.await?;
let serde_json::Value::String(repo_id) = repo_id["data"]["repository"]["id"].take() else {
anyhow::bail!("expected repo id, got {repo_id}");
};
Ok(repo_id)
}
}

#[derive(Debug, serde::Deserialize)]
Expand Down
2 changes: 2 additions & 0 deletions src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ mod review_submitted;
mod rfc_helper;
pub mod rustc_commits;
mod shortcut;
mod transfer;
pub mod types_planning_updates;
mod validate_config;

Expand Down Expand Up @@ -292,6 +293,7 @@ command_handlers! {
shortcut: Shortcut,
close: Close,
note: Note,
transfer: Transfer,
}

pub struct Context {
Expand Down
53 changes: 53 additions & 0 deletions src/handlers/transfer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//! Handles the `@rustbot transfer reponame` command to transfer an issue to
//! another repository.

use crate::{config::TransferConfig, github::Event, handlers::Context};
use parser::command::transfer::TransferCommand;

pub(super) async fn handle_command(
ctx: &Context,
_config: &TransferConfig,
event: &Event,
input: TransferCommand,
) -> anyhow::Result<()> {
let issue = event.issue().unwrap();
if issue.is_pr() {
issue
.post_comment(&ctx.github, "Only issues can be transferred.")
.await?;
return Ok(());
}
if !event
.user()
.is_team_member(&ctx.github)
.await
.ok()
.unwrap_or(false)
{
issue
.post_comment(
&ctx.github,
"Only team members may use the `transfer` command.",
)
.await?;
return Ok(());
}

let repo = input.0;
let repo = repo.strip_prefix("rust-lang/").unwrap_or(&repo);
if repo.contains('/') {
issue
.post_comment(&ctx.github, "Cross-organization transfers are not allowed.")
.await?;
return Ok(());
}

if let Err(e) = issue.transfer(&ctx.github, "rust-lang", &repo).await {
issue
.post_comment(&ctx.github, &format!("Failed to transfer issue:\n{e:?}"))
.await?;
return Ok(());
}

Ok(())
}

0 comments on commit da0d5ac

Please sign in to comment.