feat(sight): add unit tests for http endpoint and domain rule parsing #313
Workflow file for this run
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
| # PR Opened: Auto Label + DingTalk Notification | |
| # =============================================== | |
| # 1. Automatically labels the PR based on changed file paths. | |
| # Label rules are read dynamically from .github/CODEOWNERS (# auto-label: annotations). | |
| # Adding a new component to CODEOWNERS is sufficient — no workflow changes needed. | |
| # 2. Sends a DingTalk notification. | |
| # Requires secrets: DINGTALK_WEBHOOK, DINGTALK_SECRET | |
| name: PR Opened | |
| on: | |
| pull_request_target: | |
| types: [opened] | |
| branches: [main] | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| jobs: | |
| label: | |
| name: Auto Label | |
| runs-on: ubuntu-22.04 | |
| steps: | |
| - name: Apply component and scope labels | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const prNumber = context.payload.pull_request.number; | |
| // ── Parse CODEOWNERS # auto-label: annotations ──────────────── | |
| const { data: coFile } = await github.rest.repos.getContent( | |
| { owner, repo, path: '.github/CODEOWNERS' } | |
| ); | |
| const coContent = Buffer.from(coFile.content, 'base64').toString(); | |
| const rules = []; | |
| for (const raw of coContent.split('\n')) { | |
| const line = raw.trim(); | |
| if (!line || line.startsWith('#')) continue; | |
| const m = line.match(/#\s*auto-label:\s*(\S+)/); | |
| if (!m) continue; | |
| const rawPrefix = line.split(/\s+/)[0]; | |
| // Detect glob patterns like *.md (suffix match), otherwise prefix match | |
| const isGlob = rawPrefix.startsWith('*'); | |
| const prefix = isGlob ? rawPrefix.slice(1) : rawPrefix.replace(/^\//, ''); | |
| const label = m[1]; | |
| const type = label.startsWith('component:') ? 'component' : 'scope'; | |
| rules.push({ prefix, label, type, isGlob }); | |
| } | |
| // ── Fetch changed files (paginate up to 300) ────────────────── | |
| let filenames = []; | |
| for (let page = 1; page <= 3; page++) { | |
| const { data } = await github.rest.pulls.listFiles({ | |
| owner, repo, pull_number: prNumber, per_page: 100, page | |
| }); | |
| filenames.push(...data.map(f => f.filename)); | |
| if (data.length < 100) break; | |
| } | |
| // ── Match files against rules ───────────────────────────────── | |
| const labels = new Set(); | |
| const scopeHit = new Set(); | |
| const reasons = {}; // label → first matched file | |
| for (const fn of filenames) { | |
| let componentMatch = null, componentPrefix = null; | |
| let scopeMatch = null, scopePrefix = null; | |
| for (const { prefix, label, type, isGlob } of rules) { | |
| const matched = isGlob ? fn.endsWith(prefix) : fn.startsWith(prefix); | |
| if (matched) { | |
| if (type === 'component') { componentMatch = label; componentPrefix = prefix; } | |
| else { scopeMatch = label; scopePrefix = prefix; } | |
| } | |
| } | |
| if (componentMatch && !labels.has(componentMatch)) { | |
| labels.add(componentMatch); | |
| reasons[componentMatch] = `${fn} matches prefix '${componentPrefix}'`; | |
| } | |
| if (scopeMatch) { | |
| if (!labels.has(scopeMatch)) { | |
| labels.add(scopeMatch); | |
| reasons[scopeMatch] = `${fn} matches prefix '${scopePrefix}'`; | |
| } | |
| scopeHit.add(scopeMatch); | |
| } | |
| } | |
| const hasComponent = Array.from(labels).some(l => l.startsWith('component:')); | |
| if (scopeHit.size === 0 && !hasComponent) { | |
| labels.add('scope:chore'); | |
| reasons['scope:chore'] = 'no component or scope matched (fallback)'; | |
| } | |
| if (labels.size > 0) { | |
| await github.rest.issues.addLabels({ | |
| owner, repo, issue_number: prNumber, | |
| labels: Array.from(labels) | |
| }); | |
| core.info('=== Auto-label results ==='); | |
| for (const [label, reason] of Object.entries(reasons)) { | |
| core.info(` [${label}] ← ${reason}`); | |
| } | |
| } | |
| notify: | |
| name: DingTalk Notification | |
| runs-on: ubuntu-22.04 | |
| env: | |
| DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }} | |
| DINGTALK_SECRET: ${{ secrets.DINGTALK_SECRET }} | |
| steps: | |
| - name: 📣 Send DingTalk message | |
| if: env.DINGTALK_WEBHOOK != '' | |
| env: | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| PR_TITLE: ${{ github.event.pull_request.title }} | |
| PR_URL: ${{ github.event.pull_request.html_url }} | |
| PR_AUTHOR: ${{ github.event.pull_request.user.login }} | |
| PR_BRANCH: ${{ github.event.pull_request.head.ref }} | |
| PR_BODY: ${{ github.event.pull_request.body }} | |
| run: | | |
| python3 << 'PYEOF' | |
| import hmac, hashlib, base64, urllib.parse, time, os, subprocess, json | |
| ts = str(round(time.time() * 1000)) | |
| secret = os.environ.get('DINGTALK_SECRET', '') | |
| sig = hmac.new(secret.encode(), (ts + '\n' + secret).encode(), hashlib.sha256).digest() | |
| sign = urllib.parse.quote_plus(base64.b64encode(sig).decode()) | |
| pr_number = os.environ.get('PR_NUMBER', '') | |
| pr_title = os.environ.get('PR_TITLE', '') | |
| pr_url = os.environ.get('PR_URL', '') | |
| pr_author = os.environ.get('PR_AUTHOR', '') | |
| pr_branch = os.environ.get('PR_BRANCH', '') | |
| webhook = os.environ.get('DINGTALK_WEBHOOK', '') | |
| text = ( | |
| f"## 🔁 ANOLISA New PR\n\n" | |
| f"**PR**: [#{pr_number} {pr_title}]({pr_url})\n\n" | |
| f"**Branch**: `{pr_branch}` → `main`\n\n" | |
| f"**Author**: {pr_author}\n\n" | |
| f"> 🤖 ANOLISA CI Bot" | |
| ) | |
| payload = json.dumps({ | |
| "msgtype": "markdown", | |
| "markdown": {"title": f"New PR #{pr_number}", "text": text} | |
| }) | |
| url = f"{webhook}×tamp={ts}&sign={sign}" | |
| subprocess.run( | |
| ["curl", "-s", "-X", "POST", url, "-H", "Content-Type: application/json", "-d", payload], | |
| check=True | |
| ) | |
| PYEOF |