mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 09:26:52 +01:00
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
This commit is contained in:
parent
ad8ccde595
commit
94d3d4358d
8 changed files with 1313 additions and 1 deletions
36
.github/scripts/validate_frontmatter.py
vendored
36
.github/scripts/validate_frontmatter.py
vendored
|
|
@ -34,6 +34,11 @@ KNOWLEDGE_REQUIRED_KEYS = {
|
|||
"bc-version", "domain", "keywords", "technologies",
|
||||
"countries", "application-area",
|
||||
}
|
||||
# Optional knowledge keys (do not trigger the R02 closed-key-set error).
|
||||
# `signals`: OPTIONAL routing-signal declarations. RESERVED & DORMANT — the shape
|
||||
# is validated (R29 below) so authors and the authoring-assist advisory can
|
||||
# populate it, but no production pipeline consumes it yet. See tools/authoring-assist.md.
|
||||
KNOWLEDGE_OPTIONAL_KEYS = {"signals"}
|
||||
ACTION_SKILL_REQUIRED_KEYS = {
|
||||
"kind", "id", "version", "title", "description", "inputs", "outputs",
|
||||
}
|
||||
|
|
@ -203,7 +208,7 @@ def validate_knowledge(path: Path, parsed: Parsed, report: Report) -> None:
|
|||
|
||||
# R02 required keys, no extras, none empty
|
||||
missing = KNOWLEDGE_REQUIRED_KEYS - fm.keys()
|
||||
extras = fm.keys() - KNOWLEDGE_REQUIRED_KEYS
|
||||
extras = fm.keys() - KNOWLEDGE_REQUIRED_KEYS - KNOWLEDGE_OPTIONAL_KEYS
|
||||
if missing:
|
||||
report.error(path, "R02", f"missing required frontmatter keys: {sorted(missing)}", 1)
|
||||
if extras:
|
||||
|
|
@ -213,6 +218,35 @@ def validate_knowledge(path: Path, parsed: Parsed, report: Report) -> None:
|
|||
if v is None or v == "" or v == []:
|
||||
report.error(path, "R02", f"frontmatter key '{k}' must not be empty", 1)
|
||||
|
||||
# R29 optional routing `signals` block. Each entry is either a bare token
|
||||
# string or a mapping with a required 'token' and optional 'pattern'/'domain'/
|
||||
# 'effect' (all non-empty strings; effect in {raise, suppress}). Reserved &
|
||||
# dormant: validated for shape only so the block stays well-formed for the
|
||||
# authoring-assist advisory; no production pipeline consumes it yet.
|
||||
if "signals" in fm:
|
||||
sigs = fm["signals"]
|
||||
if not isinstance(sigs, list) or not sigs:
|
||||
report.error(path, "R29", "signals must be a non-empty list", 1)
|
||||
else:
|
||||
for entry in sigs:
|
||||
if isinstance(entry, str):
|
||||
if not entry.strip():
|
||||
report.error(path, "R29", "signals token string must not be empty", 1)
|
||||
elif isinstance(entry, dict):
|
||||
tok = entry.get("token")
|
||||
if not isinstance(tok, str) or not tok.strip():
|
||||
report.error(path, "R29", "signals entry must have a non-empty 'token'", 1)
|
||||
for opt in ("pattern", "domain"):
|
||||
if opt in entry and (not isinstance(entry[opt], str) or not entry[opt].strip()):
|
||||
report.error(path, "R29", f"signals '{opt}' must be a non-empty string", 1)
|
||||
if "effect" in entry and entry["effect"] not in ("raise", "suppress"):
|
||||
report.error(path, "R29", "signals 'effect' must be 'raise' or 'suppress'", 1)
|
||||
unknown = set(entry.keys()) - {"token", "pattern", "domain", "effect"}
|
||||
if unknown:
|
||||
report.error(path, "R29", f"signals entry has unknown keys: {sorted(unknown)}", 1)
|
||||
else:
|
||||
report.error(path, "R29", "signals entry must be a string or a mapping", 1)
|
||||
|
||||
# R03 bc-version
|
||||
if "bc-version" in fm:
|
||||
_, err = expand_bc_version(fm["bc-version"])
|
||||
|
|
|
|||
242
.github/workflows/authoring-assist-runner.yml
vendored
Normal file
242
.github/workflows/authoring-assist-runner.yml
vendored
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
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.'
|
||||
}
|
||||
33
.github/workflows/authoring-assist-selftest.yml
vendored
Normal file
33
.github/workflows/authoring-assist-selftest.yml
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
name: Authoring-assist self-test
|
||||
|
||||
# Regression-guards the authoring-assist feature (suggestion engine + JSON
|
||||
# contract + comment renderer) against this repo's own corpus. Runs on changes
|
||||
# to the tool, renderer, the optional routing seed, or the test itself.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'tools/Suggest-ArticleSignals.ps1'
|
||||
- 'tools/New-AuthoringAssistComment.ps1'
|
||||
- 'tools/Test-SuggestArticleSignals.ps1'
|
||||
- 'tools/routing-seed.json'
|
||||
- '.github/workflows/authoring-assist*.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'tools/Suggest-ArticleSignals.ps1'
|
||||
- 'tools/New-AuthoringAssistComment.ps1'
|
||||
- 'tools/Test-SuggestArticleSignals.ps1'
|
||||
- 'tools/routing-seed.json'
|
||||
|
||||
jobs:
|
||||
selftest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run authoring-assist smoke test
|
||||
shell: pwsh
|
||||
run: ./tools/Test-SuggestArticleSignals.ps1 -BCQualityRoot .
|
||||
50
.github/workflows/authoring-assist.yml
vendored
Normal file
50
.github/workflows/authoring-assist.yml
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
name: Authoring-assist advisory
|
||||
|
||||
# Unprivileged intake. Fires on PRs that add/modify knowledge articles and
|
||||
# captures PR metadata as an artifact. All privileged work (reading the PR head,
|
||||
# generating suggestions, posting the advisory comment) happens in the companion
|
||||
# `Authoring-assist advisory runner` workflow, which runs in the trusted
|
||||
# `workflow_run` context. This split lets the advisory comment on fork PRs
|
||||
# without ever exposing a write-scoped token to a job that reads untrusted PR
|
||||
# content. The advisory is NON-BLOCKING: it only suggests, never fails a check.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
paths:
|
||||
- '**/knowledge/**/*.md'
|
||||
|
||||
concurrency:
|
||||
group: authoring-assist-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
intake:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
steps:
|
||||
- name: Save PR metadata
|
||||
run: |
|
||||
$outputDir = Join-Path $env:GITHUB_WORKSPACE 'advisory-input'
|
||||
New-Item -Path $outputDir -ItemType Directory -Force | Out-Null
|
||||
|
||||
@{
|
||||
prNumber = "${{ github.event.pull_request.number }}"
|
||||
headSha = "${{ github.event.pull_request.head.sha }}"
|
||||
baseRef = "${{ github.event.pull_request.base.ref }}"
|
||||
repository = "${{ github.repository }}"
|
||||
} | ConvertTo-Json -Depth 5 | Set-Content -Path (Join-Path $outputDir 'pr-metadata.json') -Encoding UTF8
|
||||
|
||||
- name: Upload advisory input
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: authoring-assist-input-${{ github.event.pull_request.number }}
|
||||
path: ${{ github.workspace }}/advisory-input
|
||||
if-no-files-found: error
|
||||
Loading…
Add table
Add a link
Reference in a new issue