id: docs-drift-guard
namespace: company.team
description: |
When a pull request is merged, find the documentation pages that still mention
something it removed or renamed, let an LLM propose exact text fixes, and open
a follow-up docs pull request once a human approves them.
labels:
team: developer-experience
use-case: docs-drift
variables:
claude_model: claude-sonnet-5-5
triggers:
- id: on_pull_request
type: io.kestra.plugin.core.trigger.Webhook
description: Point a GitHub repository webhook (Pull requests events,
application/json) at this URL. Events that are not a merge exit right
away.
key: "{{ secret('DOCS_DRIFT_WEBHOOK_KEY') }}"
inputs:
repository: "{{ trigger.body.repository.full_name ?? '' }}"
pr_number: "{{ trigger.body.pull_request.number ?? 0 }}"
inputs:
- id: repository
type: STRING
displayName: Repository
description: The GitHub repository as owner/name.
defaults: your-org/your-repo
- id: pr_number
type: INT
displayName: Pull request number
description: The merged pull request to check.
defaults: 1
- id: docs_paths
type: STRING
displayName: Documentation paths
description: Comma-separated glob patterns for the documentation files, relative
to the repository root.
defaults: README.md,docs/**/*.md
- id: max_pages
type: INT
displayName: Max pages to review
description: The most documentation pages sent to the model per pull request,
which caps the cost.
defaults: 8
- id: dry_run
type: BOOL
displayName: Dry run
description: Propose fixes and stop. No approval request, no branch, no pull
request, no saved state.
defaults: false
tasks:
- id: check_event
type: io.kestra.plugin.core.flow.If
description: GitHub also sends ping and other events. Stop quietly when the
payload has no pull request.
condition: "{{ inputs.pr_number < 1 or inputs.repository == '' }}"
then:
- id: not_a_pull_request
type: io.kestra.plugin.core.execution.Exit
state: SUCCESS
- id: pull_request
type: io.kestra.plugin.core.http.Request
description: Read the pull request to learn whether it was merged, its merge
commit and its base branch.
uri: "https://api.github.com/repos/{{ inputs.repository }}/pulls/{{
inputs.pr_number }}"
headers:
Accept: application/vnd.github+json
Authorization: "Bearer {{ secret('GITHUB_TOKEN') }}"
User-Agent: kestra-docs-drift-guard
retry:
type: exponential
interval: PT10S
maxInterval: PT2M
maxAttempts: 3
- id: guard
type: io.kestra.plugin.core.flow.If
description: Only merged pull requests are checked, and each one only once, so
re-delivered webhooks never open a second docs pull request.
condition: "{{ not fromJson(outputs.pull_request.body).merged or
kv('docs-drift-' ~ (inputs.repository | replace({'/': '-'}) | slugify) ~
'-' ~ inputs.pr_number, errorOnMissing=false) != null }}"
then:
- id: log_skip
type: io.kestra.plugin.core.log.Log
message: "Skipping {{ inputs.repository }}#{{ inputs.pr_number }}: {{
fromJson(outputs.pull_request.body).merged ? 'already handled' : 'not
merged' }}."
- id: skip
type: io.kestra.plugin.core.execution.Exit
state: SUCCESS
- id: pr_diff
type: io.kestra.plugin.core.http.Download
description: Save the pull request's unified diff as a file. It is passed on as
a file, never inlined, so its text is never evaluated as a template.
uri: "https://api.github.com/repos/{{ inputs.repository }}/pulls/{{
inputs.pr_number }}"
headers:
Accept: application/vnd.github.diff
Authorization: "Bearer {{ secret('GITHUB_TOKEN') }}"
User-Agent: kestra-docs-drift-guard
retry:
type: exponential
interval: PT10S
maxInterval: PT2M
maxAttempts: 3
- id: find_affected_docs
type: io.kestra.plugin.core.flow.WorkingDirectory
description: Check out the merge commit and find documentation pages that still
mention an identifier the pull request removed or renamed. Plain text
matching, no model call.
tasks:
- id: clone_merged
type: io.kestra.plugin.git.Clone
url: "https://github.com/{{ inputs.repository }}.git"
username: "{{ secret('GITHUB_TOKEN') }}"
password: "{{ secret('GITHUB_TOKEN') }}"
commit: "{{ fromJson(outputs.pull_request.body).merge_commit_sha }}"
directory: repo
- id: match
type: io.kestra.plugin.scripts.python.Script
description: Extract the CLI flags, environment variables, function names,
config keys and API paths that disappeared from the code, then find
the pages that still mention them.
dependencies:
- kestra
inputFiles:
pr.diff: "{{ outputs.pr_diff.uri }}"
script: |
import base64, json, re
from pathlib import Path
from kestra import Kestra
docs_globs = [g.strip() for g in {{ inputs.docs_paths | toJson }}.split(",") if g.strip()]
max_pages = int({{ inputs.max_pages }})
diff = Path("pr.diff").read_text(encoding="utf-8", errors="replace")
IDENTIFIERS = [
r"--[a-z][a-z0-9-]{2,}", # CLI flags
r"\b[A-Z][A-Z0-9]*_[A-Z0-9_]{2,}\b", # environment variables and constants
r"(?:def|function|func|class)\s+([A-Za-z_]\w{2,})", # functions and classes
r"(?<![\w/.])/(?:api|v\d+)(?:/[\w{}:.-]+)+", # API paths
]
CONFIG_KEY = r"^\s*([a-z][a-z0-9_.-]{2,})\s*[:=]"
CONFIG_FILES = (".yaml", ".yml", ".toml", ".ini", ".env", ".properties")
def is_doc(path):
return any(Path(path).match(g) or Path(path).match(g.replace("**/", "")) for g in docs_globs)
def identifiers(line, path):
found = set()
for pattern in IDENTIFIERS:
for m in re.finditer(pattern, line):
found.add(m.group(1) if m.groups() else m.group(0))
if path.endswith(CONFIG_FILES):
m = re.match(CONFIG_KEY, line)
if m:
found.add(m.group(1))
return found
removed, added, hunks, path = set(), set(), [], None
for line in diff.splitlines():
if line.startswith("+++ "):
path = line[6:] if line.startswith("+++ b/") else None
elif line.startswith("@@"):
hunks.append([path, line])
elif path and not is_doc(path) and hunks:
hunks[-1].append(line)
if line.startswith("-") and not line.startswith("---"):
removed |= identifiers(line[1:], path)
elif line.startswith("+"):
added |= identifiers(line[1:], path)
# An identifier that left the code and did not come back is a rename or a removal.
gone = sorted(removed - added)
pages = sorted({str(p.relative_to("repo")) for g in docs_globs for p in Path("repo").glob(g) if p.is_file()})
candidates = []
for page in pages:
text = Path("repo", page).read_text(encoding="utf-8", errors="replace")
matched = [t for t in gone if re.search(r"(?<![\w-])" + re.escape(t) + r"(?![\w-])", text)]
if not matched:
continue
related = "\n".join("\n".join(h[1:]) for h in hunks if h[0] and not is_doc(h[0]) and any(t in "\n".join(h) for t in matched))
candidates.append({
"path": page,
"identifiers": matched,
# Base64 keeps untrusted text inert while it travels through templated task properties.
"page_b64": base64.b64encode(text[:40000].encode()).decode(),
"diff_b64": base64.b64encode(related[:12000].encode()).decode(),
})
candidates.sort(key=lambda c: len(c["identifiers"]), reverse=True)
Kestra.outputs({
"removed_identifiers": gone,
"pages_scanned": len(pages),
"candidate_count": min(len(candidates), max_pages),
"pages_over_limit": max(0, len(candidates) - max_pages),
"candidates": candidates[:max_pages],
})
- id: review
type: io.kestra.plugin.core.flow.If
description: Ask the model only about pages that matched, then validate its edits.
condition: "{{ outputs.match.vars.candidate_count > 0 }}"
then:
- id: check_pages
type: io.kestra.plugin.core.flow.Loop
description: Review up to three pages at a time. Each page gets one model call
that returns structured JSON.
values: "{{ outputs.match.vars.candidates }}"
concurrencyLimit: 3
tasks:
- id: judge_page
type: io.kestra.plugin.ai.completion.ChatCompletion
description: Decide whether the page is now wrong and return exact
find-and-replace edits as JSON.
provider:
type: io.kestra.plugin.ai.provider.Anthropic
apiKey: "{{ secret('ANTHROPIC_API_KEY') }}"
modelName: "{{ vars.claude_model }}"
configuration:
maxToken: 4000
retry:
type: exponential
interval: PT10S
maxInterval: PT2M
maxAttempts: 3
messages:
- type: SYSTEM
content: |
You keep documentation in sync with code. You receive a code diff from a merged pull request and one documentation page. Decide whether the page is now incorrect because of the diff, and if so return the smallest edits that make it correct.
Rules:
- "find" must be copied exactly from the page, long enough to appear only once, and "replace" is its corrected text.
- Change only what the diff makes wrong. Keep the page's wording, tone and formatting.
- If the page is still correct, return stale=false and an empty edits list.
- "reason" is one sentence for the reviewer, in English.
Respond with one JSON object and nothing else, shaped like this:
{"stale": true, "reason": "...", "edits": [{"find": "exact text from the page", "replace": "corrected text"}]}
- type: USER
content: |
Identifiers removed or renamed by the pull request and still mentioned on this page: {{ fromJson(item.value).identifiers | join(', ') }}
Code diff:
{{ fromJson(item.value).diff_b64 | base64decode }}
Documentation page {{ fromJson(item.value).path }}:
{{ fromJson(item.value).page_b64 | base64decode }}
outputs:
- id: path
type: STRING
value: "{{ fromJson(item.value).path }}"
- id: verdict_b64
type: STRING
value: "{{ outputs.judge_page.textOutput | base64encode }}"
- id: build_patch
type: io.kestra.plugin.core.flow.WorkingDirectory
description: Apply only edits whose text appears exactly once on the page, so an
invented or ambiguous edit can never corrupt a page, and produce a
reviewable diff.
tasks:
- id: clone_for_patch
type: io.kestra.plugin.git.Clone
url: "https://github.com/{{ inputs.repository }}.git"
username: "{{ secret('GITHUB_TOKEN') }}"
password: "{{ secret('GITHUB_TOKEN') }}"
commit: "{{ fromJson(outputs.pull_request.body).merge_commit_sha }}"
directory: repo
- id: patch
type: io.kestra.plugin.scripts.python.Script
dependencies:
- kestra
inputFiles:
paths.json: "{{ loopOutputs(outputs.check_pages.outputs, 'path') | toJson }}"
verdicts.json: "{{ loopOutputs(outputs.check_pages.outputs, 'verdict_b64') |
toJson }}"
outputFiles:
- docs.diff
- edits.json
script: |
import base64, difflib, json, re
from pathlib import Path
from kestra import Kestra
def inert(text):
# Model text ends up in Slack and in the pull request body: break template markers.
return re.sub(r"([{}])(?=[{}%#])", r"\1 ", text)
paths = json.load(open("paths.json"))
verdicts = [base64.b64decode(v).decode() for v in json.load(open("verdicts.json"))]
accepted, rejected, rows, patch, diff = {}, 0, [], [], []
for path, raw in zip(paths, verdicts):
try:
verdict = json.loads(re.sub(r"^```(?:json)?|```$", "", raw.strip(), flags=re.M).strip())
except ValueError:
rows.append(f"| `{path}` | not checked | The model's answer was not valid JSON. |")
continue
if not verdict.get("stale") or not verdict.get("edits"):
rows.append(f"| `{path}` | up to date | {inert(verdict.get('reason', ''))} |")
continue
original = Path("repo", path).read_text(encoding="utf-8")
text, kept = original, []
for edit in verdict["edits"]:
find, replace = edit.get("find", ""), edit.get("replace", "")
if find and find != replace and text.count(find) == 1:
text = text.replace(find, replace)
kept.append({"find": find, "replace": replace})
else:
rejected += 1
if kept:
accepted[path] = kept
diff += difflib.unified_diff(original.splitlines(True), text.splitlines(True), f"a/{path}", f"b/{path}")
rows.append(f"| `{path}` | {len(kept)} edit(s) | {inert(verdict.get('reason', ''))} |")
Path("docs.diff").write_text("".join(diff), encoding="utf-8")
Path("edits.json").write_text(json.dumps(accepted), encoding="utf-8")
Kestra.outputs({
"files_changed": len(accepted),
"edits_accepted": sum(len(v) for v in accepted.values()),
"edits_rejected": rejected,
"summary": "| Page | Result | Why |\n|---|---|---|\n" + "\n".join(rows),
})
- id: needs_approval
type: io.kestra.plugin.core.flow.If
description: Ask for a human decision only when there is something to change and
this is not a dry run.
condition: "{{ not inputs.dry_run and outputs.patch.vars.files_changed > 0 }}"
then:
- id: ask_reviewer
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Tell the team a docs proposal is waiting, with a link to the paused
execution.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
retry:
type: exponential
interval: PT10S
maxInterval: PT2M
maxAttempts: 3
messageText: |
📝 *Docs fixes are waiting for review* for {{ inputs.repository }}#{{ inputs.pr_number }}: {{ outputs.patch.vars.edits_accepted }} edit(s) in {{ outputs.patch.vars.files_changed }} page(s).
Review and decide: {{ kestra.url ?? '' }}/ui/main/executions/{{ flow.namespace }}/{{ flow.id }}/{{ execution.id }}
- id: approval
type: io.kestra.plugin.core.flow.Pause
description: Review docs.diff in the build_patch outputs, then choose whether to
open the docs pull request. Without a decision within three days
the execution is cancelled.
pauseDuration: P3D
behavior: CANCEL
onResume:
- id: decision
type: SELECT
displayName: Decision
values:
- Open the docs PR
- Discard
defaults: Open the docs PR
- id: note
type: STRING
displayName: Note for the docs PR (optional)
required: false
- id: publish
type: io.kestra.plugin.core.flow.If
condition: "{{ outputs.approval.onResume.decision == 'Open the docs PR' }}"
then:
- id: rebase_edits
type: io.kestra.plugin.core.flow.WorkingDirectory
description: Re-apply the approved edits on the latest base branch, so docs
changes made since the merge are kept.
tasks:
- id: clone_base
type: io.kestra.plugin.git.Clone
url: "https://github.com/{{ inputs.repository }}.git"
username: "{{ secret('GITHUB_TOKEN') }}"
password: "{{ secret('GITHUB_TOKEN') }}"
branch: "{{ fromJson(outputs.pull_request.body).base.ref }}"
directory: repo
- id: reapply
type: io.kestra.plugin.scripts.python.Script
dependencies:
- kestra
inputFiles:
edits.json: "{{ outputs.patch.outputFiles['edits.json'] }}"
outputFiles:
- edited/**
script: |
import json
from pathlib import Path
from kestra import Kestra
applied = skipped = 0
for path, edits in json.load(open("edits.json")).items():
text = Path("repo", path).read_text(encoding="utf-8")
for edit in edits:
if text.count(edit["find"]) == 1:
text = text.replace(edit["find"], edit["replace"])
applied += 1
else:
skipped += 1
target = Path("edited", path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(text, encoding="utf-8")
if applied == 0:
raise SystemExit("None of the approved edits still apply to the base branch. Someone may have fixed the docs already.")
Kestra.outputs({"applied": applied, "skipped": skipped})
- id: push_branch
type: io.kestra.plugin.git.PushExecutionFiles
description: Commit only the edited pages to docs-drift/pr-<number> at their
real paths. delete is off, so no other file in the repository
is touched.
url: "https://github.com/{{ inputs.repository }}.git"
username: "{{ secret('GITHUB_TOKEN') }}"
password: "{{ secret('GITHUB_TOKEN') }}"
branch: "docs-drift/pr-{{ inputs.pr_number }}"
gitDirectory: .
delete: false
authorName: Kestra docs drift guard
commitMessage: "docs: update for the changes in #{{ inputs.pr_number }}"
filesMap: "{ {% for file in outputs.reapply.outputFiles %}{{ file.key |
replace({'edited/': ''}) | toJson }}: {{ file.value | toJson
}}{% if not loop.last %}, {% endif %}{% endfor %} }"
retry:
type: exponential
interval: PT10S
maxInterval: PT2M
maxAttempts: 3
- id: open_docs_pr
type: io.kestra.plugin.github.pulls.Create
description: Open the docs pull request with the reasons for every change.
oauthToken: "{{ secret('GITHUB_TOKEN') }}"
repository: "{{ inputs.repository }}"
sourceBranch: "docs-drift/pr-{{ inputs.pr_number }}"
targetBranch: "{{ fromJson(outputs.pull_request.body).base.ref }}"
title: "docs: update for the changes in #{{ inputs.pr_number }}"
body: |
#{{ inputs.pr_number }} removed or renamed `{{ outputs.match.vars.removed_identifiers | join('`, `') }}`, and these pages still referred to them.
{{ outputs.patch.vars.summary }}
{{ outputs.approval.onResume.note ?? '' }}
Proposed by the Kestra docs drift guard and approved before opening.
retry:
type: exponential
interval: PT10S
maxInterval: PT2M
maxAttempts: 3
- id: comment_on_source_pr
type: io.kestra.plugin.github.issues.Comment
description: Link the docs follow-up from the pull request that caused it.
oauthToken: "{{ secret('GITHUB_TOKEN') }}"
repository: "{{ inputs.repository }}"
issueNumber: "{{ inputs.pr_number }}"
body: "📝 The docs still described what this pull request changed. Follow-up: {{
outputs.open_docs_pr.pullRequestUrl }}"
retry:
type: exponential
interval: PT10S
maxInterval: PT2M
maxAttempts: 3
- id: remember
type: io.kestra.plugin.core.flow.If
description: Record that this pull request was handled, whatever the outcome, so
it is never processed twice. Dry runs leave no state.
condition: "{{ not inputs.dry_run }}"
then:
- id: save_outcome
type: io.kestra.plugin.core.kv.Set
key: "docs-drift-{{ inputs.repository | replace({'/': '-'}) | slugify }}-{{
inputs.pr_number }}"
kvType: JSON
value: |
{
"pages_matched": {{ outputs.match.vars.candidate_count }},
"files_changed": {{ outputs.patch.vars.files_changed ?? 0 }},
"decision": {{ (outputs.approval.onResume.decision ?? 'no change needed') | toJson }},
"docs_pr": {{ (outputs.open_docs_pr.pullRequestUrl ?? '') | toJson }},
"execution": "{{ execution.id }}"
}
errors:
- id: alert_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Alert with the failed task, the error and a link. Nothing is saved,
so the run can simply be retried.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
retry:
type: constant
interval: PT10S
maxAttempts: 3
messageText: |
❌ *Docs drift guard FAILED* for {{ inputs.repository }}#{{ inputs.pr_number }} (flow {{ flow.namespace }}.{{ flow.id }}, execution {{ execution.id }}).
Failed task: {{ (errorLogs() | length) > 0 ? errorLogs()[0].taskId : 'unknown' }}. Error: {{ (errorLogs() | length) > 0 ? errorLogs()[0].message : 'see the execution logs' }}
{{ kestra.url ?? '' }}/ui/main/executions/{{ flow.namespace }}/{{ flow.id }}/{{ execution.id }}
outputs:
- id: pages_matched
type: INT
displayName: Pages matched
description: Documentation pages that still mention an identifier the pull
request removed or renamed.
value: "{{ outputs.match.vars.candidate_count ?? 0 }}"
- id: docs_patch
type: STRING
displayName: Proposed docs patch
description: Internal storage URI of docs.diff, the unified diff of the validated edits.
value: "{{ outputs.patch.outputFiles['docs.diff'] ?? 'none' }}"
- id: docs_pr_url
type: STRING
displayName: Docs pull request
value: "{{ outputs.open_docs_pr.pullRequestUrl ?? 'none' }}"