Repository navigation
feishu-notify #4247
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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" |