From 0ae4545bbcd770e5302ed14f5a9869da593c56e5 Mon Sep 17 00:00:00 2001 From: Vijay Janapa Reddi Date: Mon, 2 Mar 2026 09:27:59 -0500 Subject: [PATCH] Enables multi-project contributor additions Allows a single `@all-contributors` comment to add or update a contributor across multiple projects simultaneously. Updates the workflow to: - Detect multiple projects from explicit mentions in the trigger comment. - Iterate over all detected projects to update their respective `.all-contributorsrc` files and project `README.md` tables. - Adapt commit messages and bot replies to reflect multi-project changes. This improves efficiency for managing contributors in multi-project repositories by reducing repetitive commands. --- .github/workflows/all-contributors-add.yml | 236 +++++++++++---------- 1 file changed, 125 insertions(+), 111 deletions(-) diff --git a/.github/workflows/all-contributors-add.yml b/.github/workflows/all-contributors-add.yml index 2885a0bd6..29c9ff735 100644 --- a/.github/workflows/all-contributors-add.yml +++ b/.github/workflows/all-contributors-add.yml @@ -6,16 +6,16 @@ # Username is extracted DETERMINISTICALLY via regex from @mentions. # Uses Ollama LLM ONLY to classify contribution type(s) from natural language. # -# Project detection is DETERMINISTIC (not LLM-guessed): -# - PR file paths: tinytorch/ → tinytorch, book/ → book, kits/ → kits, labs/ → labs -# - Explicit mention in comment: "in tinytorch", "for kits", etc. +# Project detection is DETERMINISTIC (not LLM-guessed); MULTIPLE projects supported: +# - Explicit mention in comment: "in TinyTorch", "for book, kits" → adds to all mentioned +# - PR file paths (single project only): tinytorch/ → tinytorch, book/ → book, etc. # - Issue labels/title context # - If none of the above → asks the user (never silently defaults) # # Flexible formats - all of these work: # @all-contributors @username helped verify the fix worked -# @all-contributors please add @jane-doe for documentation -# @all-contributors @user123 fixed typos in the book +# @all-contributors please add @jane-doe for Doc in TinyTorch +# @all-contributors @user123 for code, doc in tinytorch, book # @all-contributors @dev42 implemented feature and wrote tests in tinytorch # ============================================================================= @@ -92,16 +92,17 @@ jobs: }); } - // --- Helper: detect project name in text --- - const detectProjectInText = (text) => { + // --- Helper: detect ALL project names in text (for multi-project support) --- + const detectProjectsInText = (text) => { const lower = text.toLowerCase(); + const found = new Set(); for (const p of validProjects) { - if (lower.includes(p)) return p; + if (lower.includes(p)) found.add(p); } for (const [alias, proj] of Object.entries(projectAliases)) { - if (lower.includes(alias)) return proj; + if (lower.includes(alias)) found.add(proj); } - return null; + return [...found]; }; // --- Get issue/PR context --- @@ -111,20 +112,21 @@ jobs: // ============================================================= // PROJECT DETECTION (deterministic, priority order) + // Supports multiple projects in one comment, e.g. "in TinyTorch, Book, Kits" // ============================================================= - let project = null; + let projects = []; let projectSource = 'unknown'; - // Priority 1: Explicit mention in the trigger comment - const commentProject = detectProjectInText(triggerLine); - if (commentProject) { - project = commentProject; + // Priority 1: Explicit mention(s) in the trigger comment (can be multiple) + const commentProjects = detectProjectsInText(triggerLine); + if (commentProjects.length > 0) { + projects = commentProjects; projectSource = 'comment'; - console.log(`Project from comment text: "${project}"`); + console.log(`Projects from comment: ${JSON.stringify(projects)}`); } // Priority 2: PR changed files (top-level dir → project) - if (!project && issue.pull_request) { + if (projects.length === 0 && issue.pull_request) { try { const { data: files } = await github.rest.pulls.listFiles({ owner: context.repo.owner, @@ -145,9 +147,9 @@ jobs: console.log('PR file project counts:', JSON.stringify(projectCounts)); if (detected.length === 1) { - project = detected[0]; + projects = [detected[0]]; projectSource = 'pr_files'; - console.log(`Project from PR files: "${project}"`); + console.log(`Project from PR files: "${projects[0]}"`); } else if (detected.length > 1) { projectSource = 'ambiguous'; console.log('PR spans multiple projects:', detected.join(', ')); @@ -158,17 +160,16 @@ jobs: } // Priority 3: Issue labels / title - if (!project) { - const contextProject = detectProjectInText(issueContext); - if (contextProject) { - project = contextProject; + if (projects.length === 0) { + const contextProjects = detectProjectsInText(issueContext); + if (contextProjects.length > 0) { + projects = contextProjects; projectSource = 'issue_context'; - console.log(`Project from issue context: "${project}"`); + console.log(`Projects from issue context: ${JSON.stringify(projects)}`); } } - // If still null → projectSource stays 'unknown', handled downstream - console.log(`Final project: ${project || 'NONE'} (source: ${projectSource})`); + console.log(`Final projects: ${JSON.stringify(projects)} (source: ${projectSource})`); // ============================================================= // USERNAME EXTRACTION (deterministic — regex, not LLM) @@ -189,7 +190,8 @@ jobs: core.setOutput('trigger_line', triggerLine); core.setOutput('username', username); core.setOutput('issue_context', issueContext); - core.setOutput('project', project || ''); + core.setOutput('projects', JSON.stringify(projects)); + core.setOutput('project', projects.length > 0 ? projects[0] : ''); core.setOutput('project_source', projectSource); # ===================================================================== @@ -251,7 +253,7 @@ jobs: LLM_RESPONSE: ${{ steps.llm.outputs.response || '' }} USERNAME: ${{ steps.extract.outputs.username }} TRIGGER_LINE: ${{ steps.extract.outputs.trigger_line }} - PROJECT: ${{ steps.extract.outputs.project }} + PROJECTS_JSON: ${{ steps.extract.outputs.projects }} PROJECT_SOURCE: ${{ steps.extract.outputs.project_source }} CONTRIBUTION_TYPES: ${{ env.CONTRIBUTION_TYPES }} PROJECTS: ${{ env.PROJECTS }} @@ -260,14 +262,21 @@ jobs: const response = process.env.LLM_RESPONSE || ''; const username = process.env.USERNAME || ''; const triggerLine = process.env.TRIGGER_LINE || ''; - const project = process.env.PROJECT || ''; - const projectSource = process.env.PROJECT_SOURCE || ''; const validTypes = process.env.CONTRIBUTION_TYPES.split(','); const validProjects = process.env.PROJECTS.split(','); + let projects = []; + try { + projects = JSON.parse(process.env.PROJECTS_JSON || '[]'); + if (!Array.isArray(projects)) projects = []; + } catch (e) { + console.log('Failed to parse projects JSON'); + } + const projectSource = process.env.PROJECT_SOURCE || ''; + console.log('Username (from regex):', username); console.log('LLM response:', response); - console.log('Deterministic project:', project || 'NONE', `(source: ${projectSource})`); + console.log('Projects:', JSON.stringify(projects), `(source: ${projectSource})`); // --- Validate username (extracted deterministically in Step 1) --- if (!username) { @@ -300,9 +309,10 @@ jobs: return; } - // --- Validate project (deterministic — already resolved in Step 1) --- - if (!project || !validProjects.includes(project)) { - console.log('No valid project detected — will ask user'); + // --- Validate projects (one or more, all must be valid) --- + const validProjectList = projects.filter(p => p && validProjects.includes(p)); + if (validProjectList.length === 0) { + console.log('No valid project(s) detected — will ask user'); core.setOutput('success', 'false'); core.setOutput('error', 'no_project'); core.setOutput('username', username); @@ -311,13 +321,14 @@ jobs: return; } - // --- All good --- - console.log('Final result:', { username, types, project, projectSource }); + // --- All good (may have multiple projects) --- + console.log('Final result:', { username, types, projects: validProjectList, projectSource }); core.setOutput('success', 'true'); core.setOutput('username', username); core.setOutput('types', JSON.stringify(types)); - core.setOutput('project', project); + core.setOutput('projects', JSON.stringify(validProjectList)); + core.setOutput('project', validProjectList[0]); core.setOutput('project_source', projectSource); # ===================================================================== @@ -370,70 +381,71 @@ jobs: import json import os - project = "${{ steps.parse.outputs.project }}" + projects = json.loads('${{ steps.parse.outputs.projects }}') username = "${{ steps.parse.outputs.username }}" types = json.loads('${{ steps.parse.outputs.types }}') name = "${{ steps.userinfo.outputs.name }}" avatar_url = "${{ steps.userinfo.outputs.avatar_url }}" profile = "${{ steps.userinfo.outputs.profile }}" - # Build config paths from PROJECTS env var - projects = os.environ.get('PROJECTS', 'book').split(',') - config_paths = {p: f'{p}/.all-contributorsrc' for p in projects} + valid_projects = os.environ.get('PROJECTS', 'book').split(',') + config_paths = {p: f'{p}/.all-contributorsrc' for p in valid_projects} + updated_paths = [] - config_path = config_paths[project] + for project in projects: + config_path = config_paths.get(project) + if not config_path or not os.path.isfile(config_path): + print(f"Skipping {project}: no config at {config_path}") + continue - # Read existing config - with open(config_path, 'r') as f: - config = json.load(f) + with open(config_path, 'r') as f: + config = json.load(f) - contributors = config.get('contributors', []) + contributors = config.get('contributors', []) - # Check if user already exists - existing = None - for i, c in enumerate(contributors): - if c.get('login', '').lower() == username.lower(): - existing = i - break + existing = None + for i, c in enumerate(contributors): + if c.get('login', '').lower() == username.lower(): + existing = i + break - if existing is not None: - # Merge contribution types - existing_types = set(contributors[existing].get('contributions', [])) - new_types = existing_types | set(types) - contributors[existing]['contributions'] = sorted(list(new_types)) - print(f"Updated existing contributor {username} with types: {sorted(list(new_types))}") - else: - # Add new contributor - new_contributor = { - 'login': username, - 'name': name, - 'avatar_url': avatar_url, - 'profile': profile, - 'contributions': sorted(types) - } - contributors.append(new_contributor) - print(f"Added new contributor {username} with types: {sorted(types)}") + if existing is not None: + existing_types = set(contributors[existing].get('contributions', [])) + new_types = existing_types | set(types) + contributors[existing]['contributions'] = sorted(list(new_types)) + print(f"[{project}] Updated existing contributor {username} with types: {sorted(list(new_types))}") + else: + new_contributor = { + 'login': username, + 'name': name, + 'avatar_url': avatar_url, + 'profile': profile, + 'contributions': sorted(types) + } + contributors.append(new_contributor) + print(f"[{project}] Added new contributor {username} with types: {sorted(types)}") - config['contributors'] = contributors + config['contributors'] = contributors - # Write updated config - with open(config_path, 'w') as f: - json.dump(config, f, indent=4) - f.write('\n') + with open(config_path, 'w') as f: + json.dump(config, f, indent=4) + f.write('\n') - print(f"Updated {config_path}") + print(f"Updated {config_path}") + updated_paths.append(config_path) - # Save info for later steps with open(os.environ['GITHUB_OUTPUT'], 'a') as f: - f.write(f"config_path={config_path}\n") + f.write(f"updated_configs={json.dumps(updated_paths)}\n") EOF - name: Generate README tables if: steps.extract.outputs.should_run == 'true' && steps.parse.outputs.success == 'true' run: | - # Generate tables for the specific project - python3 ${{ github.workspace }}/.github/workflows/contributors/generate_readme_tables.py --project ${{ steps.parse.outputs.project }} --update - + # Generate tables for each project we updated + PROJECTS_JSON='${{ steps.parse.outputs.projects }}' + for project in $(echo "$PROJECTS_JSON" | python3 -c "import sys,json; print(' '.join(json.load(sys.stdin)))"); do + python3 ${{ github.workspace }}/.github/workflows/contributors/generate_readme_tables.py --project "$project" --update + done # Also regenerate the main README python3 ${{ github.workspace }}/.github/workflows/contributors/generate_main_readme.py @@ -446,19 +458,21 @@ jobs: - name: Commit and push changes if: steps.extract.outputs.should_run == 'true' && steps.parse.outputs.success == 'true' run: | - PROJECT="${{ steps.parse.outputs.project }}" + PROJECTS_JSON='${{ steps.parse.outputs.projects }}' USERNAME="${{ steps.parse.outputs.username }}" TYPES=$(echo '${{ steps.parse.outputs.types }}' | python3 -c "import sys,json; print(', '.join(json.load(sys.stdin)))") + PROJECTS_LIST=$(echo "$PROJECTS_JSON" | python3 -c "import sys,json; print(', '.join(json.load(sys.stdin)))") - # Stage contributor files - git add "${PROJECT}/.all-contributorsrc" "${PROJECT}/README.md" README.md 2>/dev/null || true + # Stage contributor files for each project + for project in $(echo "$PROJECTS_JSON" | python3 -c "import sys,json; print(' '.join(json.load(sys.stdin)))"); do + git add "${project}/.all-contributorsrc" "${project}/README.md" 2>/dev/null || true + done + git add README.md 2>/dev/null || true - # Check if there are changes to commit if git diff --staged --quiet; then echo "No changes to commit" else - git commit -m "docs: add @${USERNAME} as ${PROJECT} contributor for ${TYPES}" - # Pull before push so concurrent comment-triggered runs don't reject each other + git commit -m "docs: add @${USERNAME} as contributor for ${TYPES} (${PROJECTS_LIST})" git pull --rebase origin ${{ env.TARGET_BRANCH }} git push origin ${{ env.TARGET_BRANCH }} echo "Changes committed and pushed!" @@ -472,7 +486,6 @@ jobs: uses: actions/github-script@v7 with: script: | - // Add reaction to the comment await github.rest.reactions.createForIssueComment({ owner: context.repo.owner, repo: context.repo.repo, @@ -480,14 +493,12 @@ jobs: content: '+1' }); - // Post a reply const username = '${{ steps.parse.outputs.username }}'; - const project = '${{ steps.parse.outputs.project }}'; + const projects = JSON.parse('${{ steps.parse.outputs.projects }}'); const projectSource = '${{ steps.parse.outputs.project_source }}'; const types = JSON.parse('${{ steps.parse.outputs.types }}'); const triggerLine = `${{ steps.extract.outputs.trigger_line }}`; - // Map source to human-readable explanation const sourceLabels = { comment: 'explicitly mentioned in comment', pr_files: 'detected from PR changed files', @@ -495,16 +506,18 @@ jobs: }; const sourceNote = sourceLabels[projectSource] || projectSource; + const projectList = projects.length === 1 ? projects[0] : projects.join(', '); + const filesList = projects.map(p => `- \`${p}/.all-contributorsrc\`, \`${p}/README.md\``).join('\n'); + const body = [ - "I've added @" + username + " as a contributor to **" + project + "**! :tada:", + "I've added @" + username + " as a contributor" + (projects.length > 1 ? " to **" + projectList + "**" : " to **" + projects[0] + "**") + "! :tada:", "", "**Recognized for:** " + types.join(', '), - "**Project:** " + project + " (" + sourceNote + ")", + "**Project(s):** " + projectList + " (" + sourceNote + ")", "**Based on:** " + triggerLine, "", "The contributor list has been updated in:", - "- `" + project + "/.all-contributorsrc`", - "- `" + project + "/README.md`", + filesList, "- Main `README.md`", "", "We love recognizing our contributors! :heart:" @@ -543,28 +556,31 @@ jobs: const typesPart = types.length > 0 ? ` for ${types.join(', ')}` : ' for code'; if (projectSource === 'ambiguous') { - // PR touches multiple projects body = [ - "This PR touches files in **multiple projects**, so I need you to tell me which one. :thinking:", + "This PR touches files in **multiple projects**, so I need you to tell me which one(s). :thinking:", "", - `I detected${userPart}${typesPart}, but which project should I add them to?`, + `I detected${userPart}${typesPart}, but which project(s) should I add them to?`, "", - "Please reply with the project specified:", - ...projects.map(p => `- \`@all-contributors${userPart}${typesPart} in ${p}\``), + "You can specify **one or more** projects in your reply, e.g.:", + "- `@all-contributors" + userPart + typesPart + " in tinytorch`", + "- `@all-contributors" + userPart + typesPart + " in tinytorch, book, kits`", + "", + "Options: " + projects.map(p => `\`${p}\``).join(', '), ].join('\n'); } else { - // No project signal at all (issue with no labels, no PR files) body = [ - `I couldn't determine which project to add the contributor to. :thinking:`, + `I couldn't determine which project(s) to add the contributor to. :thinking:`, "", "**Your comment:** " + triggerLine, "", - "This repo has multiple projects, so please specify which one:", - ...projects.map(p => `- \`@all-contributors${userPart}${typesPart} in ${p}\``), + "This repo has multiple projects. Specify one or more explicitly, e.g.:", + "- `@all-contributors" + userPart + typesPart + " in tinytorch`", + "- `@all-contributors" + userPart + typesPart + " in TinyTorch, Book, Kits`", "", "**How project detection works:**", - "- On **PRs**: auto-detected from changed file paths (`tinytorch/` → tinytorch, `book/` → book, etc.)", - "- On **issues**: detected from labels or title, otherwise you need to specify explicitly", + "- **In comment:** Say \"in TinyTorch\", \"for book, labs\", etc. (multiple projects OK)", + "- **On PRs:** Auto-detected from changed file paths when only one project is touched", + "- **On issues:** From labels or title, or specify in the comment", ].join('\n'); } } else { @@ -584,16 +600,14 @@ jobs: "**Example formats that work:**", "```", "@all-contributors @jane-doe fixed typos in the documentation", - "@all-contributors please add @john_smith for reviewing the PR", - "@all-contributors @user123 helped verify the fix worked", + "@all-contributors please add @john_smith for Doc in TinyTorch", + "@all-contributors @user123 for code, doc in tinytorch, book", "@all-contributors @dev42 implemented the new caching feature in tinytorch", "```", "", - "**Contribution types I understand:**", - "bug, code, doc, design, ideas, review, test, tool", + "**Contribution types:** bug, code, doc, design, ideas, review, test, tool", "", - `**Projects:** ${projects.join(', ')}`, - "On PRs, project is auto-detected from file paths. Otherwise, specify explicitly (e.g., 'in tinytorch')." + `**Projects (one or more):** ${projects.join(', ')} — specify in comment (e.g. "in TinyTorch, Book") or auto-detected from PR file paths.` ].join('\n'); }