Automate all the things
JSM 2026 - Boston
Colin Rundel
Duke University
Who am I
Assoc. Professor of the Practice, Dept. of Statistical Science, Duke University
Sta 523 - Programming for Statistical Science R, 1st yr MS
Sta 323 - Statistical Computing R, 2nd,3rd yr UG
Sta 663 - Statistical Computing and Computation Python, 1st yr MS
All three courses run on GitHub - one (private) organization per course, students added as members, one repository per assignment per student or team.
Click or script?
Myself and others have written a lot about why you should be using Git & GitHub for your courses.
Having adopted GitHub, you still have to decide how to manage things.
Point-and-click is fine for small courses and tasks
GitHub Classroom reduces the number of clicks (but doesn’t eliminate them) and constrains workflows
Using the API is what makes things scale
one function call can iterate over an entire roster
ghclass
An R package that wraps the GitHub REST and GraphQL APIs in class focused operations.
github_ - auth and account
org_ - membership, teams, repos
repo_ - files, collaborators, settings
team_ - teams within an org
user_ - GitHub accounts
issue_ - issues
pr_ / branch_ - PRs and branches
action_ - runs, artifacts, badges
pages_ - GitHub Pages
local_repo_ - local git ops (clone, commit, …)
Functions are vectorized over a roster (data frame), API wrapped in purrr::safely(), and report success or failure per operation.
Course as organization
One org per course per semester. Students added as members (to use teams). The default repository permission is set to none, so students only see their assigned repos.
org_repos ("ghclass-paper" )
[1] "ghclass-paper/.github"
[2] "ghclass-paper/hw1"
[3] "ghclass-paper/hw1-key"
[4] "ghclass-paper/hw1_lab01_team01"
[5] "ghclass-paper/hw1_lab01_team02"
[6] "ghclass-paper/instructor"
[7] "ghclass-paper/jsm26_lab01_team1"
[8] "ghclass-paper/jsm26_lab01_team2"
[9] "ghclass-paper/midterm1"
[10] "ghclass-paper/midterm1_ghclass-alice"
[11] "ghclass-paper/midterm1_ghclass-bob"
[12] "ghclass-paper/midterm1_ghclass-carol"
[13] "ghclass-paper/midterm1_ghclass-dave"
[14] "ghclass-paper/midterm1_ghclass-elizabeth"
[15] "ghclass-paper/midterm1_ghclass-fred"
Templates repos (hw1, midterm1)
Key repos (hw1-key)
Student repos (hw1_lab01_*, midterm1_ghclass-*)
Workflow details (instructor)
1. Setting up the course
Configure the organization before anyone is invited:
org_set_repo_permission ("ghclass-paper" , "none" )
✔ Set org "ghclass-paper"'s repo permissions to "none".
org_set_workflow_permissions ("ghclass-paper" , "read" )
✔ Set org "ghclass-paper"'s default workflow permissions to "read".
Check the org’s status with:
org_sitrep ("ghclass-paper" )
── ghclass-paper sitrep: ───────────────────────────────────────────────────────
• Default repository permission: "none"
• Default workflow permissions: "read" <- Warning: this may prevent some GitHub
actions from working correctly.
• Members can create public repos: TRUE
• Members can create private repos: TRUE
• Members can fork private repos: FALSE
Adding students
Collect student usernames via a web form / survey and validate before inviting:
user_exists (c ("rundel" , "mine-cetinkaya-rundel" , "ghclass-alice" ))
Use ghclass to manage invites:
need = setdiff (
responded$ github,
c (org_members ("ghclass-paper" ), org_pending ("ghclass-paper" ))
)
org_invite ("ghclass-paper" , need)
2.1 Assignments as repositories
hw1 - the source repo, holds the student scaffold and the CI config
hw1-key - the answer key
hw1_lab01_team01, … - one repo per team/individual, mirrored from the source
repo_tree ("ghclass-paper/hw1" )
ghclass-paper/hw1
├─.github
│ └─workflows
│ └─check_assignment.yml
├─.gitignore
├─README.md
├─hw1.Rproj
└─hw1.qmd
repo_tree ("ghclass-paper/hw1-key" )
ghclass-paper/hw1-key
├─.gitignore
├─README.md
└─hw1.qmd
2.2 Assigning teams
Per assignment we build a roster that tracks student details and random team assignments,
roster = readr:: read_csv ("github_roster.csv" , show_col_types = FALSE ) |>
team_roster (
size = 3 ,
by = "section" ,
name = "jsm26_lab{section}_team{team_id}" ,
pad = 2 ,
seed = 20250901
)
roster |> dplyr:: select (name, email, section, github, team)
# A tibble: 6 × 5
name email section github team
<chr> <chr> <chr> <chr> <chr>
1 Alice alice@example.edu 01 ghclass-alice jsm26_lab01_team02
2 Bob bob@example.edu 01 ghclass-bob jsm26_lab01_team02
3 Carol carol@example.edu 01 ghclass-carol jsm26_lab01_team01
4 Dave dave@example.edu 01 ghclass-dave jsm26_lab01_team01
5 Elizabeth elizabeth@example.edu 01 ghclass-elizabeth jsm26_lab01_team02
6 Fred fred@example.edu 01 ghclass-fred jsm26_lab01_team01
Size is the desired team size (we typically use 4)
Padding applies numeric pading to the team index e.g. 02 vs 2
This function is useful if you want to do random team assignments
2.3 Distributing an assignment
Make sure the source repo is a template ,
repo_set_template ("ghclass-paper/hw1" )
✔ Changed the template status of repo "ghclass-paper/hw1" to TRUE.
then create student repos from it,
org_create_assignment (
org = "ghclass-paper" ,
repo = roster$ team,
user = rep ("rundel" , nrow (roster)),
team = roster$ team,
source_repo = "ghclass-paper/hw1" ,
add_badges = TRUE
)
✔ Mirrored repo "ghclass-paper/hw1" to repo "ghclass-paper/jsm26_lab01_team02".
✔ Mirrored repo "ghclass-paper/hw1" to repo "ghclass-paper/jsm26_lab01_team01".
✔ Created team "jsm26_lab01_team02" in org "ghclass-paper".
✔ Created team "jsm26_lab01_team01" in org "ghclass-paper".
✔ Added user "rundel" to team "jsm26_lab01_team02".
✔ Added user "rundel" to team "jsm26_lab01_team01".
✔ Team "jsm26_lab01_team02" given "push" access to repo "ghclass-paper/jsm26_lab01_team02"
✔ Team "jsm26_lab01_team01" given "push" access to repo "ghclass-paper/jsm26_lab01_team01"
✔ Modified file "ghclass-paper/jsm26_lab01_team01/README.md".
✔ Modified file "ghclass-paper/jsm26_lab01_team02/README.md".
3.1 Live assignments
While an assignment is open, student work is queryable and assignments can be edited / corrected,
repos = org_repos ("sta523-fa25" , "hw01_" )
# A tibble: 12 × 3
repo branch n
<chr> <chr> <int>
1 sta523-fa25/hw01_lab01_team01 main 41
2 sta523-fa25/hw01_lab01_team02 main 14
3 sta523-fa25/hw01_lab01_team03 main 75
4 sta523-fa25/hw01_lab01_team04 main 49
5 sta523-fa25/hw01_lab01_team05 main 30
6 sta523-fa25/hw01_lab01_team06 main 20
7 sta523-fa25/hw01_lab01_team07 main 35
8 sta523-fa25/hw01_lab01_team08 main 50
9 sta523-fa25/hw01_lab01_team09 main 27
10 sta523-fa25/hw01_lab02_team01 main 23
11 sta523-fa25/hw01_lab02_team02 main 11
12 sta523-fa25/hw01_lab02_team03 main 13
repo_add_file (
repos,
file = "late_addition.csv" ,
repo_folder = "data"
)
repo_modify_file (
repos, path = "README.md" ,
pattern = "Due: .*" ,
content = "Due: 2026-09-22" ,
method = "replace"
)
3.2 Automated feedback
GitHub Actions provide a powerful tool for automating a varierty of things,
Our feedback usually focuses process not correctness (reproducibility)
We include workflows in student repos (.github/workflows/check_assignment.yml) as part of the source repo
The checks are mostly R functions from checklist - required files present, document renders, etc.
Uses a prebuilt docker image - r_gh_action
add_badges = TRUE at distribution put a pass / fail badge at the top of each README
.github/workflows/check_assignment.yml
on : push
name : Check Assignment
jobs :
check-allowed-files :
runs-on : ubuntu-latest
container :
image : ghcr.io/dukestatsci/r_gh_actions:latest
steps :
- name : Checkout
uses : actions/checkout@v7
- name : Check Files
run : |
checklist::quit_on_failure({
checklist::check_allowed_files(
c("hw1.qmd", "hw1.Rproj", "README.md", "data/*")
)
})
shell : Rscript {0}
check-renders :
runs-on : ubuntu-latest
container :
image : ghcr.io/dukestatsci/r_gh_actions:latest
steps :
- name : Checkout
uses : actions/checkout@v7
- name : Check Renders
run : |
checklist::check_qmd_renders("hw1.qmd", install_missing = TRUE)
shell : Rscript {0}
- name : Create artifacts
uses : actions/upload-artifact@v7
with :
name : hw1-html
path : hw1.html
Steps should be self explanitory - this is a simplified version of the real thing
Point out the artifact bit is important
4.1 Collecting assignments
After the due date, clone every repo, downloads CI artifacts, and scaffolds comment markdown files.
org_grade_assignment (
"hw1" , "ghclass-paper" , "jsm26_" ,
key_repo = "ghclass-paper/hw1-key" ,
comment_template = "## hw1 feedback \n\n "
)
ℹ Cloning 2 student repos matching "jsm26_".
✔ Cloned "ghclass-paper/jsm26_lab01_team1".
✔ Cloned "ghclass-paper/jsm26_lab01_team2".
ℹ Creating comment files.
✔ Created 2 comment files in 'hw1/comments'.
ℹ Cloning key repo "ghclass-paper/hw1-key".
✔ Cloned "ghclass-paper/hw1-key".
hw1
├── comments
│ ├── jsm26_lab01_team1.md
│ └── jsm26_lab01_team2.md
├── hw1-key
│ ├── README.md
│ └── hw1.qmd
└── repos
├── jsm26_lab01_team1
│ ├── README.md
│ ├── hw1.Rproj
│ └── hw1.qmd
└── jsm26_lab01_team2
├── README.md
├── hw1.Rproj
└── hw1.qmd
4.2 Scores & feedback
Numeric scores go to the gradebook,
scores = readr:: read_csv ("hw1/scores.csv" )
upload = dplyr:: left_join (roster, scores, by = "team" )
stopifnot (! any (is.na (upload$ score)))
readr:: write_csv (upload, "hw1/hw1_upload.csv" )
Written feedback shared via GitHub issues,
comments = fs:: dir_ls ("hw1/comments" , glob = "*.md" )
repos = paste0 ("ghclass-paper/" , fs:: path_ext_remove (fs:: path_file (comments)))
issue_create (repos, title = "hw1 feedback" , body = purrr:: map_chr (comments, readr:: read_file))
✔ Created issue "hw1 feedback" for repo "ghclass-paper/jsm26_lab01_team1".
✔ Created issue "hw1 feedback" for repo "ghclass-paper/jsm26_lab01_team2".
Upload to gradebooks require specific formating - we keep scores.csv simple and then join with a template file the gives us the specific format.
Comment files are just markdown files we read and then turn into issues.
markermd
An interactive (Shiny) grading interface for Quarto and R Markdown assignments that works with org_grade_assignment().
1st ss - Template builder - parses the key to build out a document template. We associate various questions with sections of the document using headers (###). Templates can also have rules that allow us to validate the content (e.g. this section must have one chunk and some markdown)
2nd ss - mark() validation tab - shows the result of applying the template across student repos, shows the validation status for each question as well as grading progress.
3rd ss - mark() marking tab - gradescope like interface for applying rubric items by question and repo. Left side highlights either the raw code or rendered output (if available) as you move between questions.
Not show - rubric interface that lets you add rubric items by question
Using markermd
A grading project (folder) is initialized with init_project() which creates .markermd/.
Once initialized you can use:
template() to build an assignment template - document subsections and validation rules
mark() to validate submissions, build a rubric, and apply it across submissions
export_marks() to collects results and populates scores.csv and comments/
Some advice
Set org_set_repo_permission() and org_set_workflow_permissions() before anyone is invited, use org_sitrep() to check.
Every org gets 2,000 Actions minutes / month free, 3,000 if using Team upgrade
easily exhausted for a medium-sized course - use self-host runners
Artifacts expire after 90 days and count against org storage (500 MB free, 2 GB on Team) - just keep the latest
Beware of secondary rate limits, e.g. bulk invitations and bulk issue creation
Grading framework is well positioned for use with Agentic AI tools, but be aware of your schools data policies / FERPA / etc.
ghclass has initial support for “best effort” anonymization via local_repo_anonymize()