Draft docs from merged Docs/Needed via author personas (#38764)

* docs: draft pages from merged Docs/Needed via author personas

When a PR merges still carrying Docs/Needed, read the gap sticky comment,
author MDX with the matching audience personas (pre-reviewed in-loop), and
open docs/pr-<n>. Closing that draft flips Docs/Needed to Docs/Done.
Retires the cross-repo docs-needed.yml workflow.

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

* fix(docs-ai): harden writer fence parse, version check, and brief fallback

Match CodeRabbit findings that still hold: outer fences of equal length so
nested code samples survive; require the milestone version (not any vX.Y);
fall back to PR evidence when the gap sticky is missing; narrow the App
token. Keep pull_request (not pull_request_target) to match the gap
pipeline's fork posture.

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

* fix(docs-ai): tighten author path scope, ERROR halt, and sync provenance

Restrict each author pass to its group targets; fail the loop on review
ERROR instead of opening a half-reviewed draft; require same-repo head
and DOCS_AI_BOT_LOGIN before flipping Docs/Done. Also cover additionsDiff
edge cases and longest-prefix author matching.

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

* docs: lift observability and parity guidance into docs-writer prompt

Carry the useful bits from draft_docs.md without restoring triage: parity
vs net-new as a one-line scope rule, plus when to document logs/metrics.

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

* fix(docs-ai): require author passes to cover every group target

Emitted ⊆ allowlist was not enough — an incomplete pass could drop a
named page. Reject initial omissions; keep prior groupFiles when a
revision fails to re-emit every target.

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

* fix(docs-ai): harden revision limit, impact paths, and anchor reject

Validate DOCS_AI_MAX_REVISIONS as a non-negative integer; filter gap
impact locations to allowed .md/.mdx before grouping; keep the prior
pass when a revision drops the milestone version anchor.

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

* fix(docs-ai): drop AI-Drafted label; frame unknown versions

Identify drafts by App authorship instead of Docs/AI-Drafted. Require
"From Mattermost [NOT PRESENT — …]" for unknown releases, and replace
the DEBUG observability example with admin-facing cases.

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

* fix(docs-ai): address writer review on sync, paths, and author context

Require draft merge before Docs/Done, filter deny-listed paths at extraction,
prefer branch provenance, pass existing pages into the author prompt, and
tighten workflow input/credential handling.

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

* fix(docs-ai): keep full page context on author and revision passes

Reject oversized pages instead of truncating them, feed the in-memory draft
into revision prompts, and tighten line-based MDX import/export checks.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Eva SarafianouandCursor authored and GitHub committed 2026-09-28 18:18:51 +03:00
1 parent 05553d083c
commit 5427f6b024
18 files changed
+1969 -687

No files matched your search

+6 -4
View File
@@ -14,8 +14,9 @@ to, in this form:
**Authoring.** Never invent a version or infer one from a branch name or date; a wrong
version is worse than none, because readers believe it. Take it from the PR or issue
milestone. With no milestone, write `[NOT PRESENT — REQUIRES HUMAN JUDGMENT]` and leave
it for a human.
milestone. With no milestone, write `From Mattermost [NOT PRESENT — REQUIRES HUMAN JUDGMENT]`
and leave it for a human — keep the "From Mattermost …" frame so readers see a version
is missing.
**Reviewing.** Check the anchor, never supply one.
@@ -23,8 +24,9 @@ it for a human.
knows when their own work ships and you do not.
- Present — accept it. You have no release schedule to check it against, so a version is
not suspect merely because you cannot verify it.
- `[NOT PRESENT — REQUIRES HUMAN JUDGMENT]` still in the content — always a finding, and
one only a human can clear. Report it; never try to resolve it by supplying a version.
- `From Mattermost [NOT PRESENT — REQUIRES HUMAN JUDGMENT]` still in the content — always
a finding, and one only a human can clear. Report it; never try to resolve it by
supplying a version.
## Frontmatter
+88
View File
@@ -0,0 +1,88 @@
# Docs writer
You draft Mattermost documentation pages on behalf of the technical writing team.
You receive untrusted PR evidence and a gap brief; you emit MDX file blocks only.
## Role
Write the pages named in the brief so a reader in your audience lens can act on the
merged change. Prefer updating an existing page over inventing a new one when the
brief names a path that already exists.
Gap analysis already decided that documentation is needed and named targets. Your job
is to close that brief, not re-triage the PR.
## Scope
Parity (closing a gap or removing a limitation) → smallest update to existing pages,
often a version line and a short behavioural note. Net-new capability → a new page only
when the brief asks for one; otherwise extend the named existing page.
Ask: what can the reader do now that they could not before? Answer in one sentence and
write only what that sentence requires.
## Hard rules
- Claims must map to evidence in the supplied PR metadata, description, code diff, or
gap brief. If evidence is absent, write `[NOT PRESENT IN PR — REQUIRES HUMAN JUDGMENT]`
at the claim site — never invent behaviour, UI labels, config keys, or defaults.
- Version anchoring comes only from `milestone.version` in the brief. When present, use
`From Mattermost vX.Y` for new or changed capability. When absent, write
`From Mattermost [NOT PRESENT — REQUIRES HUMAN JUDGMENT]` — never guess a version.
- Do not hand-author `docs/api/reference/**`, `docs/site/**`, `docs/styles/**`,
`docs/pdf/**`, `docs/vendor/**`, or `docs/main/agents/docs/**`.
- Do not edit changelogs, important upgrade notes, or version-archive pages unless the
brief explicitly requires it.
- Follow `conventions.md` for frontmatter, plan availability, callouts, links, MDX
escaping, heading case, and voice.
## Observability and diagnostics
When the PR adds logging, metrics, monitoring events, or diagnostic output:
Document when the product already has logging/metrics/observability reference docs, when
the message helps admins troubleshoot or understand system behaviour, or when new log
levels, categories, configuration options, or audit events appear.
Do not document internal debug or trace noise with no admin troubleshooting value, or
logging changes when the product has no observability documentation for that surface
(implementation-only logs).
Examples to document: an Info/Warn log an admin uses to diagnose a failed job; a new
metric such as `api_request_duration_seconds`; a new audit event such as
`USER_PASSWORD_CHANGED`; a config option that changes what is logged.
Examples not to document: "added DEBUG/trace logging in `processWidgets()`"; "improved
log formatting" when the meaning of the output is unchanged.
## Anti-patterns
- Do not document implementation details (code structure, internal algorithms) unless
an admin must operate on them (config keys, logs, metrics, CLI flags).
- Do not treat PR size as a proxy for docs scope.
- Prefer the smallest change that closes the gap.
## Output format
Return one or more fenced code blocks, each preceded by a `path=` attribute naming the
destination relative to the repository root. No prose between blocks.
Use a **four-backtick** outer fence so any three-backtick code samples inside the page
do not close the block early:
````mdx path=docs/main/administration-guide/configure/example.mdx
---
title: "Example setting"
---
From Mattermost v11.7, …
```bash
mmctl config get ServiceSettings.SiteURL
```
…
````
Include only files you create or modify. Every `path=` must be under `docs/main/`,
`docs/develop/`, or be exactly `docs/api/examples.mdx` / `docs/api/index.mdx`.
+44 -18
View File
@@ -1,15 +1,15 @@
# Docs AI
Two pipelines, one persona registry, one package.
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` |
Both are advisory. No verdict blocks a merge. `docs/api` is out of scope for review — its
reference pages are generated from the OpenAPI spec, so corrections belong in
`api/v4/source/` — and out of scope as a gap action for the same reason.
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
@@ -17,7 +17,8 @@ reference pages are generated from the OpenAPI spec, so corrections belong in
docs-ai/
review/ # persona review entry points (router, persona-review, report)
gap/ # gap analysis entry points (prepare, report) + lifecycle helpers
lib/ # shared (anthropic, github, untrusted, personas)
writer/ # author loop + merged-PR entry + Docs/Done sync
lib/ # shared (anthropic, github, untrusted, personas, diff)
package.json
README.md
```
@@ -26,7 +27,7 @@ One `package.json`. Separate packages would only duplicate the lockfile and CI i
## How to run it
The workflow is manual while we tune personas. From the Actions tab, run
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
@@ -43,9 +44,11 @@ single job so it becomes one check rather than a matrix of them.
| `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.
before reviewing or writing.
## Adding or changing a persona
@@ -73,15 +76,16 @@ Every key is required except `code_signals`, which is required only with `impact
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.
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 |
| `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.
`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
@@ -89,8 +93,8 @@ persona's paths updates both in one edit.
## The Docs/Needed lifecycle
`gap/report.mjs` is the only writer of the label. It reads prior state back out of the
comment it wrote last time, so it can tell its own label from a human's:
`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 -->
@@ -104,7 +108,12 @@ comment it wrote last time, so it can tell its own label from a human's:
| label, no state (human-applied) | `none` | leave it, note the override |
| `Docs/Not Needed` or `Docs/Done` | any | skip entirely |
Three constraints to preserve if you change this:
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).
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.
@@ -113,13 +122,27 @@ Three constraints to preserve if you change this:
- **A failed run keeps the prior state block.** Dropping it has the same effect as writing
a label with no state.
Every branch renders locally without touching a PR:
Every gap branch renders locally without touching a PR:
```bash
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.
The milestone on the merged PR is mandatory: it is the only source of the
`From Mattermost vX.Y` version anchor.
`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:
@@ -128,10 +151,12 @@ Anything that does not vary by audience goes in the shared prompts, stated once:
- `.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 first two are sent as a byte-identical prefix across every persona, so nothing in them
may be templated per persona. The gap prompt carries `{{DATA_NOTICE}}`, `{{INPUTS}}` and
`{{PERSONAS}}`; renaming or dropping one fails the render rather than reaching the model.
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
@@ -142,4 +167,5 @@ 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.
forge the `docs-gap-state` block the next run reads. The writer aborts when it sees no
`path=` blocks or when a milestone was set and a page lacks a version anchor.
+30
View File
@@ -0,0 +1,30 @@
/*
* Synthesise an all-additions unified diff so pre-open persona review takes the
* same prompt shape as docs-review.yml's git diff.
*/
export function additionsDiff(files) {
const chunks = [];
for (const {path, content} of files) {
const lines = content === ''
? []
: (() => {
const body = content.endsWith('\n') ? content : `${content}\n`;
const parts = body.split('\n');
// Trailing empty from the final newline — drop it so +++ counts match.
if (parts.at(-1) === '') parts.pop();
return parts;
})();
const n = lines.length;
chunks.push(
`diff --git a/${path} b/${path}`,
`new file mode 100644`,
`--- /dev/null`,
`+++ b/${path}`,
n === 0 ? `@@ -0,0 +0,0 @@` : `@@ -0,0 +1,${n} @@`,
...lines.map((l) => `+${l}`),
);
}
return chunks.join('\n') + (chunks.length ? '\n' : '');
}
+52
View File
@@ -0,0 +1,52 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {additionsDiff} from './diff.mjs';
function hunkHeader(diff) {
const m = diff.match(/@@ -0,0 \+(\d+)(?:,(\d+))? @@/);
if (!m) return null;
// +0,0 → count 0; +1,N → count N; +N alone is not used here.
if (m[2] !== undefined) return Number(m[2]);
return Number(m[1]);
}
test('additionsDiff normalises a file with no trailing newline', () => {
const diff = additionsDiff([{path: 'docs/main/a.mdx', content: 'line1\nline2'}]);
assert.ok(diff.endsWith('\n'));
assert.equal(hunkHeader(diff), 2);
assert.match(diff, /\+line1\n\+line2\n/);
assert.doesNotMatch(diff, /\+line2\n\+\n/);
});
test('additionsDiff drops the phantom empty line after a trailing newline', () => {
const diff = additionsDiff([{path: 'docs/main/a.mdx', content: 'line1\nline2\n'}]);
assert.equal(hunkHeader(diff), 2);
assert.match(diff, /\+line1\n\+line2\n$/);
});
test('additionsDiff handles empty content without crashing', () => {
const diff = additionsDiff([{path: 'docs/main/empty.mdx', content: ''}]);
assert.match(diff, /diff --git a\/docs\/main\/empty\.mdx/);
assert.match(diff, /new file mode 100644/);
assert.match(diff, /@@ -0,0 \+0,0 @@/);
assert.equal(hunkHeader(diff), 0);
// No content lines after the hunk header (+++ file header still uses +).
assert.equal((diff.split(/@@[^\n]*\n/)[1] ?? '').trim(), '');
assert.ok(diff.endsWith('\n'));
});
test('additionsDiff joins multiple files as separate chunks', () => {
const diff = additionsDiff([
{path: 'docs/main/a.mdx', content: 'A\n'},
{path: 'docs/main/b.mdx', content: 'B\n'},
]);
const a = diff.indexOf('+++ b/docs/main/a.mdx');
const b = diff.indexOf('+++ b/docs/main/b.mdx');
assert.ok(a !== -1 && b !== -1 && a < b);
assert.match(diff, /\+A\n/);
assert.match(diff, /\+B\n/);
});
test('additionsDiff returns empty string for no files', () => {
assert.equal(additionsDiff([]), '');
});
+58
View File
@@ -153,4 +153,62 @@ export function reviewSystemBlocks(id) {
];
}
export function writerPrompt() {
return readFileSync(join(PROMPTS_DIR, 'docs-writer.md'), 'utf8').trim();
}
// Authoring persona body is a review rubric; the writer prompt reframes it as
// the audience lens. Same cache prefix (conventions) as reviewers.
export function authorSystemBlocks(id) {
const persona = getPersona(id);
return [
{type: 'text', text: conventions(), cache_control: {type: 'ephemeral'}},
{type: 'text', text: writerPrompt(), cache_control: {type: 'ephemeral'}},
{
type: 'text',
text: `## Audience lens: ${persona.label}\n\nThe brief below describes the reader you write for. Author for them; do not emit review JSON.\n\n${persona.prompt}`,
cache_control: {type: 'ephemeral'},
},
];
}
export function neutralAuthorSystemBlocks() {
return [
{type: 'text', text: conventions(), cache_control: {type: 'ephemeral'}},
{type: 'text', text: writerPrompt(), cache_control: {type: 'ephemeral'}},
{
type: 'text',
text: '## Audience lens: general technical writer\n\nNo path-matched authoring persona applied. Write clear operator-facing documentation.',
cache_control: {type: 'ephemeral'},
},
];
}
/** Longest matching docs_paths prefix wins among author-scoped personas. */
export function authorForPath(relPath) {
const authors = personasWithScope('author');
let best = null;
let bestLen = -1;
for (const p of authors) {
for (const prefix of p.docsPaths) {
if ((relPath === prefix || relPath.startsWith(`${prefix}/`)) && prefix.length > bestLen) {
best = p;
bestLen = prefix.length;
}
}
}
return best;
}
export function groupPathsByAuthor(paths) {
const groups = new Map(); // personaId | 'neutral' → paths[]
for (const path of paths) {
const persona = authorForPath(path);
const key = persona?.id ?? 'neutral';
if (!groups.has(key)) groups.set(key, {personaId: persona?.id ?? null, paths: []});
groups.get(key).paths.push(path);
}
return [...groups.values()];
}
export {PERSONAS_DIR, PROMPTS_DIR, REPO_ROOT};
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "1.0.0",
"private": true,
"type": "module",
"description": "AI docs pipelines: persona review (review/) and gap analysis (gap/), with shared lib/.",
"description": "AI docs pipelines: persona review (review/), gap analysis (gap/), and writer (writer/), with shared lib/.",
"scripts": {
"test": "node --test"
},
+19 -1
View File
@@ -10,7 +10,7 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {conventions, reviewContract} from './lib/personas.mjs';
import {conventions, reviewContract, writerPrompt} from './lib/personas.mjs';
test('conventions state the version anchoring rule', () => {
// The requirement most likely to be silently dropped in an edit, and the
@@ -63,3 +63,21 @@ test('review contract names the fields the reviewer parses', () => {
}
assert.match(contract, /At most three findings/);
});
test('writer prompt states the path= output contract and version rule', () => {
const prompt = writerPrompt();
assert.match(prompt, /path=/);
assert.match(prompt, /From Mattermost vX\.Y/);
assert.match(prompt, /From Mattermost \[NOT PRESENT — REQUIRES HUMAN JUDGMENT\]/);
assert.match(prompt, /milestone\.version/);
assert.match(prompt, /docs\/api\/reference/);
});
test('writer prompt states scope and observability guidance', () => {
const prompt = writerPrompt();
assert.match(prompt, /Parity/);
assert.match(prompt, /Net-new/);
assert.match(prompt, /Observability and diagnostics/);
assert.match(prompt, /USER_PASSWORD_CHANGED/);
assert.match(prompt, /processWidgets/);
});
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import {readFileSync, writeFileSync, mkdirSync, existsSync} from 'node:fs';
import {dirname} from 'node:path';
import {fileURLToPath} from 'node:url';
import {complete, parseJson, usageLine} from '../lib/anthropic.mjs';
import {getPersona, reviewSystemBlocks} from '../lib/personas.mjs';
import {DATA_NOTICE, block} from '../lib/untrusted.mjs';
@@ -19,7 +20,7 @@ function readIfSet(name) {
return path && existsSync(path) ? readFileSync(path, 'utf8') : null;
}
function buildUserPrompt({diff, prTitle, prBody}) {
export function buildReviewUserPrompt({diff, prTitle, prBody}) {
const parts = [DATA_NOTICE, ''];
if (prTitle) parts.push(block('pull-request-title', prTitle, {maxChars: 500}), '');
@@ -34,7 +35,7 @@ function buildUserPrompt({diff, prTitle, prBody}) {
return parts.join('\n');
}
function normalize(parsed, persona) {
export function normalizeReview(parsed, persona, model = MODEL) {
const verdict = VERDICTS.includes(parsed?.verdict) ? parsed.verdict : 'COMMENT';
const feedback = (Array.isArray(parsed?.feedback) ? parsed.feedback : [])
@@ -47,7 +48,39 @@ function normalize(parsed, persona) {
? parsed.summary.trim().slice(0, 500)
: 'No summary returned.';
return {persona: persona.id, label: persona.label, verdict, summary, feedback, model: MODEL};
return {persona: persona.id, label: persona.label, verdict, summary, feedback, model};
}
export async function reviewPersona({
personaId,
diff,
prTitle,
prBody,
model = MODEL,
completeFn = complete,
}) {
const persona = getPersona(personaId);
try {
const {text, usage} = await completeFn({
model,
system: reviewSystemBlocks(personaId),
userPrompt: buildReviewUserPrompt({diff, prTitle, prBody}),
maxTokens: 2048,
temperature: 0.2,
});
console.error(`[${personaId}] ${model} ${usageLine(usage)}`);
return normalizeReview(parseJson(text), persona, model);
} catch (e) {
console.error(`[${personaId}] review failed: ${e.message}`);
return {
persona: persona.id,
label: persona.label,
verdict: 'ERROR',
summary: `Review did not complete: ${e.message}`.slice(0, 500),
feedback: [],
model,
};
}
}
async function main() {
@@ -58,41 +91,21 @@ async function main() {
const out = arg('out');
if (!out) throw new Error('--out <file> is required');
const persona = getPersona(id);
let result;
try {
const {text, usage} = await complete({
model: MODEL,
system: reviewSystemBlocks(id),
userPrompt: buildUserPrompt({
diff: readFileSync(diffPath, 'utf8'),
prTitle: arg('pr-title'),
prBody: readIfSet('pr-body-file'),
}),
maxTokens: 2048,
temperature: 0.2,
});
console.error(`[${id}] ${MODEL} ${usageLine(usage)}`);
result = normalize(parseJson(text), persona);
} catch (e) {
console.error(`[${id}] review failed: ${e.message}`);
result = {
persona: persona.id,
label: persona.label,
verdict: 'ERROR',
summary: `Review did not complete: ${e.message}`.slice(0, 500),
feedback: [],
model: MODEL,
};
}
const result = await reviewPersona({
personaId: id,
diff: readFileSync(diffPath, 'utf8'),
prTitle: arg('pr-title'),
prBody: readIfSet('pr-body-file'),
});
mkdirSync(dirname(out), {recursive: true});
writeFileSync(out, `${JSON.stringify(result, null, 2)}\n`);
console.error(`[${id}] ${result.verdict} — ${result.summary}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((e) => {
console.error(e);
process.exit(1);
});
}
@@ -0,0 +1,404 @@
import {existsSync, readFileSync} from 'node:fs';
import {join} from 'node:path';
import {complete, usageLine} from '../lib/anthropic.mjs';
import {additionsDiff} from '../lib/diff.mjs';
import {
alwaysOnPersonaIds,
authorSystemBlocks,
groupPathsByAuthor,
neutralAuthorSystemBlocks,
personasWithScope,
REPO_ROOT as DEFAULT_ROOT,
} from '../lib/personas.mjs';
import {DATA_NOTICE, block} from '../lib/untrusted.mjs';
import {reviewPersona} from '../review/persona-review.mjs';
import {assertSafeMdx, assertVersionAnchors, mergeFiles, parseFileBlocks} from './files.mjs';
import {isAllowedPath} from './paths.mjs';
const WRITER_MODEL = process.env.DOCS_AI_WRITER_MODEL || 'claude-sonnet-4-6';
const REVIEW_MODEL = process.env.DOCS_AI_REVIEW_MODEL || 'claude-sonnet-4-5-20250929';
const ROUTER_MODEL = process.env.DOCS_AI_ROUTER_MODEL || 'claude-haiku-4-5-20251001';
/** Accept only non-negative integers; fall back when missing or malformed. */
export function parseMaxRevisions(raw, fallback = 2) {
if (raw == null || raw === '') return fallback;
const n = Number(raw);
return Number.isInteger(n) && n >= 0 ? n : fallback;
}
const MAX_REVISIONS = parseMaxRevisions(process.env.DOCS_AI_MAX_REVISIONS);
export function normalizeDocsPath(p) {
return String(p || '')
.trim()
.replace(/^\.\//, '')
.replace(/\/+$/, '');
}
/** Concrete docs/ targets for this author group; empty means "no path filter". */
export function groupPathAllowlist(groupPaths) {
return new Set(
(groupPaths || [])
.map(normalizeDocsPath)
.filter((p) => p.startsWith('docs/')),
);
}
/** Targets in the group allowlist that are absent from the authored files. */
export function missingGroupTargets(files, groupPaths) {
const allow = groupPathAllowlist(groupPaths);
if (allow.size === 0) return [];
const have = new Set((files || []).map((f) => normalizeDocsPath(f.path)));
return [...allow].filter((t) => !have.has(t)).sort();
}
/** Load on-disk pages for targets so updates edit the real page, not a blank rewrite. */
export function loadExistingPages(repoRoot, groupPaths) {
const root = repoRoot || DEFAULT_ROOT;
const pages = [];
for (const raw of groupPaths || []) {
const path = normalizeDocsPath(raw);
if (!path.startsWith('docs/') || !isAllowedPath(path)) continue;
const abs = join(root, path);
if (!existsSync(abs)) continue;
pages.push({path, content: readFileSync(abs, 'utf8')});
}
return pages;
}
/** Refuse pages too large to fit in the prompt without truncation (would risk deleting omitted content). */
export const MAX_PAGE_PROMPT_CHARS = 100_000;
export function assertPagesFitPrompt(pages, maxChars = MAX_PAGE_PROMPT_CHARS) {
for (const p of pages || []) {
const n = String(p.content ?? '').length;
if (n > maxChars) {
throw new Error(
`${p.path}: page is ${n} characters (limit ${maxChars}); refusing to author a replacement that might omit content`,
);
}
}
}
function buildAuthorUserPrompt({brief, groupPaths, input, revisionFeedback, existingPages}) {
const parts = [
DATA_NOTICE,
'',
block('gap-brief', JSON.stringify(brief, null, 2), {maxChars: 20_000}),
'',
];
if (input.prTitle) parts.push(block('pull-request-title', input.prTitle, {maxChars: 500}), '');
if (input.prBody) parts.push(block('pull-request-description', input.prBody, {maxChars: 6000}), '');
if (input.milestoneTitle) {
parts.push(
block(
'pr-metadata',
[
`PR URL: ${input.prUrl || '(unknown)'}`,
`milestone.title: ${input.milestoneTitle}`,
`milestone.version: ${input.milestoneVersion || '(none)'}`,
].join('\n'),
{maxChars: 2000},
),
'',
);
}
if (input.codeDiff) parts.push(block('code_diff', input.codeDiff, {maxChars: 40_000}), '');
parts.push(
block('target-paths', groupPaths.join('\n') || '(derive from gap brief)'),
'',
);
if (existingPages?.length) {
// Full page only (no truncation): assertPagesFitPrompt already rejected oversized files.
const pageTag = revisionFeedback?.length ? 'current-draft' : 'existing-page';
for (const page of existingPages) {
parts.push(
block(`${pageTag} path=${page.path}`, page.content, {maxChars: Number.MAX_SAFE_INTEGER}),
'',
);
}
parts.push(
revisionFeedback?.length
? 'For paths with a current-draft block, revise that draft in place. Output the full updated file.'
: 'For paths with an existing-page block, edit that page in place (smallest change that closes the gap). Output the full updated file.',
'',
);
}
if (revisionFeedback?.length) {
parts.push(
block('reviewer-feedback', JSON.stringify(revisionFeedback, null, 2), {maxChars: 12_000}),
'',
'Revise the pages to address REQUEST_CHANGES feedback. Output the full file blocks again.',
);
} else {
parts.push(
'Draft the MDX file(s) that close the documentation gap for the target paths. Output only the file blocks.',
);
}
return parts.join('\n');
}
async function authorPass({personaId, groupPaths, brief, input, revisionFeedback, basePages, completeFn}) {
const system = personaId ? authorSystemBlocks(personaId) : neutralAuthorSystemBlocks();
// Revisions use the in-memory draft; initial passes load the on-disk page when present.
const existingPages = basePages ?? loadExistingPages(input.repoRoot, groupPaths);
assertPagesFitPrompt(existingPages);
const {text, usage} = await completeFn({
model: WRITER_MODEL,
system,
userPrompt: buildAuthorUserPrompt({
brief,
groupPaths,
input,
revisionFeedback,
existingPages,
}),
maxTokens: 8192,
temperature: 0.3,
});
console.error(`[author:${personaId ?? 'neutral'}] ${WRITER_MODEL} ${usageLine(usage)}`);
const allow = groupPathAllowlist(groupPaths);
const files = parseFileBlocks(text)
.map((f) => ({...f, path: normalizeDocsPath(f.path)}))
.filter((f) => {
if (!isAllowedPath(f.path)) {
console.error(`[author] dropping disallowed path ${f.path}`);
return false;
}
if (allow.size > 0 && !allow.has(f.path)) {
console.error(`[author] dropping path outside group targets: ${f.path}`);
return false;
}
return true;
});
if (files.length === 0) {
throw new Error(`author ${personaId ?? 'neutral'} emitted no allowed file blocks`);
}
return files;
}
async function selectReviewers({diff, completeFn}) {
const alwaysOn = alwaysOnPersonaIds();
const candidates = personasWithScope('review').filter((p) => !alwaysOn.includes(p.id));
const validIds = new Set(candidates.map((p) => p.id));
try {
const menu = candidates
.map((p) => `- ${p.id} (${p.label})\n Applies to: ${p.routerHints}`)
.join('\n');
const {text, usage} = await completeFn({
model: ROUTER_MODEL,
system: `You route Mattermost documentation changes to reviewers.\n\n${DATA_NOTICE}\n\nAvailable reviewers:\n\n${menu}\n\nReturn strict JSON: {"personas": ["id"]}`,
userPrompt: `${block('diff', diff)}\n\nWhich reviewers apply?`,
maxTokens: 512,
temperature: 0,
});
console.error(`[router] ${ROUTER_MODEL} ${usageLine(usage)}`);
const raw = JSON.parse(text.match(/\{[\s\S]*\}/)?.[0] ?? text).personas;
const selected = (Array.isArray(raw) ? raw : []).filter((id) => validIds.has(id));
return [...new Set([...selected, ...alwaysOn])].sort();
} catch (e) {
console.error(`[router] failed (${e.message}); using all reviewers`);
return [...new Set([...candidates.map((p) => p.id), ...alwaysOn])].sort();
}
}
async function reviewFiles({files, input, completeFn}) {
const diff = additionsDiff(files);
const reviewers = await selectReviewers({diff, completeFn});
const results = await Promise.all(
reviewers.map((personaId) =>
reviewPersona({
personaId,
diff,
prTitle: input.prTitle,
prBody: input.prBody,
model: REVIEW_MODEL,
completeFn,
}),
),
);
return {diff, reviewers, results};
}
/**
* Author → review → revise loop. Returns files + trail for the draft PR body.
*/
export async function authorLoop({brief, input, completeFn = complete} = {}) {
const targets =
brief.targetPaths?.length > 0
? brief.targetPaths
: (brief.actions ?? []).length
? brief.actions
: ['(unspecified — derive from the gap brief)'];
// When we only have action strings, groupPathsByAuthor still needs path-like targets.
const pathTargets = targets.filter((t) => t.startsWith('docs/'));
const groups =
pathTargets.length > 0
? groupPathsByAuthor(pathTargets)
: [{personaId: null, paths: targets}];
let files = [];
const trail = {iterations: [], openConcerns: []};
for (const group of groups) {
let groupFiles = await authorPass({
personaId: group.personaId,
groupPaths: group.paths,
brief,
input,
completeFn,
});
const initialMissing = missingGroupTargets(groupFiles, group.paths);
if (initialMissing.length) {
throw new Error(
`author ${group.personaId ?? 'neutral'} omitted required targets: ${initialMissing.join(', ')}`,
);
}
assertVersionAnchors(groupFiles, {milestoneVersion: input.milestoneVersion});
assertSafeMdx(groupFiles);
let revision = 0;
let lastResults = [];
for (;;) {
const {results} = await reviewFiles({files: groupFiles, input, completeFn});
lastResults = results;
trail.iterations.push({
author: group.personaId ?? 'neutral',
revision,
paths: groupFiles.map((f) => f.path),
reviews: results.map((r) => ({
persona: r.persona,
verdict: r.verdict,
summary: r.summary,
feedback: r.feedback,
})),
});
const errors = results.filter((r) => r.verdict === 'ERROR');
if (errors.length) {
throw new Error(
`pre-open review failed (${errors.map((r) => r.persona).join(', ')}): ${errors.map((r) => r.summary).join('; ')}`,
);
}
const blocking = results.filter((r) => r.verdict === 'REQUEST_CHANGES');
if (blocking.length === 0 || revision >= MAX_REVISIONS) {
if (blocking.length) {
trail.openConcerns.push(
...blocking.map((r) => ({
persona: r.persona,
summary: r.summary,
feedback: r.feedback,
})),
);
}
break;
}
revision += 1;
const revised = await authorPass({
personaId: group.personaId,
groupPaths: group.paths,
brief,
input,
revisionFeedback: blocking,
basePages: groupFiles,
completeFn,
});
const revisedMissing = missingGroupTargets(revised, group.paths);
if (revisedMissing.length) {
// Incomplete revision must not replace a complete prior pass.
console.error(
`[author-loop] revision omitted targets (${revisedMissing.join(', ')}); keeping prior files`,
);
trail.openConcerns.push({
persona: group.personaId ?? 'neutral',
summary: `Revision omitted required targets: ${revisedMissing.join(', ')}`,
feedback: [],
});
break;
}
try {
assertVersionAnchors(revised, {milestoneVersion: input.milestoneVersion});
assertSafeMdx(revised);
} catch (e) {
// Dropped anchor / unsafe MDX must not replace a complete prior pass.
console.error(
`[author-loop] revision failed validation (${e.message}); keeping prior files`,
);
trail.openConcerns.push({
persona: group.personaId ?? 'neutral',
summary: `Revision rejected: ${e.message}`,
feedback: [],
});
break;
}
groupFiles = revised;
}
files = mergeFiles(files, groupFiles);
console.error(
`[author-loop] ${group.personaId ?? 'neutral'}: ${groupFiles.map((f) => f.path).join(', ')} ` +
`(${lastResults.map((r) => `${r.persona}:${r.verdict}`).join(', ')})`,
);
}
return {files, trail};
}
export function renderPrBody({sourcePr, trail, brief}) {
const lines = [
'<!-- docs-ai-draft:v1 -->',
`<!-- docs-ai-source-pr:${sourcePr} -->`,
'',
`AI-drafted documentation for #${sourcePr}.`,
'',
'Authored by audience personas from the gap analysis sticky comment, then reviewed',
'in-loop before this PR opened. Treat as a draft pending human editorial review.',
'',
'## Gap brief',
'',
brief.summary || '_No summary recovered from the sticky comment._',
'',
];
if (brief.actions?.length) {
lines.push('### Recommended actions', '', ...brief.actions.map((a) => `- [ ] ${a}`), '');
}
lines.push('## Pre-open review trail', '');
for (const iter of trail.iterations) {
lines.push(
`### Author \`${iter.author}\` — revision ${iter.revision}`,
'',
`Paths: ${iter.paths.map((p) => `\`${p}\``).join(', ')}`,
'',
);
for (const r of iter.reviews) {
lines.push(`- **${r.persona}** — \`${r.verdict}\`: ${r.summary}`);
for (const f of r.feedback ?? []) lines.push(` - ${f}`);
}
lines.push('');
}
if (trail.openConcerns?.length) {
lines.push('## Open concerns after revision cap', '');
for (const c of trail.openConcerns) {
lines.push(`- **${c.persona}**: ${c.summary}`);
for (const f of c.feedback ?? []) lines.push(` - ${f}`);
}
lines.push('');
}
lines.push(`Closes the docs gap filed against #${sourcePr}.`);
return `${lines.join('\n')}\n`;
}
+107
View File
@@ -0,0 +1,107 @@
import {mkdirSync, writeFileSync} from 'node:fs';
import {dirname} from 'node:path';
import {resolveAllowed} from './paths.mjs';
// Outer fence length must match so nested ``` code samples do not truncate the page.
const BLOCK_RE = /^(`{3,})(?:\w+)?[ \t]+path=([^\s`]+)[ \t]*\n([\s\S]*?)\n\1[ \t]*$/gm;
export const VERSION_ANCHOR_RE = /From Mattermost v\d+\.\d+/;
export const HUMAN_JUDGMENT_MARKER = '[NOT PRESENT — REQUIRES HUMAN JUDGMENT]';
/** Escape hatch when the release is unknown — keeps the "From Mattermost …" frame. */
export const HUMAN_JUDGMENT_VERSION_ANCHOR = `From Mattermost ${HUMAN_JUDGMENT_MARKER}`;
// Line-start import; capture module. Optional "X from" covers `import X from 'm'` and `import 'm'`.
const MDX_IMPORT_RE = /^import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]\s*;?\s*$/;
// Word boundary so `export{…}` / `export*` match, not only `export `.
const MDX_EXPORT_RE = /^export\b/;
/** Import sources AI-authored MDX may use. Everything else is rejected before write. */
const SAFE_IMPORT_RE =
/^(?:@theme\/|@docusaurus\/|\.\.?\/).+$/;
/** Drop a trailing // comment so `import X from 'y'; // note` is still checked. */
function stripLineComment(line) {
// Require whitespace before // so https:// inside a string is left alone.
return line.replace(/\s+\/\/.*$/, '');
}
export function parseFileBlocks(text) {
const blocks = [];
let m;
const re = new RegExp(BLOCK_RE);
while ((m = re.exec(text)) !== null) {
blocks.push({path: m[2].trim(), content: m[3]});
}
return blocks;
}
export function versionFromMilestone(title) {
if (!title) return null;
const m = String(title).match(/v(\d+\.\d+)/i);
return m ? `v${m[1]}` : null;
}
export function hasVersionAnchor(content, version) {
if (content.includes(HUMAN_JUDGMENT_VERSION_ANCHOR)) return true;
if (version) return content.includes(`From Mattermost ${version}`);
return VERSION_ANCHOR_RE.test(content);
}
// Reject pages that document capability without an anchor when a milestone was supplied.
export function assertVersionAnchors(files, {milestoneVersion, required = true} = {}) {
if (!required || !milestoneVersion) return;
for (const f of files) {
if (!hasVersionAnchor(f.content, milestoneVersion)) {
throw new Error(
`${f.path}: missing version anchor (expected "From Mattermost ${milestoneVersion}" or "${HUMAN_JUDGMENT_VERSION_ANCHOR}")`,
);
}
}
}
/** Drop fenced code samples so prose gates do not false-positive on examples. */
function withoutFencedCode(content) {
return String(content).replace(/^(`{3,}).*?\n[\s\S]*?\n\1[ \t]*$/gm, '');
}
/**
* Structural MDX gate before write: known import sources only, no export.
* Line-based on fenced-stripped content (no MDX parser dependency). House-style
* JSX is allowed; model protections + docs CI + human review cover the rest.
*/
export function assertSafeMdx(files) {
for (const f of files) {
const content = withoutFencedCode(f.content ?? '');
for (const rawLine of content.split('\n')) {
const line = stripLineComment(rawLine).trimEnd();
if (!line) continue;
if (MDX_EXPORT_RE.test(line)) {
throw new Error(`${f.path}: MDX export statements are not allowed in AI drafts`);
}
const m = line.match(MDX_IMPORT_RE);
if (m && !SAFE_IMPORT_RE.test(m[1])) {
throw new Error(`${f.path}: disallowed MDX import source "${m[1]}"`);
}
}
}
}
export function writeFiles(repoRoot, files) {
assertSafeMdx(files);
const written = [];
for (const f of files) {
const {abs, rel} = resolveAllowed(repoRoot, f.path);
mkdirSync(dirname(abs), {recursive: true});
const body = f.content.endsWith('\n') ? f.content : `${f.content}\n`;
writeFileSync(abs, body);
written.push({path: rel, bytes: body.length});
console.error(`[writer] wrote ${rel} (${body.length} chars)`);
}
return written;
}
export function mergeFiles(existing, incoming) {
const byPath = new Map(existing.map((f) => [f.path, f]));
for (const f of incoming) byPath.set(f.path, f);
return [...byPath.values()];
}
@@ -0,0 +1,73 @@
/*
* Recover a writer brief from the gap sticky comment.
* Prefer machine-readable fields when present; otherwise parse the rendered markdown.
*/
import {MARKER, parseState} from '../gap/gap.mjs';
import {extractDocsPaths, isAllowedPath} from './paths.mjs';
const ACTIONS_RE = /^\- \[ \] (.+)$/gm;
export function parseGapBrief(body) {
if (!body || !body.includes(MARKER)) return null;
const state = parseState(body);
const actions = [];
let m;
const actionRe = new RegExp(ACTIONS_RE.source, 'gm');
while ((m = actionRe.exec(body)) !== null) {
actions.push(m[1].trim());
}
const paths = [
...extractDocsPaths(actions.join('\n')),
...extractDocsPaths(body),
];
// Summary sits between the blank line after the verdict and the next section.
let summary = '';
const summaryMatch = body.match(
/\*\*[^*]+\.\*\*[^\n]*\n\n([\s\S]*?)(?=\n<details>|\n\*\*Recommended actions\*\*|\n---\n)/,
);
if (summaryMatch) summary = summaryMatch[1].replace(/\s+/g, ' ').trim();
return {
state,
assessment: state?.verdict ?? null,
summary,
actions,
targetPaths: [...new Set(paths)],
};
}
export function briefFromGapResult(result) {
const actions = result.actions ?? [];
const fromImpacts = (result.impacts ?? [])
.map((r) => r.docsLocation || r.docs_location)
.filter((p) => typeof p === 'string')
.map((p) => p.trim().replace(/^\.\//, ''))
.filter((p) => /\.(?:md|mdx)$/.test(p) && isAllowedPath(p));
return {
state: null,
assessment: result.assessment,
summary: result.summary ?? '',
actions,
targetPaths: [...new Set([...extractDocsPaths(actions.join('\n')), ...fromImpacts])],
};
}
// Human-applied Docs/Needed (or a missing sticky) — draft from PR evidence alone.
export function briefFromPrEvidence({prTitle, prBody} = {}) {
const blob = [prTitle, prBody].filter(Boolean).join('\n');
return {
state: null,
assessment: 'required',
summary:
(prTitle && String(prTitle).trim()) ||
'Docs/Needed was present without a docs-gap sticky comment; draft from the merged PR evidence.',
actions: [
'Draft documentation for the merged change using the PR title, description, and code diff as evidence.',
],
targetPaths: extractDocsPaths(blob),
};
}
+48
View File
@@ -0,0 +1,48 @@
/*
* Path allow/deny for AI-authored content. Deny wins.
* docs/api/reference/ is OpenAPI-generated and must never be hand-authored.
*/
import {isAbsolute, relative, resolve} from 'node:path';
export const ALLOW_PREFIXES = ['docs/main/', 'docs/develop/'];
export const ALLOW_EXACT = ['docs/api/examples.mdx', 'docs/api/index.mdx'];
export const DENY_PREFIXES = [
'docs/site/',
'docs/styles/',
'docs/pdf/',
'docs/vendor/',
'docs/api/reference/',
'docs/main/agents/docs/',
];
// Paths the gap prompt and sticky comment are allowed to name.
export const DOCS_PATH_RE =
/docs\/(?:main|develop)\/[A-Za-z0-9_./-]+\.(?:mdx|md)|docs\/api\/(?:examples|index)\.mdx/g;
export function extractDocsPaths(text) {
if (!text) return [];
const found = String(text).match(DOCS_PATH_RE) ?? [];
// Deny list (e.g. docs/main/agents/docs/) can still match DOCS_PATH_RE.
return [...new Set(found)].filter(isAllowedPath);
}
export function isAllowedPath(relPath) {
const rel = relPath.replace(/^\.\//, '');
if (rel.startsWith('/') || rel.includes('\0') || rel.includes('..')) return false;
if (DENY_PREFIXES.some((p) => rel.startsWith(p))) return false;
if (ALLOW_EXACT.includes(rel)) return true;
return ALLOW_PREFIXES.some((p) => rel.startsWith(p));
}
export function resolveAllowed(repoRoot, relPath) {
const abs = resolve(repoRoot, relPath);
const rel = relative(repoRoot, abs);
if (rel.startsWith('..') || isAbsolute(rel)) {
throw new Error(`refusing to write outside repo root: ${relPath}`);
}
if (!isAllowedPath(rel)) {
throw new Error(`refusing to write outside content allowlist: ${rel}`);
}
return {abs, rel};
}
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
/*
* Merged-PR writer entry point.
*
* node writer/run.mjs
*
* Env:
* GITHUB_TOKEN, GITHUB_REPOSITORY, PR_NUMBER
* REPO_ROOT (default cwd)
* Optional: PR_TITLE, PR_BODY_FILE, CODE_DIFF_FILE, MILESTONE_TITLE,
* GAP_COMMENT_FILE (skip GitHub sticky fetch), DRY_RUN=1
*
* Writes docs files into REPO_ROOT and emits:
* .docs-ai/writer/trail.json
* .docs-ai/writer/pr-body.md
* .docs-ai/writer/written.json
*/
import {mkdirSync, readFileSync, writeFileSync, existsSync} from 'node:fs';
import {join} from 'node:path';
import {findStickyComment} from '../lib/github.mjs';
import {MARKER} from '../gap/gap.mjs';
import {REPO_ROOT as DEFAULT_ROOT} from '../lib/personas.mjs';
import {authorLoop, renderPrBody} from './author-loop.mjs';
import {versionFromMilestone, writeFiles} from './files.mjs';
import {briefFromGapResult, briefFromPrEvidence, parseGapBrief} from './gap-brief.mjs';
function arg(name) {
const i = process.argv.indexOf(`--${name}`);
return i === -1 ? null : process.argv[i + 1];
}
function readPrEvidence() {
const prBodyFile = process.env.PR_BODY_FILE;
return {
prTitle: process.env.PR_TITLE || '',
prBody: prBodyFile && existsSync(prBodyFile) ? readFileSync(prBodyFile, 'utf8') : '',
};
}
async function loadBrief({repo, pr}) {
const file = arg('brief-file') || process.env.GAP_COMMENT_FILE;
if (file) {
const raw = readFileSync(file, 'utf8');
// Accept either a sticky comment body or a clampResult JSON object.
if (raw.trimStart().startsWith('{')) {
return briefFromGapResult(JSON.parse(raw));
}
const brief = parseGapBrief(raw);
if (!brief) throw new Error(`no gap sticky marker in ${file}`);
return brief;
}
const sticky = await findStickyComment(repo, pr, {marker: MARKER});
if (!sticky?.body) {
console.error(
`[writer] no docs-gap sticky on #${pr}; falling back to PR title/body/diff as the brief`,
);
return briefFromPrEvidence(readPrEvidence());
}
const brief = parseGapBrief(sticky.body);
if (!brief) {
console.error(`[writer] gap sticky on #${pr} unparseable; falling back to PR evidence`);
return briefFromPrEvidence(readPrEvidence());
}
if (!brief.actions.length && !brief.targetPaths.length) {
console.error(`[writer] gap sticky on #${pr} has no actions/paths; falling back to PR evidence`);
return briefFromPrEvidence(readPrEvidence());
}
return brief;
}
async function main() {
const repo = process.env.GITHUB_REPOSITORY;
const pr = process.env.PR_NUMBER || arg('pr');
const repoRoot = process.env.REPO_ROOT || DEFAULT_ROOT;
const outDir = join(repoRoot, '.docs-ai', 'writer');
mkdirSync(outDir, {recursive: true});
if (!repo || !pr) throw new Error('GITHUB_REPOSITORY and PR_NUMBER are required');
const milestoneTitle = process.env.MILESTONE_TITLE || '';
const milestoneVersion = versionFromMilestone(milestoneTitle);
if (!milestoneVersion) {
throw new Error('milestone title must contain vMAJOR.MINOR (version anchor source)');
}
const brief = await loadBrief({repo, pr});
console.error(
`[writer] assessment=${brief.assessment} actions=${brief.actions.length} paths=${brief.targetPaths.join(', ') || '(none)'}`,
);
const prBodyFile = process.env.PR_BODY_FILE;
const codeDiffFile = process.env.CODE_DIFF_FILE;
const input = {
prTitle: process.env.PR_TITLE || '',
prBody: prBodyFile && existsSync(prBodyFile) ? readFileSync(prBodyFile, 'utf8') : '',
codeDiff: codeDiffFile && existsSync(codeDiffFile) ? readFileSync(codeDiffFile, 'utf8') : '',
milestoneTitle,
milestoneVersion,
prUrl: `https://github.com/${repo}/pull/${pr}`,
repoRoot,
};
const {files, trail} = await authorLoop({brief, input});
writeFileSync(join(outDir, 'trail.json'), `${JSON.stringify(trail, null, 2)}\n`);
const body = renderPrBody({sourcePr: pr, trail, brief});
writeFileSync(join(outDir, 'pr-body.md'), body);
if (process.env.DRY_RUN === '1') {
writeFileSync(join(outDir, 'files.json'), `${JSON.stringify(files, null, 2)}\n`);
console.error(`[writer] dry-run: ${files.length} file(s) not written`);
return;
}
const written = writeFiles(repoRoot, files);
writeFileSync(join(outDir, 'written.json'), `${JSON.stringify(written, null, 2)}\n`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env node
/*
* When a docs/pr-<n> draft merges, flip Docs/Needed → Docs/Done on the source PR.
*
* node writer/sync.mjs
*
* Env:
* GITHUB_TOKEN, GITHUB_REPOSITORY
* HEAD_REPO, PR_USER — trusted pull_request event fields (provenance)
* DOCS_AI_BOT_LOGIN — exact App bot login that opened the draft
* SOURCE_PR or HEAD_REF / PR_BODY_FILE — resolve which source PR to update
*/
import {readFileSync, existsSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
import {addLabel, removeLabel} from '../lib/github.mjs';
const NEEDED = 'Docs/Needed';
const DONE = 'Docs/Done';
const BRANCH_RE = /^docs\/pr-(\d+)$/;
const BODY_RE = /<!--\s*docs-ai-source-pr:(\d+)\s*-->/;
export function sourcePrFrom({headRef, body, explicit}) {
if (explicit) return String(explicit);
// Prefer branch name: body markers are easy to edit; head.ref is event metadata.
const fromBranch = headRef?.match(BRANCH_RE);
if (fromBranch) return fromBranch[1];
const fromBody = body?.match(BODY_RE);
if (fromBody) return fromBody[1];
return null;
}
/** Provenance from trusted event metadata only — not branch name, body, or labels. */
export function assertWriterProvenance({repo, headRepo, prUser, botLogin}) {
if (!repo) throw new Error('GITHUB_REPOSITORY is required');
if (!headRepo || headRepo !== repo) {
throw new Error(`refusing sync: head repo "${headRepo || '(missing)'}" is not ${repo}`);
}
if (!botLogin) {
throw new Error('DOCS_AI_BOT_LOGIN is required to verify writer provenance');
}
if (!prUser || prUser !== botLogin) {
throw new Error(`refusing sync: PR author "${prUser || '(missing)'}" is not ${botLogin}`);
}
}
async function main() {
const repo = process.env.GITHUB_REPOSITORY;
assertWriterProvenance({
repo,
headRepo: process.env.HEAD_REPO,
prUser: process.env.PR_USER,
botLogin: process.env.DOCS_AI_BOT_LOGIN,
});
const bodyFile = process.env.PR_BODY_FILE;
const body = bodyFile && existsSync(bodyFile) ? readFileSync(bodyFile, 'utf8') : process.env.PR_BODY || '';
const source = sourcePrFrom({
headRef: process.env.HEAD_REF,
body,
explicit: process.env.SOURCE_PR,
});
if (!source) {
throw new Error('could not resolve source PR from HEAD_REF, body marker, or SOURCE_PR');
}
console.error(`[sync] #${source}: ${NEEDED} → ${DONE}`);
await removeLabel(repo, source, NEEDED);
await addLabel(repo, source, DONE);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((e) => {
console.error(e);
process.exit(1);
});
}
@@ -0,0 +1,487 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {additionsDiff} from '../lib/diff.mjs';
import {authorForPath, groupPathsByAuthor, REPO_ROOT} from '../lib/personas.mjs';
import {
assertSafeMdx,
assertVersionAnchors,
hasVersionAnchor,
parseFileBlocks,
versionFromMilestone,
} from './files.mjs';
import {briefFromGapResult, briefFromPrEvidence, parseGapBrief} from './gap-brief.mjs';
import {DENY_PREFIXES, extractDocsPaths, isAllowedPath} from './paths.mjs';
import {sourcePrFrom, assertWriterProvenance} from './sync.mjs';
import {
assertPagesFitPrompt,
groupPathAllowlist,
loadExistingPages,
MAX_PAGE_PROMPT_CHARS,
missingGroupTargets,
normalizeDocsPath,
parseMaxRevisions,
authorLoop,
} from './author-loop.mjs';
import {MARKER, buildComment, decide} from '../gap/gap.mjs';
test('allowlist accepts hand-authored content roots', () => {
assert.equal(isAllowedPath('docs/main/administration-guide/foo.mdx'), true);
assert.equal(isAllowedPath('docs/develop/contribute/bar.mdx'), true);
assert.equal(isAllowedPath('docs/api/examples.mdx'), true);
assert.equal(isAllowedPath('docs/api/index.mdx'), true);
});
test('deny list wins over allow prefixes', () => {
for (const prefix of DENY_PREFIXES) {
assert.equal(isAllowedPath(`${prefix}x.mdx`), false, prefix);
}
assert.equal(isAllowedPath('docs/api/reference/users.mdx'), false);
assert.equal(isAllowedPath('docs/site/docusaurus.config.js'), false);
assert.equal(isAllowedPath('../etc/passwd'), false);
});
test('extractDocsPaths pulls exact content paths from prose', () => {
const text =
'Update docs/main/administration-guide/configure/x.mdx and docs/api/examples.mdx; ignore docs/site/foo.';
assert.deepEqual(extractDocsPaths(text).sort(), [
'docs/api/examples.mdx',
'docs/main/administration-guide/configure/x.mdx',
]);
});
test('extractDocsPaths drops deny-listed paths that match the path regex', () => {
const text =
'See docs/main/agents/docs/foo.mdx and docs/main/administration-guide/configure/x.mdx';
assert.deepEqual(extractDocsPaths(text), ['docs/main/administration-guide/configure/x.mdx']);
});
test('versionFromMilestone reads vMAJOR.MINOR', () => {
assert.equal(versionFromMilestone('v11.7.0'), 'v11.7');
assert.equal(versionFromMilestone('Mattermost v10.12'), 'v10.12');
assert.equal(versionFromMilestone('no version here'), null);
});
test('hasVersionAnchor accepts the convention or the framed human marker', () => {
assert.equal(hasVersionAnchor('From Mattermost v11.7, users can…'), true);
assert.equal(
hasVersionAnchor('From Mattermost [NOT PRESENT — REQUIRES HUMAN JUDGMENT], users can…'),
true,
);
assert.equal(hasVersionAnchor('See [NOT PRESENT — REQUIRES HUMAN JUDGMENT].'), false);
assert.equal(hasVersionAnchor('No version at all.'), false);
});
test('hasVersionAnchor requires the milestone version when supplied', () => {
assert.equal(hasVersionAnchor('From Mattermost v9.5, legacy…', 'v11.7'), false);
assert.equal(hasVersionAnchor('From Mattermost v11.7, new…', 'v11.7'), true);
assert.equal(
hasVersionAnchor('From Mattermost [NOT PRESENT — REQUIRES HUMAN JUDGMENT]', 'v11.7'),
true,
);
assert.equal(hasVersionAnchor('[NOT PRESENT — REQUIRES HUMAN JUDGMENT]', 'v11.7'), false);
});
test('assertVersionAnchors rejects capability pages without an anchor', () => {
assert.throws(
() =>
assertVersionAnchors(
[{path: 'docs/main/x.mdx', content: '---\ntitle: X\n---\n\nHello.\n'}],
{milestoneVersion: 'v11.7'},
),
/missing version anchor/,
);
});
test('assertVersionAnchors rejects a stale anchor that is not the milestone', () => {
assert.throws(
() =>
assertVersionAnchors(
[{path: 'docs/main/x.mdx', content: '---\ntitle: X\n---\n\nFrom Mattermost v9.5, …\n'}],
{milestoneVersion: 'v11.7'},
),
/missing version anchor/,
);
});
test('parseFileBlocks reads path= fences', () => {
const text = [
'```mdx path=docs/main/end-user-guide/a.mdx',
'---',
'title: A',
'---',
'',
'From Mattermost v11.7, a.',
'```',
'',
'```mdx path=docs/main/administration-guide/b.mdx',
'---',
'title: B',
'---',
'',
'From Mattermost v11.7, b.',
'```',
].join('\n');
const files = parseFileBlocks(text);
assert.equal(files.length, 2);
assert.equal(files[0].path, 'docs/main/end-user-guide/a.mdx');
assert.match(files[1].content, /title: B/);
});
test('parseFileBlocks preserves nested three-backtick samples inside a longer fence', () => {
const text = [
'````mdx path=docs/main/administration-guide/example.mdx',
'---',
'title: Example',
'---',
'',
'From Mattermost v11.7, run:',
'',
'```bash',
'mmctl config get ServiceSettings.SiteURL',
'```',
'',
'Done.',
'````',
].join('\n');
const files = parseFileBlocks(text);
assert.equal(files.length, 1);
assert.equal(files[0].path, 'docs/main/administration-guide/example.mdx');
assert.match(files[0].content, /```bash/);
assert.match(files[0].content, /mmctl config get/);
assert.match(files[0].content, /Done\./);
});
test('additionsDiff synthesises a reviewable unified diff', () => {
const diff = additionsDiff([
{path: 'docs/main/x.mdx', content: '---\ntitle: X\n---\n\nBody\n'},
]);
assert.match(diff, /diff --git a\/docs\/main\/x\.mdx/);
assert.match(diff, /\+---/);
assert.match(diff, /\+Body/);
});
test('authorForPath matches an exact docs_paths prefix and paths under it', () => {
assert.equal(authorForPath('docs/main/administration-guide')?.id, 'system-admin');
assert.equal(authorForPath('docs/main/administration-guide/configure/x.mdx')?.id, 'system-admin');
assert.equal(authorForPath('docs/main/end-user-guide/collaborate/y.mdx')?.id, 'end-user');
assert.equal(authorForPath('docs/develop/api/foo.mdx')?.id, 'developer-dx');
});
test('authorForPath prefers the longest matching docs_paths prefix', () => {
// security-compliance owns administration-guide/comply; system-admin owns administration-guide.
assert.equal(
authorForPath('docs/main/administration-guide/comply/compliance-export.mdx')?.id,
'security-compliance',
);
assert.equal(
authorForPath('docs/main/administration-guide/onboard/ad-ldap.mdx')?.id,
'security-compliance',
);
});
test('authorForPath returns null when nothing matches', () => {
assert.equal(authorForPath('docs/main/unknown-section/x.mdx'), null);
assert.equal(authorForPath('server/channels/app/foo.go'), null);
});
test('groupPathsByAuthor batches by persona and keeps unmatched paths neutral', () => {
const groups = groupPathsByAuthor([
'docs/main/administration-guide/a.mdx',
'docs/main/deployment-guide/b.mdx',
'docs/main/end-user-guide/c.mdx',
'docs/main/orphan/page.mdx',
]);
const byId = Object.fromEntries(groups.map((g) => [g.personaId, g.paths]));
assert.deepEqual(byId['system-admin'], [
'docs/main/administration-guide/a.mdx',
'docs/main/deployment-guide/b.mdx',
]);
assert.deepEqual(byId['end-user'], ['docs/main/end-user-guide/c.mdx']);
assert.deepEqual(byId[null], ['docs/main/orphan/page.mdx']);
});
test('parseGapBrief recovers actions and paths from a sticky comment', () => {
const comment = buildComment({
result: {
assessment: 'required',
summary: 'Adds EnableFoo.',
confidence: 'high',
impacts: [
{
change: 'Config',
files: 'config.go',
audiences: 'Admin',
action: 'Document',
docsLocation: 'docs/main/administration-guide/configure/foo.mdx',
},
],
actions: ['Document EnableFoo in docs/main/administration-guide/configure/foo.mdx'],
},
decision: decide({assessment: 'required', labels: [], priorState: null}),
sha: 'abc1234',
runUrl: 'https://example.test/run',
});
assert.match(comment, new RegExp(MARKER));
const brief = parseGapBrief(comment);
assert.equal(brief.assessment, 'required');
assert.equal(brief.actions.length, 1);
assert.ok(brief.targetPaths.includes('docs/main/administration-guide/configure/foo.mdx'));
assert.match(brief.summary, /EnableFoo/);
});
test('briefFromGapResult uses impact docs_location', () => {
const brief = briefFromGapResult({
assessment: 'recommended',
summary: 's',
actions: [],
impacts: [{docs_location: 'docs/main/security-guide/x.mdx'}],
});
assert.deepEqual(brief.targetPaths, ['docs/main/security-guide/x.mdx']);
});
test('briefFromGapResult drops directory, denied, and non-string impact locations', () => {
const brief = briefFromGapResult({
assessment: 'required',
summary: 's',
actions: [],
impacts: [
{docs_location: 'docs/main/administration-guide/'},
{docs_location: 'docs/site/foo.mdx'},
{docs_location: 'docs/api/reference/openapi.mdx'},
{docsLocation: 42},
{docs_location: ' ./docs/main/administration-guide/configure/foo.mdx '},
{docs_location: null},
],
});
assert.deepEqual(brief.targetPaths, ['docs/main/administration-guide/configure/foo.mdx']);
});
test('parseMaxRevisions accepts non-negative integers and rejects malformed values', () => {
assert.equal(parseMaxRevisions(undefined), 2);
assert.equal(parseMaxRevisions(''), 2);
assert.equal(parseMaxRevisions('two'), 2);
assert.equal(parseMaxRevisions('2.5'), 2);
assert.equal(parseMaxRevisions('2abc'), 2);
assert.equal(parseMaxRevisions('0'), 0);
assert.equal(parseMaxRevisions('3'), 3);
assert.equal(parseMaxRevisions('-1'), 2);
assert.equal(parseMaxRevisions(null, 5), 5);
});
test('briefFromPrEvidence drafts from title/body when no sticky exists', () => {
const brief = briefFromPrEvidence({
prTitle: 'Add EnableFoo',
prBody: 'See docs/main/administration-guide/configure/foo.mdx',
});
assert.equal(brief.assessment, 'required');
assert.equal(brief.actions.length, 1);
assert.ok(brief.targetPaths.includes('docs/main/administration-guide/configure/foo.mdx'));
assert.match(brief.summary, /EnableFoo/);
});
test('sourcePrFrom reads branch and body marker', () => {
assert.equal(sourcePrFrom({headRef: 'docs/pr-38177'}), '38177');
assert.equal(sourcePrFrom({body: '<!-- docs-ai-source-pr:99 -->\nhello'}), '99');
assert.equal(sourcePrFrom({explicit: '12', headRef: 'docs/pr-99'}), '12');
assert.equal(sourcePrFrom({headRef: 'feature/x'}), null);
});
test('sourcePrFrom prefers branch over editable body marker', () => {
assert.equal(
sourcePrFrom({
headRef: 'docs/pr-38177',
body: '<!-- docs-ai-source-pr:99 -->\nforged',
}),
'38177',
);
});
test('assertSafeMdx allows theme imports and house JSX; rejects odd imports and exports', () => {
assert.doesNotThrow(() =>
assertSafeMdx([
{
path: 'docs/main/x.mdx',
content: [
'---',
'title: X',
'---',
'',
"import Tabs from '@theme/Tabs';",
"import Help from './help.mdx';",
'',
'<PlanAvailability slug="ent-adv" />',
'',
'From Mattermost v11.7, …',
'',
'```js',
'export default function demo() {}',
'import fs from "fs";',
'```',
'',
].join('\n'),
},
]),
);
assert.throws(
() =>
assertSafeMdx([
{
path: 'docs/main/x.mdx',
content: "import fs from 'fs';\n\nHello.\n",
},
]),
/disallowed MDX import source/,
);
assert.throws(
() =>
assertSafeMdx([
{
path: 'docs/main/x.mdx',
content: "export const meta = {};\n\nHello.\n",
},
]),
/export statements/,
);
assert.throws(
() =>
assertSafeMdx([
{
path: 'docs/main/x.mdx',
content: "export{meta};\n\nHello.\n",
},
]),
/export statements/,
);
assert.throws(
() =>
assertSafeMdx([
{
path: 'docs/main/x.mdx',
content: "import fs from 'fs'; // ignored\n\nHello.\n",
},
]),
/disallowed MDX import source/,
);
});
test('assertPagesFitPrompt rejects oversized pages', () => {
assert.throws(
() =>
assertPagesFitPrompt([{path: 'docs/main/huge.mdx', content: 'x'.repeat(MAX_PAGE_PROMPT_CHARS + 1)}]),
/refusing to author/,
);
assert.doesNotThrow(() =>
assertPagesFitPrompt([{path: 'docs/main/ok.mdx', content: 'x'.repeat(100)}]),
);
});
test('assertWriterProvenance requires same-repo head and exact bot author', () => {
assert.throws(
() => assertWriterProvenance({repo: 'o/r', headRepo: 'fork/r', prUser: 'bot[bot]', botLogin: 'bot[bot]'}),
/head repo/,
);
assert.throws(
() => assertWriterProvenance({repo: 'o/r', headRepo: 'o/r', prUser: 'human', botLogin: 'bot[bot]'}),
/PR author/,
);
assert.throws(
() => assertWriterProvenance({repo: 'o/r', headRepo: 'o/r', prUser: 'bot[bot]', botLogin: ''}),
/DOCS_AI_BOT_LOGIN/,
);
assert.doesNotThrow(() =>
assertWriterProvenance({repo: 'o/r', headRepo: 'o/r', prUser: 'bot[bot]', botLogin: 'bot[bot]'}),
);
});
test('groupPathAllowlist keeps only concrete docs/ targets', () => {
const allow = groupPathAllowlist([
'docs/main/administration-guide/a.mdx',
'./docs/main/b.mdx/',
'(unspecified — derive from the gap brief)',
]);
assert.equal(allow.size, 2);
assert.ok(allow.has('docs/main/administration-guide/a.mdx'));
assert.ok(allow.has('docs/main/b.mdx'));
assert.equal(normalizeDocsPath('./docs/main/x.mdx'), 'docs/main/x.mdx');
});
test('loadExistingPages reads allowed on-disk targets only', () => {
const pages = loadExistingPages(REPO_ROOT, [
'docs/api/index.mdx',
'docs/main/agents/docs/missing.mdx',
'docs/main/does-not-exist-ai-writer-test.mdx',
]);
assert.deepEqual(
pages.map((p) => p.path),
['docs/api/index.mdx'],
);
assert.ok(pages[0].content.length > 0);
});
test('missingGroupTargets requires every concrete target to be authored', () => {
const targets = [
'docs/main/administration-guide/a.mdx',
'docs/main/administration-guide/b.mdx',
];
assert.deepEqual(
missingGroupTargets([{path: 'docs/main/administration-guide/a.mdx', content: 'x'}], targets),
['docs/main/administration-guide/b.mdx'],
);
assert.deepEqual(
missingGroupTargets(
[
{path: 'docs/main/administration-guide/a.mdx', content: 'x'},
{path: 'docs/main/administration-guide/b.mdx', content: 'y'},
],
targets,
),
[],
);
assert.deepEqual(missingGroupTargets([{path: 'docs/main/a.mdx', content: 'x'}], ['(unspecified)']), []);
});
test('authorLoop keeps prior files when a revision drops the version anchor', async () => {
const path = 'docs/main/administration-guide/configure/foo.mdx';
const fileBlock = (body) =>
['````mdx path=' + path, '---', 'title: Foo', '---', '', body, '````'].join('\n');
let authorCalls = 0;
let sawCurrentDraft = false;
const completeFn = async ({userPrompt}) => {
const prompt = String(userPrompt || '');
if (prompt.includes('Which reviewers apply?')) {
return {text: '{"personas": ["system-admin"]}', usage: {}};
}
if (prompt.includes('Review the diff above')) {
return {
text: JSON.stringify({
verdict: 'REQUEST_CHANGES',
summary: 'Needs a clearer lead.',
feedback: ['Clarify the lead sentence.'],
}),
usage: {},
};
}
authorCalls += 1;
if (prompt.includes('Revise the pages')) {
if (prompt.includes('current-draft path=' + path) && prompt.includes('From Mattermost v11.7')) {
sawCurrentDraft = true;
}
return {text: fileBlock('Foo is available.'), usage: {}};
}
return {text: fileBlock('From Mattermost v11.7, Foo is available.'), usage: {}};
};
const {files, trail} = await authorLoop({
brief: {summary: 's', actions: [], targetPaths: [path]},
input: {milestoneVersion: 'v11.7', milestoneTitle: 'v11.7.0', prTitle: 'Add Foo'},
completeFn,
});
assert.equal(authorCalls, 2);
assert.equal(sawCurrentDraft, true);
assert.equal(files.length, 1);
assert.match(files[0].content, /From Mattermost v11\.7/);
assert.ok(trail.openConcerns.some((c) => /Revision rejected/.test(c.summary)));
});
-628
View File
@@ -1,628 +0,0 @@
# .github/workflows/docs-needed.yml
#
# Place this file in ALL of:
# mattermost/mattermost -> .github/workflows/docs-needed.yml
# mattermost/mattermost-mobile -> .github/workflows/docs-needed.yml
# mattermost/desktop -> .github/workflows/docs-needed.yml
#
# Authentication — reuses the two-app pattern from changelog automation:
#
# Read app (vars.CHANGELOG_READ_CLIENT_ID / secrets.CHANGELOG_READ_PRIVATE_KEY)
# Installed on mattermost/mattermost with contents: read.
# Used only to look up milestones (step 3).
#
# Write app (vars.CHANGELOG_WRITE_CLIENT_ID / secrets.CHANGELOG_WRITE_PRIVATE_KEY)
# Installed on mattermost/docs with contents: write + pull-requests: write.
# Used to push the docs branch, open the draft PR, and manage milestones/reviewers.
# changelog-automation-docs[bot] must be on the CLA ignore list in mattermost/docs.
#
# Other required secrets (set in each repo's Settings -> Secrets and Variables):
# secrets.ANTHROPIC_API_KEY - Claude API key for doc generation
# vars.CHANGELOG_GIT_USERNAME - Git display name for automated commits
# vars.CHANGELOG_GIT_EMAIL - Git email for automated commits
#
# Behaviour:
# When Docs/Needed is added to a non-draft PR from within the same repo,
# opens one docs PR in mattermost/docs:
# Branch: docs/<repo-name>-pr-<number>
# e.g. docs/mattermost-pr-1234, docs/desktop-pr-42
# Targets the versioned release branch (e.g. v11.7-documentation) if it
# exists in mattermost/docs; falls back to master with a warning.
# Draft doc generated by Claude using the draft_docs.md prompt.
# Milestone on the docs PR mirrors the dev PR milestone.
# Dev PR author added as reviewer on the docs PR.
# Comment posted on the dev PR linking to the new docs PR.
# If Docs/Needed is re-applied after removal, a fresh run creates or
# updates the same branch (idempotent via the exists check).
name: "docs/needed - Open Docs PR"
on:
pull_request:
types: [labeled]
workflow_dispatch:
inputs:
pr_number:
description: >-
PR number to process. Intended use cases:
(1) smoke-test the workflow after merging without having to apply a label;
(2) re-run after a transient failure (e.g. Claude API timeout, push error)
without bouncing the Docs/Needed label;
(3) validate the workflow against a known PR during development or review.
required: true
type: string
# One run at a time per dev PR prevents a race if the label bounces quickly.
# cancel-in-progress is false because a mid-run cancellation can leave the
# docs branch in a partial state (e.g. commit pushed but PR not yet opened).
# Queuing is safer; the idempotency checks in each step handle duplicates.
concurrency:
group: >-
docs-needed-${{ github.repository }}-${{ github.event.pull_request.number || inputs.pr_number }}
cancel-in-progress: false
jobs:
open-docs-pr:
name: "Open Docs PR"
runs-on: ubuntu-latest
# Fork guard: only run on PRs opened from within this repo, not forks.
# Draft guard: skip draft PRs - apply Docs/Needed when the PR is ready.
# workflow_dispatch bypasses both guards for manual testing.
if: |
github.event_name == 'workflow_dispatch' ||
(github.event.label.name == 'Docs/Needed' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.draft == false)
permissions:
contents: read
pull-requests: write
steps:
# 0. Generate short-lived installation tokens — one per app.
# Tokens expire automatically after 1 hour and are not tied to any
# individual user account.
#
# read-token: scoped to mattermost/mattermost for milestone lookups
# write-token: scoped to mattermost/docs for branch push, PR creation,
# milestone sync, and reviewer assignment
- name: Generate read token
id: read-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.CHANGELOG_READ_CLIENT_ID }}
private-key: ${{ secrets.CHANGELOG_READ_PRIVATE_KEY }}
repositories: mattermost
- name: Generate write token
id: write-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.CHANGELOG_WRITE_CLIENT_ID }}
private-key: ${{ secrets.CHANGELOG_WRITE_PRIVATE_KEY }}
repositories: docs
# 0b. Resolve PR data — normalises pull_request event fields and
# workflow_dispatch inputs into one set of outputs so all subsequent
# steps are trigger-agnostic. For workflow_dispatch the PR is fetched
# by number from this repo using the built-in GITHUB_TOKEN.
- name: Resolve PR data
id: pr-data
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
EVENT_MILESTONE_NUMBER: ${{ github.event.pull_request.milestone.number }}
EVENT_MILESTONE_TITLE: ${{ github.event.pull_request.milestone.title }}
EVENT_MILESTONE_DUE: ${{ github.event.pull_request.milestone.due_on }}
EVENT_PR_TITLE: ${{ github.event.pull_request.title }}
EVENT_PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
DATA=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$INPUT_PR_NUMBER")
echo "pr_number=$INPUT_PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "milestone_number=$(echo "$DATA" | jq -r '.milestone.number // ""')" >> "$GITHUB_OUTPUT"
echo "milestone_title=$(echo "$DATA" | jq -r '.milestone.title // ""')" >> "$GITHUB_OUTPUT"
echo "milestone_due=$(echo "$DATA" | jq -r '.milestone.due_on // ""')" >> "$GITHUB_OUTPUT"
echo "pr_title=$(echo "$DATA" | jq -r '.title')" >> "$GITHUB_OUTPUT"
echo "pr_author=$(echo "$DATA" | jq -r '.user.login')" >> "$GITHUB_OUTPUT"
else
echo "pr_number=$EVENT_PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "milestone_number=$EVENT_MILESTONE_NUMBER" >> "$GITHUB_OUTPUT"
echo "milestone_title=$EVENT_MILESTONE_TITLE" >> "$GITHUB_OUTPUT"
echo "milestone_due=$EVENT_MILESTONE_DUE" >> "$GITHUB_OUTPUT"
echo "pr_title=$EVENT_PR_TITLE" >> "$GITHUB_OUTPUT"
echo "pr_author=$EVENT_PR_AUTHOR" >> "$GITHUB_OUTPUT"
fi
# 1. Guard: milestone is required
- name: Require milestone
env:
MILESTONE: ${{ steps.pr-data.outputs.milestone_number }}
run: |
if [ -z "$MILESTONE" ]; then
echo "::error title=Missing milestone::Set a milestone on this PR before applying Docs/Needed."
exit 1
fi
# 2. Compute shared variables
- name: Set variables
id: vars
env:
MILESTONE_TITLE: ${{ steps.pr-data.outputs.milestone_title }}
MILESTONE_NUMBER: ${{ steps.pr-data.outputs.milestone_number }}
PR_NUMBER: ${{ steps.pr-data.outputs.pr_number }}
PR_TITLE: ${{ steps.pr-data.outputs.pr_title }}
PR_AUTHOR: ${{ steps.pr-data.outputs.pr_author }}
SOURCE_REPO: ${{ github.repository }}
run: |
REPO_NAME="${SOURCE_REPO##*/}"
BRANCH="docs/${REPO_NAME}-pr-${PR_NUMBER}"
DOCS_FILE="source/about/drafts/${REPO_NAME}-pr-${PR_NUMBER}.md"
echo "repo_name=$REPO_NAME" >> "$GITHUB_OUTPUT"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
echo "docs_file=$DOCS_FILE" >> "$GITHUB_OUTPUT"
echo "milestone_title=$MILESTONE_TITLE" >> "$GITHUB_OUTPUT"
echo "milestone_number=$MILESTONE_NUMBER" >> "$GITHUB_OUTPUT"
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "pr_title=$PR_TITLE" >> "$GITHUB_OUTPUT"
echo "pr_author=$PR_AUTHOR" >> "$GITHUB_OUTPUT"
echo "source_repo=$SOURCE_REPO" >> "$GITHUB_OUTPUT"
# 3. Resolve the versioned base branch in mattermost/docs
# mattermost/mattermost milestones (e.g. "v11.7.0") map directly to
# the docs branch (e.g. "v11.7-documentation").
# mattermost-mobile and desktop use different versioning schemes, but
# releases are coordinated with mattermost/mattermost by due date.
# mobile v2.40.0 due 2025-03-15 == mattermost v11.7.0 due 2025-03-15
# => docs branch v11.7-documentation.
- name: Resolve docs base branch
id: base-branch
env:
GH_TOKEN: ${{ steps.read-token.outputs.token }}
MILESTONE_TITLE: ${{ steps.vars.outputs.milestone_title }}
MILESTONE_DUE: ${{ steps.pr-data.outputs.milestone_due }}
REPO_NAME: ${{ steps.vars.outputs.repo_name }}
run: |
derive_branch_from_title() {
local title="$1"
local version
version=$(echo "$title" | grep -oE 'v[0-9]+\.[0-9]+' | head -1)
echo "$version"
}
if [ "$REPO_NAME" == "mattermost" ]; then
VERSION=$(derive_branch_from_title "$MILESTONE_TITLE")
if [ -z "$VERSION" ]; then
echo "::warning::Could not parse vMAJOR.MINOR from milestone" \
"'${MILESTONE_TITLE}' - targeting master."
echo "base=master" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Derived docs branch: ${VERSION}-documentation"
else
if [ -z "$MILESTONE_DUE" ] || [ "$MILESTONE_DUE" == "null" ]; then
echo "::warning::Milestone '${MILESTONE_TITLE}' has no due date" \
"- cannot match to a mattermost/mattermost release. Targeting master."
echo "base=master" >> "$GITHUB_OUTPUT"
exit 0
fi
MILESTONE_DATE="${MILESTONE_DUE%%T*}"
MM_MILESTONE=$(gh api repos/mattermost/mattermost/milestones \
--paginate \
| jq -r --arg date "$MILESTONE_DATE" \
'.[] | select(((.due_on // "")[0:10]) == $date) | .title' \
| head -1)
if [ -z "$MM_MILESTONE" ]; then
echo "::warning::No mattermost/mattermost milestone found with" \
"due date '${MILESTONE_DUE}'. Targeting master."
echo "base=master" >> "$GITHUB_OUTPUT"
exit 0
fi
VERSION=$(derive_branch_from_title "$MM_MILESTONE")
if [ -z "$VERSION" ]; then
echo "::warning::Could not parse vMAJOR.MINOR from matched" \
"milestone '${MM_MILESTONE}'. Targeting master."
echo "base=master" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Matched '${MILESTONE_TITLE}' to '${MM_MILESTONE}'" \
"=> ${VERSION}-documentation"
fi
DOCS_BRANCH="${VERSION}-documentation"
EXISTS=$(gh api "repos/mattermost/docs/branches/${DOCS_BRANCH}" \
--jq '.name' 2>/dev/null || echo "")
if [ -n "$EXISTS" ]; then
echo "base=$DOCS_BRANCH" >> "$GITHUB_OUTPUT"
echo "Docs branch '${DOCS_BRANCH}' found."
else
echo "base=master" >> "$GITHUB_OUTPUT"
echo "::warning::Branch '${DOCS_BRANCH}' not found in" \
"mattermost/docs - targeting master." \
"Ensure docs-branch-create.yml has run for this milestone."
fi
# 4. Check for an existing docs PR (idempotency)
- name: Check for existing docs PR
id: existing
env:
GH_TOKEN: ${{ steps.write-token.outputs.token }}
BRANCH: ${{ steps.vars.outputs.branch }}
run: |
RESULT=$(gh pr list \
--repo mattermost/docs \
--head "$BRANCH" \
--state open \
--json number,url \
--jq 'first // empty')
if [ -n "$RESULT" ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "number=$(echo "$RESULT" | jq -r .number)" >> "$GITHUB_OUTPUT"
echo "url=$(echo "$RESULT" | jq -r .url)" >> "$GITHUB_OUTPUT"
EXISTING_URL=$(echo "$RESULT" | jq -r .url)
echo "::notice::Docs PR already open at ${EXISTING_URL} - updating content only."
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
# 5. Checkout docs repo
# Pinned to a specific commit SHA for supply-chain security.
# To update: find the SHA for the desired tag at
# https://github.com/actions/checkout/releases
- name: Checkout docs repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: mattermost/docs
token: ${{ steps.write-token.outputs.token }}
path: docs-repo
fetch-depth: 1
- name: Configure git identity
working-directory: docs-repo
run: |
git config user.name "${{ vars.CHANGELOG_GIT_USERNAME }}"
git config user.email "${{ vars.CHANGELOG_GIT_EMAIL }}"
- name: Set up branch
working-directory: docs-repo
env:
BRANCH: ${{ steps.vars.outputs.branch }}
BASE_BRANCH: ${{ steps.base-branch.outputs.base }}
run: |
if git ls-remote --exit-code origin "$BRANCH" > /dev/null 2>&1; then
git fetch origin "$BRANCH"
git checkout "$BRANCH"
else
git fetch origin "$BASE_BRANCH"
git checkout -b "$BRANCH" "origin/$BASE_BRANCH"
fi
# 6. Fetch PR content for Claude
- name: Fetch PR diff and description
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.vars.outputs.pr_number }}
SOURCE_REPO: ${{ steps.vars.outputs.source_repo }}
run: |
gh pr diff "$PR_NUMBER" --repo "$SOURCE_REPO" 2>/dev/null \
| head -c 20000 > /tmp/pr-diff.txt || true
gh pr view "$PR_NUMBER" --repo "$SOURCE_REPO" \
--json body --jq '.body // ""' > /tmp/pr-body.txt
# 7. Generate documentation draft via Claude
#
# System prompt is read from mattermost/docs at runtime so it can be
# updated independently of this workflow file.
# File location in mattermost/docs: .github/prompts/draft_docs.md
#
# Raw API call (not anthropics/claude-code-action) because:
# - We supply a custom system prompt read from a file in the docs repo
# - We HTML-escape untrusted PR content before insertion (security)
# - We validate the response structure before writing any file
# claude-code-action is designed for code-execution tasks and does not
# support file-based system prompts or the output validation required
# by the security model here.
#
# Model: claude-sonnet-4-6 balances quality and cost for doc drafting
# and is consistent with other Mattermost docs automation workflows.
- name: Generate documentation draft
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
PR_TITLE: ${{ steps.vars.outputs.pr_title }}
PR_NUMBER: ${{ steps.vars.outputs.pr_number }}
SOURCE_REPO: ${{ steps.vars.outputs.source_repo }}
MILESTONE_TITLE: ${{ steps.vars.outputs.milestone_title }}
run: |
PROMPT_FILE="docs-repo/.github/prompts/draft_docs.md"
if [ ! -f "$PROMPT_FILE" ]; then
echo "::error::Prompt file not found: $PROMPT_FILE"
echo "Add draft_docs.md to mattermost/docs at .github/prompts/draft_docs.md"
exit 1
fi
cp "$PROMPT_FILE" /tmp/system_prompt.txt
# Untrusted PR content is HTML-escaped then wrapped in XML tags.
# Escaping converts < > & to &lt; &gt; &amp; so that an attacker
# cannot inject a closing tag (e.g. </pr_description>) to break out
# of the data section and inject arbitrary instructions.
escape_xml() {
python3 -c \
'import sys,html;print(html.escape(sys.stdin.read(),quote=False),end="")'
}
escape_xml < /tmp/pr-body.txt > /tmp/pr-body-safe.txt
escape_xml < /tmp/pr-diff.txt > /tmp/pr-diff-safe.txt
# Escape user-controlled metadata fields for the prompt.
# PR title is set by the PR author; milestone title is set by
# maintainers but escaped here for defence-in-depth.
PR_TITLE_SAFE=$(printf '%s' "$PR_TITLE" | escape_xml)
MILESTONE_SAFE=$(printf '%s' "$MILESTONE_TITLE" | escape_xml)
PR_URL="https://github.com/${SOURCE_REPO}/pull/${PR_NUMBER}"
{
echo "<pr_metadata>"
echo "PR URL: ${PR_URL}"
echo "PR Title: ${PR_TITLE_SAFE}"
echo "Source Repository: ${SOURCE_REPO}"
echo "milestone.title: ${MILESTONE_SAFE}"
echo "</pr_metadata>"
echo ""
echo "<pr_description>"
cat /tmp/pr-body-safe.txt
echo "</pr_description>"
echo ""
echo "<code_diff>"
cat /tmp/pr-diff-safe.txt
echo "</code_diff>"
} > /tmp/user_message.txt
PAYLOAD=$(jq -n \
--rawfile system /tmp/system_prompt.txt \
--rawfile content /tmp/user_message.txt \
'{
model: "claude-sonnet-4-6",
max_tokens: 2048,
system: $system,
messages: [{
role: "user",
content: $content
}]
}')
# --max-time 60: fail fast if the API is unresponsive.
# Capture the full response before piping so we can inspect it.
RESPONSE=$(curl -sf --max-time 60 \
https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d "$PAYLOAD") || {
echo "::error::Claude API call failed (timeout or HTTP error)."
exit 1
}
echo "$RESPONSE" | jq -r '.content[0].text' > /tmp/generated-docs.md
# Fail immediately if the API returned null or an empty body.
# Log only the error type and message from the JSON response - not
# the full body - to avoid exposing API metadata in workflow logs.
if [ ! -s /tmp/generated-docs.md ] || \
[ "$(cat /tmp/generated-docs.md)" = "null" ]; then
ERROR_INFO=$(echo "$RESPONSE" | jq -r '
if .error then "\(.error.type): \(.error.message)"
else "unexpected null or empty content"
end' 2>/dev/null || echo "response could not be parsed")
echo "::error::Claude API returned empty/null content. ${ERROR_INFO}"
exit 1
fi
# Validate the output matches the required structure defined in the
# system prompt. Missing sections indicate the model was steered away
# from its instructions - a signal of possible prompt injection.
if ! grep -qF '=== CAPABILITY SUMMARY ===' /tmp/generated-docs.md; then
echo "::error::Output missing CAPABILITY SUMMARY section." \
"Possible prompt injection. Aborting."
exit 1
fi
if ! grep -qF '=== DOCUMENTATION DRAFT ===' /tmp/generated-docs.md; then
echo "::error::Output missing DOCUMENTATION DRAFT section." \
"Possible prompt injection. Aborting."
exit 1
fi
# 8. Write docs file
- name: Write docs file
working-directory: docs-repo
env:
DOCS_FILE: ${{ steps.vars.outputs.docs_file }}
SOURCE_REPO: ${{ steps.vars.outputs.source_repo }}
PR_NUMBER: ${{ steps.vars.outputs.pr_number }}
PR_TITLE: ${{ steps.vars.outputs.pr_title }}
PR_AUTHOR: ${{ steps.vars.outputs.pr_author }}
run: |
mkdir -p "$(dirname "$DOCS_FILE")"
# Sanitize PR title for the Markdown heading to prevent XSS if the
# docs are rendered as HTML (e.g. < > become &lt; &gt;).
PR_TITLE_SAFE=$(printf '%s' "$PR_TITLE" | \
python3 -c \
'import sys,html;print(html.escape(sys.stdin.read(),quote=False),end="")')
{
printf '# %s\n\n' "$PR_TITLE_SAFE"
printf '> **Status:** Draft - auto-generated. Requires editorial review.\n'
printf '> **Source:** [%s#%s](https://github.com/%s/pull/%s)\n' \
"$SOURCE_REPO" "$PR_NUMBER" "$SOURCE_REPO" "$PR_NUMBER"
printf '> **Dev reviewer:** @%s\n\n' "$PR_AUTHOR"
printf -- '---\n\n'
cat /tmp/generated-docs.md
} > "$DOCS_FILE"
# 9. Commit and push
- name: Commit and push
working-directory: docs-repo
env:
BRANCH: ${{ steps.vars.outputs.branch }}
DOCS_FILE: ${{ steps.vars.outputs.docs_file }}
SOURCE_REPO: ${{ steps.vars.outputs.source_repo }}
PR_NUMBER: ${{ steps.vars.outputs.pr_number }}
PR_TITLE: ${{ steps.vars.outputs.pr_title }}
run: |
# Stage only the specific docs file - not the entire working tree.
git add "$DOCS_FILE"
if git diff --cached --quiet; then
echo "No content changes - skipping commit."
else
# Sanitize PR title for commit message: PR_TITLE is author-controlled
# and may contain newlines, quotes, or be very long. Strip control
# characters and truncate to 72 chars (git convention).
PR_TITLE_SHORT=$(printf '%s' "$PR_TITLE" | tr -d '\n\r' | cut -c1-72)
# Use printf with a literal format string and positional arguments,
# then pipe to -F - so the message is never interpolated into a
# shell command string. This avoids any risk of shell metacharacters
# in PR_TITLE_SHORT being evaluated.
printf 'docs: draft for %s#%s - %s\n' \
"$SOURCE_REPO" "$PR_NUMBER" "$PR_TITLE_SHORT" \
| git commit -F -
git push origin "$BRANCH"
fi
# 10. Sync milestone to mattermost/docs
- name: Sync milestone
id: docs-milestone
env:
GH_TOKEN: ${{ steps.write-token.outputs.token }}
MILESTONE_TITLE: ${{ steps.vars.outputs.milestone_title }}
run: |
# Pipe to standalone jq with --arg to pass the title safely.
# gh api does not support jq variable bindings directly.
NUM=$(gh api 'repos/mattermost/docs/milestones?state=all' \
--paginate \
| jq -r --arg title "$MILESTONE_TITLE" \
'.[] | select(.title == $title) | .number' \
| head -1)
if [ -z "$NUM" ]; then
NUM=$(gh api repos/mattermost/docs/milestones \
--method POST \
-f title="$MILESTONE_TITLE" \
--jq '.number')
echo "::notice::Created milestone '$MILESTONE_TITLE' (#$NUM) in mattermost/docs."
fi
echo "number=$NUM" >> "$GITHUB_OUTPUT"
# 11a. Create docs PR
- name: Create docs PR
id: create-pr
if: steps.existing.outputs.exists == 'false'
env:
GH_TOKEN: ${{ steps.write-token.outputs.token }}
BRANCH: ${{ steps.vars.outputs.branch }}
MILESTONE_TITLE: ${{ steps.vars.outputs.milestone_title }}
SOURCE_REPO: ${{ steps.vars.outputs.source_repo }}
PR_NUMBER: ${{ steps.vars.outputs.pr_number }}
PR_TITLE: ${{ steps.vars.outputs.pr_title }}
PR_AUTHOR: ${{ steps.vars.outputs.pr_author }}
run: |
PR_LINK="[${SOURCE_REPO}#${PR_NUMBER}]"
PR_LINK="${PR_LINK}(https://github.com/${SOURCE_REPO}/pull/${PR_NUMBER})"
BODY=$(printf '%s\n\n%s\n\n%s\n%s\n%s\n%s\n%s\n\n%s' \
"Documentation draft for ${PR_LINK}." \
"> Auto-generated by Claude - requires editorial review before merging." \
"| Field | Value |" \
"|---|---|" \
"| **Source PR** | ${PR_LINK} |" \
"| **Dev reviewer** | @${PR_AUTHOR} |" \
"| **Milestone** | ${MILESTONE_TITLE} |" \
"<!-- dev-pr: ${SOURCE_REPO}#${PR_NUMBER} -->")
# Sanitize PR title for the docs PR title: same rules as commit message.
PR_TITLE_SHORT=$(printf '%s' "$PR_TITLE" | tr -d '\n\r' | cut -c1-72)
# Open as a draft so a human must explicitly un-draft before merging.
# This is the primary control against auto-committing injected content.
# gh pr create --milestone takes a title string, not a numeric ID.
URL=$(gh pr create \
--repo mattermost/docs \
--head "$BRANCH" \
--base "${{ steps.base-branch.outputs.base }}" \
--title "docs: ${PR_TITLE_SHORT}" \
--body "$BODY" \
--milestone "$MILESTONE_TITLE" \
--draft)
echo "number=$(echo "$URL" | grep -oE '[0-9]+$')" >> "$GITHUB_OUTPUT"
echo "url=$URL" >> "$GITHUB_OUTPUT"
# 11b. Update existing docs PR milestone (re-trigger path)
- name: Update existing docs PR
if: steps.existing.outputs.exists == 'true'
env:
GH_TOKEN: ${{ steps.write-token.outputs.token }}
DOCS_PR: ${{ steps.existing.outputs.number }}
MILESTONE_TITLE: ${{ steps.vars.outputs.milestone_title }}
run: |
gh pr edit "$DOCS_PR" \
--repo mattermost/docs \
--milestone "$MILESTONE_TITLE"
# 12. Resolve final docs PR reference
- name: Resolve docs PR reference
id: resolve
run: |
if [ "${{ steps.existing.outputs.exists }}" == "true" ]; then
echo "number=${{ steps.existing.outputs.number }}" >> "$GITHUB_OUTPUT"
echo "url=${{ steps.existing.outputs.url }}" >> "$GITHUB_OUTPUT"
else
echo "number=${{ steps.create-pr.outputs.number }}" >> "$GITHUB_OUTPUT"
echo "url=${{ steps.create-pr.outputs.url }}" >> "$GITHUB_OUTPUT"
fi
# 13. Add dev PR author as reviewer
- name: Add reviewer
env:
GH_TOKEN: ${{ steps.write-token.outputs.token }}
PR_AUTHOR: ${{ steps.vars.outputs.pr_author }}
run: |
gh pr edit "${{ steps.resolve.outputs.number }}" \
--repo mattermost/docs \
--add-reviewer "$PR_AUTHOR" \
|| echo "::warning::Could not add @${PR_AUTHOR} as reviewer" \
"- they may need docs repo access first."
# 14. Comment on the dev PR
- name: Comment on dev PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.vars.outputs.pr_number }}
SOURCE_REPO: ${{ steps.vars.outputs.source_repo }}
DOCS_PR_NUMBER: ${{ steps.resolve.outputs.number }}
DOCS_PR_URL: ${{ steps.resolve.outputs.url }}
run: |
if [ "${{ steps.existing.outputs.exists }}" == "true" ]; then
ACTION="refreshed at"
else
ACTION="opened at"
fi
DOCS_LINK="[mattermost/docs#${DOCS_PR_NUMBER}](${DOCS_PR_URL})"
COMMENT=$(printf '%s\n\n%s\n\n%s' \
"A documentation draft has been ${ACTION} ${DOCS_LINK}." \
"The docs PR is opened as a **draft**. Please review the AI-generated content, edit as needed, then mark it ready for review before merging." \
"The Docs/Needed label will be replaced with Docs/Done once the docs PR is merged or closed.")
gh pr comment "$PR_NUMBER" \
--repo "$SOURCE_REPO" \
--body "$COMMENT"
+302
View File
@@ -0,0 +1,302 @@
name: Docs Writer (Merged PR)
# When a PR merges still carrying Docs/Needed, draft the missing pages in this
# repo from the gap sticky comment, then open a draft PR. Closing that draft
# flips Docs/Needed → Docs/Done on the originating PR.
#
# Requires DOCS_AI_APP_CLIENT_ID (variable) + DOCS_AI_APP_PRIVATE_KEY (secret)
# so the opened PR triggers docs-review.yml (GITHUB_TOKEN PRs do not).
#
# Fork merges soft-skip: pull_request from a fork gets no secrets (same posture
# as docs-gap.yml). Re-run via workflow_dispatch after merge if needed. Do not
# switch this to pull_request_target — the sync job must not run on forged
# fork heads named docs/pr-*.
on:
pull_request:
types: [closed]
workflow_dispatch:
inputs:
pr_number:
description: Merged PR number that still carries Docs/Needed
required: true
type: string
permissions:
contents: read
pull-requests: write
issues: write
concurrency:
group: >-
docs-writer-${{ github.event.pull_request.number || inputs.pr_number || github.event.pull_request.head.ref }}
cancel-in-progress: false
jobs:
draft:
name: Draft docs PR
runs-on: ubuntu-24.04
if: |
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true &&
contains(github.event.pull_request.labels.*.name, 'Docs/Needed') &&
!startsWith(github.event.pull_request.head.ref, 'docs/pr-'))
env:
HAS_ANTHROPIC_KEY: ${{ secrets.ANTHROPIC_API_KEY != '' }}
HAS_DOCS_AI_APP: ${{ vars.DOCS_AI_APP_CLIENT_ID != '' && secrets.DOCS_AI_APP_PRIVATE_KEY != '' }}
steps:
- name: Soft-skip when secrets are unset
id: gate
run: |
set -euo pipefail
if [ "$HAS_ANTHROPIC_KEY" != "true" ]; then
echo "::warning::ANTHROPIC_API_KEY unset; skipping docs writer."
echo "run=false" >> "$GITHUB_OUTPUT"
elif [ "$HAS_DOCS_AI_APP" != "true" ]; then
echo "::warning::DOCS_AI_APP credentials unset; skipping docs writer."
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
- name: Generate App token
if: steps.gate.outputs.run == 'true'
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.DOCS_AI_APP_CLIENT_ID }}
private-key: ${{ secrets.DOCS_AI_APP_PRIVATE_KEY }}
repositories: mattermost
# Narrow below the App installation grant (cannot exceed it).
permission-contents: write
permission-pull-requests: write
permission-issues: write
- name: Checkout
if: steps.gate.outputs.run == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: master
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0
persist-credentials: false
- name: Resolve source PR
if: steps.gate.outputs.run == 'true'
id: pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_PR: ${{ inputs.pr_number }}
EVENT_PR: ${{ github.event.pull_request.number }}
EVENT_TITLE: ${{ github.event.pull_request.title }}
EVENT_AUTHOR: ${{ github.event.pull_request.user.login }}
EVENT_MILESTONE_TITLE: ${{ github.event.pull_request.milestone.title }}
EVENT_MILESTONE_NUMBER: ${{ github.event.pull_request.milestone.number }}
run: |
set -euo pipefail
mkdir -p .docs-ai/writer
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
if [[ ! "$INPUT_PR" =~ ^[0-9]+$ ]]; then
echo "::error::pr_number must be a positive integer (got '${INPUT_PR}')."
exit 1
fi
DATA=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${INPUT_PR}")
PR_NUMBER="$INPUT_PR"
TITLE=$(echo "$DATA" | jq -r '.title')
AUTHOR=$(echo "$DATA" | jq -r '.user.login')
MILESTONE_TITLE=$(echo "$DATA" | jq -r '.milestone.title // ""')
MILESTONE_NUMBER=$(echo "$DATA" | jq -r '.milestone.number // ""')
MERGED=$(echo "$DATA" | jq -r '.merged')
LABELS=$(echo "$DATA" | jq -r '[.labels[].name] | join(",")')
if [ "$MERGED" != "true" ]; then
echo "::error::PR #${PR_NUMBER} is not merged."
exit 1
fi
if [[ ",$LABELS," != *",Docs/Needed,"* ]]; then
echo "::error::PR #${PR_NUMBER} does not carry Docs/Needed."
exit 1
fi
else
PR_NUMBER="$EVENT_PR"
TITLE="$EVENT_TITLE"
AUTHOR="$EVENT_AUTHOR"
MILESTONE_TITLE="$EVENT_MILESTONE_TITLE"
MILESTONE_NUMBER="$EVENT_MILESTONE_NUMBER"
fi
if [ -z "$MILESTONE_NUMBER" ] || [ "$MILESTONE_NUMBER" = "null" ]; then
echo "::error title=Missing milestone::Set a milestone on #${PR_NUMBER} before Docs/Needed can draft pages (version anchor source)."
exit 1
fi
printf '%s' "$TITLE" > .docs-ai/writer/pr-title.txt
gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.body // ""' \
> .docs-ai/writer/pr-body.txt
{
echo "pr_number=${PR_NUMBER}"
echo "pr_author=${AUTHOR}"
echo "milestone_title=${MILESTONE_TITLE}"
echo "branch=docs/pr-${PR_NUMBER}"
} >> "$GITHUB_OUTPUT"
- name: Collect merged code diff
if: steps.gate.outputs.run == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
run: |
set -euo pipefail
# Cap so an unbounded monorepo diff cannot overwhelm the prompt.
gh pr diff "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" 2>/dev/null \
| head -c 80000 > .docs-ai/writer/code.diff || true
- name: Set up Node
if: steps.gate.outputs.run == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: .github/scripts/docs-ai/package-lock.json
- name: Install dependencies
if: steps.gate.outputs.run == 'true'
working-directory: .github/scripts/docs-ai
run: npm ci --ignore-scripts
- name: Validate persona registry
if: steps.gate.outputs.run == 'true'
working-directory: .github/scripts/docs-ai
run: npm test
- name: Author docs
if: steps.gate.outputs.run == 'true'
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
MILESTONE_TITLE: ${{ steps.pr.outputs.milestone_title }}
PR_BODY_FILE: .docs-ai/writer/pr-body.txt
CODE_DIFF_FILE: .docs-ai/writer/code.diff
DOCS_AI_WRITER_MODEL: ${{ vars.DOCS_AI_WRITER_MODEL }}
DOCS_AI_REVIEW_MODEL: ${{ vars.DOCS_AI_REVIEW_MODEL }}
DOCS_AI_ROUTER_MODEL: ${{ vars.DOCS_AI_ROUTER_MODEL }}
DOCS_AI_MAX_REVISIONS: ${{ vars.DOCS_AI_MAX_REVISIONS }}
REPO_ROOT: ${{ github.workspace }}
run: |
set -euo pipefail
PR_TITLE=$(cat .docs-ai/writer/pr-title.txt)
export PR_TITLE
node .github/scripts/docs-ai/writer/run.mjs
- name: Configure git identity
if: steps.gate.outputs.run == 'true'
env:
GIT_USERNAME: ${{ vars.DOCS_AI_GIT_USERNAME || 'mattermost-docs-ai[bot]' }}
GIT_EMAIL: ${{ vars.DOCS_AI_GIT_EMAIL || '41898282+github-actions[bot]@users.noreply.github.com' }}
run: |
git config user.name "$GIT_USERNAME"
git config user.email "$GIT_EMAIL"
- name: Commit and push
if: steps.gate.outputs.run == 'true'
id: push
env:
BRANCH: ${{ steps.pr.outputs.branch }}
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git checkout -B "$BRANCH"
git add docs/main docs/develop docs/api/examples.mdx docs/api/index.mdx 2>/dev/null || true
if git diff --cached --quiet; then
echo "::error::Writer produced no file changes."
exit 1
fi
printf 'docs: AI draft for #%s\n' "$PR_NUMBER" | git commit -F -
if git ls-remote --exit-code origin "refs/heads/${BRANCH}" >/dev/null 2>&1; then
git push origin "HEAD:refs/heads/${BRANCH}" --force-with-lease
else
git push -u origin "HEAD:refs/heads/${BRANCH}"
fi
- name: Open or update draft PR
if: steps.gate.outputs.run == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
BRANCH: ${{ steps.pr.outputs.branch }}
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
PR_AUTHOR: ${{ steps.pr.outputs.pr_author }}
run: |
set -euo pipefail
EXISTING=$(gh pr list --head "$BRANCH" --state open --json number,url --jq 'first // empty')
TITLE="docs: AI draft for #${PR_NUMBER}"
if [ -n "$EXISTING" ]; then
NUM=$(echo "$EXISTING" | jq -r .number)
URL=$(echo "$EXISTING" | jq -r .url)
gh pr edit "$NUM" --body-file .docs-ai/writer/pr-body.md
else
URL=$(gh pr create \
--base master \
--head "$BRANCH" \
--title "$TITLE" \
--body-file .docs-ai/writer/pr-body.md \
--draft)
NUM=$(gh pr view "$BRANCH" --json number --jq .number)
gh pr edit "$NUM" --add-reviewer "$PR_AUTHOR" || true
fi
{
echo "## Docs writer"
echo ""
echo "Draft PR: ${URL}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Comment on source PR
if: steps.gate.outputs.run == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
BRANCH: ${{ steps.pr.outputs.branch }}
run: |
set -euo pipefail
URL=$(gh pr view "$BRANCH" --json url --jq .url)
gh pr comment "$PR_NUMBER" --body "Draft documentation PR opened: ${URL}"
sync:
name: Mark Docs/Done
runs-on: ubuntu-24.04
if: |
github.event_name == 'pull_request' &&
github.event.action == 'closed' &&
github.event.pull_request.merged == true &&
startsWith(github.event.pull_request.head.ref, 'docs/pr-')
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
- name: Sync labels on source PR
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Provenance: trusted event fields only (not branch/body/label).
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
PR_USER: ${{ github.event.pull_request.user.login }}
DOCS_AI_BOT_LOGIN: ${{ vars.DOCS_AI_BOT_LOGIN }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
mkdir -p .docs-ai/writer
gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.body // ""' \
> .docs-ai/writer/sync-body.txt
PR_BODY_FILE=.docs-ai/writer/sync-body.txt node .github/scripts/docs-ai/writer/sync.mjs