diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..a126dd0 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,24 @@ +# ============================================================================= +# CODEOWNERS β€” automatic review request on pull requests +# +# GitHub requests a review from the listed owners whenever a PR touches +# the matched path. Combined with Branch Protection "Require review from +# Code Owners", this enforces sub-project ownership. +# +# Syntax: <@github-user> ... +# Patterns are evaluated top-to-bottom; the LAST matching rule wins. +# ============================================================================= + +# Global fallback β€” catches anything not matched below +* @casparant + +# Sub-project owners +/src/copilot-shell/ @kongche-jbw @samchu-zsl +/src/agent-sec-core/ @1570005763 +/src/os-skills/ @Ziqi002 +/src/agentsight/ @chengshuyi + +# CI / project-wide config β€” always require lead review +/.github/ @kongche-jbw +/cliff.toml @samchu-zsl +/Makefile @samchu-zsl diff --git a/.github/commitlint.config.json b/.github/commitlint.config.json index 55d4159..64e68ec 100644 --- a/.github/commitlint.config.json +++ b/.github/commitlint.config.json @@ -1,8 +1,30 @@ { - "extends": ["@commitlint/config-conventional"], + "extends": [ + "@commitlint/config-conventional" + ], "rules": { - "scope-enum": [2, "always", ["cosh", "agent-sec-core", "os-skills", "agentsight", "deps", "ci", "docs"]], - "scope-empty": [2, "never"], - "header-max-length": [2, "always", 120] + "scope-enum": [ + 2, + "always", + [ + "cosh", + "agent-sec-core", + "os-skills", + "agentsight", + "deps", + "ci", + "docs", + "chore" + ] + ], + "scope-empty": [ + 2, + "never" + ], + "header-max-length": [ + 2, + "always", + 120 + ] } -} +} \ No newline at end of file diff --git a/.github/maintainers.json b/.github/maintainers.json new file mode 100644 index 0000000..bb1ea8b --- /dev/null +++ b/.github/maintainers.json @@ -0,0 +1,46 @@ +{ + "scopes": [ + { + "label": "component:cosh", + "maintainers": [ + { + "github": "kongche-jbw" + }, + { + "github": "samchu-zsl" + } + ] + }, + { + "label": "component:sec-core", + "maintainers": [ + { + "github": "1570005763" + } + ] + }, + { + "label": "component:skill", + "maintainers": [ + { + "github": "Ziqi002" + } + ] + }, + { + "label": "component:sight", + "maintainers": [ + { + "github": "chengshuyi" + } + ] + } + ], + "default": { + "maintainers": [ + { + "github": "kongche-jbw" + } + ] + } +} \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 36b8d95..e86d3a5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,9 +4,19 @@ ## Related Issue - + + +closes # ## Type of Change diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 19bb600..155a29f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, 'release/**'] pull_request: - branches: [main] + branches: [main, 'release/**'] workflow_dispatch: inputs: run_copilot_shell: @@ -32,7 +32,7 @@ jobs: # ========================================================================= detect-changes: name: Detect Changes - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 outputs: copilot_shell: ${{ steps.changes.outputs.copilot_shell }} agent_sec_core: ${{ steps.changes.outputs.agent_sec_core }} @@ -101,7 +101,7 @@ jobs: name: Build copilot-shell needs: detect-changes if: needs.detect-changes.outputs.copilot_shell == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 @@ -145,7 +145,7 @@ jobs: name: Test copilot-shell/cli needs: [detect-changes, build-copilot-shell] if: needs.detect-changes.outputs.copilot_shell == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 @@ -175,7 +175,7 @@ jobs: name: Test copilot-shell/core needs: [detect-changes, build-copilot-shell] if: needs.detect-changes.outputs.copilot_shell == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 @@ -208,7 +208,7 @@ jobs: name: Test agent-sec-core needs: detect-changes if: needs.detect-changes.outputs.agent_sec_core == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 @@ -249,7 +249,7 @@ jobs: name: Test agentsight needs: detect-changes if: needs.detect-changes.outputs.agentsight == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/issue-automation.yml b/.github/workflows/issue-automation.yml new file mode 100644 index 0000000..985d929 --- /dev/null +++ b/.github/workflows/issue-automation.yml @@ -0,0 +1,69 @@ +############################################################################### +# Issue Automation β€” Parse Issue Form and apply component label +# +# Triggered when a new issue is opened. +# Reads the "Component" dropdown from the Issue Form body and maps it to a +# component:xxx label. The label addition then triggers issue-triage.yml, +# which handles assignee routing and notifications. +############################################################################### + +name: πŸ€– Issue Automation + +on: + issues: + types: [opened] + +permissions: + issues: write + +jobs: + auto-label: + name: 🏷️ Auto Label + runs-on: ubuntu-22.04 + + steps: + # ----------------------------------------------------------------------- + # Parse Issue Form body β†’ map Component dropdown value to a label + # ----------------------------------------------------------------------- + - name: πŸ” Parse component and apply label + uses: actions/github-script@v7 + with: + script: | + const body = context.payload.issue.body || ''; + + // Skip if a component label is already present (e.g. applied via API or Issue Form) + const existingLabels = context.payload.issue.labels.map(l => l.name); + const alreadyLabeled = existingLabels.some(l => l.startsWith('component:')); + if (alreadyLabeled) { + console.log(`Issue already has a component label: ${existingLabels.filter(l => l.startsWith('component:')).join(', ')} β€” skipping.`); + return; + } + + // Issue Form renders dropdown fields as: "### FieldName\n\nValue" + const componentMatch = body.match(/###\s*Component\s*\n\n(.+)/i); + const component = componentMatch ? componentMatch[1].trim() : ''; + + console.log(`Parsed component: "${component}"`); + + // Map dropdown display value β†’ label name + const labelMap = { + 'copilot-shell (cosh)': 'component:cosh', + 'agent-sec-core': 'component:sec-core', + 'os-skills': 'component:skill', + 'agentsight': 'component:sight', + }; + + const label = labelMap[component]; + if (!label) { + console.log('No matching component label found β€” skipping.'); + return; + } + + console.log(`Applying label: ${label}`); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: [label], + }); + // Label addition will trigger issue-triage.yml for assign + notify diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml new file mode 100644 index 0000000..f765658 --- /dev/null +++ b/.github/workflows/issue-triage.yml @@ -0,0 +1,160 @@ +############################################################################### +# Issue Triage β€” Assign maintainers and send notifications +# +# Triggered when a component:xxx label is added to an issue. +# Works for both auto-labeling (via issue-automation.yml) and manual labeling. +# +# Maintainer routing is configured in .github/maintainers.json. +# No checkout or external tools required β€” config is fetched via GitHub API. +############################################################################### + +name: πŸ”€ Issue Triage + +on: + issues: + types: [labeled] + +permissions: + issues: write + contents: read + +jobs: + assign-and-notify: + name: πŸ“Œ Assign & Notify Maintainers + runs-on: ubuntu-22.04 + if: startsWith(github.event.label.name, 'component:') + + steps: + # ----------------------------------------------------------------------- + # Fetch maintainers.json via API, resolve maintainers, assign + comment + # No checkout or yq needed. + # ----------------------------------------------------------------------- + - name: πŸ” Resolve, assign and comment + id: triage + uses: actions/github-script@v7 + with: + script: | + const label = context.payload.label.name; + const scopeName = label.replace('component:', ''); + + // Fetch maintainers.json from the repository via API + const { data } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/maintainers.json', + }); + const config = JSON.parse(Buffer.from(data.content, 'base64').toString()); + + // Resolve maintainers for this label, fall back to default + const scope = config.scopes.find(s => s.label === label); + const entries = scope ? scope.maintainers : config.default.maintainers; + const maintainers = entries.map(e => e.github); + + if (maintainers.length === 0) { + console.log('No maintainers configured β€” skipping.'); + return; + } + + console.log(`Assigning: ${maintainers.join(', ')}`); + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + assignees: maintainers, + }); + + const mentionList = maintainers.map(m => `@${m}`).join(' '); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: [ + `## πŸ”€ Issue Triage`, + ``, + `This issue has been automatically assigned to **${mentionList}** ` + + `as the maintainer(s) of \`${scopeName}\`.`, + ``, + `> Maintainers, please review and triage this issue. ` + + `Set a priority label and update the status as needed. Thanks! πŸ™`, + ].join('\n'), + }); + + core.setOutput('maintainers', maintainers.join(',')); + + # ----------------------------------------------------------------------- + # Email notification (skipped if MAIL_TO / MAIL_SERVER secrets are absent) + # ----------------------------------------------------------------------- + - name: πŸ“§ Check email configuration + if: steps.triage.outputs.maintainers != '' + id: mail-check + env: + MAIL_TO: ${{ secrets.MAIL_TO }} + MAIL_SERVER: ${{ secrets.MAIL_SERVER }} + run: | + if [ -n "$MAIL_TO" ] && [ -n "$MAIL_SERVER" ]; then + echo "mail_configured=true" >> "$GITHUB_OUTPUT" + else + echo "Email secrets not configured β€” skipping" + fi + + - name: πŸ“§ Send email notification + if: steps.mail-check.outputs.mail_configured == 'true' + uses: dawidd6/action-send-mail@v3 + with: + server_address: ${{ secrets.MAIL_SERVER }} + server_port: ${{ secrets.MAIL_PORT }} + secure: true + username: ${{ secrets.MAIL_USERNAME }} + password: ${{ secrets.MAIL_PASSWORD }} + subject: "[ANOLISA] New Issue #${{ github.event.issue.number }}: ${{ github.event.issue.title }}" + to: ${{ secrets.MAIL_TO }} + from: "ANOLISA Bot <${{ secrets.MAIL_USERNAME }}>" + body: | + # New Issue Notification + + **Repository**: ${{ github.repository }} + **Issue**: #${{ github.event.issue.number }} + **Title**: ${{ github.event.issue.title }} + **Label**: ${{ github.event.label.name }} + **Assigned to**: ${{ steps.triage.outputs.maintainers }} + **Opened by**: ${{ github.event.issue.user.login }} + + **Link**: ${{ github.event.issue.html_url }} + + > This email was sent automatically by the ANOLISA Issue Triage Bot. + convert_markdown: true + + # ----------------------------------------------------------------------- + # DingTalk Webhook notification (skipped if secrets are absent) + # ----------------------------------------------------------------------- + - name: πŸ”” Send DingTalk notification + if: steps.triage.outputs.maintainers != '' + env: + DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }} + DINGTALK_SECRET: ${{ secrets.DINGTALK_SECRET }} + run: | + if [ -z "$DINGTALK_WEBHOOK" ] || [ -z "$DINGTALK_SECRET" ]; then + echo "DingTalk webhook not configured β€” skipping" + exit 0 + fi + + TIMESTAMP=$(date +%s%3N) + STRING_TO_SIGN="${TIMESTAMP}\n${DINGTALK_SECRET}" + SIGN=$(echo -ne "$STRING_TO_SIGN" \ + | openssl dgst -sha256 -hmac "$DINGTALK_SECRET" -binary \ + | base64) + ENCODED_SIGN=$(python3 -c "import urllib.parse; print(urllib.parse.quote_plus('$SIGN'))") + WEBHOOK_URL="${DINGTALK_WEBHOOK}×tamp=${TIMESTAMP}&sign=${ENCODED_SIGN}" + + curl -s -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d @- < πŸ€– ANOLISA Issue Triage Bot" + } + } + EOF + echo "DingTalk notification sent" diff --git a/.github/workflows/pr-merged.yml b/.github/workflows/pr-merged.yml new file mode 100644 index 0000000..03fb363 --- /dev/null +++ b/.github/workflows/pr-merged.yml @@ -0,0 +1,69 @@ +# PR Merged Notification +# ====================== +# Sends a DingTalk notification when a pull request is merged into main. +# Requires secrets: DINGTALK_WEBHOOK, DINGTALK_SECRET + +name: PR Merged Notification + +on: + pull_request: + types: [closed] + branches: [main] + +permissions: + contents: read + +jobs: + notify: + name: DingTalk Notification + if: github.event.pull_request.merged == true + 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 }} + 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', '') + string_to_sign = ts + '\n' + secret + sig = hmac.new(secret.encode(), string_to_sign.encode(), hashlib.sha256).digest() + sign = urllib.parse.quote_plus(base64.b64encode(sig).decode()) + + webhook = os.environ.get('DINGTALK_WEBHOOK', '') + 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', '') + + text = ( + f"## βœ… ANOLISA PR Merged\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"PR #{pr_number} Merged", "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 diff --git a/.github/workflows/prelint.yml b/.github/workflows/prelint.yml new file mode 100644 index 0000000..cadaf5d --- /dev/null +++ b/.github/workflows/prelint.yml @@ -0,0 +1,194 @@ +############################################################################### +# PR Lint gate β€” Commit Message + PR Title + Branch Name + Issue Link +# +# Jobs: +# commit-lint β€” requires checkout; runs independently +# pr-checks β€” all github-script checks consolidated into one runner +# (PR title, branch name, linked issue) +############################################################################### + +name: πŸ” PR Lint + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + branches: [main, 'feature/**', 'release/**'] + +permissions: + contents: read + pull-requests: read + +jobs: + # ========================================================================= + # Job 1: Commit Message Lint + # ========================================================================= + commit-lint: + name: πŸ“ Commit Message Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Lint commit messages + uses: wagoid/commitlint-github-action@v6 + with: + configFile: .github/commitlint.config.json + + # ========================================================================= + # Job 2: PR Checks (title + branch name + linked issue + merge hint) + # All steps share one runner β€” no checkout needed. + # ========================================================================= + pr-checks: + name: πŸ” PR Checks + runs-on: ubuntu-latest + steps: + # ----------------------------------------------------------------------- + # Step 1: PR Title Validation + # ----------------------------------------------------------------------- + - name: πŸ“‹ Validate PR title + uses: actions/github-script@v7 + with: + script: | + const title = context.payload.pull_request.title; + console.log(`PR Title: "${title}"`); + + // Conventional Commits pattern: type(scope): description + // Optional ! for breaking changes + const pattern = /^(feat|fix|refactor|perf|docs|chore|test|ci|build|style|revert)(\(.+\))!?: .+/; + + if (!pattern.test(title)) { + core.setFailed( + `❌ PR title does not follow Conventional Commits format.\n\n` + + `Expected: type(scope): description\n` + + `Examples: feat(cosh): add json output\n` + + ` fix(agent-sec-core): handle sandbox escape\n\n` + + `Valid types: feat, fix, refactor, perf, docs, chore, test, ci, build, style, revert\n` + + `Valid scopes: cosh, agent-sec-core, os-skills, agentsight, deps, ci, docs, chore\n\n` + + `Current title: "${title}"` + ); + return; + } + + // Validate scope + const scopeMatch = title.match(/^\w+\(([^)]+)\)/); + if (scopeMatch) { + const scope = scopeMatch[1]; + const validScopes = [ + 'cosh', 'agent-sec-core', 'os-skills', 'agentsight', + 'deps', 'ci', 'docs', 'chore' + ]; + if (!validScopes.includes(scope)) { + core.setFailed( + `❌ Scope "${scope}" in PR title is not in the allowed list.\n\n` + + `Valid scopes: ${validScopes.join(', ')}\n\n` + + `Current title: "${title}"` + ); + return; + } + } + + console.log('βœ… PR title format is valid'); + + # ----------------------------------------------------------------------- + # Step 2: Branch Name Validation + # ----------------------------------------------------------------------- + - name: 🌿 Validate branch name + uses: actions/github-script@v7 + with: + script: | + const branch = context.payload.pull_request.head.ref; + const baseBranch = context.payload.pull_request.base.ref; + console.log(`Head branch: "${branch}"`); + console.log(`Base branch: "${baseBranch}"`); + + // Whitelist: these branches are always allowed + const whitelist = [ + /^main$/, + /^dependabot\/.+/, + /^renovate\/.+/, + /^revert-.+/, + /^chore\/changelog-.+/, // created by changelog.yml workflow + ]; + + for (const pattern of whitelist) { + if (pattern.test(branch)) { + console.log(`βœ… Branch "${branch}" is whitelisted`); + return; + } + } + + // Valid scopes β€” sub-projects and project-wide categories + const validScopes = ['cosh', 'agent-sec-core', 'os-skills', 'agentsight', 'ci', 'docs', 'deps']; + const scopePattern = validScopes.join('|'); + + // Valid branch patterns per development spec + const validPatterns = [ + // feature// + new RegExp(`^feature/(${scopePattern})/[a-z0-9][a-z0-9._-]*$`), + // feature/// (collaborative track) + new RegExp(`^feature/(${scopePattern})/[a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*$`), + // fix// + new RegExp(`^fix/(${scopePattern})/[a-z0-9][a-z0-9._-]*$`), + // release//vX.Y + new RegExp(`^release/(${scopePattern})/v\\d+\\.\\d+$`), + // hotfix// + new RegExp(`^hotfix/(${scopePattern})/[a-z0-9][a-z0-9._-]*$`), + ]; + + const isValid = validPatterns.some(p => p.test(branch)); + + if (!isValid) { + core.setFailed( + `❌ Branch "${branch}" does not follow the naming convention.\n\n` + + `Valid formats:\n` + + ` feature// e.g. feature/cosh/json-output\n` + + ` feature/// e.g. feature/cosh/auth-refactor/oauth\n` + + ` fix// e.g. fix/agent-sec-core/sandbox-escape\n` + + ` release//vX.Y e.g. release/cosh/v2.1\n` + + ` hotfix// e.g. hotfix/os-skills/broken-load\n\n` + + `Valid scopes: ${validScopes.join(', ')}\n` + + `Branch names must use lowercase letters, digits, hyphens, and dots only.` + ); + return; + } + + console.log('βœ… Branch name is valid'); + + # ----------------------------------------------------------------------- + # Step 3: Linked Issue Check + # ----------------------------------------------------------------------- + - name: πŸ”— Check linked issue + uses: actions/github-script@v7 + with: + script: | + const body = context.payload.pull_request.body || ''; + + // Accept standard GitHub closing keywords linking to an issue number + const closingKeywords = /\b(closes|fixes|resolves|close|fix|resolve)\s+#\d+/i; + + // Accept explicit exemption: no-issue: + const noIssueExemption = /no-issue\s*:/i; + + if (closingKeywords.test(body)) { + const match = body.match(/(?:closes|fixes|resolves|close|fix|resolve)\s+#(\d+)/i); + console.log(`βœ… PR is linked to issue #${match[1]}`); + return; + } + + if (noIssueExemption.test(body)) { + const exemptMatch = body.match(/no-issue\s*:\s*(.+)/i); + console.log(`βœ… Issue link exempted: ${exemptMatch?.[1]?.trim() || '(no reason given)'}`); + return; + } + + core.setFailed( + `❌ This PR does not reference a linked issue.\n\n` + + `Every PR must include one of the following in the PR description:\n\n` + + ` closes #\n` + + ` fixes #\n` + + ` resolves #\n\n` + + `If there is genuinely no applicable issue, add:\n` + + ` no-issue: \n\n` + + `Please create an issue first at https://github.com/alibaba/anolisa/issues/new` + );