Skip to content

Add create-pr: a draft PR containing only one lesson's diff - #84

Merged
mattpocock merged 3 commits into
mainfrom
feat/create-pr
Oct 9, 2026
Merged

mattpocock merged 3 commits into
mainfrom
feat/create-pr

Conversation

@mattpocock

@mattpocock mattpocock commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds create-pr. A student runs npm run create-pr <slug> and gets a draft PR on their own repo (origin) whose diff is exactly one lesson commit. Then they ask their agent to rewrite the PR body.

ai-hero create-pr <lesson-id> --branch=live-run-through --upstream=<url>

Course package.json: "create-pr": "ai-hero-cli create-pr --branch=live-run-through --upstream=<url>", the same flags as reset.

Size: 3 commits (the feature, fixes from an independent review, then a fallback when drafts aren't available), about 410 lines of source and 750 lines of tests. Reading order: src/create-pr.ts → src/github-service.ts (the new gh pr methods and parseGitHubRemoteUrl) → src/git-service/git-service-impl.ts (5 small primitives) → test/create-pr-e2e.test.ts.

runCreatePr
  preflight                 (no changes until all of these pass)
    git repo, not on live-run-through, clean working tree
    gh installed and authenticated
    origin URL parses as GitHub, and `gh repo view` can see it
  resolve lesson C          selectLessonCommit, the same as reset; P = C^
  pr/<slug>, pr-base/<slug> must be legal branch names (git check-ref-format)
  pr/<slug> exists locally? confirmContinue(..., false)
  commit-tree C^{tree} -p P (message is C's with "<slug>: " stripped)
  push --force P → origin refs/heads/pr-base/<slug>
  checkout -B pr/<slug> <new commit>
  git config branch.pr/<slug>.gh-merge-base pr-base/<slug>
  push --force pr/<slug> → origin
  open PR for head pr/<slug>?
    yes → gh pr edit --title --body --base pr-base/<slug>  +  gh pr ready --undo
    no  → gh pr create --draft --title --body --base pr-base/<slug> --head pr/<slug>
  drafts rejected? (private repo on GitHub Free)
    create → retry without --draft;  ready --undo → non-fatal
    print: "the PR was opened ready-for-review because draft pull requests aren't available on this repo's GitHub plan" 
  print PR URL and the next step

One departure from the spec: the spec asked for gh pr create --fill. This passes --title/--body explicitly instead, with the values --fill would produce for a single-commit branch: the subject with the prefix stripped, and the body. The reason is that --fill runs git log <remote>/<base>..<head> on local remote-tracking refs. Those refs are missing when the clone has a narrow fetch refspec, or when gh can't match origin to --repo. Passing the values also guarantees that the create path and the retake path (gh pr edit) write the same title and body.

Every gh call passes --repo <origin's owner/repo>. The reason: while create-pr runs, an upstream remote exists, and gh ranks upstream above origin when it picks a base repo. Without --repo, the PR would target the course repo.

Evidence

test/create-pr-e2e.test.ts runs the real GitService against real repos: an upstream bare repo with main and the lesson stack, and an origin bare repo reached through a https://github.com/student/course.git URL rewritten locally with insteadOf. Because of that rewrite, the GitHub-remote check runs for real. gh and prompts are faked.

Failure it guards against Test
The PR carries more than the one lesson carries only the chosen lesson's diff for a lesson mid-stack: one commit on top of the parent, and only the lesson's files differ
The lesson is first on the stack, so P is main's tip opens a draft PR whose base is main's tip…: pr-base equals main, and the origin/pr-base/<slug> tracking ref exists (--fill reads it)
The slug lookup later matches the PR commit never gives the PR commit a subject the lesson lookup could match, plus toPrCommitMessage unit tests (repeated prefix, bare slug:)
A retake after the course stack is rewritten a retake after the stack is rewritten…: both refs are force-moved to the new shas, the student is asked before pr/<slug> is replaced, and the open PR is edited and re-drafted rather than duplicated
The student's work is lost dirty tree is refused and the file is untouched; declining the replace prompt leaves the local branch and origin untouched
A lesson id with spaces or other ref-illegal characters fails before pushing anything when the lesson id can't be a branch name
A private repo on GitHub Free (what fork creates) rejects drafts, leaving pushed branches and no PR opens a ready-for-review PR when the repo's plan has no drafts (create retried without --draft); still refreshes an existing PR when it can't be turned back into a draft
Half-done state on bad setup slug not found, gh not authenticated, non-GitHub origin, no origin, on live-run-through: all fail with origin still empty
  • Before: ai-hero create-pr is not a command.
    After: pnpm check, pnpm lint and pnpm test all pass, with 188 tests (160 before).

Left out deliberately

  • SSH host aliases (git@github-work:me/repo) are read as an Enterprise host, so create-pr refuses them with a clear error. This only matters for multi-account setups.
  • No real-GitHub smoke test. gh is faked in tests. The gh pr create, edit and ready --undo argument shapes, and how ready --undo behaves on a PR that is already a draft, were checked against the gh v2.80 source.

Handled: drafts on private repos

ai-hero fork creates private repos, and GitHub only allows draft PRs on private repos on paid plans (Pro, Team, Enterprise). On GitHub Free:

  • If gh pr create --draft fails, the command retries without --draft.
  • On the existing-PR path, a failing gh pr ready --undo is not fatal.

In both cases it prints a one-line note that the PR is ready-for-review because drafts aren't available on the repo's plan.

The fallback applies to any failure of --draft, because gh's stderr goes straight to the terminal and isn't parsed. If the failure had another cause, the retry fails as well and its error is shown.

Merge Danger

Door: two-way

It adds a new command and touches no existing behaviour. Once it is published and wired into course package.jsons, the pr/<slug> and pr-base/<slug> branch names become a convention that students' repos hold.

Blast Radius: additive

It only runs when invoked. It force-pushes two CLI-owned branch names on the student's origin, and if the student has no git identity set, it writes a local user.name/user.email from their GitHub login, as fork does.

🤖 Generated with Claude Code

Opens a draft PR on origin containing only one lesson's diff:
pr-base/<slug> at the lesson's parent, pr/<slug> as one commit carrying
the lesson's tree, refreshed in place on retakes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pkg-pr-new

pkg-pr-new Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/ai-hero-cli@84

commit: b75dd8b

mattpocock and others added 2 commits October 9, 2026 11:31
…d branch names

- gh's --fill reads <remote>/<base>..<head> from local tracking refs,
  which a narrow fetch refspec or an unmatched remote leaves missing.
  Pass the same title/body explicitly instead.
- rev-parse doesn't fail through Command.string, so a root lesson's
  missing parent was never detected; check for a sha.
- Reject lesson ids that can't be branch names before pushing.
- Drop the misleading commit count from the replace prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lable

Private repos on GitHub Free don't support draft PRs, and `fork` creates
private repos. Retry `gh pr create` without --draft, treat a failing
`gh pr ready --undo` as non-fatal, and print a one-line note either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mattpocock
mattpocock merged commit 18c4eb0 into main Oct 9, 2026
3 checks passed
@mattpocock
mattpocock deleted the feat/create-pr branch October 9, 2026 10:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant