Files
mattermost/.github/scripts/docs-ai
Eva SarafianouandCursor 356bdc7d9b docs: scope AI documentation drafts to the merged change (#39211)
* docs: scope AI documentation drafts to the merged change

Draft PRs were titled by number, filled with the review loop, and restyling whole pages because pre-open review treated every line as new.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: strip HTML comment delimiters from AI draft summaries

An unmatched comment opener in the model summary is an HTML block through the end of the pull request body, which hides the editorial-review notice.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: drop a draft summary that does not match the accepted revision

An accepted revision with no summary fence was keeping the previous pass's summary, so the draft pull request could describe files that had already been replaced.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: match sibling spacing when a draft adds a section

The command reference puts each label on the line above its fence. Drafts were inserting a blank line there, which made a new command harder to scan than the rest of the page.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: let related brief actions share a section

Forcing a heading per gap-brief action split behaviours that belong together on the page.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: keep the writer trail and the full action list

The pre-open trail disappeared with the runner, and a maxed gap brief could drop the last action before review.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs-ai: use git diff --no-index for pre-open review diffs

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs-ai: keep prior draft when a revision reverts the page; fold draftTitle into author-loop

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs-ai: never draft changelogs, upgrade notes, or the version archive

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs-ai: keep gap analysis recommendations unchanged

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 17:31:25 +00:00
..

Docs AI

Three pipelines, one persona registry, one package.

Workflow Question Runs on
docs-review.yml Is this prose right? Manual workflow_dispatch with a PR number (while tuning)
docs-gap.yml Is a page missing? Every PR
docs-writer-merged-pr.yml Write the missing pages PR merged with Docs/Needed

Review and gap are advisory. No verdict blocks a merge. docs/api reference pages are generated from OpenAPI — out of scope for review and as a gap action.

Layout

docs-ai/
  review/     # persona review entry points (router, persona-review, report)
  gap/        # gap analysis entry points (prepare, report) + lifecycle helpers
  writer/     # author loop + merged-PR entry + Docs/Done sync
  lib/        # shared (anthropic, github, untrusted, personas, diff)
  package.json
  README.md

One package.json. Separate packages would only duplicate the lockfile and CI install.

How to run it

The review workflow is manual while we tune personas. From the Actions tab, run Docs AI Review with the target PR number (same-repo branches only). That posts one sticky comment on the PR and produces a single workflow run — no checks appear on other PRs. When the output is trustworthy, add a pull_request trigger back onto the same single job so it becomes one check rather than a matrix of them.

This package

.github/scripts/docs-ai/ is a small Node module (ESM). Entry points:

Script Role
review/router.mjs Cheap model call that picks which personas apply
review/persona-review.mjs One persona → one JSON verdict
review/report.mjs Upserts the sticky review comment
gap/prepare.mjs Collects diffs and renders the gap prompt
gap/report.mjs Posts the sticky gap comment and owns Docs/Needed
writer/run.mjs Reads the gap sticky, runs the author loop, writes MDX
writer/sync.mjs On merge of docs/pr-*, flips Docs/Needed → Docs/Done

npm test (run from this directory) is the registry validator the workflow also runs before reviewing or writing.

Adding or changing a persona

One file in .github/prompts/personas/. The filesystem is the registry; there is no list to keep in sync.

---
id: system-admin              # must match the filename
label: System Administrator   # shown in the PR comment
scope: [author, review, impact]
docs_paths:                   # pages this persona owns
  - docs/main/administration-guide
code_signals:                 # code paths implying docs are needed (scope: impact)
  - server/public/model/config.go
router_hints: >               # when the router should select this persona
  Apply to administration, deployment and configuration content…
---

You are a senior Mattermost system administrator…

Every key is required except code_signals, which is required only with impact scope. docs_paths entries must exist on disk.

Everything below the closing --- is the persona's system prompt, sent verbatim. Write it as the brief you would give a human reviewer: who they are, what they are accountable for, what to look for and in what order. The writer reframes the same body as an audience lens when scope includes author.

Scope Used by
review Reviewing a docs PR
author Writing a page (path → persona via docs_paths)
impact Deciding whether a code change needs docs

brand-voice is always selected and cannot be dropped by the router. It never authors.

An impact persona's docs_paths and code_signals are rendered into the gap prompt by gap/prompt.mjs, so the two pipelines cannot disagree about who reads what. Moving a persona's paths updates both in one edit.

The Docs/Needed lifecycle

gap/report.mjs is the only writer of the label on open PRs. It reads prior state back out of the comment it wrote last time, so it can tell its own label from a human's:

<!-- docs-gap:v1 -->
<!-- docs-gap-state {"applied_by":"bot","verdict":"required","sha":"…"} -->
Prior state Verdict Action
no label required / recommended add, record applied_by: bot
label, applied_by: bot none remove — the gap closed
label, no state (human-applied) none leave it, note the override
Docs/Not Needed or Docs/Done any skip entirely

When a PR merges still carrying Docs/Needed, docs-writer-merged-pr.yml reads the sticky comment's recommended actions, authors MDX with the matching author personas, pre-reviews in-loop, and opens docs/pr-<n> as a draft (authored by the Docs AI App). The draft title comes from the source PR title. The draft body says what the documentation change does. The pre-open review trail stays in trail.json, uploaded as a workflow artifact, not the body. Closing that draft flips Docs/Needed → Docs/Done on the originating PR.

Three constraints to preserve if you change the gap label logic:

  • The label follows the verdict, not the presence of a docs diff. A push that edits an unrelated page must not clear it, or the self-clearing behaviour is an escape hatch.
  • Comment before label. A label written without its state reads as human-applied forever; state written without a label re-applies on the next run.
  • A failed run keeps the prior state block. Dropping it has the same effect as writing a label with no state.

Every gap branch renders locally without touching a PR:

node gap/report.mjs --dry-run --result-file ./result.json \
  --labels 'Docs/Needed' --prior-state '{"applied_by":"bot"}'

Writer credentials

Opening a branch and PR needs a GitHub App (DOCS_AI_APP_CLIENT_ID / DOCS_AI_APP_PRIVATE_KEY). The default GITHUB_TOKEN cannot push under org policy, and PRs it opens would not re-trigger docs-review.yml. Soft-skip when unset.

When the merged PR has a milestone whose title contains vMAJOR.MINOR, that is the From Mattermost vX.Y version anchor. With no milestone, or a title that does not parse, the draft still opens and uses From Mattermost [NOT PRESENT — REQUIRES HUMAN JUDGMENT] for a human to replace.

docs-writer-merged-pr.yml's sync job also needs DOCS_AI_BOT_LOGIN (the App's {slug}[bot] login). Label flips run only when the closed PR's head repo is this repository and its author matches that login — branch name and body markers are not provenance.

Where a rule belongs

Anything that does not vary by audience goes in the shared prompts, stated once:

  • .github/prompts/conventions.md — docs house rules. Reviewers and writers.
  • .github/prompts/review-contract.md — JSON output contract and verdict semantics. Reviewers.
  • .github/prompts/docs-gap-analysis.md — the gap prompt. Gap analysis only.
  • .github/prompts/docs-writer.md — authoring output contract and hard rules. Writers.

The conventions + contract (or conventions + writer prompt) prefixes are sent as a byte-identical cacheable prefix across calls. The gap prompt carries {{DATA_NOTICE}}, {{INPUTS}} and {{PERSONAS}}; renaming or dropping one fails the render rather than reaching the model.

Untrusted input

Diffs and PR descriptions are author-controlled. A prompt that embeds repository content must wrap it with block() from lib/untrusted.mjs and include DATA_NOTICE. The gap diffs are wrapped the same way and reach the model as files it opens, which keeps an unbounded monorepo diff out of the workflow's expression context.

Model output is untrusted on the way back out. clampResult() strips HTML comment delimiters from every string before it reaches a comment — without that, a summary could forge the docs-gap-state block the next run reads. The writer aborts when it sees no path= blocks or when a page lacks its version anchor (the milestone version, or the human-judgment phrase when no milestone version was supplied).