Skip to content

feishu-notify

feishu-notify #4247

Workflow file for this run

# Feishu (Lark) Issue mirror + event notification — observer workflow
# (Module 10 side integration, PR-3 of feishu plan v1)
#
# Mirrors GitHub Issue events to Feishu chat as message cards AND to a Feishu
# Bitable table for state tracking. SIDE INTEGRATION:
# reads labels + workflow_run completion events but does NOT transition any
# GitHub label (S2 preserved).
#
# Triggers (mirrors notion-sync.yml):
# - issues.opened / labeled / unlabeled — Issue lifecycle events
# - workflow_run.completed — module summary artifact emitted
# - push (claude/issue-*) — development branch activity
#
# Issue-number resolver (workflow_run path):
# 1. head_branch "issue-N" regex
# 2. head_commit.message "closes #N" / "fixes #N"
# 3. pull_requests[0].body (via gh api) "closes #N"
# 4. display_title first integer (fragile last resort)
# Known gap: issue-event-triggered workflows running on `dev` still skip
# (no signal in workflow_run payload). Closing that needs each module
# workflow to upload an issue-context artifact — future PR.
#
# SECURITY POSTURE (PRD §7):
# - permissions: contents: read, issues: read, actions: read
# (S1: NEVER contents:write; issues:read only)
# - FEISHU_APP_SECRET scoped per-job via `env` injection in action.yml (S3)
# - Does NOT trigger label transitions — read-only observer (S2)
# - safe() redacts all Feishu + GitHub token formats from logs (S4)
# - Silent skip when FEISHU_APP_ID / FEISHU_CHAT_ID missing — does not fail
# pipeline (aligned with notion-sync sync.mjs:410-418)
name: feishu-notify
on:
issues:
types: [opened, labeled, unlabeled]
workflow_run:
workflows:
- triage-issue
- clarify-loop
- develop-gate
- code-generate
- pr-lifecycle
- verify
- test
- review
- merge-queue
types: [completed]
push:
branches:
- 'claude/issue-*'
permissions:
actions: read
contents: read
issues: read
concurrency:
group: feishu-notify-${{ github.event.issue.number || github.event.workflow_run.id || github.ref }}
cancel-in-progress: false
jobs:
resolve-issue:
runs-on: ubuntu-latest
permissions:
contents: read
issues: read
outputs:
issue-number: ${{ steps.resolve.outputs.issue-number }}
event-type: ${{ steps.resolve.outputs.event-type }}
pr-number: ${{ steps.resolve.outputs.pr-number }}
pr-title: ${{ steps.resolve.outputs.pr-title }}
pr-url: ${{ steps.resolve.outputs.pr-url }}
issue-title: ${{ steps.resolve.outputs.issue-title }}
issue-state: ${{ steps.resolve.outputs.issue-state }}
issue-labels-csv: ${{ steps.resolve.outputs.issue-labels-csv }}
issue-assignees-csv: ${{ steps.resolve.outputs.issue-assignees-csv }}
issue-updated-at: ${{ steps.resolve.outputs.issue-updated-at }}
steps:
- name: Resolve context from trigger
id: resolve
env:
GH_TOKEN: ${{ github.token }}
# Inject ALL github.event.* values via env to avoid bash injection
# when commit messages / PR titles / display names contain `|`,
# backticks, `$()`, or other shell metacharacters. The previous
# `${{ github.event.workflow_run.head_commit.message }}` direct
# interpolation broke the case-statement parser on any commit
# message containing a markdown table row (e.g. "| Field | Value |"
# from code-generate retry summaries — runs 29637202291..29637211578,
# all failed with exit 127 on 2026-07-18).
EV_REPOSITORY: ${{ github.repository }}
EV_EVENT_NAME: ${{ github.event_name }}
EV_ISSUE_NUMBER: ${{ github.event.issue.number }}
EV_ISSUE_ACTION: ${{ github.event.action }}
EV_WF_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
EV_WF_HEAD_COMMIT_MSG: ${{ github.event.workflow_run.head_commit.message }}
EV_WF_PR_ARRAY: ${{ toJSON(github.event.workflow_run.pull_requests) }}
EV_WF_DISPLAY_TITLE: ${{ github.event.workflow_run.display_title }}
EV_PUSH_REF: ${{ github.ref }}
run: |
set -euo pipefail
number=""
etype=""
pr_number=""
pr_title=""
pr_url=""
issue_title=""
issue_state=""
issue_labels=""
issue_assignees=""
issue_updated=""
case "$EV_EVENT_NAME" in
issues)
number="$EV_ISSUE_NUMBER"
etype="issues.$EV_ISSUE_ACTION"
;;
workflow_run)
# Resolver fallback chain (most reliable first):
# 0. issue-context artifact from upstream workflow_run
# (uploaded by triage-issue / clarify-loop / etc — closes
# the "known gap" where issue-event-triggered workflows
# run on `dev` and head_branch=head_commit give no signal)
# 1. head_branch matches "issue-N" — workflow ran on a PR branch
# 2. head_commit.message has "closes #N" / "fixes #N" / "#N" —
# pipeline-fix PR merges into dev
# 3. pull_requests[].body (fetched via gh api) has "closes #N" —
# PR-triggered workflow runs
# 4. display_title first integer — fragile last resort
# 5. head_branch=dev fallback: cloud-develop auto-rebase runs
# modules on dev after PR merge; lookup the most recently
# merged PR on dev and extract issue-N from its head ref
# or `closes #N` from its body/title.
#
# Step 0 was added 2026-07-18 (pipeline-fix). Upstream workflows
# that upload `issue-context` (with JSON shape {"issue_number": N})
# at the time of writing: triage-issue, clarify-loop.
repo="$EV_REPOSITORY"
wf_run_id="${{ github.event.workflow_run.id }}"
if [ -n "$wf_run_id" ]; then
mkdir -p ./.github/artifacts
# Try every upstream artifact name that carries issue_number.
# Order: most-direct first.
# - issue-context : triage-issue / clarify-loop upload
# - develop-gate-outputs : develop-gate uploads (consumes upstream
# issue-context.json then re-emits)
# - code-generate-outputs: code-generate uploads
for artifact_name in issue-context develop-gate-outputs code-generate-outputs; do
artifact_file="./.github/artifacts/${artifact_name}/"
case "$artifact_name" in
issue-context) json_file="./.github/artifacts/issue-context.json" ;;
develop-gate-outputs) json_file="./.github/artifacts/develop-gate-outputs.json" ;;
code-generate-outputs) json_file="./.github/artifacts/code-generate-outputs.json" ;;
esac
# gh run download creates a subdir named after the artifact
# unless we use --dir; clean dir between attempts.
rm -rf ./.github/artifacts/* 2>/dev/null || true
if gh run download "$wf_run_id" \
--repo "$repo" \
--name "$artifact_name" \
--dir ./.github/artifacts 2>/dev/null; then
# The downloaded file may be at root or in a subdir
found_json=""
for candidate in \
"./.github/artifacts/$(basename "$json_file")" \
"./.github/artifacts/$artifact_name/$(basename "$json_file")"; do
if [ -f "$candidate" ]; then
found_json="$candidate"
break
fi
done
if [ -n "$found_json" ]; then
number=$(jq -r '.issue_number // ""' "$found_json" 2>/dev/null || true)
if [ -n "$number" ]; then
etype="workflow_run.completed"
# Resolve PR info if upstream also populated pr_number
pr_in_json=$(jq -r '.pr_number // ""' "$found_json" 2>/dev/null || true)
if [ -n "$pr_in_json" ]; then
pr_number="$pr_in_json"
pr_json=$(gh api "repos/${repo}/pulls/$pr_in_json" 2>/dev/null || echo "")
if [ -n "$pr_json" ]; then
pr_title=$(printf '%s' "$pr_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('title',''))" 2>/dev/null || echo "")
pr_url=$(printf '%s' "$pr_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('html_url',''))" 2>/dev/null || echo "")
fi
fi
break
fi
fi
fi
done
fi
head="$EV_WF_HEAD_BRANCH"
if [ -z "$number" ]; then
number=$(printf '%s' "$head" | sed -nE 's|.*issue-([0-9]+).*|\1|p' || true)
fi
if [ -z "$number" ]; then
commit_msg="$EV_WF_HEAD_COMMIT_MSG"
number=$(printf '%s' "$commit_msg" | sed -nE 's|.*closes #([0-9]+).*|\1|pi' \
| head -1 || true)
if [ -z "$number" ]; then
number=$(printf '%s' "$commit_msg" | sed -nE 's|.*fixes #([0-9]+).*|\1|pi' \
| head -1 || true)
fi
fi
if [ -z "$number" ]; then
# PR-linked workflow runs: payload exposes pull_requests[]
pr_array="$EV_WF_PR_ARRAY"
pr_count=$(printf '%s' "$pr_array" | python3 -c \
"import sys,json; d=json.loads(sys.stdin.read() or '[]'); print(len(d))" 2>/dev/null || echo 0)
if [ "${pr_count:-0}" -gt 0 ]; then
pr_url=$(printf '%s' "$pr_array" | python3 -c \
"import sys,json; d=json.loads(sys.stdin.read()); print(d[0].get('url',''))" 2>/dev/null || echo "")
if [ -n "$pr_url" ]; then
pr_body=$(gh api "$pr_url" --jq '.body' 2>/dev/null || echo "")
number=$(printf '%s' "$pr_body" | sed -nE 's|.*closes #([0-9]+).*|\1|pi' \
| head -1 || true)
fi
fi
fi
if [ -z "$number" ]; then
name="$EV_WF_DISPLAY_TITLE"
number=$(printf '%s' "$name" | grep -oE '[0-9]+' | head -1 || true)
fi
# Fallback N: cloud-develop pipeline runs verify/code-generate/etc
# on `dev` after a PR merge (auto-rebase). head_branch=dev,
# head_commit=chore(state) → no signal in any upstream source.
# When all else fails, look up the most recently merged PR on dev
# (within the last 10 minutes) and extract issue-N from its head
# ref or `closes #N` from its body/title.
if [ -z "$number" ] && [ "$EV_WF_HEAD_BRANCH" = "dev" ]; then
recent_pr_json=$(gh pr list \
--repo "$repo" \
--state merged \
--base dev \
--limit 1 \
--json number,headRefName,title,body 2>/dev/null || echo "")
if [ -n "$recent_pr_json" ] && [ "$recent_pr_json" != "[]" ]; then
recent_head=$(printf '%s' "$recent_pr_json" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d[0].get('headRefName',''))" 2>/dev/null || echo "")
recent_body=$(printf '%s' "$recent_pr_json" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print((d[0].get('body') or '') + ' ' + (d[0].get('title') or ''))" 2>/dev/null || echo "")
number=$(printf '%s' "$recent_head" | sed -nE 's|.*issue-([0-9]+).*|\1|p' || true)
if [ -z "$number" ]; then
number=$(printf '%s' "$recent_body" | sed -nE 's|.*closes #([0-9]+).*|\1|pi' | head -1 || true)
fi
if [ -z "$number" ]; then
number=$(printf '%s' "$recent_body" | sed -nE 's|.*fixes #([0-9]+).*|\1|pi' | head -1 || true)
fi
fi
fi
etype="workflow_run.completed"
# Resolve PR info if the workflow_run originated from a PR branch
if [ -n "$number" ]; then
pr_json=$(gh api "repos/${repo}/pulls/$number" 2>/dev/null || echo "")
if [ -n "$pr_json" ]; then
pr_number="$number"
pr_title=$(printf '%s' "$pr_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('title',''))" 2>/dev/null || echo "")
pr_url=$(printf '%s' "$pr_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('html_url',''))" 2>/dev/null || echo "")
fi
fi
;;
push)
ref="$EV_PUSH_REF"
number=$(printf '%s' "$ref" | sed -nE 's|.*issue-([0-9]+).*|\1|p' || true)
etype="push"
;;
esac
# Fetch Issue metadata for Bitable mirror (PR-3)
if [ -n "$number" ]; then
repo="$EV_REPOSITORY"
issue_json=$(gh api "repos/${repo}/issues/$number" 2>/dev/null || echo "")
if [ -n "$issue_json" ]; then
issue_title=$(printf '%s' "$issue_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('title',''))" 2>/dev/null || echo "")
issue_state=$(printf '%s' "$issue_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('state','open'))" 2>/dev/null || echo "")
issue_labels=$(printf '%s' "$issue_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(','.join(l['name'] for l in d.get('labels',[])))" 2>/dev/null || echo "")
issue_assignees=$(printf '%s' "$issue_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(','.join(a['login'] for a in d.get('assignees',[])))" 2>/dev/null || echo "")
issue_updated=$(printf '%s' "$issue_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('updated_at',''))" 2>/dev/null || echo "")
fi
fi
if [ -z "$number" ]; then
echo "::notice::Could not extract issue number for event=$etype — feishu-notify skipped"
echo "issue-number=" >> "$GITHUB_OUTPUT"
else
echo "issue-number=$number" >> "$GITHUB_OUTPUT"
fi
echo "event-type=$etype" >> "$GITHUB_OUTPUT"
echo "pr-number=$pr_number" >> "$GITHUB_OUTPUT"
echo "pr-title=$pr_title" >> "$GITHUB_OUTPUT"
echo "pr-url=$pr_url" >> "$GITHUB_OUTPUT"
echo "issue-title=$issue_title" >> "$GITHUB_OUTPUT"
echo "issue-state=$issue_state" >> "$GITHUB_OUTPUT"
echo "issue-labels-csv=$issue_labels" >> "$GITHUB_OUTPUT"
echo "issue-assignees-csv=$issue_assignees" >> "$GITHUB_OUTPUT"
echo "issue-updated-at=$issue_updated" >> "$GITHUB_OUTPUT"
notify:
needs: resolve-issue
if: needs.resolve-issue.outputs.issue-number != ''
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
issues: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Run Feishu sync
id: sync
uses: ./.github/actions/feishu-sync
env:
PR_NUMBER: ${{ needs.resolve-issue.outputs.pr-number }}
PR_TITLE: ${{ needs.resolve-issue.outputs.pr-title }}
PR_URL: ${{ needs.resolve-issue.outputs.pr-url }}
PR_VERDICT: ${{ github.event.workflow_run.conclusion || 'completed' }}
ISSUE_TITLE: ${{ needs.resolve-issue.outputs.issue-title }}
ISSUE_STATE: ${{ needs.resolve-issue.outputs.issue-state }}
ISSUE_LABELS_CSV: ${{ needs.resolve-issue.outputs.issue-labels-csv }}
ISSUE_ASSIGNEES_CSV: ${{ needs.resolve-issue.outputs.issue-assignees-csv }}
ISSUE_UPDATED_AT: ${{ needs.resolve-issue.outputs.issue-updated-at }}
with:
issue-number: ${{ needs.resolve-issue.outputs.issue-number }}
event-type: ${{ needs.resolve-issue.outputs.event-type }}
feishu-app-id: ${{ secrets.FEISHU_APP_ID }}
feishu-app-secret: ${{ secrets.FEISHU_APP_SECRET }}
feishu-chat-id: ${{ secrets.FEISHU_CHAT_ID }}
feishu-bitable-app-token: ${{ secrets.FEISHU_BITABLE_APP_TOKEN }}
feishu-bitable-table-id: ${{ secrets.FEISHU_BITABLE_TABLE_ID }}
- name: Log sync result
if: always()
env:
STATUS: ${{ steps.sync.outputs.sync-status || 'noop' }}
ISSUE: ${{ needs.resolve-issue.outputs.issue-number }}
EVENT: ${{ needs.resolve-issue.outputs.event-type }}
run: |
echo "Feishu sync for issue #$ISSUE event=$EVENT: status=$STATUS"