Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
18f2678
CI status watch and external discussion observer workflows
shodiBoy1 Jun 29, 2026
d27c73a
CI status watch and external discussion observer workflows
shodiBoy1 Jun 29, 2026
862b485
Merge branch 'main' into feature/ci-status-watch
shodiBoy1 Jun 29, 2026
60bfafb
CI status watch
shodiBoy1 Jun 30, 2026
70434a8
Merge branch 'feature/ci-status-watch' of https://github.com/shodiBoy…
shodiBoy1 Jun 30, 2026
0d36b2d
CI status watch
shodiBoy1 Jun 30, 2026
a613459
filter observer by daily team list instead of quthor association
shodiBoy1 Jul 1, 2026
f05d69d
Merge branch 'main' into feature/ci-status-watch
shodiBoy1 Jul 1, 2026
163bc41
Update issue-pr-observer.yml
shodiBoy1 Jul 1, 2026
980739c
Merge branch 'main' into feature/ci-status-watch
maybeec Jul 6, 2026
a9080ce
Apply suggestions from code review
hohwille Jul 27, 2026
67999a2
Apply suggestions from code review
hohwille Jul 30, 2026
ce0c647
Merge branch 'main' into feature/ci-status-watch
hohwille Jul 30, 2026
cf318c5
Merge remote-tracking branch 'upstream/main' into feature/ci-status-w…
maybeec Aug 16, 2026
18e3be5
Merge branch 'main' into feature/ci-status-watch
hohwille Sep 1, 2026
14dd8a2
Modify daily team logins in workflow configuration
shodiBoy1 Sep 1, 2026
c032ba2
get team members from linked project teams
shodiBoy1 Sep 1, 2026
ff395fa
retry observer activity after API failures
shodiBoy1 Sep 1, 2026
e5542aa
warn Teams when CI status checks fail
shodiBoy1 Sep 1, 2026
fc7f010
Merge branch 'main' into feature/ci-status-watch
shodiBoy1 Sep 3, 2026
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
123 changes: 123 additions & 0 deletions .github/workflows/ci-status-watch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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.
# If a status cannot be checked, Teams receives a warning to check it manually.
# 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: Check badge workflow status 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"

# Cancelled/skipped runs say nothing about the code and are ignored. A failed lookup
# is kept as an unknown status so Teams can ask the team to check it manually.
RESULTS=$(for WF_PATH in $WORKFLOWS; do
if RUNS=$(gh run list --repo "$REPO" --workflow "$WF_PATH" --branch "$BRANCH" \
--status completed --limit 5 --json conclusion,url,updatedAt,workflowName); then
echo "$RUNS" | jq --arg workflow "$WF_PATH" '
([ .[] | select(.conclusion != "cancelled" and .conclusion != "skipped") ][0]) as $run
| if $run == null then
{state: "unknown", workflow: $workflow}
elif ($run.conclusion == "failure" or $run.conclusion == "timed_out"
or $run.conclusion == "startup_failure") then
{state: "red", name: $run.workflowName, url: $run.url, since: $run.updatedAt}
else
{state: "green", workflow: $workflow}
end'
else
echo "Status lookup failed for $WF_PATH." >&2
jq -n --arg workflow "$WF_PATH" '{state: "unknown", workflow: $workflow}'
fi
done | jq -s '.')

RED=$(echo "$RESULTS" | jq '[.[] | select(.state == "red")]')
UNKNOWN=$(echo "$RESULTS" | jq '[.[] | select(.state == "unknown")]')
RED_COUNT=$(echo "$RED" | jq 'length')
UNKNOWN_COUNT=$(echo "$UNKNOWN" | jq 'length')
if [ "$RED_COUNT" -eq 0 ] && [ "$UNKNOWN_COUNT" -eq 0 ]; then
echo "No badge workflow is red. Skipping Teams notification."
exit 0
fi
echo "$RED_COUNT badge workflow(s) currently red; $UNKNOWN_COUNT status lookup(s) unknown."

if [ -z "${WEBHOOK:-}" ]; then
echo "No Teams webhook set; notification cannot be delivered."
exit 1
fi

CARD=$(jq -n \
--argjson red "$RED" \
--argjson unknown "$UNKNOWN" \
--argjson redCount "$RED_COUNT" \
--argjson unknownCount "$UNKNOWN_COUNT" \
--arg repo "$REPO" '
{
type: "AdaptiveCard",
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
version: "1.4",
body: (
[
{ type: "TextBlock", size: "Large", weight: "Bolder", wrap: true,
color: (if $redCount > 0 then "attention" else "warning" end),
text: (if $redCount > 0 then "🔴 CI ist rot" else "⚠️ CI-Status unklar" end) }
]
+ (if $redCount > 0 then
[ { type: "TextBlock", spacing: "None", isSubtle: true, wrap: true,
text: (if $redCount == 1 then "1 Workflow ist rot" else "\($redCount) Workflows sind rot" end) } ]
+ [ $red[] | {
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))" }
]
} ]
else [] end)
+ (if $unknownCount > 0 then
[
{ type: "TextBlock", weight: "Bolder", wrap: true, color: "warning",
spacing: "Large", text: "⚠️ Statusprüfung unvollständig" },
{ type: "TextBlock", wrap: true,
text: "Bitte den Status der folgenden Workflows manuell in GitHub prüfen." }
]
+ [ $unknown[] | {
type: "TextBlock", wrap: true,
text: "⚠️ [\(.workflow | split("/") | last)](https://github.com/\($repo)/actions/workflows/\(.workflow | split("/") | last))"
} ]
else [] end)
)
}')

curl -sS --fail -X POST -H "Content-Type: application/json" -d "$CARD" "$WEBHOOK"
if [ "$UNKNOWN_COUNT" -gt 0 ]; then
echo "At least one workflow status could not be checked."
exit 1
fi
207 changes: 160 additions & 47 deletions .github/workflows/issue-pr-observer.yml
Original file line number Diff line number Diff line change
@@ -1,87 +1,200 @@
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.
# Requires the repository secret TEAMS_WEBHOOK_URL (a Teams "Workflows" incoming webhook).
# 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 a member of a GitHub team linked to the IDEasy board
# (devonfw project 5).
# Issues/PRs come from the REST search API, discussions from GraphQL (no REST equivalent).
# A failed API call fails the run. The next run resumes from the last successful run,
# so activity is retried instead of being silently lost.
# Requires repository secrets TEAMS_WEBHOOK_URL (Teams "Workflows" incoming webhook)
# and TEAM_TOKEN (org Projects + Members read token).

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:
actions: read
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 }}
TEAM_TOKEN: ${{ secrets.TEAM_TOKEN }}
WEBHOOK: ${{ secrets.TEAMS_WEBHOOK_URL }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.ref_name }}
PROJECT_NUMBER: "5"
WORKFLOW: ".github/workflows/issue-pr-observer.yml"
run: |
# Monday catches up the weekend (Fri–Sun); other weekdays look back 24h.
if [ "$(date -u +%u)" -eq 1 ]; then
set -euo pipefail

# Resume from the previous successful observer run. If a GitHub API call or the
# Teams notification fails, that run stays failed and its activity is retried.
LAST_SUCCESS=$(gh run list --repo "$REPO" --workflow "$WORKFLOW" --branch "$BRANCH" \
--status success --limit 1 --json createdAt --jq '.[0].createdAt // empty') \
|| { echo "Previous successful observer run fetch failed."; exit 1; }
if [ -n "$LAST_SUCCESS" ]; then
SINCE=$LAST_SUCCESS
elif [ "$(date -u +%u)" -eq 1 ]; then
# The first Monday run catches up the weekend (Fri–Sun).
SINCE=$(date -u -d '3 days ago' +%Y-%m-%dT%H:%M:%SZ)
else
# Other first runs look back 24 hours.
SINCE=$(date -u -d '1 day ago' +%Y-%m-%dT%H:%M:%SZ)
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")
if [ -z "$TEAM_TOKEN" ]; then
echo "TEAM_TOKEN is empty."
exit 1
fi

OWNER=${REPO%/*}

# Fetch the GitHub teams explicitly linked to the IDEasy project. Project access is
# the source of truth; issue/PR assignments do not determine team membership.
PROJECT_TEAMS=$(GH_TOKEN="$TEAM_TOKEN" gh api graphql \
-F owner="$OWNER" -F project="$PROJECT_NUMBER" \
-f query='query($owner: String!, $project: Int!) {
organization(login: $owner) {
projectV2(number: $project) {
teams(first: 10) {
totalCount
nodes { slug }
}
}
}
}' \
--jq '.data.organization.projectV2.teams') \
|| { echo "Linked project-team fetch failed."; exit 1; }
if ! echo "$PROJECT_TEAMS" | jq -e '
type == "object"
and (.nodes | type == "array")
and (.totalCount | type == "number")' >/dev/null; then
echo "Linked project-team response is invalid."
exit 1
fi
if [ "$(echo "$PROJECT_TEAMS" | jq '.nodes | length')" -ne \
"$(echo "$PROJECT_TEAMS" | jq '.totalCount')" ]; then
echo "More than 10 GitHub teams are linked to project $PROJECT_NUMBER."
exit 1
fi
if [ "$(echo "$PROJECT_TEAMS" | jq '.totalCount')" -eq 0 ]; then
echo "No GitHub team is linked to project $PROJECT_NUMBER."
exit 1
fi

# Expand all linked teams (including inherited child-team members), normalize their
# logins and remove duplicates. Any failed page or team fetch fails the workflow.
TEAM=$({
for TEAM_SLUG in $(echo "$PROJECT_TEAMS" | jq -r '.nodes[].slug'); do
GH_TOKEN="$TEAM_TOKEN" gh api --method GET --paginate \
-H "X-GitHub-Api-Version: 2026-03-10" \
"/orgs/$OWNER/teams/$TEAM_SLUG/members?per_page=100" \
--jq '.[].login' || exit 1
done
} | jq -Rsc 'split("\n") | map(select(length > 0) | ascii_downcase) | unique') \
|| { echo "Linked project-team member fetch failed."; exit 1; }
if ! echo "$TEAM" | jq -e 'type == "array" and length > 0' >/dev/null; then
echo "Linked project-team member list is empty or invalid."
exit 1
fi
echo "Excluding $(echo "$TEAM" | jq 'length') linked project-team member(s)."

# Issues + PRs via REST search; drop team authors, keep the rest.
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 "Issue/PR fetch failed."; exit 1; }

# No REST search for discussions; take newest 50 via GraphQL, filter by window + team.
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 "Discussion fetch failed."; exit 1; }

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"