Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .github/workflows/ci-status-watch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: CI status watch

# Posts a Teams message listing the README badge workflows that are currently red on the
# default branch. A workflow stays in the list until it is green again, so the reminder
# repeats every weekday morning until somebody fixes it. Creates no issues, read-only.
# Requires the repository secret TEAMS_WEBHOOK_URL.

on:
workflow_dispatch:
schedule:
- cron: '0 7 * * 1-5' # 07:00 UTC, Mon-Fri (= 09:00 CEST / 08:00 CET)

permissions:
actions: read

jobs:
watch:
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WEBHOOK: ${{ secrets.TEAMS_WEBHOOK_URL }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.ref_name }}
steps:
- name: Detect newly red badge workflows and notify Teams
run: |
set -euo pipefail

# The workflows backing the README badges. Add a line here if a badge is added.
WORKFLOWS="
.github/workflows/build.yml
.github/workflows/update-urls.yml
.github/workflows/nightly-build.yml
.github/workflows/integration-tests.yml
"

echo "Checking badge workflows on branch $BRANCH"

# A workflow counts as red if its newest completed run that produced a real result
# failed; cancelled/skipped runs say nothing about the code and are ignored. This is
# exactly what the README badge shows. There is no look-back window on purpose: as
# long as it stays red it is reported every morning, so nobody can miss it.
Comment on lines +39 to +42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up to the --limit thread: documenting why the limit is what it is, so the next reader does not read it as dead weight either.

Suggested change
# A workflow counts as red if its newest completed run that produced a real result
# failed; cancelled/skipped runs say nothing about the code and are ignored. This is
# exactly what the README badge shows. There is no look-back window on purpose: as
# long as it stays red it is reported every morning, so nobody can miss it.
# A workflow counts as red if its newest completed run that produced a real result
# failed; cancelled/skipped runs say nothing about the code and are ignored. This is
# exactly what the README badge shows. There is no look-back window on purpose: as
# long as it stays red it is reported every morning, so nobody can miss it.
# The limit is not about how many runs we evaluate, that is always one. It defines how
# far we can look past a streak of cancelled runs; 5 covers the longest streak seen (4).

RED=$(for WF_PATH in $WORKFLOWS; do
gh run list --repo "$REPO" --workflow "$WF_PATH" --branch "$BRANCH" \
--status completed --limit 5 --json conclusion,url,updatedAt,workflowName \

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you increased the limit from 2 to 5. However, we only take the first result [0] and ignore the rest anyway.
IMHO the old limit was more reasonable (could be even 1).

Suggested change
--status completed --limit 5 --json conclusion,url,updatedAt,workflowName \
--status completed --limit 2 --json conclusion,url,updatedAt,workflowName \

@quando632 quando632 Jul 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The [0] does not act on the API result but on the filtered array: [ .[] | select(...) ][0] first drops cancelled/skipped runs, then takes the newest of what is left. So --limit controls how far we can look past a streak of cancelled runs, not how many results we use. With --limit 1 a single cancelled run on top hides whatever sits underneath.

Real streaks on main: nightly-build 3 in a row (16 to 18 Dec 2025), integration-tests 4 in a row (9 to 17 Dec 2025). With --limit 2 a red run below either of them would be reported as not red. I chose 5 because it covers the longest streak we have actually seen.

It is also free: gh run list issues the same 2 API requests for --limit 2 and --limit 5.

That it reads the other way means the line needs a comment. A suggestion in this thread would only replace this line, so I put it on the comment block above (lines 39 to 42) as a separate comment.

--jq '[ .[] | select(.conclusion != "cancelled" and .conclusion != "skipped") ][0]
| select(. != null)
| select(.conclusion == "failure" or .conclusion == "timed_out" or .conclusion == "startup_failure")
| {name: .workflowName, url: .url, since: .updatedAt}' || true
done | jq -s '.')

COUNT=$(echo "$RED" | jq 'length')
if [ "$COUNT" -eq 0 ]; then
echo "No badge workflow is red. Skipping Teams notification."
exit 0
fi
echo "$COUNT badge workflow(s) currently red."

if [ -z "${WEBHOOK:-}" ]; then
echo "No Teams webhook set, skipping notification."; exit 0
fi

CARD=$(echo "$RED" | jq --argjson count "$COUNT" '{
type: "AdaptiveCard",
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
version: "1.4",
body: (
[
{ type: "TextBlock", size: "Large", weight: "Bolder", wrap: true, color: "attention",
text: "🔴 CI ist rot" },
{ type: "TextBlock", spacing: "None", isSubtle: true, wrap: true,
text: (if $count == 1 then "1 Workflow ist rot" else "\($count) Workflows sind rot" end) }
]
+ [ .[] | {
type: "Container",
separator: true,
spacing: "Medium",
items: [
{ type: "TextBlock", weight: "Bolder", wrap: true, text: "🔴 \(.name)" },
{ type: "TextBlock", spacing: "None", isSubtle: true, size: "Small", wrap: true,
text: "rot seit \(.since) · [Run-Log öffnen](\(.url))" }
]
} ]
)
}')

curl -sS --fail -X POST -H "Content-Type: application/json" -d "$CARD" "$WEBHOOK"
154 changes: 110 additions & 44 deletions .github/workflows/issue-pr-observer.yml
Original file line number Diff line number Diff line change
@@ -1,30 +1,55 @@
name: External Issue & PR Observer
name: External Issue, PR & Discussion Observer

# Posts a weekday-morning digest of new EXTERNAL issues/PRs (last 24h) to a Teams
# channel so incoming reports from outside the team are noticed quickly and don't slip.
# "External" = author_association is not MEMBER, OWNER or COLLABORATOR.
# Posts a weekday-morning digest of new EXTERNAL issues, PRs and discussions (last 24h)
# to a Teams channel so incoming activity from outside the team is noticed quickly and
# "External" = author is not in the DAILY_TEAM list below.
# Issues/PRs come from the REST search API, discussions from GraphQL (no REST equivalent).
# Each source falls back to empty on error, so one failing API still posts the rest.
# Requires the repository secret TEAMS_WEBHOOK_URL (a Teams "Workflows" incoming webhook).

on:
workflow_dispatch:
workflow_dispatch:
schedule:
- cron: '0 7 * * 1-5' # 07:00 UTC, Mon-Fri (= 09:00 CEST summer / 08:00 CET winter)

permissions:
issues: read
pull-requests: read
discussions: read
contents: read

jobs:
observe:
runs-on: ubuntu-latest
steps:
- name: Scan for new external issues & PRs and notify Teams
- name: Scan for new external issues, PRs & discussions and notify Teams
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WEBHOOK: ${{ secrets.TEAMS_WEBHOOK_URL }}
REPO: ${{ github.repository }}
# Daily-team logins to exclude; one per line, case-insensitive.
DAILY_TEAM: |
AdemZarrouki
Ali-Shariati-Najafabadi
areinicke
Caylipp
hohwille
JoelAdbu
KarimALotfy
laert-ll
laim2003
marceltchanga9
maybeec
oanding-blrng
Paras14
quando632
shodiBoy1
tineff96
vivu001
run: |
# so a failing gh api trips the || fallback instead of being hidden by the trailing jq
set -o pipefail # no `set -e`: the per-source `|| { ... ITEMS='[]'; }` fallbacks must keep the script running

# Monday catches up the weekend (Fri–Sun); other weekdays look back 24h.
if [ "$(date -u +%u)" -eq 1 ]; then
SINCE=$(date -u -d '3 days ago' +%Y-%m-%dT%H:%M:%SZ)
Expand All @@ -33,55 +58,96 @@ jobs:
fi
echo "Scanning $REPO for items created since $SINCE"

# Search returns both issues and PRs; keep only external authors (not on the team)
# and emit a structured object per item so the card can lay them out nicely.
ITEMS_JSON=$(gh api -X GET search/issues \
-f q="repo:$REPO created:>=$SINCE" \
--jq '[.items[]
| select(.author_association != "MEMBER"
and .author_association != "OWNER"
and .author_association != "COLLABORATOR")
# team logins as a lowercase JSON array
TEAM=$(printf '%s\n' "$DAILY_TEAM" | grep -vE '^[[:space:]]*$' \
| tr -d ' \t\r' | tr '[:upper:]' '[:lower:]' | jq -R . | jq -s .)
echo "Excluding $(echo "$TEAM" | jq 'length') daily-team member(s)."

# Issues + PRs via REST search; drop team authors, keep the rest. On error use [].
ISSUE_ITEMS=$(gh api -X GET search/issues \
-f q="repo:$REPO created:>=$SINCE" -f per_page=100 --jq '.items' \
| jq --argjson team "$TEAM" '[.[]
| select((.user.login | ascii_downcase) as $u | $team | index($u) | not)
| {
kind: (if .pull_request then "Pull Request" else "Issue" end),
icon: (if .pull_request then "🔵" else "🟢" end),
number: .number,
title: .title,
url: .html_url,
user: .user.login
}]')
}]') \
|| { echo "WARN: issue/PR fetch failed, continuing without them."; ISSUE_ITEMS='[]'; }

# No REST search for discussions; take newest 50 via GraphQL, filter by window + team.
OWNER=${REPO%/*}
NAME=${REPO#*/}
DISC_ITEMS=$(gh api graphql \
-f owner="$OWNER" -f name="$NAME" \
-f query='
query($owner:String!, $name:String!) {
repository(owner:$owner, name:$name) {
discussions(first:50, orderBy:{field:CREATED_AT, direction:DESC}) {
nodes { number title url createdAt author { login } }
}
}
}' \
--jq '.data.repository.discussions.nodes' \
| jq --arg since "$SINCE" --argjson team "$TEAM" '[ .[]
| select(.createdAt >= $since)
| select(((.author.login // "") | ascii_downcase) as $u | $team | index($u) | not)
| {
kind: "Discussion",
icon: "💬",
number: .number,
title: .title,
url: .url,
user: (.author.login // "unknown")
} ]') \
|| { echo "WARN: discussion fetch failed, continuing without them."; DISC_ITEMS='[]'; }

COUNT=$(echo "$ITEMS_JSON" | jq 'length')
IP_COUNT=$(echo "$ISSUE_ITEMS" | jq 'length')
D_COUNT=$(echo "$DISC_ITEMS" | jq 'length')
COUNT=$((IP_COUNT + D_COUNT))
if [ "$COUNT" -eq 0 ]; then
echo "No new external issues or PRs. Skipping Teams-notification."
echo "No new external issues, PRs or discussions. Skipping Teams-notification."
exit 0
fi
echo "$COUNT new external issue(s)/PR(s) found."
echo "$COUNT new external item(s): $IP_COUNT issue(s)/PR(s), $D_COUNT discussion(s)."

# Teams' webhook wants a bare Adaptive Card: top-level type must be
# "AdaptiveCard", not wrapped in a message/attachments envelope.
CARD=$(echo "$ITEMS_JSON" | jq --argjson count "$COUNT" '{
type: "AdaptiveCard",
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
version: "1.4",
body: (
[
{ type: "TextBlock", size: "Large", weight: "Bolder", wrap: true,
text: "🆕 Neue externe Issues & Pull Requests" },
{ type: "TextBlock", spacing: "None", isSubtle: true, wrap: true,
text: ("Letzter Zeitraum · " + (if $count == 1 then "1 neuer Eintrag" else "\($count) neue Einträge" end)) }
]
+ [ .[] | {
type: "Container",
separator: true,
spacing: "Medium",
items: [
{ type: "TextBlock", weight: "Bolder", wrap: true,
text: "\(.icon) #\(.number) · \(.title)" },
{ type: "TextBlock", spacing: "None", isSubtle: true, size: "Small", wrap: true,
text: "\(.kind) · von @\(.user) · [Öffnen](\(.url))" }
]
} ]
)
}')
# Bare Adaptive Card with one group per source; a group is rendered only if it has
# items, so issues/PRs and discussions stay separated and empty sections are hidden.
CARD=$(jq -n \
--argjson ip "$ISSUE_ITEMS" \
--argjson disc "$DISC_ITEMS" \
--argjson count "$COUNT" '
def section(title; items):
if (items | length) > 0 then
[ { type: "TextBlock", size: "Medium", weight: "Bolder", wrap: true,
spacing: "Large", text: title } ]
+ [ items[] | {
type: "Container", separator: true, spacing: "Medium",
items: [
{ type: "TextBlock", weight: "Bolder", wrap: true,
text: "\(.icon) #\(.number) · \(.title)" },
{ type: "TextBlock", spacing: "None", isSubtle: true, size: "Small", wrap: true,
text: "\(.kind) · von @\(.user) · [Öffnen](\(.url))" }
]
} ]
else [] end;
{
type: "AdaptiveCard",
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
version: "1.4",
body: (
[
{ type: "TextBlock", size: "Large", weight: "Bolder", wrap: true,
text: "🆕 Neue externe Aktivität" },
{ type: "TextBlock", spacing: "None", isSubtle: true, wrap: true,
text: ("Letzter Zeitraum · " + (if $count == 1 then "1 neuer Eintrag" else "\($count) neue Einträge" end)) }
]
+ section("📋 Issues & Pull Requests"; $ip)
+ section("💬 Discussions"; $disc)
)
}')

curl -sS --fail -X POST -H "Content-Type: application/json" -d "$CARD" "$WEBHOOK"
Loading