bcquality/.github/workflows/authoring-assist-runner.yml
dayland 94d3d4358d Authoring-assist: promote to first-class tool with non-blocking PR advisory
Promotes the authoring-assist prototype to a first-class BCQuality feature that
reviews knowledge-article front-matter and proposes missing routing `signals:`
(raise triggers and `effect: suppress` suppressors). The suggestion is ADVISORY
ONLY: it never fails a build; authors apply it via a normal PR under existing
R29 + CI + CODEOWNERS review.

Feature:
- tools/Suggest-ArticleSignals.ps1: suggestion engine hardened with a stable JSON
  contract (schemaVersion/toolVersion) and a deterministic, effect-sensitive
  per-proposal suggestionId, plus -ChangedFiles scoping for PR-diff runs. Runs
  self-contained: the routing seed is now OPTIONAL (built-in domain map), so this
  does not depend on the separate routing-index tuning thread.
- tools/New-AuthoringAssistComment.ps1: renders one upsertable, feedback-instrumented
  advisory comment (stable anchor + aa:meta/aa:article markers carrying suggestionIds
  + reaction footer) for the downstream acceptance-measurement work.
- .github/workflows/authoring-assist*.yml: unprivileged pull_request intake +
  trusted workflow_run runner (review/publish split) + in-repo self-test.
- tools/Test-SuggestArticleSignals.ps1: 23-check smoke test (contract, determinism,
  scoping, suppressor/prohibition, renderer markers, seed-free graceful degradation).

Dormant schema support:
- .github/scripts/validate_frontmatter.py: adds R29, validating the optional
  `signals` block shape (bare token or mapping with token + optional
  pattern/domain/effect in {raise,suppress}). Reserved & dormant — no production
  pipeline consumes it; the existing PR Reviewer only reads `effect: suppress`
  behind its BCQ_INDEX_V2 flag and falls back safely when absent. Existing rules
  (incl. R24 skill-id uniqueness, R27, R28) are unchanged.

Excludes the routing-index tuning thread entirely (separate work).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 75d852d6-18a6-4c58-986f-ed5ab16618fa
2026-07-24 10:03:29 +01:00

242 lines
11 KiB
YAML

name: Authoring-assist advisory runner
# Privileged companion to `Authoring-assist advisory`. Runs in the trusted
# workflow_run context so it can post a comment on fork PRs. Two jobs:
# review - read-only; resolves the PR, reads the PR-head article content, runs
# the (trusted-base) suggestion tool + renderer, uploads the comment.
# publish - holds pull-requests:write; upserts the single advisory comment.
# The suggestion tool is a deterministic PowerShell script that only PARSES
# article text (it never executes AL or PR content), and it runs from the trusted
# base checkout with the trusted routing seed, so only inert article text comes
# from the PR head. The advisory is NON-BLOCKING and is never a required check.
on:
workflow_run:
workflows:
- Authoring-assist advisory
types:
- completed
concurrency:
group: authoring-assist-runner-${{ github.event.workflow_run.id }}
cancel-in-progress: true
permissions: {}
jobs:
review:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
pull-requests: read
defaults:
run:
shell: pwsh
outputs:
pr_number: ${{ steps.pr.outputs.number }}
head_sha: ${{ steps.pr.outputs.head_sha }}
has_report: ${{ steps.run.outputs.has_report }}
steps:
- name: Checkout trusted base tools
uses: actions/checkout@v4
with:
ref: refs/heads/main
path: trusted
fetch-depth: 1
# The tool reads untrusted PR-head article text; keep no token in
# .git/config. Public content is fetched unauthenticated below.
persist-credentials: false
- name: Resolve PR details
id: pr
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
$headers = @{
Accept = 'application/vnd.github+json'
Authorization = "Bearer $env:GITHUB_TOKEN"
'User-Agent' = 'bcquality-authoring-assist'
}
$expectedHeadSha = '${{ github.event.workflow_run.head_sha }}'
$prNumber = '${{ github.event.workflow_run.pull_requests[0].number }}'
if (-not $prNumber) {
$headOwner = '${{ github.event.workflow_run.head_repository.owner.login }}'
$headBranch = '${{ github.event.workflow_run.head_branch }}'
if (-not $headOwner -or -not $headBranch) {
throw 'No PR number in workflow_run payload and insufficient head metadata for fallback.'
}
$encodedHead = [System.Uri]::EscapeDataString("${headOwner}:$headBranch")
$searchUri = "https://api.github.com/repos/${{ github.repository }}/pulls?state=open&head=$encodedHead&per_page=30"
$candidates = Invoke-RestMethod -Uri $searchUri -Headers $headers -Method GET
$matching = @($candidates | Where-Object { $_.head.sha -eq $expectedHeadSha })
if ($matching.Count -eq 1) { $prNumber = [string]$matching[0].number }
elseif ($matching.Count -gt 1) { throw 'Fallback PR lookup returned multiple matches.' }
}
if (-not $prNumber) { throw 'No pull request number resolved.' }
$pr = Invoke-RestMethod -Uri "https://api.github.com/repos/${{ github.repository }}/pulls/$prNumber" -Headers $headers -Method GET
if ($expectedHeadSha -and $pr.head.sha -ne $expectedHeadSha) {
throw 'Resolved PR head SHA does not match workflow_run head SHA.'
}
"number=$($pr.number)" >> $env:GITHUB_OUTPUT
"head_sha=$($pr.head.sha)" >> $env:GITHUB_OUTPUT
"base_ref=$($pr.base.ref)" >> $env:GITHUB_OUTPUT
- name: List changed knowledge articles
id: changed
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
$headers = @{
Accept = 'application/vnd.github+json'
Authorization = "Bearer $env:GITHUB_TOKEN"
'User-Agent' = 'bcquality-authoring-assist'
}
$n = '${{ steps.pr.outputs.number }}'
$changed = [System.Collections.Generic.List[string]]::new()
for ($page = 1; $page -le 30; $page++) {
$uri = "https://api.github.com/repos/${{ github.repository }}/pulls/$n/files?per_page=100&page=$page"
$files = Invoke-RestMethod -Uri $uri -Headers $headers -Method GET
if (-not $files -or @($files).Count -eq 0) { break }
foreach ($f in $files) {
if ($f.status -in @('added','modified','renamed','changed') `
-and $f.filename -match '(^|/)knowledge/' `
-and $f.filename -match '\.md$') {
$changed.Add($f.filename) | Out-Null
}
}
if (@($files).Count -lt 100) { break }
}
$outFile = Join-Path $env:GITHUB_WORKSPACE 'changed-articles.txt'
($changed | Sort-Object -Unique) -join "`n" | Set-Content -LiteralPath $outFile -Encoding UTF8
Write-Host "Changed knowledge articles: $($changed.Count)"
"count=$($changed.Count)" >> $env:GITHUB_OUTPUT
- name: Fetch PR head content
if: steps.changed.outputs.count != '0'
run: |
$root = Join-Path $env:GITHUB_WORKSPACE 'head'
New-Item -ItemType Directory -Force -Path $root | Out-Null
git -C $root init -q
git -C $root remote add origin "https://github.com/${{ github.repository }}.git"
# PR head commits (incl. forks) are replicated under refs/pull/N/head.
git -C $root fetch --depth=1 origin "refs/pull/${{ steps.pr.outputs.number }}/head"
if ($LASTEXITCODE -ne 0) { throw "git fetch of PR head failed (exit $LASTEXITCODE)" }
git -C $root checkout -q FETCH_HEAD
- name: Generate suggestions and render comment
id: run
if: steps.changed.outputs.count != '0'
env:
AUTHORING_ASSIST_DOC_URL: ${{ vars.AUTHORING_ASSIST_DOC_URL }}
run: |
$out = Join-Path $env:GITHUB_WORKSPACE 'advisory-output'
New-Item -ItemType Directory -Force -Path $out | Out-Null
$changed = @(Get-Content -LiteralPath (Join-Path $env:GITHUB_WORKSPACE 'changed-articles.txt') |
Where-Object { $_ -and $_.Trim() })
$tool = Join-Path $env:GITHUB_WORKSPACE 'trusted/tools/Suggest-ArticleSignals.ps1'
$renderer = Join-Path $env:GITHUB_WORKSPACE 'trusted/tools/New-AuthoringAssistComment.ps1'
# Routing seed is optional (owned by the routing-index tuning thread).
# Pass it through only if that thread has landed one in trusted/tools;
# otherwise the tool runs self-contained.
$seed = Join-Path $env:GITHUB_WORKSPACE 'trusted/tools/routing-seed.json'
$headRoot = Join-Path $env:GITHUB_WORKSPACE 'head'
$reportPath = Join-Path $out 'report.json'
$commentPath = Join-Path $out 'comment.md'
# Trusted logic over head article text; enrich with the trusted seed
# only when present. Only inert article text comes from head.
$seedArgs = if (Test-Path -LiteralPath $seed) { @('-SeedPath', $seed) } else { @() }
& $tool -BCQualityRoot $headRoot @seedArgs -ChangedFiles $changed -AsJson |
Set-Content -LiteralPath $reportPath -Encoding UTF8
$repoUrl = "https://github.com/${{ github.repository }}"
& $renderer -JsonPath $reportPath `
-RepoUrl $repoUrl `
-Ref '${{ steps.pr.outputs.head_sha }}' `
-DocUrl $env:AUTHORING_ASSIST_DOC_URL |
Set-Content -LiteralPath $commentPath -Encoding UTF8
$report = Get-Content -LiteralPath $reportPath -Raw | ConvertFrom-Json
"article_count=$($report.articleCount)" >> $env:GITHUB_OUTPUT
"has_report=true" >> $env:GITHUB_OUTPUT
Write-Host "Articles with suggestions: $($report.articleCount)"
- name: Upload advisory output
if: steps.run.outputs.has_report == 'true'
uses: actions/upload-artifact@v4
with:
name: authoring-assist-output-${{ steps.pr.outputs.number }}
path: ${{ github.workspace }}/advisory-output
if-no-files-found: warn
publish:
needs: review
if: ${{ needs.review.result == 'success' && needs.review.outputs.has_report == 'true' && needs.review.outputs.pr_number != '' }}
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
defaults:
run:
shell: pwsh
steps:
- name: Download advisory output
uses: actions/download-artifact@v4
with:
name: authoring-assist-output-${{ needs.review.outputs.pr_number }}
path: ${{ github.workspace }}/advisory-output
github-token: ${{ github.token }}
run-id: ${{ github.event.workflow_run.id }}
- name: Upsert advisory comment
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
$anchor = '<!-- authoring-assist-advisory -->'
$body = Get-Content -LiteralPath (Join-Path $env:GITHUB_WORKSPACE 'advisory-output/comment.md') -Raw
$report = Get-Content -LiteralPath (Join-Path $env:GITHUB_WORKSPACE 'advisory-output/report.json') -Raw | ConvertFrom-Json
$headers = @{
Accept = 'application/vnd.github+json'
Authorization = "Bearer $env:GITHUB_TOKEN"
'User-Agent' = 'bcquality-authoring-assist'
}
$repo = '${{ github.repository }}'
$n = '${{ needs.review.outputs.pr_number }}'
# Find an existing advisory comment (idempotent update-in-place).
$existing = $null
for ($page = 1; $page -le 20; $page++) {
$uri = "https://api.github.com/repos/$repo/issues/$n/comments?per_page=100&page=$page"
$comments = Invoke-RestMethod -Uri $uri -Headers $headers -Method GET
if (-not $comments -or @($comments).Count -eq 0) { break }
$hit = $comments | Where-Object { $_.body -and $_.body.Contains($anchor) } | Select-Object -First 1
if ($hit) { $existing = $hit; break }
if (@($comments).Count -lt 100) { break }
}
# Do not create a brand-new "no suggestions" comment; only update an
# existing one to reflect that state.
if (-not $existing -and [int]$report.articleCount -eq 0) {
Write-Host 'No suggestions and no existing advisory comment; nothing to post.'
return
}
$payload = @{ body = $body } | ConvertTo-Json -Depth 4
$bytes = [System.Text.Encoding]::UTF8.GetBytes($payload)
if ($existing) {
$uri = "https://api.github.com/repos/$repo/issues/comments/$($existing.id)"
Invoke-RestMethod -Uri $uri -Headers $headers -Method PATCH -Body $bytes -ContentType 'application/json' | Out-Null
Write-Host "Updated advisory comment $($existing.id)."
} else {
$uri = "https://api.github.com/repos/$repo/issues/$n/comments"
Invoke-RestMethod -Uri $uri -Headers $headers -Method POST -Body $bytes -ContentType 'application/json' | Out-Null
Write-Host 'Created advisory comment.'
}