id: github-ai-issues-digest
namespace: company.team
description: |
Fetch recent GitHub issues, summarize and classify each issue with OpenAI,
write a Markdown digest to Kestra internal storage, and optionally send it to Slack.
triggers:
- id: every_morning
type: io.kestra.plugin.core.trigger.Schedule
description: Run the digest every morning at 08:00.
cron: "0 8 * * *"
inputs:
- id: github_owner
type: STRING
displayName: GitHub owner
description: GitHub user or organization that owns the repository.
defaults: kestra-io
required: true
- id: github_repository
type: STRING
displayName: GitHub repository
description: Repository name without the owner.
defaults: kestra
required: true
- id: labels
type: STRING
displayName: Issue labels
description: Optional comma-separated labels to include. Leave empty to include
every label.
defaults: ""
- id: window_hours
type: INT
displayName: Lookback window in hours
description: Include issues updated within this number of hours.
defaults: 24
required: true
- id: max_issues
type: INT
displayName: Maximum issues
description: Maximum number of issues sent to the model in one execution.
defaults: 20
required: true
- id: ai_model
type: STRING
displayName: OpenAI model
description: OpenAI chat model used for per-issue analysis and digest composition.
defaults: gpt-4o-mini
required: true
- id: notify_slack
type: BOOL
displayName: Send to Slack
description: If true, post the generated digest to the Slack webhook.
defaults: false
tasks:
- id: fetch_issues
type: io.kestra.plugin.core.http.Request
description: Fetch recently updated issues from the configured GitHub repository.
uri: "https://api.github.com/repos/{{ inputs.github_owner }}/{{
inputs.github_repository
}}/issues?state=all&sort=updated&direction=desc&per_page={{
inputs.max_issues }}"
method: GET
headers:
Authorization: "Bearer {{ secret('GITHUB_TOKEN') }}"
Accept: "application/vnd.github+json"
X-GitHub-Api-Version: "2022-11-28"
retry:
type: exponential
interval: PT2S
maxInterval: PT30S
maxAttempts: 3
- id: prepare_issues
type: io.kestra.plugin.scripts.python.Script
description: Keep real issues, apply the time and label filters, and limit the
batch size.
dependencies:
- kestra
script: |
import datetime as dt
import json
from kestra import Kestra
raw_issues = json.loads(r'''{{ outputs.fetch_issues.body }}''')
labels_filter = {
label.strip().lower()
for label in r'''{{ inputs.labels }}'''.split(",")
if label.strip()
}
since = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=int("{{ inputs.window_hours }}"))
def is_recent(issue):
timestamp = issue.get("updated_at") or issue.get("created_at")
if not timestamp:
return False
try:
updated = dt.datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
except ValueError:
return False
return updated >= since
issues = []
for issue in raw_issues:
if "pull_request" in issue or not is_recent(issue):
continue
issue_labels = [
label.get("name", "")
for label in issue.get("labels", [])
if isinstance(label, dict)
]
if labels_filter and not labels_filter.intersection(
label.lower() for label in issue_labels
):
continue
issues.append({
"number": issue.get("number"),
"title": issue.get("title", ""),
"body": (issue.get("body") or "")[:6000],
"state": issue.get("state", ""),
"labels": issue_labels,
"author": (issue.get("user") or {}).get("login", "unknown"),
"updated_at": issue.get("updated_at"),
"url": issue.get("html_url"),
})
if len(issues) >= int("{{ inputs.max_issues }}"):
break
Kestra.outputs({
"issues": issues,
"issue_count": len(issues),
"repository": "{{ inputs.github_owner }}/{{ inputs.github_repository }}",
})
- id: summarize_each_issue
type: io.kestra.plugin.core.flow.ForEach
description: Analyze every filtered issue independently so one execution covers
the whole batch.
values: "{{ outputs.prepare_issues.vars.issues }}"
concurrencyLimit: 4
tasks:
- id: analyze_issue
type: io.kestra.plugin.openai.ChatCompletion
description: Classify urgency and produce a concise, actionable issue summary.
apiKey: "{{ secret('OPENAI_API_KEY') }}"
model: "{{ inputs.ai_model }}"
maxTokens: 350
messages:
- role: system
content: |
You are an engineering triage assistant. Return valid Markdown only.
Analyze the supplied GitHub issue and use exactly this structure:
**Priority:** one of P0, P1, P2, P3
**Category:** one short category
**Summary:** one sentence
**Next step:** one concrete recommended action
Do not invent facts, owners, deadlines, or reproduction steps.
- role: user
content: |
Repository: {{ outputs.prepare_issues.vars.repository }}
Issue JSON:
{{ taskrun.value | json }}
retry:
type: exponential
interval: PT2S
maxInterval: PT20S
maxAttempts: 2
- id: compose_digest
type: io.kestra.plugin.openai.ChatCompletion
description: Turn the per-issue analyses into one readable daily Markdown report.
apiKey: "{{ secret('OPENAI_API_KEY') }}"
model: "{{ inputs.ai_model }}"
maxTokens: 1800
messages:
- role: system
content: |
You are an engineering manager preparing a daily GitHub issue digest.
Return Markdown only. Start with "# GitHub Issues Digest".
Include the repository and a one-line count, then one section per issue.
Preserve issue numbers, links, priorities, categories, summaries, and next steps.
If no issues were found, say so clearly and do not fabricate an issue.
- role: user
content: |
Repository: {{ outputs.prepare_issues.vars.repository }}
Issues analyzed: {{ outputs.prepare_issues.vars.issue_count }}
Per-issue analyses:
{{ outputs.summarize_each_issue }}
retry:
type: exponential
interval: PT2S
maxInterval: PT20S
maxAttempts: 2
- id: write_digest
type: io.kestra.plugin.core.storage.Write
description: Persist the Markdown report in Kestra internal storage.
content: "{{ outputs.compose_digest.choices[0].message.content }}"
extension: ".md"
- id: notify_if_enabled
type: io.kestra.plugin.core.flow.If
description: Send the report to Slack only when explicitly enabled.
condition: "{{ inputs.notify_slack }}"
then:
- id: send_to_slack
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Deliver the digest to the configured Slack incoming webhook.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: "{{ outputs.compose_digest.choices[0].message.content }}"
outputs:
- id: report
type: URI
value: "{{ outputs.write_digest.uri }}"
- id: issue_count
type: INT
value: "{{ outputs.prepare_issues.vars.issue_count }}"