diff --git a/.github/workflows/ci-status-watch.yml b/.github/workflows/ci-status-watch.yml new file mode 100644 index 000000000..1a4067d3d --- /dev/null +++ b/.github/workflows/ci-status-watch.yml @@ -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. + 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 \ + --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" diff --git a/.github/workflows/issue-pr-observer.yml b/.github/workflows/issue-pr-observer.yml index ab540a856..98b913f74 100644 --- a/.github/workflows/issue-pr-observer.yml +++ b/.github/workflows/issue-pr-observer.yml @@ -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鈥揝un); 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) @@ -33,14 +58,16 @@ 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), @@ -48,40 +75,79 @@ jobs: 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"