diff --git a/.github/workflows/docs-needed.yml b/.github/workflows/docs-needed.yml new file mode 100644 index 00000000000..857a64d7527 --- /dev/null +++ b/.github/workflows/docs-needed.yml @@ -0,0 +1,631 @@ +--- +# .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/-pr- +# 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 for + ${{ github.repository }}#${{ github.event.pull_request.number || inputs.pr_number }} + 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: + app-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: + app-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 < > & so that an attacker + # cannot inject a closing tag (e.g. ) 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 "" + echo "PR URL: ${PR_URL}" + echo "PR Title: ${PR_TITLE_SAFE}" + echo "Source Repository: ${SOURCE_REPO}" + echo "milestone.title: ${MILESTONE_SAFE}" + echo "" + echo "" + echo "" + cat /tmp/pr-body-safe.txt + echo "" + echo "" + echo "" + cat /tmp/pr-diff-safe.txt + echo "" + } > /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 < >). + 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: | + # Use --arg to pass the title safely - avoids jq injection if the + # milestone title contains quotes or other special characters. + NUM=$(gh api 'repos/mattermost/docs/milestones?state=all' \ + --paginate \ + --jq '.[] | select(.title == $title) | .number' \ + --arg title "$MILESTONE_TITLE" \ + | 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 }} + MILESTONE_NUMBER: ${{ steps.docs-milestone.outputs.number }} + 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} |" \ + "") + + # 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. + URL=$(gh pr create \ + --repo mattermost/docs \ + --head "$BRANCH" \ + --base "${{ steps.base-branch.outputs.base }}" \ + --title "docs: ${PR_TITLE_SHORT}" \ + --body "$BODY" \ + --milestone "$MILESTONE_NUMBER" \ + --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_NUMBER: ${{ steps.docs-milestone.outputs.number }} + run: | + gh pr edit "$DOCS_PR" \ + --repo mattermost/docs \ + --milestone "$MILESTONE_NUMBER" + + # 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"