Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Regression tests for the assignee handling in the student-facing challenge
// progression script. At provisioning time the learner's collaborator
// invitation is still pending, so they exist as a GitHub user but cannot yet
// be assigned issues in the repository; the script must degrade to an
// unassigned issue instead of failing with HTTP 422 (see learning rooms for
// waphi and accesswatch-student on 2026-07-02).
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 { resolveValidAssignee } = require('../../../learning-room/.github/scripts/challenge-progression');
Comment on lines +11 to +21

test('validates assignability in the repository, not global user existence', async () => {
const routes = [];
const request = async (route) => {
routes.push(route);
return null;
};

const result = await resolveValidAssignee('waphi', request);

assert.equal(result, 'waphi');
assert.deepEqual(routes, ['/repos/org/room/assignees/waphi']);
});

test('returns null when the user cannot be assigned yet (pending invitation)', async () => {
const request = async () => {
throw new Error('GET /repos/org/room/assignees/waphi failed: 404');
};

assert.equal(await resolveValidAssignee('waphi', request), null);
});

test('returns null for a missing actor without calling the API', async () => {
let called = false;
const request = async () => {
called = true;
};

assert.equal(await resolveValidAssignee('', request), null);
assert.equal(called, false);
});
48 changes: 48 additions & 0 deletions .github/scripts/provisioning/__tests__/provision-core.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,54 @@ test('a failure does not stop the batch; others still provision', async () => {
assert.equal(summary.created, 1);
});

test('seeds the first challenge issue after workflows are verified', async () => {
const seedCalls = [];
const { client, state } = makeClient({
async seedFirstChallenge({ owner, repo, assignee }) {
seedCalls.push({ owner, repo, assignee, listCallsSoFar: state.calls.list });
}
});
const roster = rosterWith({ github_handle: 'alice', cohort_id: 'c1' });
const { roster: out, summary } = await provisionRoster({ roster, client, config, sleep: noSleep });

assert.equal(out.learners[0].provision_state, 'provisioned');
assert.equal(summary.error, 0);
assert.equal(seedCalls.length, 1);
assert.equal(seedCalls[0].owner, 'Community-Access');
assert.equal(seedCalls[0].repo, 'learning-room-c1-alice');
assert.equal(seedCalls[0].assignee, 'alice');
assert.ok(seedCalls[0].listCallsSoFar >= 1, 'seeding must happen after workflow verification');
});

test('seeds the first challenge on the already-exists path too', async () => {
const seedCalls = [];
const { client, state } = makeClient({
async seedFirstChallenge(args) {
seedCalls.push(args);
}
});
state.repos.set('learning-room-c1-alice', { workflows: [...REQUIRED] });
const roster = rosterWith({ github_handle: 'alice', cohort_id: 'c1' });
const { summary } = await provisionRoster({ roster, client, config, sleep: noSleep });

assert.equal(summary.already_exists, 1);
assert.equal(seedCalls.length, 1);
});

test('marks the learner failed when first-challenge seeding fails', async () => {
const { client } = makeClient({
async seedFirstChallenge() {
throw new Error('seed boom');
}
});
const roster = rosterWith({ github_handle: 'alice', cohort_id: 'c1' });
const { roster: out, log, summary } = await provisionRoster({ roster, client, config, sleep: noSleep });

assert.equal(out.learners[0].provision_state, 'failed');
assert.equal(summary.error, 1);
assert.match(log[0].error_detail, /seed boom/);
});

test('isSecondaryRateLimit detects status and message forms', () => {
assert.ok(isSecondaryRateLimit({ status: 429 }));
assert.ok(isSecondaryRateLimit({ message: 'oops HTTP 403 here' }));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const test = require('node:test');
const assert = require('node:assert/strict');

const { createChallengeSeeder } = require('../seed-first-challenge');

function fakeSpawn(result) {
const calls = [];
const spawn = (cmd, args, opts) => {
calls.push({ cmd, args, opts });
return { status: 0, stdout: '', stderr: '', ...result };
};
return { calls, spawn };
}

test('requires a token and a learning room directory', () => {
assert.throws(() => createChallengeSeeder({ learningRoomDir: '/lr' }), /token/);
assert.throws(() => createChallengeSeeder({ token: 't' }), /learningRoomDir/);
});

test('runs the challenge progression script against the student repo', async () => {
const { calls, spawn } = fakeSpawn({ stdout: 'Created Challenge 1' });
const seeder = createChallengeSeeder({ token: 'tok', learningRoomDir: '/lr', spawn });

const result = await seeder.seedFirstChallenge({
owner: 'org',
repo: 'room',
assignee: 'alice'
});

assert.equal(calls.length, 1);
const call = calls[0];
assert.equal(call.cmd, process.execPath);
assert.match(call.args[0], /challenge-progression\.js$/);
assert.equal(call.opts.cwd, '/lr');
assert.equal(call.opts.env.GITHUB_TOKEN, 'tok');
assert.equal(call.opts.env.GITHUB_REPOSITORY, 'org/room');
assert.equal(call.opts.env.START_CHALLENGE, '1');
assert.equal(call.opts.env.CHALLENGE_ASSIGNEE, 'alice');
assert.equal(result.output, 'Created Challenge 1');
});

test('passes an empty assignee through as an empty string', async () => {
const { calls, spawn } = fakeSpawn({});
const seeder = createChallengeSeeder({ token: 'tok', learningRoomDir: '/lr', spawn });
await seeder.seedFirstChallenge({ owner: 'org', repo: 'room' });
assert.equal(calls[0].opts.env.CHALLENGE_ASSIGNEE, '');
});

test('throws with script output when the script exits non-zero', async () => {
const { spawn } = fakeSpawn({ status: 1, stderr: 'FATAL: creation exploded' });
const seeder = createChallengeSeeder({ token: 'tok', learningRoomDir: '/lr', spawn });

await assert.rejects(
() => seeder.seedFirstChallenge({ owner: 'org', repo: 'room', assignee: 'alice' }),
/creation exploded/
);
});

test('throws when the script cannot be spawned at all', async () => {
const { spawn } = fakeSpawn({ error: new Error('ENOENT node missing'), status: null });
const seeder = createChallengeSeeder({ token: 'tok', learningRoomDir: '/lr', spawn });

await assert.rejects(
() => seeder.seedFirstChallenge({ owner: 'org', repo: 'room', assignee: 'alice' }),
/ENOENT/
);
});
9 changes: 8 additions & 1 deletion .github/scripts/provisioning/provision-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const path = require('node:path');
const { parseRoster, serializeRoster } = require('./roster');
const { provisionRoster } = require('./provision-core');
const { createFetchClient, REQUIRED_WORKFLOWS } = require('./github-client');
const { createChallengeSeeder } = require('./seed-first-challenge');
const { mintInstallationToken, discoverInstallationId } = require('./github-app-auth');

function parseArgs(argv) {
Expand Down Expand Up @@ -161,7 +162,13 @@ async function main() {
}

const token = await resolveToken(env, apiBaseUrl);
const client = createFetchClient({ token, apiBaseUrl });
// The seeder runs the learning-room progression script from this checkout,
// so provisioned rooms start with their Challenge 1 issue already open.
const seeder = createChallengeSeeder({
token,
learningRoomDir: path.join(__dirname, '..', '..', '..', 'learning-room')
});
const client = { ...createFetchClient({ token, apiBaseUrl }), ...seeder };

// Preflight: fail with one clear message if the token cannot even see the
// template, instead of a confusing per-learner error cascade.
Expand Down
12 changes: 12 additions & 0 deletions .github/scripts/provisioning/provision-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,18 @@ async function provisionOne({
);
}

// A room without a Challenge 1 issue never starts the progression chain:
// the Student Progression Bot only fires on issue close or manual dispatch.
// Seed after content verification so the issue never points at an empty
// repo. The seeder is idempotent (skips when a challenge issue exists).
if (typeof client.seedFirstChallenge === 'function') {
await client.seedFirstChallenge({
owner: studentOwner,
repo: repoName,
assignee: handle
});
}

let templateSha = null;
if (typeof client.getDefaultBranchSha === 'function') {
templateSha = await client.getDefaultBranchSha({ owner: studentOwner, repo: repoName });
Expand Down
72 changes: 72 additions & 0 deletions .github/scripts/provisioning/seed-first-challenge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* First-challenge seeding for provisioning (SPEC.md section 7.2b step 3).
*
Comment on lines +1 to +3
* A freshly provisioned learning room has no issues, and the Student
* Progression Bot inside the room only fires when a challenge issue is closed
* or manually dispatched - so without this step every new room starts with an
* empty Issues tab and the progression chain never begins.
*
* Rather than duplicating the issue-rendering logic, this module runs the same
* challenge-progression.js script the student repo uses, from the workshop
* checkout's learning-room/ directory, pointed at the student repo with the
* App installation token. The script is idempotent (it skips creation when a
* matching challenge issue already exists) and degrades to an unassigned issue
* when the learner's collaborator invitation is still pending.
*
* Note: challenge-progression.js talks to https://api.github.com directly; it
* does not honor GITHUB_API_URL. That matches every current deployment.
*/

'use strict';

const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');

function createChallengeSeeder({
token,
learningRoomDir,
spawn = spawnSync,
execPath = process.execPath
}) {
if (!token) throw new Error('createChallengeSeeder requires a token');
if (!learningRoomDir) throw new Error('createChallengeSeeder requires learningRoomDir');

const scriptPath = path.join(learningRoomDir, '.github', 'scripts', 'challenge-progression.js');
// Fail at construction, not per learner, when the checkout layout is wrong.
if (spawn === spawnSync && !fs.existsSync(scriptPath)) {
throw new Error(`createChallengeSeeder cannot find ${scriptPath}`);
}

return {
async seedFirstChallenge({ owner, repo, assignee }) {
const result = spawn(execPath, [scriptPath], {
cwd: learningRoomDir,
encoding: 'utf8',
env: {
...process.env,
GITHUB_TOKEN: token,
GITHUB_REPOSITORY: `${owner}/${repo}`,
GITHUB_EVENT_NAME: 'provisioning-seed',
START_CHALLENGE: '1',
CHALLENGE_ASSIGNEE: assignee || ''
}
});

if (result.error) throw result.error;
if (result.status !== 0) {
const detail = String(result.stderr || result.stdout || '')
.trim()
.split(/\r?\n/)
.slice(-5)
.join(' | ');
throw new Error(
`seedFirstChallenge failed for ${owner}/${repo} (exit ${result.status}): ${detail}`
);
}
return { output: String(result.stdout || '').trim() };
}
};
}

module.exports = { createChallengeSeeder };
12 changes: 10 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ Grant only these. Anything beyond this list is over-privileged and fails the sec
| Repository administration | Write | Create the per-student private repository from the template |
| Contents | Write | Seed and heal repository content (workflows, issue templates) |
| Metadata | Read | Mandatory baseline for any App |
| Issues | Write | Seed the first challenge issue and provisioning status (optional; can defer to the Progression Bot) |
| Issues | Write | Seed the first challenge issue at provision time. Not deferrable: the Progression Bot only fires on issue close or manual dispatch, so a room provisioned without a Challenge 1 issue never starts the progression chain |

App configuration rules:

Expand Down Expand Up @@ -256,7 +256,15 @@ for each roster entry where provision_state in (pending, failed):
- On failure: provision_state = failed, write error_detail,
surface to watchdog. Do NOT leave a half-seeded repo silently.

5. Wait a short delay (1 to a few seconds) before the next entry.
5. Seed the Challenge 1 issue if no challenge issue exists yet
(idempotent; runs the learning-room progression script against the
student repo). The learner's collaborator invitation is usually still
pending at this point, so the issue is created unassigned; the
Progression Bot assigns subsequent challenges to whoever closes the
previous one. A seeding failure marks the learner failed and surfaces
to the watchdog.

6. Wait a short delay (1 to a few seconds) before the next entry.
On HTTP 403/429 (secondary limit), back off exponentially and retry
the same entry; never skip it.
```
Expand Down
12 changes: 10 additions & 2 deletions html/SPEC.html
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,7 @@ <h3 id="72a-github-app-permission-set">7.2a GitHub App permission set</h3>
<tr>
<td>Issues</td>
<td>Write</td>
<td>Seed the first challenge issue and provisioning status (optional; can defer to the Progression Bot)</td>
<td>Seed the first challenge issue at provision time. Not deferrable: the Progression Bot only fires on issue close or manual dispatch, so a room provisioned without a Challenge 1 issue never starts the progression chain</td>
</tr>
</tbody></table>
<p>App configuration rules:</p>
Expand Down Expand Up @@ -663,7 +663,15 @@ <h3 id="72b-provisioning-algorithm-idempotent-serial">7.2b Provisioning algorith
- On failure: provision_state = failed, write error_detail,
surface to watchdog. Do NOT leave a half-seeded repo silently.

5. Wait a short delay (1 to a few seconds) before the next entry.
5. Seed the Challenge 1 issue if no challenge issue exists yet
(idempotent; runs the learning-room progression script against the
student repo). The learner&#x27;s collaborator invitation is usually still
pending at this point, so the issue is created unassigned; the
Progression Bot assigns subsequent challenges to whoever closes the
previous one. A seeding failure marks the learner failed and surfaces
to the watchdog.

6. Wait a short delay (1 to a few seconds) before the next entry.
On HTTP 403/429 (secondary limit), back off exponentially and retry
the same entry; never skip it.</code></pre>
<p>Invariants: at-most-one repository per key; pinned template SHA; learner has access; required workflows present; every outcome written to the provisioning log; any failure visible to a human before a learner notices.</p>
Expand Down
Loading
Loading