-
Notifications
You must be signed in to change notification settings - Fork 19
Adding points for the users who contributed towards code review & quality #81
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| name: Points Allocation | ||
|
|
||
| on: | ||
| pull_request_review: | ||
| types: [submitted] | ||
| issue_comment: | ||
| types: [created] | ||
|
|
||
| permissions: | ||
| contents: write | ||
| pull-requests: write | ||
|
|
||
| jobs: | ||
| assign-points: | ||
| runs-on: ubuntu-latest | ||
| # Only run for PR reviews or comments on PRs (not regular issues) | ||
| if: > | ||
| github.event_name == 'pull_request_review' || | ||
| (github.event_name == 'issue_comment' && github.event.issue.pull_request != null) | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v3 | ||
| with: | ||
| ref: main | ||
| token: ${{ secrets.GITHUB_TOKEN }} | ||
| fetch-depth: 0 | ||
|
|
||
| - name: Set up Python | ||
| uses: actions/setup-python@v4 | ||
| with: | ||
| python-version: '3.x' | ||
|
|
||
| - name: Install dependencies | ||
| run: pip install pyyaml | ||
|
|
||
| - name: Run points script | ||
| id: assign_points | ||
| run: | | ||
| set +e # Don't exit on error | ||
| python scripts/assign_points.py | ||
| exit_code=$? | ||
| echo "exit_code=$exit_code" >> $GITHUB_OUTPUT | ||
|
|
||
| # Exit codes: | ||
| # 0 = Success (points awarded) | ||
| # 2 = No-op (no points, but not an error) | ||
| # 1 or other = Actual error | ||
|
|
||
| if [ $exit_code -eq 0 ] || [ $exit_code -eq 2 ]; then | ||
| exit 0 | ||
| else | ||
| exit $exit_code | ||
| fi | ||
|
|
||
| - name: Update leaderboard markdown | ||
LavanyaK235 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if: steps.assign_points.outputs.exit_code == '0' | ||
| run: python scripts/update_leaderboard.py | ||
|
|
||
| - name: Commit and push leaderboard changes | ||
| if: steps.assign_points.outputs.exit_code == '0' | ||
| run: | | ||
| git config --global user.name "github-actions[bot]" | ||
| git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" | ||
| git add leaderboard.json LEADERBOARD.md | ||
| git diff --staged --quiet || git commit -m "Update leaderboard [skip ci]" | ||
| git diff --staged --quiet || git push origin main | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,286 @@ | ||
| # Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| import os | ||
LavanyaK235 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| import json | ||
| import yaml | ||
| import sys | ||
|
|
||
| # Exit codes used by this script: | ||
| # 0 = Success - points were awarded and leaderboard updated | ||
| # 1 = Error - something went wrong (missing config, permissions, etc.) | ||
| # 2 = No-op - no points awarded, but not an error (duplicate event, no criteria matched) | ||
|
|
||
| # Path to config file inside scripts folder | ||
| CONFIG_FILE = os.path.join(os.path.dirname(__file__), 'config_points.yml') | ||
| PROCESSED_FILE = os.path.join(os.path.dirname(__file__), 'processed_ids.json') | ||
| # Path to leaderboard in repository root (one level up from scripts/) | ||
| LEADERBOARD_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'leaderboard.json') | ||
|
|
||
| def load_config(): | ||
| """ | ||
| Load the points configuration from config_points.yml. | ||
|
|
||
| Returns: | ||
| dict: Configuration dictionary with 'points' section | ||
|
|
||
| Exits: | ||
| 1 if config file is missing or contains invalid YAML | ||
| """ | ||
| if not os.path.exists(CONFIG_FILE): | ||
| print(f"ERROR: Config file not found: {CONFIG_FILE}", file=sys.stderr) | ||
| print("Expected location: scripts/config_points.yml", file=sys.stderr) | ||
| sys.exit(1) | ||
|
|
||
| try: | ||
| with open(CONFIG_FILE, 'r', encoding='utf-8') as f: | ||
| config = yaml.safe_load(f) | ||
| except yaml.YAMLError as e: | ||
| print(f"ERROR: Invalid YAML syntax in config file: {e}", file=sys.stderr) | ||
| print(f"File location: {CONFIG_FILE}", file=sys.stderr) | ||
| sys.exit(1) | ||
| except Exception as e: | ||
| print(f"ERROR: Failed to read config file: {e}", file=sys.stderr) | ||
| sys.exit(1) | ||
|
|
||
| # Validate that config has the expected structure | ||
| if not isinstance(config, dict) or 'points' not in config: | ||
| print(f"ERROR: Invalid config structure in {CONFIG_FILE}", file=sys.stderr) | ||
| print("Expected format: { points: { basic_review: 5, ... } }", file=sys.stderr) | ||
| sys.exit(1) | ||
|
|
||
| return config | ||
|
|
||
| def load_event(): | ||
| event_path = os.getenv('GITHUB_EVENT_PATH') | ||
| if not event_path: | ||
| print("ERROR: GITHUB_EVENT_PATH is not set.") | ||
| sys.exit(1) | ||
| if not os.path.exists(event_path): | ||
| print(f"ERROR: Event file not found: {event_path}") | ||
| sys.exit(1) | ||
| with open(event_path, 'r', encoding='utf-8') as f: | ||
| event = json.load(f) | ||
|
|
||
| # Validate that this is a PR-related event, not a regular issue comment | ||
| if 'issue' in event and 'pull_request' not in event.get('issue', {}): | ||
| print("INFO: Skipping - this is a comment on a regular issue, not a pull request.") | ||
| sys.exit(2) # Exit code 2 = no-op | ||
|
|
||
| return event | ||
|
|
||
| def load_processed_ids(): | ||
| if os.path.exists(PROCESSED_FILE): | ||
| with open(PROCESSED_FILE, 'r', encoding='utf-8') as f: | ||
| try: | ||
| return json.load(f) | ||
| except json.JSONDecodeError: | ||
| return [] | ||
| return [] | ||
|
|
||
| def save_processed_ids(ids): | ||
| """ | ||
| Save processed event IDs to prevent duplicate scoring. | ||
|
|
||
| This is critical for data integrity - if this fails after points | ||
| are awarded, the same event could be scored multiple times on retry. | ||
| """ | ||
| try: | ||
| with open(PROCESSED_FILE, 'w', encoding='utf-8') as f: | ||
| json.dump(ids, f, indent=2) | ||
| except PermissionError as e: | ||
| print(f"ERROR: Permission denied when saving processed IDs to {PROCESSED_FILE}: {e}", file=sys.stderr) | ||
| print("Check file permissions and ensure the workflow has write access.", file=sys.stderr) | ||
| sys.exit(1) | ||
| except IOError as e: | ||
| print(f"ERROR: Failed to write processed IDs to {PROCESSED_FILE}: {e}", file=sys.stderr) | ||
| print("This may be due to disk space issues or file system problems.", file=sys.stderr) | ||
| sys.exit(1) | ||
| except Exception as e: | ||
| print(f"ERROR: Unexpected error saving processed IDs: {e}", file=sys.stderr) | ||
| sys.exit(1) | ||
|
|
||
| def extract_user(event): | ||
| """ | ||
| Extract user login from GitHub event with multiple fallback strategies. | ||
|
|
||
| Priority: | ||
| 1. review.user.login (for pull_request_review events) | ||
| 2. comment.user.login (for issue_comment events) | ||
| 3. sender.login (top-level event sender) | ||
|
|
||
| Returns: | ||
| tuple: (user_login: str, source: str) or (None, None) if extraction fails | ||
| """ | ||
| # Try review user first | ||
| review = event.get('review') | ||
| if review and isinstance(review, dict): | ||
| review_user = review.get('user') | ||
| if review_user and isinstance(review_user, dict): | ||
| login = review_user.get('login') | ||
| if login: | ||
| return login, 'review.user' | ||
|
|
||
| # Try comment user second | ||
| comment = event.get('comment') | ||
| if comment and isinstance(comment, dict): | ||
| comment_user = comment.get('user') | ||
| if comment_user and isinstance(comment_user, dict): | ||
| login = comment_user.get('login') | ||
| if login: | ||
| return login, 'comment.user' | ||
|
|
||
| # Fallback to top-level sender (most reliable) | ||
| sender = event.get('sender') | ||
| if sender and isinstance(sender, dict): | ||
| login = sender.get('login') | ||
| if login: | ||
| return login, 'sender' | ||
|
|
||
| # All extraction methods failed | ||
| return None, None | ||
|
|
||
| def detect_points(event, cfg): | ||
| """ | ||
| Calculate points for a GitHub event based on review content and actions. | ||
|
|
||
| All keyword matching is CASE-INSENSITIVE. Contributors can use any capitalization. | ||
|
|
||
| Scoring Rules: | ||
| 1. Review types (mutually exclusive - only the highest applies): | ||
| - Include "detailed" anywhere in your review = detailed_review points (10) | ||
| - Include "basic review" anywhere in your review = basic_review points (5) | ||
| - If both keywords present, only "detailed" counts (higher value) | ||
|
|
||
| 2. Bonus points (additive - can stack with review types): | ||
| - Include "performance" anywhere = performance_improvement bonus (+4) | ||
| - Approve the PR (state=approved) = approve_pr bonus (+3) | ||
|
|
||
| Keyword Examples (all case-insensitive): | ||
| - "detailed", "Detailed", "DETAILED" all work | ||
| - "basic review", "Basic Review", "BASIC REVIEW" all work | ||
| - "performance", "Performance", "PERFORMANCE" all work | ||
|
|
||
| Scoring Examples: | ||
| - "This is a basic review" = 5 points | ||
| - "This is a DETAILED analysis" = 10 points (case doesn't matter) | ||
| - "detailed performance review" = 10 + 4 = 14 points | ||
| - Approved PR with "Basic Review" = 5 + 3 = 8 points | ||
| - Approved PR with "Detailed PERFORMANCE review" = 10 + 4 + 3 = 17 points | ||
| """ | ||
| action = event.get('action', '') | ||
| review = event.get('review') or {} | ||
| comment = event.get('comment') or {} | ||
|
|
||
| # Convert to lowercase for case-insensitive matching | ||
| review_body = (review.get('body') or '').lower() | ||
| review_state = (review.get('state') or '').lower() | ||
| comment_body = (comment.get('body') or '').lower() | ||
|
|
||
| user, source = extract_user(event) | ||
|
|
||
| if not user: | ||
| print("ERROR: Unable to extract user from event. Checked review.user, comment.user, and sender fields.") | ||
| print("Event structure:", json.dumps({ | ||
| 'has_review': 'review' in event, | ||
| 'has_comment': 'comment' in event, | ||
| 'has_sender': 'sender' in event, | ||
| 'action': action | ||
| }, indent=2)) | ||
| sys.exit(1) | ||
|
|
||
| print(f"User identified: {user} (source: {source})") | ||
|
|
||
| points = 0 | ||
| scoring_breakdown = [] | ||
|
|
||
| # Review type scoring (mutually exclusive - detailed takes precedence) | ||
| # All matching is case-insensitive due to .lower() above | ||
| if "detailed" in review_body: | ||
| points += cfg['points']['detailed_review'] | ||
| scoring_breakdown.append(f"detailed_review: +{cfg['points']['detailed_review']}") | ||
| elif "basic review" in review_body: | ||
| points += cfg['points']['basic_review'] | ||
| scoring_breakdown.append(f"basic_review: +{cfg['points']['basic_review']}") | ||
|
|
||
| # Performance improvement bonus (additive) | ||
| if "performance" in comment_body or "performance" in review_body: | ||
| points += cfg['points']['performance_improvement'] | ||
| scoring_breakdown.append(f"performance_improvement: +{cfg['points']['performance_improvement']}") | ||
|
|
||
| # PR approval bonus (additive) | ||
| if action == "submitted" and review_state == "approved": | ||
| points += cfg['points']['approve_pr'] | ||
| scoring_breakdown.append(f"approve_pr: +{cfg['points']['approve_pr']}") | ||
|
|
||
| # Log scoring breakdown for transparency | ||
| if scoring_breakdown: | ||
| print(f"Scoring breakdown: {', '.join(scoring_breakdown)} = {points} total") | ||
| else: | ||
| print("No scoring criteria matched.") | ||
|
|
||
| return points, user | ||
|
|
||
| def update_leaderboard(user, points): | ||
| """ | ||
| Update the leaderboard with awarded points for a user. | ||
|
|
||
| Args: | ||
| user: GitHub username | ||
| points: Points to award | ||
| """ | ||
| leaderboard = {} | ||
|
|
||
| if os.path.exists(LEADERBOARD_FILE): | ||
| with open(LEADERBOARD_FILE, 'r', encoding='utf-8') as f: | ||
| try: | ||
| leaderboard = json.load(f) | ||
| except json.JSONDecodeError: | ||
| leaderboard = {} | ||
|
|
||
| leaderboard[user] = leaderboard.get(user, 0) + points | ||
|
|
||
| try: | ||
| with open(LEADERBOARD_FILE, 'w', encoding='utf-8') as f: | ||
| json.dump(leaderboard, f, indent=2) | ||
| except PermissionError as e: | ||
| print(f"ERROR: Permission denied when saving leaderboard to {LEADERBOARD_FILE}: {e}", file=sys.stderr) | ||
| print("Check file permissions and ensure the workflow has write access.", file=sys.stderr) | ||
| sys.exit(1) | ||
| except IOError as e: | ||
| print(f"ERROR: Failed to write leaderboard to {LEADERBOARD_FILE}: {e}", file=sys.stderr) | ||
| print("This may be due to disk space issues or file system problems.", file=sys.stderr) | ||
| sys.exit(1) | ||
| except Exception as e: | ||
| print(f"ERROR: Unexpected error saving leaderboard: {e}", file=sys.stderr) | ||
| sys.exit(1) | ||
|
|
||
| def main(): | ||
| cfg = load_config() | ||
| event = load_event() | ||
| points, user = detect_points(event, cfg) | ||
|
|
||
| # Extract unique ID for duplicate prevention | ||
| event_id = event.get('review', {}).get('id') or event.get('comment', {}).get('id') | ||
| if not event_id: | ||
| print("No unique ID found in event. Skipping duplicate check.") | ||
| sys.exit(2) # Exit code 2 = no-op (not an error) | ||
|
|
||
| processed_ids = load_processed_ids() | ||
| if event_id in processed_ids: | ||
| print(f"Event {event_id} already processed. Skipping scoring.") | ||
| sys.exit(2) # Exit code 2 = no-op (not an error) | ||
|
|
||
| if points <= 0: | ||
| print("No points awarded for this event.") | ||
| sys.exit(2) # Exit code 2 = no-op (not an error) | ||
|
|
||
| # Update leaderboard first, then mark as processed | ||
| # This order ensures we can retry if processed_ids save fails | ||
| update_leaderboard(user, points) | ||
| processed_ids.append(event_id) | ||
| save_processed_ids(processed_ids) | ||
| print(f"Points awarded: {points} to {user}") | ||
| sys.exit(0) # Exit code 0 = success (points awarded) | ||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| # Contributor Points Configuration | ||
| # | ||
| # All keyword matching is CASE-INSENSITIVE. Contributors can use any capitalization. | ||
| # For example: "detailed", "Detailed", "DETAILED" all work the same way. | ||
| # | ||
| # Scoring Rules: | ||
| # 1. Review types are mutually exclusive (only highest value applies) | ||
| # - "detailed" keyword awards detailed_review points | ||
| # - "basic review" keyword awards basic_review points (only if "detailed" not present) | ||
| # 2. Bonuses are additive and can stack with review types: | ||
| # - "performance" keyword adds performance_improvement bonus | ||
| # - Approved PR (state=approved) adds approve_pr bonus | ||
| # | ||
| # Examples (all case-insensitive): | ||
| # - "basic review" OR "Basic Review" OR "BASIC REVIEW" = 5 points | ||
| # - "detailed analysis" OR "Detailed Analysis" = 10 points | ||
| # - "detailed performance review" OR "DETAILED PERFORMANCE REVIEW" = 10 + 4 = 14 points | ||
| # - Approved PR with "basic review" = 5 + 3 = 8 points | ||
| # - Approved PR with "detailed performance review" = 10 + 4 + 3 = 17 points | ||
|
|
||
| points: | ||
| basic_review: 5 # Simple review with "basic review" keyword (case-insensitive) | ||
| detailed_review: 10 # In-depth review with "detailed" keyword (case-insensitive, overrides basic) | ||
| performance_improvement: 4 # Bonus for mentioning "performance" (case-insensitive, additive) | ||
| approve_pr: 3 # Bonus for approving a PR (additive) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.