blob: d69cc3da630bbe75f6ffa92669f02b62f6674f3d [file] [log] [blame]
// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:github/github.dart';
import 'package:graphql/client.dart';
import 'src/common.dart';
class TransferIssuesCommand extends ReportCommand {
TransferIssuesCommand()
: super('transfer-issues',
'Bulk transfer issues from one repo to another.') {
argParser.addFlag(
'apply-changes',
negatable: false,
help: 'WARNING: This will transfer the issues. Please preview the changes'
"first by running without '--apply-changes'.",
defaultsTo: false,
);
argParser.addMultiOption(
'issues',
valueHelp: '1,2,3',
help: 'Specifiy the numbers of specific issues to transfer, otherwise'
' transfers all.',
);
argParser.addOption(
'source-repo',
valueHelp: 'repo-org/repo-name',
help: 'The source repository for the issues to be moved from.',
mandatory: true,
);
argParser.addOption(
'target-repo',
valueHelp: 'repo-org/repo-name',
help: 'The target repository name where the issues will be moved to.',
mandatory: true,
);
argParser.addOption(
'add-label',
help: 'Add a label to all transferred issues.',
valueHelp: 'package:foo',
);
}
@override
String get invocation =>
'${super.invocation} --source-repo repo-org/old-repo-name --target-repo repo-org/new-repo-name --add-label pkg:old-repo-name';
@override
Future<int> run() async {
var applyChanges = argResults!['apply-changes'] as bool;
var sourceRepo = argResults!['source-repo'] as String;
var targetRepo = argResults!['target-repo'] as String;
var issueNumberString = argResults!['issues'] as List<String>?;
var issueNumbers = issueNumberString?.map(int.parse).toList();
var labelName = argResults!['add-label'] as String?;
if (!applyChanges) {
print('This is a dry run, no issues will be transferred!');
}
return await transferAndLabelIssues(
RepositorySlug.full(sourceRepo),
RepositorySlug.full(targetRepo),
issueNumbers,
labelName,
applyChanges,
);
}
Future<int> transferAndLabelIssues(
RepositorySlug sourceRepo,
RepositorySlug targetRepo, [
List<int>? issueNumbers,
String? labelName,
bool applyChanges = false,
]) async {
if (labelName != null) {
print('Create label $labelName');
if (applyChanges) {
await reportRunner.github.issues.createLabel(targetRepo, labelName);
}
}
var issues = await transferIssues(
sourceRepo,
targetRepo,
labelName,
applyChanges,
);
print('Transferred ${issues.length} issues');
if (labelName != null) {
print('Adding label $labelName to all transferred issues');
for (var issueNumber in issues) {
print('Add to issue $issueNumber');
if (applyChanges) {
await reportRunner.github.issues.addLabelsToIssue(
targetRepo,
issueNumber,
[labelName],
);
}
}
}
return 0;
}
Future<List<String>> getIssueIds(
RepositorySlug slug, [
List<int>? issueNumbers,
]) async {
final queryString = '''query {
repository(owner:"${slug.owner}", name:"${slug.name}") {
issues(last:100) {
nodes {
id
number
}
}
}
}
''';
final result = await query(QueryOptions(
document: gql(queryString),
// If the cache is enabled this will always return the same issues, even
// after transferring them to another repo.
fetchPolicy: FetchPolicy.noCache,
parserFn: (data) {
var repository = data['repository'] as Map;
var issues = repository['issues'] as Map;
var nodes = issues['nodes'] as List;
return nodes
.map((node) => node as Map)
.where((node) => issueNumbers != null
? issueNumbers.contains(node['number'] as int)
: true)
.map((node) => node['id'] as String)
.toList();
},
));
return result.hasException ? throw result.exception! : result.parsedData!;
}
Future<String> getRepositoryId(RepositorySlug slug) async {
final queryString = '''query {
repository(owner:"${slug.owner}", name:"${slug.name}") {
id
}
}
''';
final result = await query(QueryOptions(
document: gql(queryString),
parserFn: (data) {
var repository = data['repository'] as Map;
return repository['id'] as String;
},
));
return result.hasException ? throw result.exception! : result.parsedData!;
}
Future<List<int>> transferIssues(
RepositorySlug sourceRepo,
RepositorySlug targetRepo,
String? issueLabel,
bool applyChanges,
) async {
var repositoryId = await getRepositoryId(targetRepo);
var allIssueIds = <int>[];
// As we can only do 100 issues at a time per GraphQL API limitations, we
// need to run this in a loop.
while (true) {
var issueIds = await getIssueIds(sourceRepo);
if (issueIds.isEmpty) {
print('Done transferring a total of ${allIssueIds.length} issues from '
'$sourceRepo to $targetRepo');
return allIssueIds;
}
print('Transfer ${issueIds.length} issues from $sourceRepo to $targetRepo'
' with id $repositoryId');
var transferredIssues = await _transferMutation(
issueIds,
repositoryId,
applyChanges,
);
allIssueIds.addAll(transferredIssues);
if (!applyChanges) {
// Return mock list of indices to allow user to see how downstream
// methods would continue.
return List.generate(issueIds.length, (index) => index);
}
print('Waiting a bit to allow Github to catch up...');
await Future<void>.delayed(const Duration(seconds: 5));
}
}
Future<List<int>> _transferMutation(
List<String> issueIds,
String repositoryId,
bool applyChanges,
) async {
var queryStringBuilder = StringBuffer('mutation {\n');
for (var i = 0; i < issueIds.length; i++) {
var issue = issueIds[i];
queryStringBuilder.writeln(
'''t${i.toString()}: transferIssue(input: {issueId: "$issue", repositoryId: "$repositoryId", createLabelsIfMissing: true}) { issue { number }}''');
}
queryStringBuilder.writeln('}');
final queryString = queryStringBuilder.toString();
if (applyChanges) {
final result = await mutate(MutationOptions(
document: gql(queryString),
parserFn: (data) {
//{__typename: Mutation, t0: {__typename: TransferIssuePayload, issue:
// {__typename: Issue, number: 18}}, t1: {__typename:
//TransferIssuePayload, issue: {__type
return data.entries
.where((entry) => entry.key != '__typename')
.map((entry) => entry.value)
.map((mutation) => mutation as Map)
.map((mutation) => mutation['issue'] as Map)
.map((issue) => issue['number'] as int)
.toList();
},
));
if (result.hasException) throw result.exception!;
return result.parsedData ?? [];
} else {
return [];
}
}
}