From 30af5856a50f6f62e6043075bb8c84fd895778ba Mon Sep 17 00:00:00 2001 From: GitHub Workshop Bot Date: Sun, 17 May 2026 17:59:06 -0700 Subject: [PATCH] chore: harden progression workflows and classroom sync scripts --- .../.github/workflows/skills-progression.yml | 46 ++++ .../.github/workflows/skills-progression.yml | 46 ++++ .../classroom/Restore-LearningRoomFiles.ps1 | 5 +- .../classroom/Seed-LearningRoomChallenge.ps1 | 31 --- .../Sync-StudentLearningRoomRepos.ps1 | 224 ++++++++++++++++++ .../classroom/Test-LearningRoomTemplate.ps1 | 59 +++++ 6 files changed, 377 insertions(+), 34 deletions(-) create mode 100644 scripts/classroom/Sync-StudentLearningRoomRepos.ps1 diff --git a/admin/qa-bundle/learning-room/.github/workflows/skills-progression.yml b/admin/qa-bundle/learning-room/.github/workflows/skills-progression.yml index d08478a1..6b5f383f 100644 --- a/admin/qa-bundle/learning-room/.github/workflows/skills-progression.yml +++ b/admin/qa-bundle/learning-room/.github/workflows/skills-progression.yml @@ -16,6 +16,8 @@ on: description: "Action (check_progress, assign_next, reset)" required: false default: "check_progress" + create: + types: [repository] concurrency: group: skills-progression-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }} @@ -162,3 +164,47 @@ jobs: } catch (error) { console.error('Error awarding achievement:', error); } + + create-first-issue: + name: Create First Challenge Issue + runs-on: ubuntu-latest + if: github.event_name == 'create' + + steps: + - name: Create First Issue + uses: actions/github-script@v7 + with: + script: | + const issueTitle = "Welcome to Your First Challenge!"; + const issueBody = ` + ## Your First Challenge + Welcome to the repository! Your first challenge is to complete the following steps: + - [ ] Clone this repository to your local machine. + - [ ] Make a small change to the README file. + - [ ] Commit and push your changes. + - [ ] Open a pull request to merge your changes. + + Once you complete these steps, close this issue to unlock the next challenge! + `; + + const { data: existingIssues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + labels: 'first-issue', + per_page: 100 + }); + + const alreadyExists = existingIssues.some((issue) => issue.title === issueTitle); + if (alreadyExists) { + console.log('First challenge issue already exists. Skipping creation.'); + return; + } + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: issueTitle, + body: issueBody, + labels: ['challenge', 'first-issue'] + }); diff --git a/learning-room/.github/workflows/skills-progression.yml b/learning-room/.github/workflows/skills-progression.yml index d08478a1..6b5f383f 100644 --- a/learning-room/.github/workflows/skills-progression.yml +++ b/learning-room/.github/workflows/skills-progression.yml @@ -16,6 +16,8 @@ on: description: "Action (check_progress, assign_next, reset)" required: false default: "check_progress" + create: + types: [repository] concurrency: group: skills-progression-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }} @@ -162,3 +164,47 @@ jobs: } catch (error) { console.error('Error awarding achievement:', error); } + + create-first-issue: + name: Create First Challenge Issue + runs-on: ubuntu-latest + if: github.event_name == 'create' + + steps: + - name: Create First Issue + uses: actions/github-script@v7 + with: + script: | + const issueTitle = "Welcome to Your First Challenge!"; + const issueBody = ` + ## Your First Challenge + Welcome to the repository! Your first challenge is to complete the following steps: + - [ ] Clone this repository to your local machine. + - [ ] Make a small change to the README file. + - [ ] Commit and push your changes. + - [ ] Open a pull request to merge your changes. + + Once you complete these steps, close this issue to unlock the next challenge! + `; + + const { data: existingIssues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + labels: 'first-issue', + per_page: 100 + }); + + const alreadyExists = existingIssues.some((issue) => issue.title === issueTitle); + if (alreadyExists) { + console.log('First challenge issue already exists. Skipping creation.'); + return; + } + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: issueTitle, + body: issueBody, + labels: ['challenge', 'first-issue'] + }); diff --git a/scripts/classroom/Restore-LearningRoomFiles.ps1 b/scripts/classroom/Restore-LearningRoomFiles.ps1 index 02c0f8e2..0482a100 100644 --- a/scripts/classroom/Restore-LearningRoomFiles.ps1 +++ b/scripts/classroom/Restore-LearningRoomFiles.ps1 @@ -45,8 +45,7 @@ function Get-ProfilePaths { 'docs/setup-guide.md' ) 'core-day2' = @( - 'docs/samples/copilot-improvement-before.md', - 'docs/samples/peer-review-practice.md' + 'docs/samples/copilot-improvement-before.md' ) 'automation-core' = @( '.github/workflows/pr-validation-bot.yml', @@ -138,7 +137,7 @@ try { Invoke-CheckedCommand git @('checkout', "template/$TemplateRef", '--', $file) } - Invoke-CheckedCommand git @('add', '--', $selectedPaths) + Invoke-CheckedCommand git (@('add', '--') + $selectedPaths) $status = git status --short if (-not $status) { diff --git a/scripts/classroom/Seed-LearningRoomChallenge.ps1 b/scripts/classroom/Seed-LearningRoomChallenge.ps1 index 7df3726c..613521d8 100644 --- a/scripts/classroom/Seed-LearningRoomChallenge.ps1 +++ b/scripts/classroom/Seed-LearningRoomChallenge.ps1 @@ -30,34 +30,3 @@ Invoke-CheckedCommand gh $arguments Write-Host "Workflow started. Check the repository Actions tab, then verify the issue list:" Write-Host " gh issue list -R $Repository --search \"Challenge $Challenge\"" -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] - [string]$Repository, - - [ValidateRange(1, 16)] - [int]$Challenge = 1, - - [string]$Assignee = '' -) - -$ErrorActionPreference = 'Stop' - -function Invoke-CheckedCommand { - param([string]$FilePath, [string[]]$Arguments) - & $FilePath @Arguments - if ($LASTEXITCODE -ne 0) { - throw "Command failed: $FilePath $($Arguments -join ' ')" - } -} - -$fields = @('-f', "start_challenge=$Challenge") -if ($Assignee) { - $fields += @('-f', "assignee=$Assignee") -} - -Write-Host "Triggering student progression workflow in $Repository for Challenge $Challenge..." -Invoke-CheckedCommand gh @('workflow', 'run', 'student-progression.yml', '-R', $Repository) + $fields - -Write-Host "Workflow started. Check the repository Actions tab, then verify the issue list:" -Write-Host " gh issue list -R $Repository --search \"Challenge $Challenge\"" diff --git a/scripts/classroom/Sync-StudentLearningRoomRepos.ps1 b/scripts/classroom/Sync-StudentLearningRoomRepos.ps1 new file mode 100644 index 00000000..801cb849 --- /dev/null +++ b/scripts/classroom/Sync-StudentLearningRoomRepos.ps1 @@ -0,0 +1,224 @@ +[CmdletBinding()] +param( + [string]$StudentRepository = '', + + [string]$ClassroomOrg = 'Community-Access-Classroom', + + [string]$TemplateRef = 'main', + + [ValidateSet('core-day1', 'core-day2', 'automation-core', 'all-core', 'custom')] + [string]$Profile = 'all-core', + + [string[]]$RepoPrefixes = @('you-belong-here-', 'you-can-build-this-'), + + [switch]$OpenPullRequest, + + [switch]$AutoMerge, + + [switch]$AdminMerge, + + [switch]$DryRun, + + [switch]$Force +) + +$ErrorActionPreference = 'Stop' + +function Invoke-CheckedCommand { + param([string]$FilePath, [string[]]$Arguments) + + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Command failed: $FilePath $($Arguments -join ' ')" + } +} + +function Invoke-GhJson { + param([string[]]$Arguments) + + $json = & gh @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Command failed: gh $($Arguments -join ' ')" + } + + if (-not $json) { + return $null + } + + return $json | ConvertFrom-Json +} + +function Test-RepositoryHasActivity { + param([string]$Repository) + + $activity = [ordered]@{ + HasNonMainBranch = $false + HasOpenPR = $false + } + + $branches = Invoke-GhJson -Arguments @('api', "repos/$Repository/branches?per_page=100") + if ($branches) { + $nonMain = @($branches | Where-Object { $_.name -ne 'main' }) + if ($nonMain.Count -gt 0) { + $activity.HasNonMainBranch = $true + } + } + + $openPrs = Invoke-GhJson -Arguments @('pr', 'list', '-R', $Repository, '--state', 'open', '--json', 'number') + if ($openPrs) { + if (@($openPrs).Count -gt 0) { + $activity.HasOpenPR = $true + } + } + + return [PSCustomObject]$activity +} + +function Get-TargetRepositories { + param( + [string]$SingleRepository, + [string]$Organization, + [string[]]$Prefixes + ) + + if ($SingleRepository) { + return @($SingleRepository) + } + + $repos = Invoke-GhJson -Arguments @('repo', 'list', $Organization, '--limit', '500', '--json', 'name,nameWithOwner,isPrivate') + if (-not $repos) { + return @() + } + + $targets = @() + foreach ($repo in $repos) { + if (-not $repo.isPrivate) { + continue + } + + foreach ($prefix in $Prefixes) { + if ($repo.name.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + $targets += $repo.nameWithOwner + break + } + } + } + + return $targets +} + +Write-Host 'Checking GitHub CLI authentication...' +Invoke-CheckedCommand gh @('auth', 'status', '-h', 'github.com') + +$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path } +$restoreScript = Join-Path $scriptDir 'Restore-LearningRoomFiles.ps1' +if (-not (Test-Path -LiteralPath $restoreScript -PathType Leaf)) { + throw "Required script not found: $restoreScript" +} + +$targets = Get-TargetRepositories -SingleRepository $StudentRepository -Organization $ClassroomOrg -Prefixes $RepoPrefixes +if (-not $targets -or $targets.Count -eq 0) { + Write-Host 'No target repositories found. Nothing to do.' + return +} + +Write-Host "Found $($targets.Count) target repository/repositories." + +$summary = @() + +foreach ($repo in $targets) { + Write-Host '' + Write-Host "Processing $repo" + + $row = [ordered]@{ + Repository = $repo + Status = 'unknown' + Details = '' + } + + try { + if (-not $Force) { + $activity = Test-RepositoryHasActivity -Repository $repo + if ($activity.HasNonMainBranch -or $activity.HasOpenPR) { + $reasons = @() + if ($activity.HasNonMainBranch) { $reasons += 'non-main branches exist' } + if ($activity.HasOpenPR) { $reasons += 'open PRs exist' } + + $row.Status = 'skipped' + $row.Details = ($reasons -join '; ') + Write-Host "Skipping due to activity: $($row.Details)" + $summary += [PSCustomObject]$row + continue + } + } + + $stamp = Get-Date -Format 'yyyyMMddHHmmss' + $branchName = "recovery/template-sync-$stamp" + + $restoreParams = @{ + StudentRepository = $repo + Profile = $Profile + TemplateRef = $TemplateRef + BranchName = $branchName + CommitMessage = 'chore: sync learning-room template baseline' + } + + if ($OpenPullRequest -or $AutoMerge) { + $restoreParams.OpenPullRequest = $true + } + + if ($DryRun) { + $row.Status = 'dry-run' + $row.Details = "Would run restore on branch $branchName" + Write-Host $row.Details + $summary += [PSCustomObject]$row + continue + } + + & $restoreScript @restoreParams + if ($LASTEXITCODE -ne 0) { + throw "Restore script failed for $repo" + } + + if ($AutoMerge) { + $prNumber = & gh pr list -R $repo --head $branchName --state open --json number --jq '.[0].number' + if ($LASTEXITCODE -ne 0) { + throw "Could not query PR number for $repo" + } + + if (-not $prNumber) { + $row.Status = 'updated' + $row.Details = 'No open recovery PR found after restore (possibly no diff)' + Write-Host $row.Details + $summary += [PSCustomObject]$row + continue + } + + $mergeArgs = @('pr', 'merge', $prNumber, '-R', $repo, '--squash', '--delete-branch') + if ($AdminMerge) { + $mergeArgs += '--admin' + } + + Invoke-CheckedCommand gh $mergeArgs + $row.Status = 'merged' + $row.Details = "Merged PR #$prNumber" + Write-Host $row.Details + } + else { + $row.Status = 'updated' + $row.Details = 'Restore completed' + Write-Host $row.Details + } + } + catch { + $row.Status = 'failed' + $row.Details = $_.Exception.Message + Write-Warning $row.Details + } + + $summary += [PSCustomObject]$row +} + +Write-Host '' +Write-Host 'Summary' +$summary | Format-Table -AutoSize diff --git a/scripts/classroom/Test-LearningRoomTemplate.ps1 b/scripts/classroom/Test-LearningRoomTemplate.ps1 index ee2f2ede..4850a551 100644 --- a/scripts/classroom/Test-LearningRoomTemplate.ps1 +++ b/scripts/classroom/Test-LearningRoomTemplate.ps1 @@ -48,6 +48,49 @@ function Wait-ForRepositoryContent { throw "Repository content did not become available in time for $Repository." } +function Get-RepositoryFileContent { + param( + [string]$Repository, + [string]$Path + ) + + $file = & gh api "repos/$Repository/contents/$Path" | ConvertFrom-Json + if ($LASTEXITCODE -ne 0 -or -not $file) { + throw "Could not read file '$Path' from $Repository." + } + + return [System.Text.Encoding]::UTF8.GetString( + [System.Convert]::FromBase64String(($file.content -replace '\s', '')) + ) +} + +function Wait-ForIssue { + param( + [string]$Repository, + [string]$TitleContains, + [int]$MaxAttempts = 18, + [int]$DelaySeconds = 5 + ) + + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + $issues = & gh issue list -R $Repository --state all --limit 100 --json number,title + if ($LASTEXITCODE -eq 0 -and $issues) { + $parsed = $issues | ConvertFrom-Json + $match = @($parsed | Where-Object { $_.title -like "*$TitleContains*" }) | Select-Object -First 1 + if ($match) { + return $match + } + } + + if ($attempt -lt $MaxAttempts) { + Write-Host "Issue '$TitleContains' not found yet (attempt $attempt/$MaxAttempts). Retrying in $DelaySeconds seconds..." + Start-Sleep -Seconds $DelaySeconds + } + } + + throw "Issue containing '$TitleContains' was not created in time for $Repository." +} + $template = "$Owner/$TemplateRepo" $smoke = "$Owner/$SmokeRepo" @@ -79,6 +122,22 @@ try { Invoke-CheckedCommand gh @('api', "repos/$smoke/contents/.github/ISSUE_TEMPLATE/challenge-01-find-your-way.yml") Invoke-CheckedCommand gh @('api', "repos/$smoke/contents/.github/scripts/challenge-progression.js") + Write-Host "Validating skills progression workflow create trigger and first-issue job..." + $skillsWorkflow = Get-RepositoryFileContent -Repository $smoke -Path '.github/workflows/skills-progression.yml' + if ($skillsWorkflow -notmatch '(?m)^\s{2}create:\s*$') { + throw "skills-progression.yml is missing 'create' trigger." + } + if ($skillsWorkflow -notmatch '(?m)^\s{2}create-first-issue:\s*$') { + throw "skills-progression.yml is missing create-first-issue job." + } + if ($skillsWorkflow -notmatch '(?m)^\s{4}if:\s+github\.event_name\s*==\s*''create''\s*$') { + throw "create-first-issue job is missing the create-event guard condition." + } + + Write-Host "Waiting for auto-created first challenge issue..." + $firstIssue = Wait-ForIssue -Repository $smoke -TitleContains 'Welcome to Your First Challenge!' + Write-Host "Detected first issue #$($firstIssue.number): $($firstIssue.title)" + Write-Host "Triggering Challenge 1 creation..." Invoke-CheckedCommand gh @('workflow', 'run', 'student-progression.yml', '-R', $smoke, '-f', 'start_challenge=1')