id: ai-github-daily-healthcheck
namespace: company.team
description: |
Pull issues, pull requests, and workflow runs from a GitHub repo in
parallel, compute KPIs in Python, summarize with an AI agent, and post
the daily brief to Slack, with retries, an SLA, and failure alerting.
inputs:
- id: github_repo
type: STRING
defaults: kestra-io/kestra
required: true
- id: window_hours
type: INT
defaults: 24
required: true
tasks:
- id: run_parallel
type: io.kestra.plugin.core.flow.Parallel
description: Call the three GitHub API endpoints concurrently.
allowFailure: true
tasks:
- id: github_issues
type: io.kestra.plugin.core.http.Request
uri: "https://api.github.com/repos/{{ inputs.github_repo
}}/issues?per_page=20&state=all&sort=updated&direction=desc"
- id: github_prs
type: io.kestra.plugin.core.http.Request
uri: "https://api.github.com/repos/{{ inputs.github_repo
}}/pulls?per_page=20&state=all&sort=updated&direction=desc"
method: GET
- id: github_actions
type: io.kestra.plugin.core.http.Request
uri: "https://api.github.com/repos/{{ inputs.github_repo
}}/actions/runs?per_page=20"
method: GET
- id: concatenate_data
type: io.kestra.plugin.core.storage.Write
description: Merge the three API responses into one JSON file in internal storage.
content: |
{
"issues": {{ outputs.github_issues.body ?? '[]' }},
"prs": {{ outputs.github_prs.body ?? '[]' }},
"actions": {{ outputs.github_actions.body ?? '[]' }}
}
extension: ".json"
- id: filter_prep_metrics
type: io.kestra.plugin.scripts.python.Script
description: Filter activity to the time window and compute repo KPIs.
dependencies:
- kestra
script: |
import json
import datetime as dt
from kestra import Kestra
def within_window(iso, since):
try:
t = dt.datetime.fromisoformat(iso.replace("Z", "+00:00"))
return t >= since
except Exception:
return False
hours = int("{{ inputs.window_hours }}")
since = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=hours)
issues, prs, actions = [], [], []
logger = Kestra.logger()
with open("{{ outputs.concatenate_data.uri }}") as f:
payload = json.load(f)
issues = payload.get('issues', [])
prs = payload.get('prs', [])
actions = payload.get('actions', [])
logger.info(f"issues: {len(issues)}, prs: {len(prs)}, actions: {len(actions)}")
select_fields = ('title', 'body', 'created_at', 'user', 'state', 'html_url', 'reactions', 'pull_request', 'merged_at', 'updated_at')
issues = [{k: (v if k != 'user' else v['login']) for k, v in x.items() if k in select_fields} for x in issues if within_window(x.get('created_at', ''), since)]
prs = [{k: (v if k != 'user' else v['login']) for k, v in x.items() if k in select_fields} for x in prs if within_window(x.get('created_at', ''), since)]
actions = [{
"run_number": item.get("run_number"),
"name": item.get("name") or item.get("display_title"),
"event": item.get("event"),
"status": item.get("status"),
"conclusion": item.get("conclusion"),
"started_at": item.get("run_started_at") or item.get("created_at"),
"actor": (item.get("actor") or {}).get("login"),
"url": item.get("html_url"),
"pr_numbers": [pr.get("number") for pr in item.get("pull_requests", []) if isinstance(pr, dict)],
"commit_message": (item.get("head_commit", {}) or {}).get("message", "").splitlines()[0] if item.get("head_commit") else None,
} for item in actions.get('workflow_runs', []) if within_window(item.get('updated_at', ''), since)]
new_issues = [i for i in issues if 'pull_request' not in i and i.get('state') == 'open']
closed_issues = [i for i in issues if 'pull_request' not in i and i.get('state') == 'closed']
opened_prs = [p for p in prs if p.get('state') in ('open', 'closed')]
merged_prs = [p for p in prs if p.get('merged_at')]
failed_runs = [r for r in actions if r.get('conclusion') in ('failure', 'cancelled', 'timed_out')]
Kestra.outputs(
{
"new_issues": new_issues,
"closed_issues": closed_issues,
"opened_prs": opened_prs,
"merged_prs": merged_prs,
"failed_runs": failed_runs,
}
)
- id: ai_day_brief
type: io.kestra.plugin.ai.agent.AIAgent
description: Turn the KPIs into a short, prioritized daily brief.
systemMessage: |
You are a concise assistant.
Use the provided JSON to write a short, friendly daily summary of GitHub repo activity.
- Include only the MOST pressing items.
- Keep it to 15 items.
- Include GitHub issue or PR ids.
- Include a concise and short snippet of the underlying data.
- Format the response in markdown.
- Include the following heading sections: new issues, closed issues, open prs, merged prs, failed runs.
- Produce nicely readable output.
After the summary, propose a quick plan of attack for our dev team under a section heading "Plan of Attack".
prompt: |
Repo: {{ inputs.github_repo }}
Time window: last {{ inputs.window_hours }} hours
new issues: {{ outputs.filter_prep_metrics.vars['new_issues'] ?? 'None' }}
---
closed issues: {{ outputs.filter_prep_metrics.vars['closed_issues'] ?? 'None' }}
---
opened prs: {{ outputs.filter_prep_metrics.vars['opened_prs'] ?? 'None' }}
---
merged prs: {{ outputs.filter_prep_metrics.vars['merged_prs'] ?? 'None' }}
---
failed runs: {{ outputs.filter_prep_metrics.vars['failed_runs'] ?? 'None' }}
- id: log_output
type: io.kestra.plugin.core.log.Log
description: Keep the brief in the execution logs.
message: |
{{ outputs.ai_day_brief.textOutput }}
- id: post_to_slack
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Deliver the daily brief to the team channel.
url: "{{ secret('SLACK_WEBHOOK') }}"
messageText: "{{ outputs.ai_day_brief.textOutput }}"
triggers:
- id: every_morning
type: io.kestra.plugin.core.trigger.Schedule
description: Run the healthcheck each morning before standup.
cron: "0 8 * * *"
errors:
- id: slack_fail
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Alert the channel if the healthcheck itself fails.
url: "{{ secret('SLACK_WEBHOOK') }}"
messageText: ":rotating_light: Flow {{ flow.id }} - Error: {{ error.message }}"
sla:
- id: must_finish_fast
type: MAX_DURATION
duration: PT10M
behavior: CANCEL
labels:
sla: miss
reason: "Exceeded allowed execution time"
outputs:
- id: brief
type: STRING
value: "{{ outputs.ai_day_brief.textOutput }}"
pluginDefaults:
- type: io.kestra.plugin.ai.agent.AIAgent
values:
provider:
type: io.kestra.plugin.ai.provider.GoogleGemini
modelName: gemini-3.5-flash-lite
apiKey: "{{ secret('GEMINI_API_KEY') }}"
allowFailure: true
configuration:
chatConfiguration:
logRequests: true
retry:
type: constant
interval: PT2S
maxAttempts: 2
- type: io.kestra.plugin.core.http.Request
values:
headers:
Accept: "application/json"
allowFailure: true
retry:
type: exponential
interval: PT1S
maxInterval: PT10S
maxAttempts: 3