diff --git a/.github/scripts/cmd/README.md b/.github/scripts/cmd/README.md new file mode 100644 index 0000000000000..fd70d52c8d545 --- /dev/null +++ b/.github/scripts/cmd/README.md @@ -0,0 +1,62 @@ +# Command Bot Documentation + +The command bot allows contributors to perform self-service actions on PRs using comment commands. + +## Available Commands + +### Label Command (Self-service) + +Add labels to your PR without requiring maintainer intervention: + +```bash +/cmd label T1-FRAME # Add single label +/cmd label T1-FRAME R0-no-crate-publish-required # Add multiple labels +/cmd label T1-FRAME A2-substantial D3-involved # Add multiple labels +``` + +**Available Labels:** +The bot dynamically fetches all current labels from the repository, ensuring it's always up-to-date. For label meanings and descriptions, see the [official label documentation](https://paritytech.github.io/labels/doc_polkadot-sdk.html). + +**Features**: +- **Auto-Correction**: Automatically fixes high-confidence typos (e.g., `T1-FRAM` → `T1-FRAME`) +- **Case Fixing**: Handles case variations (e.g., `I2-Bug` → `I2-bug`) +- **Smart Suggestions**: For ambiguous inputs, provides multiple options to choose from + +### Other Commands + +```bash +/cmd fmt # Format code (cargo +nightly fmt and taplo) +/cmd prdoc # Generate PR documentation +/cmd bench # Run benchmarks +/cmd update-ui # Update UI tests +/cmd --help # Show help for all commands +``` + +### Common Flags + +- `--quiet`: Don't post start/end messages in PR +- `--clean`: Clean up previous bot comments +- `--image `: Override docker image + +## How It Works + +1. **Command Detection**: The bot listens for comments starting with `/cmd` on PRs +2. **Permission Check**: Verifies if the user is an organization member +3. **Command Execution**: Runs the specified command in a containerized environment +4. **Result Handling**: + - For label commands: Applies labels via GitHub API + - For other commands: Commits changes back to the PR branch +5. **Feedback**: Posts success/failure messages in the PR + +## Security + +- Organization member check prevents unauthorized usage +- Commands from non-members run using bot scripts from master branch + +## Troubleshooting + +If a command fails: +1. Check the GitHub Actions logs linked in the bot's comment +2. Verify the command syntax matches the examples +3. Ensure you have permission to perform the action +4. For label commands, verify the label names are in the allowed list diff --git a/.github/scripts/cmd/cmd.py b/.github/scripts/cmd/cmd.py index bec7aa18c33bc..9614f332a1368 100755 --- a/.github/scripts/cmd/cmd.py +++ b/.github/scripts/cmd/cmd.py @@ -7,6 +7,9 @@ import _help import importlib.util import re +import urllib.request +import urllib.parse +import difflib _HelpAction = _help._HelpAction @@ -31,6 +34,143 @@ def setup_logging(): os.makedirs('/tmp/cmd') open('/tmp/cmd/command_output.log', 'w') +def fetch_repo_labels(): + """Fetch current labels from the GitHub repository""" + try: + # Use GitHub API to get current labels + repo_owner = os.environ.get('GITHUB_REPOSITORY_OWNER', 'paritytech') + repo_name = os.environ.get('GITHUB_REPOSITORY', 'paritytech/polkadot-sdk').split('/')[-1] + + api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/labels?per_page=100" + + # Add GitHub token if available for higher rate limits + headers = {'User-Agent': 'polkadot-sdk-cmd-bot'} + github_token = os.environ.get('GITHUB_TOKEN') + if github_token: + headers['Authorization'] = f'token {github_token}' + + req = urllib.request.Request(api_url, headers=headers) + + with urllib.request.urlopen(req) as response: + if response.getcode() == 200: + labels_data = json.loads(response.read().decode()) + label_names = [label['name'] for label in labels_data] + print_and_log(f"Fetched {len(label_names)} labels from repository") + return label_names + else: + print_and_log(f"Failed to fetch labels: HTTP {response.getcode()}") + return None + except Exception as e: + print_and_log(f"Error fetching labels from repository: {e}") + return None + + +def check_pr_status(pr_number): + """Check if PR is merged or in merge queue""" + try: + # Get GitHub token from environment + github_token = os.environ.get('GITHUB_TOKEN') + if not github_token: + print_and_log("Error: GITHUB_TOKEN not set, cannot verify PR status") + return False # Prevent labeling if we can't check status + + repo_owner = os.environ.get('GITHUB_REPOSITORY_OWNER', 'paritytech') + repo_name = os.environ.get('GITHUB_REPOSITORY', 'paritytech/polkadot-sdk').split('/')[-1] + api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/pulls/{pr_number}" + + headers = { + 'User-Agent': 'polkadot-sdk-cmd-bot', + 'Authorization': f'token {github_token}', + 'Accept': 'application/vnd.github.v3+json' + } + + req = urllib.request.Request(api_url, headers=headers) + + with urllib.request.urlopen(req) as response: + if response.getcode() == 200: + data = json.loads(response.read().decode()) + + # Check if PR is merged + if data.get('merged', False): + return False + + # Check if PR is closed + if data.get('state') == 'closed': + return False + + # Check if PR is in merge queue (auto_merge enabled) + if data.get('auto_merge') is not None: + return False + + return True # PR is open and not in merge queue + else: + print_and_log(f"Failed to fetch PR status: HTTP {response.getcode()}") + return False # Prevent labeling if we can't check status + except Exception as e: + print_and_log(f"Error checking PR status: {e}") + return False # Prevent labeling if we can't check status + + +def find_closest_labels(invalid_label, valid_labels, max_suggestions=3, cutoff=0.6): + """Find the closest matching labels using fuzzy string matching""" + # Get close matches using difflib + close_matches = difflib.get_close_matches( + invalid_label, + valid_labels, + n=max_suggestions, + cutoff=cutoff + ) + + return close_matches + +def auto_correct_labels(invalid_labels, valid_labels, auto_correct_threshold=0.8): + """Automatically correct labels when confidence is high, otherwise suggest""" + corrections = [] + suggestions = [] + + for invalid_label in invalid_labels: + closest = find_closest_labels(invalid_label, valid_labels, max_suggestions=1) + + if closest: + # Calculate similarity for the top match + top_match = closest[0] + similarity = difflib.SequenceMatcher(None, invalid_label.lower(), top_match.lower()).ratio() + + if similarity >= auto_correct_threshold: + # High confidence - auto-correct + corrections.append((invalid_label, top_match)) + else: + # Lower confidence - suggest alternatives + all_matches = find_closest_labels(invalid_label, valid_labels, max_suggestions=3) + if all_matches: + labels_str = ', '.join(f"'{label}'" for label in all_matches) + suggestion = f"'{invalid_label}' → did you mean: {labels_str}?" + else: + suggestion = f"'{invalid_label}' → no close matches found" + suggestions.append(suggestion) + else: + # No close matches - try prefix suggestions + prefix_match = re.match(r'^([A-Z]\d+)-', invalid_label) + if prefix_match: + prefix = prefix_match.group(1) + prefix_labels = [label for label in valid_labels if label.startswith(prefix + '-')] + if prefix_labels: + # If there's exactly one prefix match, auto-correct it + if len(prefix_labels) == 1: + corrections.append((invalid_label, prefix_labels[0])) + else: + # Multiple prefix matches - suggest alternatives + suggestion = f"'{invalid_label}' → try labels starting with '{prefix}-': {', '.join(prefix_labels[:3])}" + suggestions.append(suggestion) + else: + suggestion = f"'{invalid_label}' → no labels found with prefix '{prefix}-'" + suggestions.append(suggestion) + else: + suggestion = f"'{invalid_label}' → invalid format (expected format: 'T1-FRAME', 'I2-bug', etc.)" + suggestions.append(suggestion) + + return corrections, suggestions + parser = argparse.ArgumentParser(prog="/cmd ", description='A command runner for polkadot-sdk repo', add_help=False) parser.add_argument('--help', action=_HelpAction, help='help for help if you need some help') # help for help for arg, config in common_args.items(): @@ -93,6 +233,93 @@ def setup_logging(): parser_prdoc = subparsers.add_parser('prdoc', help='Generates PR documentation') generate_prdoc.setup_parser(parser_prdoc, pr_required=False) +""" +LABEL +""" +# Fetch current labels from repository +def get_allowed_labels(): + """Get the current list of allowed labels""" + repo_labels = fetch_repo_labels() + + if repo_labels is not None: + return repo_labels + else: + # Fail if API fetch fails + raise RuntimeError("Failed to fetch labels from repository. Please check your connection and try again.") + +def validate_and_auto_correct_labels(input_labels, valid_labels): + """Validate labels and auto-correct when confidence is high""" + final_labels = [] + correction_messages = [] + all_suggestions = [] + no_match_labels = [] + + # Process all labels first to collect all issues + for label in input_labels: + if label in valid_labels: + final_labels.append(label) + else: + # Invalid label - try auto-correction + corrections, suggestions = auto_correct_labels([label], valid_labels) + + if corrections: + # Auto-correct with high confidence + original, corrected = corrections[0] + final_labels.append(corrected) + similarity = difflib.SequenceMatcher(None, original.lower(), corrected.lower()).ratio() + correction_messages.append(f"Auto-corrected '{original}' → '{corrected}' (similarity: {similarity:.2f})") + elif suggestions: + # Low confidence - collect for batch error + all_suggestions.extend(suggestions) + else: + # No suggestions at all + no_match_labels.append(label) + + # If there are any labels that couldn't be auto-corrected, show all at once + if all_suggestions or no_match_labels: + error_parts = [] + + if all_suggestions: + error_parts.append("Labels requiring manual selection:") + for suggestion in all_suggestions: + error_parts.append(f" • {suggestion}") + + if no_match_labels: + if all_suggestions: + error_parts.append("") # Empty line for separation + error_parts.append("Labels with no close matches:") + for label in no_match_labels: + error_parts.append(f" • '{label}' → no valid suggestions available") + + error_parts.append("") + error_parts.append("For all available labels, see: https://paritytech.github.io/labels/doc_polkadot-sdk.html") + + error_msg = "\n".join(error_parts) + raise ValueError(error_msg) + + return final_labels, correction_messages + +label_example = '''**Examples**: + Add single label + %(prog)s T1-FRAME + + Add multiple labels + %(prog)s T1-FRAME R0-no-crate-publish-required + + Add multiple labels + %(prog)s T1-FRAME A2-substantial D3-involved + +Labels are fetched dynamically from the repository. +Typos are auto-corrected when confidence is high (>80% similarity). +For label meanings, see: https://paritytech.github.io/labels/doc_polkadot-sdk.html +''' + +parser_label = subparsers.add_parser('label', help='Add labels to PR (self-service for contributors)', epilog=label_example, formatter_class=argparse.RawDescriptionHelpFormatter) +for arg, config in common_args.items(): + parser_label.add_argument(arg, **config) + +parser_label.add_argument('labels', nargs='+', help='Labels to add to the PR (auto-corrects typos)') + def main(): global args, unknown, runtimesMatrix args, unknown = parser.parse_known_args() @@ -284,6 +511,54 @@ def main(): print_and_log('❌ Failed to generate prdoc') sys.exit(exit_code) + elif args.command == 'label': + # The actual labeling is handled by the GitHub Action workflow + # This script validates and auto-corrects labels + + try: + # Check if PR is still open and not merged/in merge queue + pr_number = os.environ.get('PR_NUM') + if pr_number: + if not check_pr_status(pr_number): + raise ValueError("Cannot modify labels on merged PRs or PRs in merge queue") + + # Check if user has permission to modify labels + is_org_member = os.environ.get('IS_ORG_MEMBER', 'false').lower() == 'true' + is_pr_author = os.environ.get('IS_PR_AUTHOR', 'false').lower() == 'true' + + if not is_org_member and not is_pr_author: + raise ValueError("Only the PR author or organization members can modify labels") + + # Get allowed labels dynamically + try: + allowed_labels = get_allowed_labels() + except RuntimeError as e: + raise ValueError(str(e)) + + # Validate and auto-correct labels + final_labels, correction_messages = validate_and_auto_correct_labels(args.labels, allowed_labels) + + # Show auto-correction messages + for message in correction_messages: + print(message) + + # Output labels as JSON for GitHub Action + import json + labels_output = {"labels": final_labels} + print(f"LABELS_JSON: {json.dumps(labels_output)}") + except ValueError as e: + print_and_log(f'❌ {e}') + + # Output error as JSON for GitHub Action + import json + error_output = { + "error": "validation_failed", + "message": "Invalid labels found. Please check the suggestions below and try again.", + "details": str(e) + } + print(f"ERROR_JSON: {json.dumps(error_output)}") + sys.exit(1) + print('🚀 Done') if __name__ == '__main__': diff --git a/.github/scripts/cmd/test_cmd.py b/.github/scripts/cmd/test_cmd.py index 483a25cc7981f..0d6bd25402d60 100644 --- a/.github/scripts/cmd/test_cmd.py +++ b/.github/scripts/cmd/test_cmd.py @@ -428,5 +428,346 @@ def test_prdoc_command(self, mock_system, mock_parse_args): mock_exit.assert_not_called() self.mock_generate_prdoc_main.assert_called_with(mock_parse_args.return_value[0]) + @patch.dict('os.environ', {'PR_NUM': '123', 'IS_ORG_MEMBER': 'true', 'IS_PR_AUTHOR': 'false', 'GITHUB_TOKEN': 'fake_token'}) + @patch('cmd.get_allowed_labels') + @patch('cmd.check_pr_status') + @patch('argparse.ArgumentParser.parse_known_args') + def test_label_command_valid_labels(self, mock_parse_args, mock_check_pr_status, mock_get_labels): + """Test label command with valid labels""" + mock_get_labels.return_value = ['T1-FRAME', 'R0-no-crate-publish-required', 'D2-substantial'] + mock_check_pr_status.return_value = True # PR is open + mock_parse_args.return_value = (argparse.Namespace( + command='label', + labels=['T1-FRAME', 'R0-no-crate-publish-required'] + ), []) + + with patch('sys.exit') as mock_exit, patch('builtins.print') as mock_print: + import cmd + cmd.main() + mock_exit.assert_not_called() + + # Check that JSON output was printed + json_call = None + for call in mock_print.call_args_list: + if 'LABELS_JSON:' in str(call): + json_call = call + break + + self.assertIsNotNone(json_call) + self.assertIn('T1-FRAME', str(json_call)) + self.assertIn('R0-no-crate-publish-required', str(json_call)) + + @patch.dict('os.environ', {'PR_NUM': '123', 'IS_ORG_MEMBER': 'true', 'IS_PR_AUTHOR': 'false', 'GITHUB_TOKEN': 'fake_token'}) + @patch('cmd.get_allowed_labels') + @patch('cmd.check_pr_status') + @patch('argparse.ArgumentParser.parse_known_args') + def test_label_command_auto_correction(self, mock_parse_args, mock_check_pr_status, mock_get_labels): + """Test label command with auto-correctable typos""" + mock_get_labels.return_value = ['T1-FRAME', 'R0-no-crate-publish-required', 'D2-substantial'] + mock_check_pr_status.return_value = True # PR is open + mock_parse_args.return_value = (argparse.Namespace( + command='label', + labels=['T1-FRAM', 'R0-no-crate-publish'] # Typos that should be auto-corrected + ), []) + + with patch('sys.exit') as mock_exit, patch('builtins.print') as mock_print: + import cmd + cmd.main() + mock_exit.assert_not_called() + + # Check for auto-correction messages + correction_messages = [str(call) for call in mock_print.call_args_list if 'Auto-corrected' in str(call)] + self.assertTrue(len(correction_messages) > 0) + + # Check that JSON output contains corrected labels + json_call = None + for call in mock_print.call_args_list: + if 'LABELS_JSON:' in str(call): + json_call = call + break + + self.assertIsNotNone(json_call) + self.assertIn('T1-FRAME', str(json_call)) + self.assertIn('R0-no-crate-publish-required', str(json_call)) + + @patch.dict('os.environ', {'PR_NUM': '123', 'IS_ORG_MEMBER': 'true', 'IS_PR_AUTHOR': 'false', 'GITHUB_TOKEN': 'fake_token'}) + @patch('cmd.get_allowed_labels') + @patch('cmd.check_pr_status') + @patch('argparse.ArgumentParser.parse_known_args') + def test_label_command_prefix_correction(self, mock_parse_args, mock_check_pr_status, mock_get_labels): + """Test label command with prefix matching""" + mock_get_labels.return_value = ['T1-FRAME', 'T2-pallets', 'R0-no-crate-publish-required'] + mock_check_pr_status.return_value = True # PR is open + mock_parse_args.return_value = (argparse.Namespace( + command='label', + labels=['T1-something'] # Should match T1-FRAME as the only T1- label + ), []) + + with patch('sys.exit') as mock_exit, patch('builtins.print') as mock_print: + import cmd + cmd.main() + mock_exit.assert_not_called() + + # Check that JSON output contains corrected label + json_call = None + for call in mock_print.call_args_list: + if 'LABELS_JSON:' in str(call): + json_call = call + break + + self.assertIsNotNone(json_call) + self.assertIn('T1-FRAME', str(json_call)) + + @patch.dict('os.environ', {'PR_NUM': '123', 'IS_ORG_MEMBER': 'true', 'IS_PR_AUTHOR': 'false', 'GITHUB_TOKEN': 'fake_token'}) + @patch('cmd.get_allowed_labels') + @patch('cmd.check_pr_status') + @patch('argparse.ArgumentParser.parse_known_args') + def test_label_command_invalid_labels(self, mock_parse_args, mock_check_pr_status, mock_get_labels): + """Test label command with invalid labels that cannot be corrected""" + mock_get_labels.return_value = ['T1-FRAME', 'R0-no-crate-publish-required', 'D2-substantial'] + mock_check_pr_status.return_value = True # PR is open + mock_parse_args.return_value = (argparse.Namespace( + command='label', + labels=['INVALID-LABEL', 'ANOTHER-BAD-LABEL'] + ), []) + + with patch('sys.exit') as mock_exit, patch('builtins.print') as mock_print: + import cmd + cmd.main() + mock_exit.assert_called_with(1) # Should exit with error code + + # Check for error JSON output + error_json_call = None + for call in mock_print.call_args_list: + if 'ERROR_JSON:' in str(call): + error_json_call = call + break + + self.assertIsNotNone(error_json_call) + self.assertIn('validation_failed', str(error_json_call)) + + @patch.dict('os.environ', {'PR_NUM': '123', 'IS_ORG_MEMBER': 'true', 'IS_PR_AUTHOR': 'false', 'GITHUB_TOKEN': 'fake_token'}) + @patch('cmd.get_allowed_labels') + @patch('cmd.check_pr_status') + @patch('argparse.ArgumentParser.parse_known_args') + def test_label_command_mixed_valid_invalid(self, mock_parse_args, mock_check_pr_status, mock_get_labels): + """Test label command with mix of valid and invalid labels""" + mock_get_labels.return_value = ['T1-FRAME', 'R0-no-crate-publish-required', 'D2-substantial'] + mock_check_pr_status.return_value = True # PR is open + mock_parse_args.return_value = (argparse.Namespace( + command='label', + labels=['T1-FRAME', 'INVALID-LABEL', 'D2-substantial'] + ), []) + + with patch('sys.exit') as mock_exit, patch('builtins.print') as mock_print: + import cmd + cmd.main() + mock_exit.assert_called_with(1) # Should exit with error code due to invalid label + + # Check for error JSON output + error_json_call = None + for call in mock_print.call_args_list: + if 'ERROR_JSON:' in str(call): + error_json_call = call + break + + self.assertIsNotNone(error_json_call) + + @patch.dict('os.environ', {'PR_NUM': '123', 'IS_ORG_MEMBER': 'true', 'IS_PR_AUTHOR': 'false', 'GITHUB_TOKEN': 'fake_token'}) + @patch('cmd.get_allowed_labels') + @patch('cmd.check_pr_status') + @patch('argparse.ArgumentParser.parse_known_args') + def test_label_command_fetch_failure(self, mock_parse_args, mock_check_pr_status, mock_get_labels): + """Test label command when label fetching fails""" + mock_get_labels.side_effect = RuntimeError("Failed to fetch labels from repository. Please check your connection and try again.") + mock_check_pr_status.return_value = True # PR is open + mock_parse_args.return_value = (argparse.Namespace( + command='label', + labels=['T1-FRAME'] + ), []) + + with patch('sys.exit') as mock_exit, patch('builtins.print') as mock_print: + import cmd + cmd.main() + mock_exit.assert_called_with(1) # Should exit with error code + + # Check for error JSON output + error_json_call = None + for call in mock_print.call_args_list: + if 'ERROR_JSON:' in str(call): + error_json_call = call + break + + self.assertIsNotNone(error_json_call) + self.assertIn('Failed to fetch labels from repository', str(error_json_call)) + + def test_auto_correct_labels_function(self): + """Test the auto_correct_labels function directly""" + import cmd + + valid_labels = ['T1-FRAME', 'R0-no-crate-publish-required', 'D2-substantial', 'I2-bug'] + + # Test high similarity auto-correction + corrections, suggestions = cmd.auto_correct_labels(['T1-FRAM'], valid_labels) + self.assertEqual(len(corrections), 1) + self.assertEqual(corrections[0][0], 'T1-FRAM') + self.assertEqual(corrections[0][1], 'T1-FRAME') + + # Test low similarity suggestions + corrections, suggestions = cmd.auto_correct_labels(['TOTALLY-WRONG'], valid_labels) + self.assertEqual(len(corrections), 0) + self.assertEqual(len(suggestions), 1) + + def test_find_closest_labels_function(self): + """Test the find_closest_labels function directly""" + import cmd + + valid_labels = ['T1-FRAME', 'T2-pallets', 'R0-no-crate-publish-required'] + + # Test finding close matches + matches = cmd.find_closest_labels('T1-FRAM', valid_labels) + self.assertIn('T1-FRAME', matches) + + # Test no close matches + matches = cmd.find_closest_labels('COMPLETELY-DIFFERENT', valid_labels, cutoff=0.8) + self.assertEqual(len(matches), 0) + + @patch.dict('os.environ', {'PR_NUM': '123', 'IS_ORG_MEMBER': 'true', 'IS_PR_AUTHOR': 'false', 'GITHUB_TOKEN': 'fake_token'}) + @patch('cmd.get_allowed_labels') + @patch('cmd.check_pr_status') + @patch('argparse.ArgumentParser.parse_known_args') + def test_label_command_merged_pr(self, mock_parse_args, mock_check_pr_status, mock_get_labels): + """Test label command on merged PR should fail""" + mock_get_labels.return_value = ['T1-FRAME', 'R0-no-crate-publish-required'] + mock_check_pr_status.return_value = False # PR is merged/closed + mock_parse_args.return_value = (argparse.Namespace( + command='label', + labels=['T1-FRAME'] + ), []) + + with patch('sys.exit') as mock_exit, patch('builtins.print') as mock_print: + import cmd + cmd.main() + mock_exit.assert_called_with(1) + + # Check for error JSON output + error_json_call = None + for call in mock_print.call_args_list: + if 'ERROR_JSON:' in str(call): + error_json_call = call + break + + self.assertIsNotNone(error_json_call) + self.assertIn('Cannot modify labels on merged PRs', str(error_json_call)) + + @patch.dict('os.environ', {'PR_NUM': '123', 'IS_ORG_MEMBER': 'true', 'IS_PR_AUTHOR': 'false', 'GITHUB_TOKEN': 'fake_token'}) + @patch('cmd.get_allowed_labels') + @patch('cmd.check_pr_status') + @patch('argparse.ArgumentParser.parse_known_args') + def test_label_command_open_pr(self, mock_parse_args, mock_check_pr_status, mock_get_labels): + """Test label command on open PR should succeed""" + mock_get_labels.return_value = ['T1-FRAME', 'R0-no-crate-publish-required'] + mock_check_pr_status.return_value = True # PR is open + mock_parse_args.return_value = (argparse.Namespace( + command='label', + labels=['T1-FRAME'] + ), []) + + with patch('sys.exit') as mock_exit, patch('builtins.print') as mock_print: + import cmd + cmd.main() + mock_exit.assert_not_called() + + # Check that JSON output was printed + json_call = None + for call in mock_print.call_args_list: + if 'LABELS_JSON:' in str(call): + json_call = call + break + + self.assertIsNotNone(json_call) + + @patch.dict('os.environ', {'PR_NUM': '123', 'IS_ORG_MEMBER': 'false', 'IS_PR_AUTHOR': 'false', 'GITHUB_TOKEN': 'fake_token'}) + @patch('cmd.get_allowed_labels') + @patch('cmd.check_pr_status') + @patch('argparse.ArgumentParser.parse_known_args') + def test_label_command_unauthorized_user(self, mock_parse_args, mock_check_pr_status, mock_get_labels): + """Test label command by unauthorized user should fail""" + mock_get_labels.return_value = ['T1-FRAME', 'R0-no-crate-publish-required'] + mock_check_pr_status.return_value = True # PR is open + mock_parse_args.return_value = (argparse.Namespace( + command='label', + labels=['T1-FRAME'] + ), []) + + with patch('sys.exit') as mock_exit, patch('builtins.print') as mock_print: + import cmd + cmd.main() + mock_exit.assert_called_with(1) + + # Check for error JSON output + error_json_call = None + for call in mock_print.call_args_list: + if 'ERROR_JSON:' in str(call): + error_json_call = call + break + + self.assertIsNotNone(error_json_call) + self.assertIn('Only the PR author or organization members can modify labels', str(error_json_call)) + + @patch.dict('os.environ', {'PR_NUM': '123', 'IS_ORG_MEMBER': 'false', 'IS_PR_AUTHOR': 'true', 'GITHUB_TOKEN': 'fake_token'}) + @patch('cmd.get_allowed_labels') + @patch('cmd.check_pr_status') + @patch('argparse.ArgumentParser.parse_known_args') + def test_label_command_pr_author(self, mock_parse_args, mock_check_pr_status, mock_get_labels): + """Test label command by PR author should succeed""" + mock_get_labels.return_value = ['T1-FRAME', 'R0-no-crate-publish-required'] + mock_check_pr_status.return_value = True # PR is open + mock_parse_args.return_value = (argparse.Namespace( + command='label', + labels=['T1-FRAME'] + ), []) + + with patch('sys.exit') as mock_exit, patch('builtins.print') as mock_print: + import cmd + cmd.main() + mock_exit.assert_not_called() + + # Check that JSON output was printed + json_call = None + for call in mock_print.call_args_list: + if 'LABELS_JSON:' in str(call): + json_call = call + break + + self.assertIsNotNone(json_call) + + @patch.dict('os.environ', {'PR_NUM': '123', 'IS_ORG_MEMBER': 'true', 'IS_PR_AUTHOR': 'false', 'GITHUB_TOKEN': 'fake_token'}) + @patch('cmd.get_allowed_labels') + @patch('cmd.check_pr_status') + @patch('argparse.ArgumentParser.parse_known_args') + def test_label_command_org_member(self, mock_parse_args, mock_check_pr_status, mock_get_labels): + """Test label command by org member should succeed""" + mock_get_labels.return_value = ['T1-FRAME', 'R0-no-crate-publish-required'] + mock_check_pr_status.return_value = True # PR is open + mock_parse_args.return_value = (argparse.Namespace( + command='label', + labels=['T1-FRAME'] + ), []) + + with patch('sys.exit') as mock_exit, patch('builtins.print') as mock_print: + import cmd + cmd.main() + mock_exit.assert_not_called() + + # Check that JSON output was printed + json_call = None + for call in mock_print.call_args_list: + if 'LABELS_JSON:' in str(call): + json_call = call + break + + self.assertIsNotNone(json_call) + if __name__ == '__main__': unittest.main() diff --git a/.github/workflows/cmd-run.yml b/.github/workflows/cmd-run.yml index 2a84bad75bb99..3e51b6f09dca2 100644 --- a/.github/workflows/cmd-run.yml +++ b/.github/workflows/cmd-run.yml @@ -21,6 +21,9 @@ on: is_org_member: description: "Is the user an org member" required: true + is_pr_author: + description: "Is the user the PR author" + required: true repo: description: "Repository to use" required: true @@ -72,7 +75,7 @@ jobs: - name: Comment PR (Start) # No need to comment on prdoc start or if --quiet - if: ${{ github.event.inputs.is_quiet == 'false' && !startsWith(github.event.inputs.cmd, 'prdoc') && !startsWith(github.event.inputs.cmd, 'fmt')}} + if: ${{ github.event.inputs.is_quiet == 'false' && !startsWith(github.event.inputs.cmd, 'prdoc') && !startsWith(github.event.inputs.cmd, 'fmt') && !startsWith(github.event.inputs.cmd, 'label')}} uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -151,6 +154,7 @@ jobs: env: PR_ARG: ${{ steps.pr-arg.outputs.arg }} IS_ORG_MEMBER: ${{ github.event.inputs.is_org_member }} + IS_PR_AUTHOR: ${{ github.event.inputs.is_pr_author }} RUNNER: ${{ github.event.inputs.runner }} IMAGE: ${{ github.event.inputs.image }} run: | @@ -294,7 +298,84 @@ jobs: name: command-diff path: command-diff + - name: Apply labels for label command + if: startsWith(github.event.inputs.cmd, 'label') + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ steps.generate_token.outputs.token }} + script: | + // Read the command output to get validated labels + const fs = require('fs'); + let labels = []; + + try { + const output = fs.readFileSync('/tmp/cmd/command_output.log', 'utf8'); + + // Parse JSON labels from output - look for "LABELS_JSON: {...}" + const jsonMatch = output.match(/LABELS_JSON: (.+)/); + if (jsonMatch) { + const labelsData = JSON.parse(jsonMatch[1]); + labels = labelsData.labels || []; + } + } catch (error) { + console.error(`Error reading command output: ${error.message}`); + throw new Error('Label validation failed. Check the command output for details.'); + } + + if (labels.length > 0) { + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ env.PR_NUM }}, + labels: labels + }); + } catch (error) { + console.error(`Error adding labels: ${error.message}`); + throw error; + } + } + + - name: Comment PR (Label Error) + if: ${{ failure() && startsWith(github.event.inputs.cmd, 'label') }} + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + CMD_OUTPUT: "${{ needs.cmd.outputs.cmd_output }}" + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + let runUrl = ${{ needs.before-cmd.outputs.run_url }}; + let cmdOutput = process.env.CMD_OUTPUT || ''; + + // Try to parse JSON error for better formatting + let errorMessage = 'Label validation failed. Please check the error details below and try again.'; + let errorDetails = ''; + + try { + const errorMatch = cmdOutput.match(/ERROR_JSON: (.+)/); + if (errorMatch) { + const errorData = JSON.parse(errorMatch[1]); + errorMessage = errorData.message || errorMessage; + errorDetails = errorData.details || ''; + } + } catch (e) { + // Fallback to raw output + errorDetails = cmdOutput; + } + + let cmdOutputCollapsed = errorDetails.trim() !== '' + ? `
\n\nError details:\n\n${errorDetails}\n\n
` + : ''; + + github.rest.issues.createComment({ + issue_number: ${{ env.PR_NUM }}, + owner: context.repo.owner, + repo: context.repo.repo, + body: `❌ ${errorMessage}\n\n${cmdOutputCollapsed}\n\n[See full logs here](${runUrl})` + }) + - name: Apply & Commit changes + if: ${{ !startsWith(github.event.inputs.cmd, 'label') }} run: | ls -lsa . @@ -333,10 +414,11 @@ jobs: echo "Nothing to commit"; fi + - name: Comment PR (End) # No need to comment on prdoc success or --quiet #TODO: return "&& !contains(github.event.comment.body, '--quiet')" - if: ${{ github.event.inputs.is_quiet == 'false' && needs.cmd.result == 'success' && !startsWith(github.event.inputs.cmd, 'prdoc') && !startsWith(github.event.inputs.cmd, 'fmt') }} + if: ${{ github.event.inputs.is_quiet == 'false' && needs.cmd.result == 'success' && !startsWith(github.event.inputs.cmd, 'prdoc') && !startsWith(github.event.inputs.cmd, 'fmt') && !startsWith(github.event.inputs.cmd, 'label') }} uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: SUBWEIGHT: "${{ needs.cmd.outputs.subweight }}" @@ -366,6 +448,7 @@ jobs: body: `Command "${cmd}" has finished ✅ [See logs here](${runUrl})${subweightCollapsed}${cmdOutputCollapsed}` }) + finish: needs: [before-cmd, cmd, after-cmd] if: ${{ always() }} diff --git a/.github/workflows/cmd.yml b/.github/workflows/cmd.yml index dc11972878f91..8d11cdd79c59d 100644 --- a/.github/workflows/cmd.yml +++ b/.github/workflows/cmd.yml @@ -261,8 +261,44 @@ jobs: echo "RUNNER=${{ steps.set-image.outputs.RUNNER }}" echo "IMAGE=${{ steps.set-image.outputs.IMAGE }}" + check-pr-author: + runs-on: ubuntu-latest + outputs: + is_author: ${{ steps.check-author.outputs.result }} + steps: + - name: Generate token + id: generate_token + uses: actions/create-github-app-token@db4a51e5b21b6b7c0c69dd6c93c5bf3142490885 # v1.34.1 + with: + app-id: ${{ secrets.CMD_BOT_APP_ID }} + private-key: ${{ secrets.CMD_BOT_APP_KEY }} + + - name: Check if user is PR author + id: check-author + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ steps.generate_token.outputs.token }} + result-encoding: string + script: | + const commentUser = '${{ github.event.comment.user.login }}'; + const prNumber = ${{ github.event.issue.number }}; + + try { + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + const prAuthor = pr.data.user.login; + return commentUser === prAuthor ? 'true' : 'false'; + } catch (error) { + console.error('Error checking PR author:', error); + return 'false'; + } + run-cmd-workflow: - needs: [set-image, get-pr-info, is-org-member] + needs: [set-image, get-pr-info, is-org-member, check-pr-author] runs-on: ubuntu-latest # don't run on help command if: ${{ startsWith(github.event.comment.body, '/cmd') && !contains(github.event.comment.body, '--help') }} @@ -278,6 +314,7 @@ jobs: IMAGE: ${{ needs.set-image.outputs.IMAGE }} REPO: ${{ needs.get-pr-info.outputs.repo }} IS_ORG_MEMBER: ${{ needs.is-org-member.outputs.member }} + IS_PR_AUTHOR: ${{ needs.check-pr-author.outputs.is_author }} COMMENT_ID: ${{ github.event.comment.id }} PR_NUMBER: ${{ github.event.issue.number }} steps: @@ -296,6 +333,7 @@ jobs: -f pr_num="${PR_NUMBER}" \ -f runner="${RUNNER}" \ -f is_org_member="${IS_ORG_MEMBER}" \ + -f is_pr_author="${IS_PR_AUTHOR}" \ -f comment_id="${COMMENT_ID}" \ -f image="${IMAGE}" \ -f is_quiet="${{ contains(github.event.comment.body, '--quiet') }}" diff --git a/docs/contributor/PULL_REQUEST_TEMPLATE.md b/docs/contributor/PULL_REQUEST_TEMPLATE.md index 33e71229c5e37..316d8c53b7007 100644 --- a/docs/contributor/PULL_REQUEST_TEMPLATE.md +++ b/docs/contributor/PULL_REQUEST_TEMPLATE.md @@ -38,10 +38,28 @@ possibly integration.* * [ ] My PR follows the [labeling requirements]( https://github.com/paritytech/polkadot-sdk/blob/master/docs/contributor/CONTRIBUTING.md#Process ) of this project (at minimum one label for `T` required) - * External contributors: ask maintainers to put the right label on your PR. + * External contributors: Use `/cmd label ` to add labels + * Maintainers can also add labels manually * [ ] I have made corresponding changes to the documentation (if applicable) * [ ] I have added tests that prove my fix is effective or that my feature works (if applicable) +## Bot Commands + +You can use the following bot commands in comments to help manage your PR: + +**Labeling (Self-service for contributors):** +* `/cmd label T1-FRAME` - Add a single label +* `/cmd label T1-FRAME R0-no-crate-publish-required` - Add multiple labels +* `/cmd label T6-XCM D2-substantial I5-enhancement` - Add multiple labels at once +* See [label documentation](https://paritytech.github.io/labels/doc_polkadot-sdk.html) for all available labels + +**Other useful commands:** +* `/cmd fmt` - Format code (cargo +nightly fmt and taplo) +* `/cmd prdoc` - Generate PR documentation +* `/cmd bench` - Run benchmarks +* `/cmd update-ui` - Update UI tests +* `/cmd --help` - Show help for all available commands + You can remove the "Checklist" section once all have been checked. Thank you for your contribution! ✄ -----------------------------------------------------------------------------