diff --git a/exercises/README.md b/exercises/README.md index b9ac3da..d15ac3e 100644 --- a/exercises/README.md +++ b/exercises/README.md @@ -5,6 +5,10 @@ module in the [walkthrough](../docs/walkthrough.md) and maps to a ready‑made G [`seed-issues.md`](seed-issues.md). Reference solutions are in [`solutions.md`](solutions.md) — don't peek until you've let the agent try. +If you want to reset the demo repository after a run-through, see +[`demo-reset.md`](demo-reset.md). It preserves the seeded practice issues and removes extra +issues and pull requests. + `main` ships **green** (8 passing tests). Every exercise asks the agent to add behavior *and* the tests that prove it. diff --git a/exercises/demo-reset.md b/exercises/demo-reset.md new file mode 100644 index 0000000..53e777b --- /dev/null +++ b/exercises/demo-reset.md @@ -0,0 +1,36 @@ +# Reset the demo state + +Use this helper to clean up the repository after you have finished a walkthrough or want to start over with a fresh set of practice issues and pull requests. + +## What it preserves + +The script preserves the four seeded exercise issues from [`seed-issues.md`](seed-issues.md) by matching their titles exactly. Everything else is treated as demo clutter and reset. + +## Prerequisites + +- GitHub CLI installed and authenticated (`gh auth login`) +- Run the command from the repository you want to reset + +## Preview the changes + +```bash +node scripts/reset-demo.mjs +``` + +The script runs in dry-run mode by default and prints the issues and pull requests it would close or delete. + +## Apply the reset + +```bash +node scripts/reset-demo.mjs --apply +``` + +This will: + +- preserve the seeded exercise issues +- close any extra issues that are still open +- delete extra issues +- close any extra pull requests that are still open +- delete extra pull requests + +> The reset is destructive for extra issues and pull requests. Review the dry-run output carefully before using `--apply`. diff --git a/scripts/reset-demo.mjs b/scripts/reset-demo.mjs new file mode 100644 index 0000000..625c49e --- /dev/null +++ b/scripts/reset-demo.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const args = new Set(process.argv.slice(2)); +const apply = args.has('--apply'); +const repoRoot = process.cwd(); +const gitRoot = runGitCommand(['rev-parse', '--show-toplevel'], repoRoot).trim(); +const repo = runGhCommand(['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], gitRoot).trim(); +const seededTitles = readSeededIssueTitles(resolve(gitRoot, 'exercises', 'seed-issues.md')); + +console.log(`Repository: ${repo}`); +console.log(`Preserving seeded issues: ${seededTitles.join(', ')}`); +console.log(apply ? 'Applying changes.' : 'Dry run only. Re-run with --apply to make changes.'); + +const issues = listIssues(repo); +const pullRequests = listPullRequests(repo); + +const extraIssues = issues.filter((issue) => !seededTitles.includes(issue.title)); +const extraPullRequests = pullRequests; + +if (extraIssues.length === 0 && extraPullRequests.length === 0) { + console.log('Nothing to reset.'); + process.exit(0); +} + +for (const issue of extraIssues) { + const action = issue.state === 'OPEN' ? `close issue #${issue.number}` : `delete issue #${issue.number}`; + if (apply) { + if (issue.state === 'OPEN') { + runGhCommand(['issue', 'close', String(issue.number), '--repo', repo]); + } + runGhCommand(['issue', 'delete', String(issue.number), '--repo', repo, '--yes']); + } + console.log(`${apply ? 'Applied' : 'Would'} ${action}: ${issue.title}`); +} + +for (const pr of extraPullRequests) { + const action = pr.state === 'OPEN' ? `close pull request #${pr.number}` : `delete pull request #${pr.number}`; + if (apply) { + if (pr.state === 'OPEN') { + runGhCommand(['pr', 'close', String(pr.number), '--repo', repo]); + } + runGhCommand(['pr', 'delete', String(pr.number), '--repo', repo, '--yes']); + } + console.log(`${apply ? 'Applied' : 'Would'} ${action}: ${pr.title}`); +} + +function runGitCommand(args, cwd = repoRoot) { + const result = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (result.status !== 0) { + throw new Error(result.stderr.trim() || `git ${args.join(' ')} failed`); + } + return result.stdout; +} + +function runGhCommand(args, cwd = repoRoot) { + const result = spawnSync('gh', args, { cwd, encoding: 'utf8' }); + if (result.status !== 0) { + throw new Error(result.stderr.trim() || `gh ${args.join(' ')} failed`); + } + return result.stdout; +} + +function readSeededIssueTitles(seedFilePath) { + if (!existsSync(seedFilePath)) { + throw new Error(`Seed issues file not found: ${seedFilePath}`); + } + + const content = readFileSync(seedFilePath, 'utf8'); + const titles = [...content.matchAll(/--title\s+"([^"]+)"/g)].map((match) => match[1]); + if (titles.length === 0) { + throw new Error(`No seeded issue titles found in ${seedFilePath}`); + } + + return titles; +} + +function listIssues(repo) { + const output = runGhCommand(['issue', 'list', '--state', 'all', '--limit', '1000', '--repo', repo, '--json', 'number,title,state']); + return JSON.parse(output); +} + +function listPullRequests(repo) { + const output = runGhCommand(['pr', 'list', '--state', 'all', '--limit', '1000', '--repo', repo, '--json', 'number,title,state']); + return JSON.parse(output); +}