id: github-pr-contribution-rules-check
namespace: company.team
inputs:
- id: repository
type: STRING
displayName: Repository
description: owner/repo of the catalog that receives contributions.
defaults: kestra-io/blueprints
- id: pr_number
type: INT
displayName: Pull request number
description: Used for manual runs. Webhook runs read the number from the GitHub event.
defaults: 1
- id: max_open_prs
type: INT
displayName: Max open PRs per author
description: The program rule. A contributor with more open PRs than this gets a
reminder.
defaults: 2
- id: count_scope
type: SELECT
displayName: Count open PRs across
description: >
ORGANIZATION counts the author's open PRs in every repository of the owner
(for a program that spans several repositories). REPOSITORY counts only
this one.
values:
- ORGANIZATION
- REPOSITORY
defaults: ORGANIZATION
- id: dry_run
type: BOOL
displayName: Dry run
description: When true, the report is only logged. Set it to false to post or
update the comment on the pull request.
defaults: true
variables:
pr: "{{ trigger.body.pull_request.number ?? inputs.pr_number }}"
allowed_tags:
[
"Data",
"Infrastructure",
"Business",
"Cloud",
"Core",
"AI",
"Getting Started",
"System"
]
concurrency:
limit: 1
behavior: QUEUE
triggers:
- id: on_pull_request
type: io.kestra.plugin.core.trigger.Webhook
description: >
Point a GitHub webhook (content type application/json, event "Pull
requests") at
/api/v1/main/executions/webhook/company.team/github-pr-contribution-rules-check/<key>.
Replace the key with a long random value first.
key: replace-with-a-long-random-key
tasks:
- id: relevant_event
type: io.kestra.plugin.core.flow.If
description: Check only when a PR is opened, reopened or gets new commits.
Manual runs always check.
condition: "{{ ['manual', 'opened', 'reopened', 'synchronize',
'ready_for_review'] contains (trigger.body.action ?? 'manual') }}"
then:
- id: inspect
type: io.kestra.plugin.scripts.python.Script
description: >
Read the pull request and every changed flow file at the PR head, then
check each flow against the contribution rules. Prints the findings as
Kestra outputs.
containerImage: python:3.12-slim
beforeCommands:
- pip install -q requests==2.32.3 PyYAML==6.0.2
env:
GITHUB_TOKEN: "{{ secret('GITHUB_TOKEN') }}"
REPO: "{{ inputs.repository }}"
PR: "{{ render(vars.pr) }}"
ALLOWED_TAGS: "{{ vars.allowed_tags | toJson }}"
script: |
import json, os, re, requests, yaml
api = "https://api.github.com"
h = {"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}", "Accept": "application/vnd.github+json"}
repo, pr = os.environ["REPO"], int(os.environ["PR"])
allowed = set(json.loads(os.environ["ALLOWED_TAGS"]))
FLOWABLE = {"If", "Switch", "Parallel", "Loop", "LoopUntil", "ForEach", "ForEachItem",
"Sequential", "Dag", "EachParallel", "EachSequential", "AllowFailure"}
def get(url, **params):
r = requests.get(url, headers=h, params=params, timeout=30)
r.raise_for_status()
return r.json()
p = get(f"{api}/repos/{repo}/pulls/{pr}")
files = []
page = 1
while True:
batch = get(f"{api}/repos/{repo}/pulls/{pr}/files", per_page=100, page=page)
files += batch
if len(batch) < 100:
break
page += 1
def walk(tasks, acc):
for t in tasks or []:
if not isinstance(t, dict):
continue
short = str(t.get("type", "")).split(".")[-1]
core = str(t.get("type", "")).startswith("io.kestra.plugin.core.")
acc["flowable" if core and short in FLOWABLE else "runnable"].append(t.get("type", ""))
for k in ("tasks", "then", "else", "errors", "finally", "defaults"):
walk(t.get(k), acc)
for branch in (t.get("cases") or {}).values():
walk(branch, acc)
reports = []
for f in files:
name = f["filename"]
if f["status"] == "removed" or not re.match(r"^flows/[^/]+\.ya?ml$", name):
continue
raw = requests.get(f["raw_url"], headers=h, timeout=30).text
checks = []
try:
d = yaml.safe_load(raw) or {}
except yaml.YAMLError as e:
reports.append({"file": name, "checks": [["YAML parses", False, str(e).splitlines()[0]]]})
continue
stem = name.rsplit("/", 1)[1].rsplit(".", 1)[0]
acc = {"runnable": [], "flowable": []}
walk(d.get("tasks"), acc)
walk(d.get("errors"), acc)
walk(d.get("finally"), acc)
ext = d.get("extend") or {}
tags = ext.get("tags") or []
meta = ext.get("metaDescription") or ""
non_core = sorted({t for t in acc["runnable"] + [x.get("type", "") for x in d.get("triggers") or [] if isinstance(x, dict)]
if t and not t.startswith("io.kestra.plugin.core.")})
hardcoded = re.findall(r"(?i)(password|token|api[_-]?key|secret)\s*:\s*[\"']?(?!\{\{)[A-Za-z0-9_\-]{12,}", raw)
checks += [
["`id` matches the file name", d.get("id") == stem, f"id `{d.get('id')}`"],
["`namespace` set", bool(d.get("namespace")), ""],
["At least 3 runnable tasks", len(acc["runnable"]) >= 3, f"{len(acc['runnable'])} found"],
["At least 1 flow task (If, Switch, Parallel, Loop...)", len(acc["flowable"]) >= 1, f"{len(acc['flowable'])} found"],
["At least 1 trigger", bool(d.get("triggers")), f"{len(d.get('triggers') or [])} found"],
["At least 1 non-core plugin", bool(non_core), ", ".join(t.split(".")[-1] for t in non_core[:3])],
["`extend` has title, description, metaDescription, tags, ee, demo",
all(k in ext for k in ("title", "description", "metaDescription", "tags", "ee", "demo")),
("missing: " + ", ".join(k for k in ("title", "description", "metaDescription", "tags", "ee", "demo") if k not in ext)) if not all(k in ext for k in ("title", "description", "metaDescription", "tags", "ee", "demo")) else ""],
["`metaDescription` at most 160 characters", 0 < len(meta) <= 160, f"{len(meta)} characters"],
["At most 3 tags from the allowed list", 0 < len(tags) <= 3 and set(tags) <= allowed,
", ".join(str(t) for t in tags) or "none"],
["No hardcoded credentials", not hardcoded, f"{len(hardcoded)} suspicious value(s)"],
]
reports.append({"file": name, "checks": checks})
other = [f["filename"] for f in files if not re.match(r"^flows/[^/]+\.ya?ml$", f["filename"])]
failed = sum(1 for r in reports for c in r["checks"] if not c[1])
out = {
"author": p["user"]["login"],
"state": p["state"],
"title": p["title"],
"flow_files": len(reports),
"other_files": ", ".join(other),
"failed_checks": failed,
"reports": json.dumps(reports),
}
print("::" + json.dumps({"outputs": out}) + "::")
- id: author_open_prs
type: io.kestra.plugin.github.pulls.Search
description: Count the open pull requests of this author in the organization or
the repository.
oauthToken: "{{ secret('GITHUB_TOKEN') }}"
query: "{{ inputs.count_scope == 'ORGANIZATION' ? 'org:' ~ (inputs.repository |
split('/') | first) : 'repo:' ~ inputs.repository }} is:pr is:open
author:{{ outputs.inspect.vars.author }}"
fetchType: FETCH
- id: report
type: io.kestra.plugin.core.output.OutputValues
description: Build one Markdown report from the file checks and the open PR count.
values:
over_cap: "{{ outputs.author_open_prs.size > inputs.max_open_prs }}"
ok: "{{ outputs.inspect.vars.failed_checks == 0 and outputs.author_open_prs.size
<= inputs.max_open_prs }}"
markdown: |
### Contribution rules check
{% if outputs.author_open_prs.size > inputs.max_open_prs %}
:warning: @{{ outputs.inspect.vars.author }} has **{{ outputs.author_open_prs.size }} open pull request{{ outputs.author_open_prs.size == 1 ? '' : 's' }}** in {{ inputs.count_scope == 'ORGANIZATION' ? (inputs.repository | split('/') | first) ~ ' repositories' : 'this repository' }}. The program allows {{ inputs.max_open_prs }} at a time. Please close or finish the extra ones before opening new ones: {% for p in outputs.author_open_prs.rows %}{{ p.url | replace({'https://github.com/': ''}) | replace({'/pull/': '#'}) }}{% if not loop.last %}, {% endif %}{% endfor %}.
{% endif %}
{% for r in outputs.inspect.vars.reports | jq('.[]') %}
**`{{ r.file }}`**
| Rule | Result | Detail |
|---|---|---|
{% for c in r.checks %}| {{ c[0] }} | {{ c[1] ? ':white_check_mark:' : ':x:' }} | {{ c[2] }} |
{% endfor %}
{% endfor %}
{% if outputs.inspect.vars.flow_files == 0 %}No file under `flows/` changed, so the flow rules were not checked{% if outputs.inspect.vars.other_files is not empty %} (changed: {{ outputs.inspect.vars.other_files }}){% endif %}.{% endif %}
{% if outputs.inspect.vars.failed_checks == 0 and outputs.author_open_prs.size <= inputs.max_open_prs %}All checks passed. A maintainer will review this soon.{% else %}Rules: see the repository README. This comment updates itself when you push new commits.{% endif %}
<sub>Automated check by Kestra flow `{{ flow.id }}`, execution {{ execution.id }}.</sub>
- id: log_report
type: io.kestra.plugin.core.log.Log
message: "PR #{{ render(vars.pr) }} by {{ outputs.inspect.vars.author }}: {{
outputs.inspect.vars.failed_checks }} failed check(s), {{
outputs.author_open_prs.size }} open PR(s).
{{ outputs.report.values.markdown }}"
- id: post
type: io.kestra.plugin.core.flow.If
description: Post the report, or update the previous one, unless this is a dry run.
condition: "{{ not inputs.dry_run and outputs.inspect.vars.state == 'open' }}"
then:
- id: comment
type: io.kestra.plugin.github.issues.Comment
description: The update tag keeps one comment per PR, updated in place on every
push instead of adding new ones.
oauthToken: "{{ secret('GITHUB_TOKEN') }}"
repository: "{{ inputs.repository }}"
issueNumber: "{{ render(vars.pr) }}"
updateTag: contribution-rules-check
body: "{{ outputs.report.values.markdown }}"
else:
- id: ignored_event
type: io.kestra.plugin.core.log.Log
message: "Ignored pull_request action {{ trigger.body.action }}."