diff --git a/.github/workflows/octo-pr-feed.yml b/.github/workflows/octo-pr-feed.yml deleted file mode 100644 index 31f87c7..0000000 --- a/.github/workflows/octo-pr-feed.yml +++ /dev/null @@ -1,198 +0,0 @@ -# Reusable workflow: notify Octo IM when a PR event occurs. -# Called from per-repo caller workflows. -name: Octo PR Feed (reusable) - -on: - workflow_call: - inputs: - repo_name: - type: string - required: true - pr_number: - type: number - required: true - pr_title: - type: string - required: true - pr_url: - type: string - required: true - pr_author: - type: string - required: true - pr_merged: - type: boolean - default: false - pr_additions: - type: number - default: 0 - pr_deletions: - type: number - default: 0 - pr_changed_files: - type: number - default: 0 - event_action: - type: string - required: true - project_group_id: - type: string - required: true - api_base_url: - type: string - required: false - default: 'https://im.deepminer.com.cn/api' - description: 'Octo IM API base URL. Only the production endpoint is allowlisted; any other value will cause the workflow to fail.' - feed_group_id: - type: string - required: false - default: '1c303c142e9840f2a9b46c10b0972e8d' - description: 'Octo IM feed/broadcast channel group ID (pr-feed channel). Defaults to the shared pr-feed group.' - secrets: - OCTO_BOT_TOKEN: - required: true - -permissions: {} - -jobs: - notify: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Notify Octo IM - env: - OCTO_BOT_TOKEN: ${{ secrets.OCTO_BOT_TOKEN }} - REPO_NAME: ${{ inputs.repo_name }} - PR_NUMBER: ${{ inputs.pr_number }} - PR_TITLE: ${{ inputs.pr_title }} - PR_URL: ${{ inputs.pr_url }} - PR_AUTHOR: ${{ inputs.pr_author }} - PR_MERGED: ${{ inputs.pr_merged }} - PR_ADDITIONS: ${{ inputs.pr_additions }} - PR_DELETIONS: ${{ inputs.pr_deletions }} - PR_CHANGED_FILES: ${{ inputs.pr_changed_files }} - EVENT_ACTION: ${{ inputs.event_action }} - PROJECT_GROUP_ID: ${{ inputs.project_group_id }} - FEED_GROUP_ID: ${{ inputs.feed_group_id }} - API_BASE_URL: ${{ inputs.api_base_url }} - run: | - python3 - << 'PYEOF' - import os, json, re, sys, time, urllib.request, urllib.error - - - def require_env(name): - val = os.environ.get(name, '').strip() - if not val: - print(f'ERROR: required environment variable {name} is missing or empty') - sys.exit(2) - return val - - - def sanitize_text(s, max_len=300): - """Strip control characters (CR, LF, tabs, etc.) to prevent IM message injection.""" - s = str(s or '') - # Strip all C0 control characters (U+0000-U+001F) and DEL (U+007F) - s = re.sub(r'[\x00-\x1f\x7f]', ' ', s) - return s[:max_len] - - - def require_group_id(name): - """Validate format only (32-char hex); this is not an authorization check.""" - val = require_env(name) - if not re.fullmatch(r'[0-9a-f]{32}', val): - print(f'ERROR: {name} must be a 32-char lowercase hex group id') - sys.exit(2) - return val - - - def require_repo_name(name): - """Validate repo name to prevent path traversal in GitHub API URLs.""" - val = require_env(name) - if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,99}', val) or val in {'.', '..'}: - print(f'ERROR: {name} contains invalid characters: {val!r}') - sys.exit(2) - return val - - - action = require_env('EVENT_ACTION') - merged = require_env('PR_MERGED').lower() == 'true' - - if action == 'closed': - emoji = '๐ŸŸข' if merged else '๐Ÿ”ด' - else: - emoji = {'opened': '๐Ÿ”ต', 'reopened': '๐Ÿ”„', - 'review_requested': '๐Ÿ‘€', 'ready_for_review': 'โœ…'}.get(action, 'โ„น๏ธ') - - adds = int(os.environ.get('PR_ADDITIONS', 0) or 0) - dels = int(os.environ.get('PR_DELETIONS', 0) or 0) - files = int(os.environ.get('PR_CHANGED_FILES', 0) or 0) - stats_part = f' ยท +{adds} -{dels} ยท {files} files' if (adds or dels or files) else '' - - repo = require_repo_name('REPO_NAME') - num = require_env('PR_NUMBER') - title = sanitize_text(require_env('PR_TITLE'), max_len=300) - url = require_env('PR_URL') - author = sanitize_text(require_env('PR_AUTHOR'), max_len=80) - - feed_msg = f"{emoji} [{repo}] PR #{num} ยท {title}\n๐Ÿ‘ค {author}{stats_part}\n๐Ÿ”— {url}" - proj_msg = f"{emoji} PR #{num} ยท {title}\n๐Ÿ‘ค {author}{stats_part}\n๐Ÿ”— {url}" - - ALLOWED_API_BASES = { - 'https://im.deepminer.com.cn/api', - } - _api_base = os.environ.get('API_BASE_URL', 'https://im.deepminer.com.cn/api').rstrip('/') - if _api_base.lower() not in {b.lower() for b in ALLOWED_API_BASES}: - print(f'ERROR: API_BASE_URL is not in the allowlist: {_api_base}') - sys.exit(2) - api = _api_base + '/v1/bot/sendMessage' - token = require_env('OCTO_BOT_TOKEN') - headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'} - - failed = [] - - def send(group_id, message): - body = json.dumps({ - 'channel_id': group_id, - 'channel_type': 2, - 'payload': {'type': 1, 'content': message}, - }).encode() - last_err = None - for attempt in range(1, 4): - req = urllib.request.Request(api, data=body, headers=headers, method='POST') - try: - with urllib.request.urlopen(req, timeout=15) as r: - print(f' โ†’ {group_id[:8]}... HTTP {r.status}') - return # success - except urllib.error.HTTPError as e: - last_err = e - if e.code in (429, 500, 502, 503, 504) and attempt < 3: - wait = 2 ** attempt - if e.code == 429: - try: - retry_after = int(e.headers.get('Retry-After', 0)) - if retry_after > 0: - wait = max(retry_after, wait) - except (ValueError, TypeError): - pass - print(f' WARN: HTTP {e.code} on attempt {attempt}, retrying in {wait}s...') - time.sleep(wait) - else: - break - except (urllib.error.URLError, TimeoutError) as e: - last_err = e - if attempt < 3: - wait = 2 ** attempt - print(f' WARN: {e} on attempt {attempt}, retrying in {wait}s...') - time.sleep(wait) - else: - break - print(f'ERROR: failed to send message to {group_id[:8]}...: {last_err}') - failed.append(group_id) - - feed_gid = require_group_id('FEED_GROUP_ID') - proj_gid = require_group_id('PROJECT_GROUP_ID') - send(feed_gid, feed_msg) - send(proj_gid, proj_msg) - if failed: - sys.exit(1) - PYEOF