Skip to content

Commit da0d5ac

Browse files
authored
Merge pull request #1780 from ehuss/transfer
Add an issue transfer command
2 parents d5e4458 + f054b94 commit da0d5ac

File tree

6 files changed

+216
-4
lines changed

6 files changed

+216
-4
lines changed

parser/src/command.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub mod prioritize;
1313
pub mod relabel;
1414
pub mod second;
1515
pub mod shortcut;
16+
pub mod transfer;
1617

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

3133
#[derive(Debug)]
@@ -132,6 +134,11 @@ impl<'a> Input<'a> {
132134
Command::Close,
133135
&original_tokenizer,
134136
));
137+
success.extend(parse_single_command(
138+
transfer::TransferCommand::parse,
139+
Command::Transfer,
140+
&original_tokenizer,
141+
));
135142

136143
if success.len() > 1 {
137144
panic!(
@@ -207,6 +214,7 @@ impl<'a> Command<'a> {
207214
Command::Shortcut(r) => r.is_ok(),
208215
Command::Close(r) => r.is_ok(),
209216
Command::Note(r) => r.is_ok(),
217+
Command::Transfer(r) => r.is_ok(),
210218
}
211219
}
212220

parser/src/command/transfer.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//! Parses the `@bot transfer reponame` command.
2+
3+
use crate::error::Error;
4+
use crate::token::{Token, Tokenizer};
5+
use std::fmt;
6+
7+
#[derive(Debug, PartialEq, Eq)]
8+
pub struct TransferCommand(pub String);
9+
10+
#[derive(Debug)]
11+
pub enum ParseError {
12+
MissingRepo,
13+
}
14+
15+
impl std::error::Error for ParseError {}
16+
17+
impl fmt::Display for ParseError {
18+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
19+
match self {
20+
ParseError::MissingRepo => write!(f, "missing repository name"),
21+
}
22+
}
23+
}
24+
25+
impl TransferCommand {
26+
pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result<Option<Self>, Error<'a>> {
27+
if !matches!(input.peek_token()?, Some(Token::Word("transfer"))) {
28+
return Ok(None);
29+
}
30+
input.next_token()?;
31+
let repo = if let Some(Token::Word(repo)) = input.next_token()? {
32+
repo.to_owned()
33+
} else {
34+
return Err(input.error(ParseError::MissingRepo));
35+
};
36+
Ok(Some(TransferCommand(repo)))
37+
}
38+
}

src/config.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ pub(crate) struct Config {
4444
#[serde(default = "ValidateConfig::default")]
4545
pub(crate) validate_config: Option<ValidateConfig>,
4646
pub(crate) pr_tracking: Option<ReviewPrefsConfig>,
47+
pub(crate) transfer: Option<TransferConfig>,
4748
}
4849

4950
#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
@@ -344,6 +345,11 @@ pub(crate) struct ReviewPrefsConfig {
344345
_empty: (),
345346
}
346347

348+
#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
349+
#[serde(rename_all = "kebab-case")]
350+
#[serde(deny_unknown_fields)]
351+
pub(crate) struct TransferConfig {}
352+
347353
fn get_cached_config(repo: &str) -> Option<Result<Arc<Config>, ConfigurationError>> {
348354
let cache = CONFIG_CACHE.read().unwrap();
349355
cache.get(repo).and_then(|(config, fetch_time)| {
@@ -520,6 +526,7 @@ mod tests {
520526
no_merges: None,
521527
validate_config: Some(ValidateConfig {}),
522528
pr_tracking: None,
529+
transfer: None,
523530
}
524531
);
525532
}

src/github.rs

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -877,6 +877,63 @@ impl Issue {
877877
));
878878
Ok(client.json(req).await?)
879879
}
880+
881+
/// Returns the GraphQL ID of this issue.
882+
async fn graphql_issue_id(&self, client: &GithubClient) -> anyhow::Result<String> {
883+
let repo = self.repository();
884+
let mut issue_id = client
885+
.graphql_query(
886+
"query($owner:String!, $repo:String!, $issueNum:Int!) {
887+
repository(owner: $owner, name: $repo) {
888+
issue(number: $issueNum) {
889+
id
890+
}
891+
}
892+
}
893+
",
894+
serde_json::json!({
895+
"owner": repo.organization,
896+
"repo": repo.repository,
897+
"issueNum": self.number,
898+
}),
899+
)
900+
.await?;
901+
let serde_json::Value::String(issue_id) =
902+
issue_id["data"]["repository"]["issue"]["id"].take()
903+
else {
904+
anyhow::bail!("expected issue id, got {issue_id}");
905+
};
906+
Ok(issue_id)
907+
}
908+
909+
/// Transfers this issue to the given repository.
910+
pub async fn transfer(
911+
&self,
912+
client: &GithubClient,
913+
owner: &str,
914+
repo: &str,
915+
) -> anyhow::Result<()> {
916+
let issue_id = self.graphql_issue_id(client).await?;
917+
let repo_id = client.graphql_repo_id(owner, repo).await?;
918+
client
919+
.graphql_query(
920+
"mutation ($issueId: ID!, $repoId: ID!) {
921+
transferIssue(
922+
input: {createLabelsIfMissing: true, issueId: $issueId, repositoryId: $repoId}
923+
) {
924+
issue {
925+
id
926+
}
927+
}
928+
}",
929+
serde_json::json!({
930+
"issueId": issue_id,
931+
"repoId": repo_id,
932+
}),
933+
)
934+
.await?;
935+
Ok(())
936+
}
880937
}
881938

882939
#[derive(Debug, serde::Deserialize)]
@@ -2203,24 +2260,50 @@ impl GithubClient {
22032260
}
22042261

22052262
/// Issues an ad-hoc GraphQL query.
2206-
pub async fn graphql_query<T: serde::de::DeserializeOwned>(
2263+
///
2264+
/// You are responsible for checking the `errors` array when calling this
2265+
/// function to determine if there is an error. Only use this if you are
2266+
/// looking for specific error codes, or don't care about errors. Use
2267+
/// [`GithubClient::graphql_query`] if you would prefer to have a generic
2268+
/// error message.
2269+
pub async fn graphql_query_with_errors(
22072270
&self,
22082271
query: &str,
22092272
vars: serde_json::Value,
2210-
) -> anyhow::Result<T> {
2273+
) -> anyhow::Result<serde_json::Value> {
22112274
self.json(self.post(&self.graphql_url).json(&serde_json::json!({
22122275
"query": query,
22132276
"variables": vars,
22142277
})))
22152278
.await
22162279
}
22172280

2281+
/// Issues an ad-hoc GraphQL query.
2282+
///
2283+
/// See [`GithubClient::graphql_query_with_errors`] if you need to check
2284+
/// for specific errors.
2285+
pub async fn graphql_query(
2286+
&self,
2287+
query: &str,
2288+
vars: serde_json::Value,
2289+
) -> anyhow::Result<serde_json::Value> {
2290+
let result: serde_json::Value = self.graphql_query_with_errors(query, vars).await?;
2291+
if let Some(errors) = result["errors"].as_array() {
2292+
let messages: Vec<_> = errors
2293+
.iter()
2294+
.map(|err| err["message"].as_str().unwrap_or_default())
2295+
.collect();
2296+
anyhow::bail!("error: {}", messages.join("\n"));
2297+
}
2298+
Ok(result)
2299+
}
2300+
22182301
/// Returns the object ID of the given user.
22192302
///
22202303
/// Returns `None` if the user doesn't exist.
22212304
pub async fn user_object_id(&self, user: &str) -> anyhow::Result<Option<String>> {
22222305
let user_info: serde_json::Value = self
2223-
.graphql_query(
2306+
.graphql_query_with_errors(
22242307
"query($user:String!) {
22252308
user(login:$user) {
22262309
id
@@ -2273,7 +2356,7 @@ impl GithubClient {
22732356
// work on forks. This GraphQL query seems to work fairly reliably,
22742357
// and seems to cost only 1 point.
22752358
match self
2276-
.graphql_query::<serde_json::Value>(
2359+
.graphql_query_with_errors(
22772360
"query($repository_owner:String!, $repository_name:String!, $user_id:ID!) {
22782361
repository(owner: $repository_owner, name: $repository_name) {
22792362
defaultBranchRef {
@@ -2398,6 +2481,27 @@ impl GithubClient {
23982481
.with_context(|| format!("failed to set milestone for {url} to milestone {milestone:?}"))?;
23992482
Ok(())
24002483
}
2484+
2485+
/// Returns the GraphQL ID of the given repository.
2486+
async fn graphql_repo_id(&self, owner: &str, repo: &str) -> anyhow::Result<String> {
2487+
let mut repo_id = self
2488+
.graphql_query(
2489+
"query($owner:String!, $repo:String!) {
2490+
repository(owner: $owner, name: $repo) {
2491+
id
2492+
}
2493+
}",
2494+
serde_json::json!({
2495+
"owner": owner,
2496+
"repo": repo,
2497+
}),
2498+
)
2499+
.await?;
2500+
let serde_json::Value::String(repo_id) = repo_id["data"]["repository"]["id"].take() else {
2501+
anyhow::bail!("expected repo id, got {repo_id}");
2502+
};
2503+
Ok(repo_id)
2504+
}
24012505
}
24022506

24032507
#[derive(Debug, serde::Deserialize)]

src/handlers.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ mod review_submitted;
4747
mod rfc_helper;
4848
pub mod rustc_commits;
4949
mod shortcut;
50+
mod transfer;
5051
pub mod types_planning_updates;
5152
mod validate_config;
5253

@@ -292,6 +293,7 @@ command_handlers! {
292293
shortcut: Shortcut,
293294
close: Close,
294295
note: Note,
296+
transfer: Transfer,
295297
}
296298

297299
pub struct Context {

src/handlers/transfer.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//! Handles the `@rustbot transfer reponame` command to transfer an issue to
2+
//! another repository.
3+
4+
use crate::{config::TransferConfig, github::Event, handlers::Context};
5+
use parser::command::transfer::TransferCommand;
6+
7+
pub(super) async fn handle_command(
8+
ctx: &Context,
9+
_config: &TransferConfig,
10+
event: &Event,
11+
input: TransferCommand,
12+
) -> anyhow::Result<()> {
13+
let issue = event.issue().unwrap();
14+
if issue.is_pr() {
15+
issue
16+
.post_comment(&ctx.github, "Only issues can be transferred.")
17+
.await?;
18+
return Ok(());
19+
}
20+
if !event
21+
.user()
22+
.is_team_member(&ctx.github)
23+
.await
24+
.ok()
25+
.unwrap_or(false)
26+
{
27+
issue
28+
.post_comment(
29+
&ctx.github,
30+
"Only team members may use the `transfer` command.",
31+
)
32+
.await?;
33+
return Ok(());
34+
}
35+
36+
let repo = input.0;
37+
let repo = repo.strip_prefix("rust-lang/").unwrap_or(&repo);
38+
if repo.contains('/') {
39+
issue
40+
.post_comment(&ctx.github, "Cross-organization transfers are not allowed.")
41+
.await?;
42+
return Ok(());
43+
}
44+
45+
if let Err(e) = issue.transfer(&ctx.github, "rust-lang", &repo).await {
46+
issue
47+
.post_comment(&ctx.github, &format!("Failed to transfer issue:\n{e:?}"))
48+
.await?;
49+
return Ok(());
50+
}
51+
52+
Ok(())
53+
}

0 commit comments

Comments
 (0)