diff --git a/.github/workflows/issue_autodoc.yml b/.github/workflows/issue_autodoc.yml index af0e4f59561..5128ed3fb9c 100644 --- a/.github/workflows/issue_autodoc.yml +++ b/.github/workflows/issue_autodoc.yml @@ -7,7 +7,6 @@ on: jobs: preflight: name: Determine whether to run - # Run if the issue carries any of the three trigger labels. if: | contains(github.event.issue.labels.*.name, 'Epic') || contains(github.event.issue.labels.*.name, 'Doc : Needs Doc') || @@ -25,7 +24,6 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} REPO: ${{ github.repository }} - # Epic label present means unconditional run — no further checks needed. HAS_EPIC: ${{ contains(github.event.issue.labels.*.name, 'Epic') }} run: | if [ "$HAS_EPIC" = "true" ]; then @@ -34,8 +32,6 @@ jobs: exit 0 fi - # Non-Epic issue: check whether it is a sub-task of an Epic. - # If so, skip — the parent Epic closure will handle documentation. PARENT_LABELS=$(gh api graphql \ -f query=' query($owner: String!, $repo: String!, $number: Int!) { @@ -51,7 +47,7 @@ jobs: -f owner="${REPO%/*}" \ -f repo="${REPO#*/}" \ -F number="$ISSUE_NUMBER" \ - --jq '.data.repository.issue.parent.labels.nodes[].name // empty') + --jq '(.data.repository.issue.parent.labels.nodes // []) | .[].name') if echo "$PARENT_LABELS" | grep -qx "Epic"; then echo "Issue #$ISSUE_NUMBER is a sub-task of an Epic — skipping; Epic closure will handle docs." @@ -67,7 +63,7 @@ jobs: if: needs.preflight.outputs.should_run == 'true' runs-on: ubuntu-latest permissions: - issues: write # post/edit the report comment + issues: write contents: read env: @@ -75,53 +71,228 @@ jobs: REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + # Used by the Claude subprocess when it executes the curl in burlap.txt — not called directly here. AUTODOC_DOTCMS_API_TOKEN_AISEARCH: ${{ secrets.AUTODOC_DOTCMS_API_TOKEN_AISEARCH }} AUTODOC_DOTCMS_API_TOKEN_DRAFTING: ${{ secrets.AUTODOC_DOTCMS_API_TOKEN_DRAFTING }} AUTODOC_DOTCMS_BASE_URL: ${{ secrets.AUTODOC_DOTCMS_BASE_URL }} AUTODOC_DOTCMS_SITE_FOLDER: ${{ secrets.AUTODOC_DOTCMS_SITE_FOLDER }} steps: - - name: Checkout triggering repo (for gh context) + - name: Checkout triggering repo uses: actions/checkout@v4 - - name: Checkout dotcms-aios (includes autodoc scripts) + - name: Checkout dotcms-aios uses: actions/checkout@v4 with: repository: dotCMS/dotcms-aios token: ${{ secrets.CI_MACHINE_TOKEN }} path: dotcms-aios - - name: Set up uv - uses: astral-sh/setup-uv@v5 - with: - working-directory: dotcms-aios/autodoc - - - name: Install autodoc dependencies - working-directory: dotcms-aios/autodoc - run: uv sync - - name: Install Claude Code CLI + # Version is unpinned intentionally — claude-code updates frequently and + # the package is published by Anthropic. Pin if supply-chain policy requires it. run: npm install -g @anthropic-ai/claude-code - - name: Run eval → Claude - working-directory: dotcms-aios/autodoc + - name: Build eval context run: | - uv run python scripts/run_eval.py \ - --epic "$EPIC_NUMBER" \ - --repo "$REPO" \ - --prompt burlap \ - --aios-dir ../ \ - > /tmp/eval_context.md + cat > /tmp/ctx.py << 'PYEOF' + import json, os, subprocess, sys + + epic_num = os.environ['EPIC_NUMBER'] + repo = os.environ['REPO'] + MAX_COMMENT_CHARS = 4000 + + result = subprocess.run( + ['gh', 'issue', 'view', epic_num, '--repo', repo, + '--json', 'number,title,body,labels,state,url,' + 'closedByPullRequestsReferences,comments'], + capture_output=True, text=True, check=True) + epic = json.loads(result.stdout) + + labels = ', '.join(l['name'] for l in epic.get('labels', [])) + pr_nums = [r['number'] for r in epic.get('closedByPullRequestsReferences', [])] + + def render_comments(comments): + parts, skipped = [], 0 + for c in (comments or []): + body = (c.get('body') or '').strip() + if not body: + continue + if len(body) > MAX_COMMENT_CHARS: + skipped += 1 + continue + login = (c.get('author') or {}).get('login', '?') + parts.append(f'**{login}:** {body}') + out = '\n\n'.join(parts) + if skipped: + out += (f'\n\n*({skipped} comment{"s" if skipped > 1 else ""}' + f' omitted — exceeded {MAX_COMMENT_CHARS}-character limit)*') + return out + + lines = [ + '# Evaluation Context', '', + f'## Epic: #{epic["number"]} — {repo}', + f'**Title:** {epic["title"]}', + f'**URL:** {epic.get("url", "")}', + f'**State:** {epic.get("state", "?")}', + f'**Labels:** {labels or "(none)"}', + f'**Related PRs:** {", ".join(f"#{n}" for n in pr_nums) or "(none)"}', + '', '### Epic Body', '', + (epic.get('body') or '').strip(), + ] + ct = render_comments(epic.get('comments')) + if ct: + lines += ['', '### Comments', '', ct] - claude --print --allowedTools Bash,Write < /tmp/eval_context.md + for pr_num in pr_nums: + r = subprocess.run( + ['gh', 'pr', 'view', str(pr_num), '--repo', repo, + '--json', 'number,title,body,state,mergedAt,author,comments'], + capture_output=True, text=True) + if r.returncode != 0: + continue + pr = json.loads(r.stdout) + author = (pr.get('author') or {}).get('login', '?') + lines += [ + '', '---', '', + f'## Related PR: #{pr["number"]} — {pr["title"]}', + f'**State:** {pr.get("state","?")} | ' + f'**Author:** {author} | ' + f'**Merged:** {pr.get("mergedAt") or "N/A"}', + '', + (pr.get('body') or '').strip(), + ] + pct = render_comments(pr.get('comments')) + if pct: + lines += ['', '### Comments', '', pct] - - name: Run finalize (apply + post comment + commit) - working-directory: dotcms-aios/autodoc + g = subprocess.run( + ['grep', '-rl', f'/issues/{epic_num}', 'dotcms-aios/work/epics/'], + capture_output=True, text=True) + vault_file = g.stdout.strip().split('\n')[0] if g.stdout.strip() else '' + if vault_file: + lines += ['', '---', '', '## Vault Context', '', + open(vault_file).read().strip()] + else: + print(f'warning: no vault file for Epic #{epic_num}', file=sys.stderr) + + prompt = open('dotcms-aios/autodoc/prompts/burlap.txt').read().strip() + report_path = f'dotcms-aios/autodoc/reports/Epic-{epic_num}_burlap.md' + lines += [ + '', '---', '', '## Prompt: burlap', '', prompt, + '', '---', '', + f'**Report path:** `{report_path}`', + '', + 'Use the Write tool to write the report to exactly that path.', + ] + + with open('/tmp/eval_context.md', 'w') as f: + f.write('\n'.join(lines)) + PYEOF + python3 /tmp/ctx.py + + - name: Run Claude + run: claude --print --allowedTools Bash,Write < /tmp/eval_context.md + + - name: Post comment, apply to dotCMS, commit report run: | + REPORT="dotcms-aios/autodoc/reports/Epic-${EPIC_NUMBER}_burlap.md" + + if [ ! -f "$REPORT" ]; then + echo "No report at $REPORT — skipping finalize." + exit 0 + fi + + # Prepend idempotency marker so subsequent runs can find and edit this comment. + { echo ''; cat "$REPORT"; } > /tmp/comment_body.md + + # Edit the existing autodoc comment if one exists, otherwise create a new one. + # --paginate ensures we search all comments, not just the first page. + EXISTING=$(gh api "repos/$REPO/issues/$EPIC_NUMBER/comments" --paginate \ + --jq '[.[] | select(.body | contains("")) | .id] | first // empty') + if [ -n "$EXISTING" ]; then + gh api --method PATCH "repos/$REPO/issues/comments/$EXISTING" \ + -F "body=@/tmp/comment_body.md" + else + gh issue comment "$EPIC_NUMBER" --repo "$REPO" --body-file /tmp/comment_body.md + fi + + # Parse action and urlTitle from the machine-readable meta block. + # All fields use sub() to preserve the full value after the key prefix. + ACTION=$(awk '/BEGIN_DOC_META/{p=1;next} /END_DOC_META/{p=0} p && /^action:/{print $2; exit}' "$REPORT") + URL_TITLE=$(awk '/BEGIN_DOC_META/{p=1;next} /END_DOC_META/{p=0} p && /^urlTitle:/{sub(/^urlTitle:[[:space:]]*/,"");print;exit}' "$REPORT") + + # Validate urlTitle is a URL slug before interpolating into the JSON query. + # Non-slug chars (quotes, backslashes) would break the shell-built JSON payload. + if [ -n "$URL_TITLE" ] && ! echo "$URL_TITLE" | grep -qE '^[a-z0-9][a-z0-9-]*[a-z0-9]$'; then + echo "urlTitle '${URL_TITLE}' is not a valid slug — skipping dotCMS apply." + ACTION=none + fi + + if [ "$ACTION" = "update" ] && [ -n "$URL_TITLE" ]; then + IDENTIFIER=$(curl -s --fail-with-body -X POST "$AUTODOC_DOTCMS_BASE_URL/api/es/search" \ + -H "Authorization: Bearer $AUTODOC_DOTCMS_API_TOKEN_DRAFTING" \ + -H "Content-Type: application/json" \ + -d "{\"query\":{\"query_string\":{\"query\":\"+contentType:DotcmsDocumentation +DotcmsDocumentation.urlTitle:\\\"$URL_TITLE\\\"\"}},\"size\":1}" \ + | jq -r '(.esresponse[0].hits.hits[0]._source.identifier) // empty') + + # Verify the identifier is UUID-shaped before placing it in a URL. + if echo "$IDENTIFIER" | grep -qE '^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$' && [ -n "$IDENTIFIER" ]; then + export REPORT IDENTIFIER + python3 << 'PYEOF' + import json, os + report = open(os.environ['REPORT']).read() + marker = '' + draft = report.split(marker, 1)[1].lstrip('\n') if marker in report else '' + payload = {'contentlet': { + 'identifier': os.environ['IDENTIFIER'], + 'languageId': 1, 'documentation': draft, + 'disabledWYSIWYG': ['documentation']}} + json.dump(payload, open('/tmp/payload.json', 'w')) + PYEOF + curl -s --fail-with-body -X PUT \ + "$AUTODOC_DOTCMS_BASE_URL/api/v1/workflow/actions/default/fire/EDIT?identifier=$IDENTIFIER" \ + -H "Authorization: Bearer $AUTODOC_DOTCMS_API_TOKEN_DRAFTING" \ + -H "Content-Type: application/json" \ + --data @/tmp/payload.json + fi + + elif [ "$ACTION" = "create" ]; then + TITLE=$(awk '/BEGIN_DOC_META/{p=1;next} /END_DOC_META/{p=0} p && /^title:/{sub(/^title:[[:space:]]*/,"");print;exit}' "$REPORT") + TAGS=$(awk '/BEGIN_DOC_META/{p=1;next} /END_DOC_META/{p=0} p && /^tags:/{sub(/^tags:[[:space:]]*/,"");print;exit}' "$REPORT") + SEO=$(awk '/BEGIN_DOC_META/{p=1;next} /END_DOC_META/{p=0} p && /^seoDescription:/{sub(/^seoDescription:[[:space:]]*/,"");print;exit}' "$REPORT") + export REPORT URL_TITLE TITLE TAGS SEO + python3 << 'PYEOF' + import json, os + report = open(os.environ['REPORT']).read() + marker = '' + draft = report.split(marker, 1)[1].lstrip('\n') if marker in report else '' + sf = os.environ.get('AUTODOC_DOTCMS_SITE_FOLDER', '').strip() + payload = {'contentlet': { + 'contentType': 'DotcmsDocumentation', + 'urlTitle': os.environ['URL_TITLE'], + 'title': os.environ['TITLE'], + 'tag': os.environ['TAGS'], + 'seoDescription': os.environ['SEO'], + 'languageId': 1, 'documentation': draft, + 'disabledWYSIWYG': ['documentation']}} + if sf: + payload['contentlet']['navFolder'] = sf + json.dump(payload, open('/tmp/payload.json', 'w')) + PYEOF + curl -s --fail-with-body -X PUT \ + "$AUTODOC_DOTCMS_BASE_URL/api/v1/workflow/actions/default/fire/NEW" \ + -H "Authorization: Bearer $AUTODOC_DOTCMS_API_TOKEN_DRAFTING" \ + -H "Content-Type: application/json" \ + --data @/tmp/payload.json + fi + + # Commit the report back to dotcms-aios. + # Push only runs if commit succeeds (i.e. there were actual changes to commit). + cd dotcms-aios git config user.name "autodoc[bot]" git config user.email "autodoc-bot@users.noreply.github.com" - - uv run python scripts/finalize.py \ - --epic "$EPIC_NUMBER" \ - --prompt burlap \ - --repo "$REPO" + git add "autodoc/reports/Epic-${EPIC_NUMBER}_burlap.md" + if git commit -m "eval: Epic-${EPIC_NUMBER} (burlap)"; then + git push origin HEAD + fi