From 23d75a3c4798b2c17450297a9e37630a2b31ce8b Mon Sep 17 00:00:00 2001 From: GitHub Workshop Bot Date: Sat, 4 Jul 2026 09:52:35 -0700 Subject: [PATCH] fix(learning-room): seed peer simulation issue, branch, and PR during progression Challenges 1-13 tell students to comment on, review, or compare with the seeded 'Peer Simulation: Welcome Link Needs Context' issue and the 'Peer Simulation: Improve contribution guidance' pull request. Those artifacts were created by scripts/classroom/Seed-PeerSimulation.ps1, which was deleted in the GitHub Classroom removal (c567eb7ef) without moving the behavior into hybrid provisioning, so every provisioned room was missing them (support issue Community-Access/support#58). The learning room progression script now seeds all peer simulation artifacts (labels, both issues, the peer-simulation/review-pr branch with docs/samples/peer-review-practice.md, and the PR) idempotently on every progression run, so new rooms get them at Challenge 1 seed time and existing rooms self-repair on their next challenge close. Seeding failures log a warning instead of failing progression. The student-progression workflow gains pull-requests: write for the PR creation. Co-Authored-By: Claude Fable 5 --- ...llenge-progression-peer-simulation.test.js | 161 +++++++++++++ .../.github/scripts/challenge-progression.js | 212 +++++++++++++++++- .../.github/workflows/student-progression.yml | 3 + 3 files changed, 370 insertions(+), 6 deletions(-) create mode 100644 .github/scripts/__tests__/challenge-progression-peer-simulation.test.js diff --git a/.github/scripts/__tests__/challenge-progression-peer-simulation.test.js b/.github/scripts/__tests__/challenge-progression-peer-simulation.test.js new file mode 100644 index 00000000..1fa2280b --- /dev/null +++ b/.github/scripts/__tests__/challenge-progression-peer-simulation.test.js @@ -0,0 +1,161 @@ +// Regression tests for peer simulation seeding in the student-facing +// challenge progression script. Challenges 1-13 reference a seeded +// "Peer Simulation: Welcome Link Needs Context" issue and a +// "Peer Simulation: Improve contribution guidance" pull request. These were +// originally created by scripts/classroom/Seed-PeerSimulation.ps1, which was +// deleted with GitHub Classroom, leaving provisioned rooms without the +// artifacts (support issue Community-Access/support#58, 2026-07-04). The +// progression script must seed them idempotently on every run. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +process.env.GITHUB_TOKEN = 'test-token'; +process.env.GITHUB_REPOSITORY = 'org/room'; +process.env.START_CHALLENGE = ''; +process.env.GITHUB_EVENT_NAME = ''; +process.env.CLOSED_ISSUE_TITLE = ''; + +// The script resolves its issue-template directory from the working +// directory, so requiring it must happen from inside learning-room/. +process.chdir(path.join(__dirname, '..', '..', '..', 'learning-room')); + +const { + ensurePeerSimulationArtifacts, + ensureLabel, + ensurePeerSimulationBranch, + ensurePeerSimulationPullRequest +} = require('../../../learning-room/.github/scripts/challenge-progression'); + +function fakeRequest(overrides = {}) { + const calls = []; + const request = async (route, options = {}) => { + const method = options.method || 'GET'; + calls.push({ method, route, body: options.body }); + for (const [pattern, handler] of Object.entries(overrides)) { + const [overrideMethod, routePrefix] = pattern.split(' '); + if (method === overrideMethod && route.startsWith(routePrefix)) { + return handler({ route, options }); + } + } + // Defaults that describe an empty, freshly provisioned room. + if (method === 'GET' && route.startsWith('/repos/org/room/issues?')) { + return []; + } + if (method === 'GET' && route.startsWith('/repos/org/room/pulls?')) { + return []; + } + if (method === 'GET' && route.startsWith('/repos/org/room/git/ref/heads/peer-simulation')) { + throw new Error('GET failed: 404 Not Found'); + } + if (method === 'GET' && route.startsWith('/repos/org/room/git/ref/heads/main')) { + return { object: { sha: 'abc123' } }; + } + if (method === 'GET' && route.startsWith('/repos/org/room/contents/')) { + throw new Error('GET failed: 404 Not Found'); + } + if (method === 'POST' && route === '/repos/org/room/issues') { + return { number: 42, html_url: 'https://example.test/issue/42' }; + } + if (method === 'POST' && route === '/repos/org/room/pulls') { + return { number: 7, html_url: 'https://example.test/pull/7' }; + } + return null; + }; + return { calls, request }; +} + +test('seeds labels, both issues, branch, file, and PR in an empty room', async () => { + const { calls, request } = fakeRequest(); + + await ensurePeerSimulationArtifacts(request); + + const posts = calls.filter((c) => c.method === 'POST'); + const labelPosts = posts.filter((c) => c.route === '/repos/org/room/labels'); + assert.equal(labelPosts.length, 4); + + const issuePosts = posts.filter((c) => c.route === '/repos/org/room/issues'); + assert.deepEqual( + issuePosts.map((c) => c.body.title), + [ + 'Peer Simulation: Welcome Link Needs Context', + 'Peer Simulation: Review Request for Contribution Guidance' + ] + ); + assert.ok(issuePosts[0].body.labels.includes('peer-simulation')); + + const refPosts = posts.filter((c) => c.route === '/repos/org/room/git/refs'); + assert.equal(refPosts.length, 1); + assert.equal(refPosts[0].body.ref, 'refs/heads/peer-simulation/review-pr'); + assert.equal(refPosts[0].body.sha, 'abc123'); + + const filePuts = calls.filter((c) => c.method === 'PUT'); + assert.equal(filePuts.length, 1); + assert.ok(filePuts[0].route.includes('docs/samples/peer-review-practice.md')); + assert.equal(filePuts[0].body.branch, 'peer-simulation/review-pr'); + + const prPosts = posts.filter((c) => c.route === '/repos/org/room/pulls'); + assert.equal(prPosts.length, 1); + assert.equal(prPosts[0].body.title, 'Peer Simulation: Improve contribution guidance'); + assert.equal(prPosts[0].body.head, 'peer-simulation/review-pr'); + assert.equal(prPosts[0].body.base, 'main'); + assert.match(prPosts[0].body.body, /#42/); +}); + +test('is idempotent: creates nothing when the artifacts already exist', async () => { + const { calls, request } = fakeRequest({ + 'GET /repos/org/room/issues?': () => [ + { number: 5, title: 'Peer Simulation: Welcome Link Needs Context' }, + { number: 6, title: 'Peer Simulation: Review Request for Contribution Guidance' } + ], + 'GET /repos/org/room/git/ref/heads/peer-simulation': () => ({ object: { sha: 'abc123' } }), + 'GET /repos/org/room/contents/': () => ({ sha: 'filesha' }), + 'GET /repos/org/room/pulls?': () => [{ number: 7 }] + }); + + await ensurePeerSimulationArtifacts(request); + + const writes = calls.filter( + (c) => (c.method === 'POST' || c.method === 'PUT') && c.route !== '/repos/org/room/labels' + ); + assert.deepEqual(writes, []); +}); + +test('never throws: progression survives a seeding failure', async () => { + const request = async () => { + throw new Error('boom: no network'); + }; + + await assert.doesNotReject(ensurePeerSimulationArtifacts(request)); +}); + +test('ensureLabel tolerates a label that already exists', async () => { + const request = async () => { + throw new Error('POST /repos/org/room/labels failed: 422 already_exists'); + }; + + await assert.doesNotReject( + ensureLabel({ name: 'peer-simulation', color: '5319e7', description: 'x' }, request) + ); +}); + +test('branch seeding skips the commit when the practice file is present', async () => { + const { calls, request } = fakeRequest({ + 'GET /repos/org/room/git/ref/heads/peer-simulation': () => ({ object: { sha: 'abc123' } }), + 'GET /repos/org/room/contents/': () => ({ sha: 'filesha' }) + }); + + await ensurePeerSimulationBranch(request); + + assert.deepEqual(calls.filter((c) => c.method !== 'GET'), []); +}); + +test('PR seeding skips creation when a PR for the branch exists in any state', async () => { + const { calls, request } = fakeRequest({ + 'GET /repos/org/room/pulls?': () => [{ number: 7, state: 'closed' }] + }); + + await ensurePeerSimulationPullRequest(6, request); + + assert.deepEqual(calls.filter((c) => c.method === 'POST'), []); +}); diff --git a/learning-room/.github/scripts/challenge-progression.js b/learning-room/.github/scripts/challenge-progression.js index 7f1058bb..7fe9d2c6 100644 --- a/learning-room/.github/scripts/challenge-progression.js +++ b/learning-room/.github/scripts/challenge-progression.js @@ -233,15 +233,15 @@ async function githubRequest(route, options = {}, retries = 3) { } } -async function issueAlreadyExists(titlePrefix) { +async function findIssueByTitlePrefix(titlePrefix, request = githubRequest) { log('INFO', `Checking if issue exists with prefix: "${titlePrefix}"`); try { // Fetch with pagination to handle more than 100 issues let page = 1; const perPage = 100; while (page <= 5) { // Check up to 500 issues max - const issues = await githubRequest(`/repos/${owner}/${repo}/issues?state=all&per_page=${perPage}&page=${page}`); - + const issues = await request(`/repos/${owner}/${repo}/issues?state=all&per_page=${perPage}&page=${page}`); + if (!Array.isArray(issues)) { log('WARN', `Expected array from issues API, got: ${typeof issues}`); break; @@ -255,20 +255,24 @@ async function issueAlreadyExists(titlePrefix) { const found = issues.find(issue => issue.title && issue.title.startsWith(titlePrefix)); if (found) { log('INFO', `Found existing issue: ${found.title} (#${found.number})`); - return true; + return found; } page += 1; } log('DEBUG', `No existing issue found with prefix "${titlePrefix}"`); - return false; + return null; } catch (error) { log('ERROR', `Failed to check for existing issues: ${error.message}`); throw error; } } +async function issueAlreadyExists(titlePrefix, request = githubRequest) { + return Boolean(await findIssueByTitlePrefix(titlePrefix, request)); +} + // A GitHub user can only be assigned when they have access to the repository. // Right after provisioning the learner's collaborator invitation is still // pending, so they exist as a user but are not yet assignable; creating the @@ -399,6 +403,190 @@ async function seedMergeConflict() { } } +// Peer simulation artifacts. Challenges 1-13 tell the student to comment on, +// react to, review, or compare with a seeded "peer" issue and pull request. +// These were originally created by scripts/classroom/Seed-PeerSimulation.ps1, +// which was removed with GitHub Classroom; the learning room now seeds them +// itself. Every helper is idempotent so this can run on every progression +// event and quietly repair a room that is missing an artifact. +const PEER_SIM_ISSUE_TITLE = 'Peer Simulation: Welcome Link Needs Context'; +const PEER_SIM_REVIEW_ISSUE_TITLE = 'Peer Simulation: Review Request for Contribution Guidance'; +const PEER_SIM_PR_TITLE = 'Peer Simulation: Improve contribution guidance'; +const PEER_SIM_BRANCH = 'peer-simulation/review-pr'; +const PEER_SIM_FILE = 'docs/samples/peer-review-practice.md'; + +const PEER_SIM_LABELS = [ + { name: 'peer-simulation', color: '5319e7', description: 'Seeded scenario that simulates peer collaboration in a private Learning Room' }, + { name: 'needs-triage', color: 'fbca04', description: 'Needs issue triage or clarification' }, + { name: 'accessibility', color: '0052cc', description: 'Accessibility-related practice item' }, + { name: 'review-practice', color: '0e8a16', description: 'Pull request review practice' } +]; + +async function ensureLabel(label, request = githubRequest) { + try { + await request(`/repos/${owner}/${repo}/labels`, { method: 'POST', body: label }, 1); + log('INFO', `Created label: ${label.name}`); + } catch (error) { + if (/already_exists|422/.test(error.message)) { + log('DEBUG', `Label ${label.name} already exists.`); + return; + } + throw error; + } +} + +async function ensurePeerIssue(title, bodyLines, labels, request = githubRequest) { + const existing = await findIssueByTitlePrefix(title, request); + if (existing) { + log('DEBUG', `${title} already exists (#${existing.number}).`); + return existing; + } + const issue = await request(`/repos/${owner}/${repo}/issues`, { + method: 'POST', + body: { title, body: bodyLines.join('\n'), labels } + }); + log('INFO', `Created peer simulation issue: ${issue.html_url}`); + console.log(`Created ${title}: ${issue.html_url}`); + return issue; +} + +async function ensurePeerSimulationBranch(request = githubRequest) { + try { + await request(`/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(PEER_SIM_BRANCH)}`, {}, 1); + log('DEBUG', `Branch ${PEER_SIM_BRANCH} already exists.`); + } catch (error) { + const mainRef = await request(`/repos/${owner}/${repo}/git/ref/heads/main`, {}, 1); + await request(`/repos/${owner}/${repo}/git/refs`, { + method: 'POST', + body: { ref: `refs/heads/${PEER_SIM_BRANCH}`, sha: mainRef.object.sha } + }); + log('INFO', `Created branch ${PEER_SIM_BRANCH}.`); + } + + try { + await request(`/repos/${owner}/${repo}/contents/${PEER_SIM_FILE}?ref=${encodeURIComponent(PEER_SIM_BRANCH)}`, {}, 1); + log('DEBUG', `${PEER_SIM_FILE} already exists on ${PEER_SIM_BRANCH}.`); + return; + } catch (error) { + log('DEBUG', `${PEER_SIM_FILE} not found on ${PEER_SIM_BRANCH}; creating it.`); + } + + const sampleLines = [ + '# Peer Review Practice', + '', + 'This file simulates a contribution from another workshop participant.', + '', + '## Proposed Change', + '', + 'Open source contributors should explain what they changed and why it matters. This practice file gives you something realistic to review without requiring access to another student repository.', + '', + '## Review Prompts', + '', + '- Is the purpose of the change clear?', + '- Is the language welcoming?', + '- Is anything missing for screen reader users?', + '- What is one specific improvement you can suggest?' + ]; + await request(`/repos/${owner}/${repo}/contents/${PEER_SIM_FILE}`, { + method: 'PUT', + body: { + message: 'Add peer review practice scenario', + content: Buffer.from(sampleLines.join('\n') + '\n').toString('base64'), + branch: PEER_SIM_BRANCH + } + }); + log('INFO', `Committed ${PEER_SIM_FILE} to ${PEER_SIM_BRANCH}.`); +} + +async function ensurePeerSimulationPullRequest(reviewIssueNumber, request = githubRequest) { + const existing = await request(`/repos/${owner}/${repo}/pulls?head=${owner}:${encodeURIComponent(PEER_SIM_BRANCH)}&state=all`, {}, 1); + if (Array.isArray(existing) && existing.length > 0) { + log('DEBUG', `Peer simulation PR already exists (#${existing[0].number}).`); + return; + } + const bodyLines = [ + '## Peer Simulation PR', + '', + 'This pull request simulates a contribution from another workshop participant.', + '', + 'Use it for challenges that ask you to review, comment on, or compare with a peer contribution.', + reviewIssueNumber ? `\nRelated issue: #${reviewIssueNumber}` : '' + ].filter(Boolean); + const pr = await request(`/repos/${owner}/${repo}/pulls`, { + method: 'POST', + body: { + title: PEER_SIM_PR_TITLE, + head: PEER_SIM_BRANCH, + base: 'main', + body: bodyLines.join('\n') + } + }); + log('INFO', `Created peer simulation PR: ${pr.html_url}`); + console.log(`Created ${PEER_SIM_PR_TITLE}: ${pr.html_url}`); +} + +async function ensurePeerSimulationArtifacts(request = githubRequest) { + log('INFO', 'Ensuring peer simulation artifacts exist...'); + try { + for (const label of PEER_SIM_LABELS) { + await ensureLabel(label, request); + } + + const studentLine = actor + ? `This scenario was prepared for @${actor}.` + : 'This scenario was prepared for the student using this repository.'; + + await ensurePeerIssue(PEER_SIM_ISSUE_TITLE, [ + '## Peer Simulation: Welcome Link Needs Context', + '', + studentLine, + '', + 'A simulated peer noticed that `docs/welcome.md` still has placeholder TODO content and does not yet explain why assistive technology users bring valuable perspective to open source projects.', + '', + '### Your collaboration task', + '', + 'Use this issue when a challenge asks you to interact with another person. Treat this as a real peer report:', + '', + '1. Read the issue description.', + '2. Leave a constructive comment or clarifying question.', + '3. Practice an @mention in one comment when a challenge asks for it. Mentioning your own username works fine.', + '4. Add a reaction if the challenge asks you to practice reactions.', + '5. For triage practice, add or discuss useful labels.', + '', + '### Real-world skill', + '', + 'This simulates reviewing a teammate issue in a shared open source project while keeping the workshop repository private.' + ], ['peer-simulation', 'needs-triage', 'accessibility'], request); + + const reviewIssue = await ensurePeerIssue(PEER_SIM_REVIEW_ISSUE_TITLE, [ + '## Peer Simulation: Review Request for Contribution Guidance', + '', + studentLine, + '', + 'A simulated peer opened a pull request that improves contribution guidance. Your task is to review it the way you would review a teammate contribution.', + '', + '### What to practice', + '', + '- Read the linked peer simulation pull request.', + '- Leave at least one specific comment.', + '- For Challenge 12, submit a formal review verdict if GitHub offers that option.', + '- Be kind, specific, and useful.', + '', + 'This issue exists so private learning rooms can still include realistic peer collaboration.' + ], ['peer-simulation', 'review-practice'], request); + + await ensurePeerSimulationBranch(request); + await ensurePeerSimulationPullRequest(reviewIssue && reviewIssue.number, request); + log('INFO', 'Peer simulation artifacts are in place.'); + } catch (error) { + // Never fail challenge progression because peer simulation seeding + // hiccuped; the next progression event will retry, and students can + // fall back to a real buddy repository. + log('WARN', `Could not fully seed peer simulation artifacts: ${error.message}`); + console.log(`Warning: peer simulation seeding incomplete: ${error.message}`); + } +} + if (require.main === module) { const progressionTargets = getProgressionTargets(); if (!progressionTargets.length) { @@ -407,6 +595,11 @@ if (require.main === module) { } else { log('INFO', `Proceeding with ${progressionTargets.length} progression target(s).`); (async () => { + // Seed (or repair) the peer simulation issue, branch, and PR before the + // next challenge issue lands, so its instructions always have something + // to point at. + await ensurePeerSimulationArtifacts(); + for (const target of progressionTargets) { if (target.kind === 'challenge') { await createChallenge(target.number); @@ -428,4 +621,11 @@ if (require.main === module) { } } -module.exports = { resolveValidAssignee }; \ No newline at end of file +module.exports = { + resolveValidAssignee, + ensurePeerSimulationArtifacts, + ensureLabel, + ensurePeerIssue, + ensurePeerSimulationBranch, + ensurePeerSimulationPullRequest +}; \ No newline at end of file diff --git a/learning-room/.github/workflows/student-progression.yml b/learning-room/.github/workflows/student-progression.yml index 5ab7d966..d4eee219 100644 --- a/learning-room/.github/workflows/student-progression.yml +++ b/learning-room/.github/workflows/student-progression.yml @@ -20,6 +20,9 @@ concurrency: permissions: contents: write issues: write + # Needed to open the seeded "Peer Simulation: Improve contribution + # guidance" pull request that Challenges 4-13 reference. + pull-requests: write jobs: create-next-challenge: