diff --git a/.github/workflows/award-project-badge.yml b/.github/workflows/award-project-badge.yml new file mode 100644 index 0000000..d23419b --- /dev/null +++ b/.github/workflows/award-project-badge.yml @@ -0,0 +1,325 @@ +name: Award Project Contributor Badges + +on: + workflow_call: + inputs: + pr_number: + description: "Merged Pull Request number in the caller repository" + required: true + type: number + dry_run: + description: "Simulate badge evaluation and skip live awards/labels" + required: false + type: boolean + default: false + secrets: + SLACK_BOT_TOKEN: + description: "Slack Bot Token for /award-badge dispatches" + required: false + +permissions: + contents: read + issues: write + +concurrency: + group: badge-award-${{ github.repository }}-${{ inputs.pr_number }} + cancel-in-progress: false + +jobs: + evaluate-and-award: + name: Evaluate and Award Badges + runs-on: ubuntu-latest + steps: + - name: Validate authorized repository allowlist + env: + TARGET_REPO: ${{ github.repository }} + run: | + set -euo pipefail + NORMALIZED_REPO=$(echo "${TARGET_REPO}" | tr '[:upper:]' '[:lower:]') + case "${NORMALIZED_REPO}" in + "layer5io/sistent"|"meshery/meshery"|"meshery/meshery-operator"|"meshery/meshsync"|"layer5io/docs"|"meshery/meshery.io"|"layer5io/layer5") + echo "Repository '${TARGET_REPO}' is authorized for Track 2 badge automation." + ;; + *) + echo "::error::Repository '${TARGET_REPO}' is not an authorized Track 2 participating repository. Failing workflow." + exit 1 + ;; + esac + + - name: Checkout trusted recognition engine + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 pinned SHA + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .recognition-engine + sparse-checkout: | + utils + + - name: Verify PR status and collect metadata + id: collect-meta + env: + GH_TOKEN: ${{ github.token }} + TARGET_REPO: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + set -euo pipefail + echo "Fetching Pull Request #${PR_NUMBER} in ${TARGET_REPO}..." + + GH_RESP_FILE=$(mktemp) + GH_ERR_FILE=$(mktemp) + set +e + gh api --include "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > "${GH_RESP_FILE}" 2> "${GH_ERR_FILE}" + GH_EXIT_CODE=$? + set -e + + HTTP_STATUS="" + if [ -s "${GH_RESP_FILE}" ]; then + HTTP_STATUS=$(head -n 1 "${GH_RESP_FILE}" | awk '{print $2}') + fi + + if [ "${HTTP_STATUS}" = "200" ]; then + sed -e '1,/^\r\{0,1\}$/d' "${GH_RESP_FILE}" > .pr-info.json + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + elif [ "${HTTP_STATUS}" = "404" ]; then + echo "::warning::Pull Request #${PR_NUMBER} not found in ${TARGET_REPO} (HTTP 404). Exiting without dispatch." + echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY" + echo "Pull Request #${PR_NUMBER} was not found in \`${TARGET_REPO}\` (HTTP 404). Zero badges awarded." >> "$GITHUB_STEP_SUMMARY" + echo "skip=true" >> "$GITHUB_OUTPUT" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 0 + elif [ "${HTTP_STATUS}" = "403" ]; then + echo "::error::GitHub API access forbidden (HTTP 403) while querying PR #${PR_NUMBER} in ${TARGET_REPO}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + elif [ "${HTTP_STATUS}" = "429" ]; then + echo "::error::GitHub API rate limit exceeded (HTTP 429) while querying PR #${PR_NUMBER} in ${TARGET_REPO}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + elif [[ "${HTTP_STATUS}" =~ ^5[0-9]{2}$ ]]; then + echo "::error::GitHub API server error (HTTP ${HTTP_STATUS}) while querying PR #${PR_NUMBER} in ${TARGET_REPO}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + elif [ -n "${HTTP_STATUS}" ]; then + echo "::error::Unexpected GitHub API HTTP status (${HTTP_STATUS}) while querying PR #${PR_NUMBER} in ${TARGET_REPO}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + else + GH_ERR_MSG=$(cat "${GH_ERR_FILE}") + echo "::error::GitHub API network/transport error while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + fi + + IS_MERGED=$(jq -r '.merged // false' .pr-info.json) + if [ "${IS_MERGED}" != "true" ]; then + echo "::warning::Pull Request #${PR_NUMBER} in ${TARGET_REPO} is not merged (merged=${IS_MERGED}). Skipping badge evaluation." + echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY" + echo "Pull Request #${PR_NUMBER} in \`${TARGET_REPO}\` is not in a merged state. Zero badges awarded." >> "$GITHUB_STEP_SUMMARY" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Pull Request #${PR_NUMBER} verified as merged. Fetching files, commits, and labels..." + gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/files" --paginate --slurp | jq 'add // []' > .pr-files.json + gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/commits" --paginate --slurp | jq 'add // []' > .pr-commits.json + gh api "repos/${TARGET_REPO}/issues/${PR_NUMBER}/labels" --paginate --slurp | jq 'add // []' > .existing-labels.json + + node -e ' + const fs = require("fs"); + const pr = JSON.parse(fs.readFileSync(".pr-info.json")); + const files = JSON.parse(fs.readFileSync(".pr-files.json")); + const commits = JSON.parse(fs.readFileSync(".pr-commits.json")); + fs.writeFileSync(".pr-metadata.json", JSON.stringify({ pr, files, commits }, null, 2)); + ' + echo "skip=false" >> "$GITHUB_OUTPUT" + + - name: Run badge award orchestrator + if: ${{ steps.collect-meta.outputs.skip == 'false' }} + env: + TARGET_REPO: ${{ github.repository }} + run: | + set -euo pipefail + node .recognition-engine/utils/award-orchestrator.js \ + --metadata=".pr-metadata.json" \ + --existing-labels=".existing-labels.json" \ + --repo="${TARGET_REPO}" \ + --out=".evaluation-result.json" \ + --dispatch-out=".dispatch-context.json" + + - name: Publish evaluation step summary + if: ${{ steps.collect-meta.outputs.skip == 'false' }} + run: | + node -e ' + const fs = require("fs"); + const res = JSON.parse(fs.readFileSync(".evaluation-result.json")); + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, res.summaryMarkdown + "\n"); + ' + + - name: Verify Slack credentials for production run + if: ${{ steps.collect-meta.outputs.skip == 'false' && inputs.dry_run == false }} + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + run: | + PENDING_COUNT=$(node -e ' + const fs = require("fs"); + const res = JSON.parse(fs.readFileSync(".evaluation-result.json")); + console.log(res.pendingAwards.length); + ') + + if [ "${PENDING_COUNT}" -gt 0 ] && [ -z "${SLACK_BOT_TOKEN:-}" ]; then + echo "::error::SLACK_BOT_TOKEN secret is required for production badge awards but was not provided. Failing workflow to prevent silent omission." + exit 1 + fi + + - name: Sequentially dispatch awards and apply tracking labels + if: ${{ steps.collect-meta.outputs.skip == 'false' }} + env: + GH_TOKEN: ${{ github.token }} + TARGET_REPO: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pr_number }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + CANONICAL_SLACK_CHANNEL: "CLDRKJZ0T" + IS_DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + AWARDS_JSON=$(node -e ' + const fs = require("fs"); + const res = JSON.parse(fs.readFileSync(".dispatch-context.json")); + console.log(JSON.stringify(res.pendingAwards || [])); + ') + + EMAIL=$(node -e ' + const fs = require("fs"); + const res = JSON.parse(fs.readFileSync(".dispatch-context.json")); + console.log(res.recipientEmail || ""); + ') + + MASKED_EMAIL=$(node -e ' + const fs = require("fs"); + const res = JSON.parse(fs.readFileSync(".dispatch-context.json")); + console.log(res.maskedEmail || "unknown"); + ') + + # Securely wipe dispatch context file containing raw recipient email + rm -f .dispatch-context.json + + AWARD_COUNT=$(echo "${AWARDS_JSON}" | jq '. | length') + echo "Pending awards count: ${AWARD_COUNT}" + + if [ "${AWARD_COUNT}" -eq 0 ]; then + echo "No pending awards to dispatch." + exit 0 + fi + + for i in $(seq 0 $((AWARD_COUNT - 1))); do + BADGE_SLUG=$(echo "${AWARDS_JSON}" | jq -r ".[$i].slug") + BADGE_NAME=$(echo "${AWARDS_JSON}" | jq -r ".[$i].name") + LABEL_NAME=$(echo "${AWARDS_JSON}" | jq -r ".[$i].trackingLabel") + + echo "--------------------------------------------------------" + echo "Processing award $((i + 1)) of ${AWARD_COUNT}: ${BADGE_NAME} (${BADGE_SLUG})" + + # Step 1: Dispatch to Slack (Never echoing raw email to stdout) + if [ "${IS_DRY_RUN}" = "true" ]; then + echo "[DRY-RUN] Would post award command for ${BADGE_SLUG} to recipient (${MASKED_EMAIL})" + else + echo "Dispatching award for ${BADGE_SLUG} to recipient (${MASKED_EMAIL})..." + # The bot posts a message containing "/award-badge " to the + # canonical Slack channel. The Layer5 Cloud Slack integration monitors this + # channel and consumes the message to issue the badge award. + PAYLOAD=$(jq -n \ + --arg ch "${CANONICAL_SLACK_CHANNEL}" \ + --arg txt "/award-badge ${EMAIL} ${BADGE_SLUG}" \ + '{channel: $ch, text: $txt}') + + SLACK_RESP=$(curl -s -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${PAYLOAD}") + + SLACK_OK=$(echo "${SLACK_RESP}" | jq -r '.ok // false') + if [ "${SLACK_OK}" != "true" ]; then + SLACK_ERR=$(echo "${SLACK_RESP}" | jq -r '.error // "unknown"') + echo "::error::Slack dispatch failed for badge ${BADGE_SLUG}: ${SLACK_ERR}" + exit 1 + fi + echo "Slack dispatch successful for ${BADGE_SLUG}." + fi + + # Step 2: Race-Safe Label Provisioning & Label Write + if [ "${IS_DRY_RUN}" = "true" ]; then + echo "[DRY-RUN] Would create/verify label '${LABEL_NAME}' and apply to PR #${PR_NUMBER}" + else + echo "Ensuring label '${LABEL_NAME}' exists on ${TARGET_REPO}..." + LABEL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: token ${GH_TOKEN}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${TARGET_REPO}/labels/${LABEL_NAME}") + + if [ "${LABEL_STATUS}" = "200" ]; then + echo "Label '${LABEL_NAME}' already exists on ${TARGET_REPO}." + elif [ "${LABEL_STATUS}" = "404" ]; then + echo "Label '${LABEL_NAME}' does not exist on ${TARGET_REPO}. Creating..." + CREATE_RESP_FILE=$(mktemp) + HTTP_CODE=$(curl -s -w "%{http_code}" -o "${CREATE_RESP_FILE}" \ + -X POST \ + -H "Authorization: token ${GH_TOKEN}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${TARGET_REPO}/labels" \ + -d "{\"name\":\"${LABEL_NAME}\",\"color\":\"0E7090\",\"description\":\"Automated contributor badge tracking\"}") + + if [ "${HTTP_CODE}" = "201" ]; then + echo "Label '${LABEL_NAME}' created successfully." + elif [ "${HTTP_CODE}" = "422" ]; then + IS_ALREADY_EXISTS=$(jq -r '.errors[]? | select(.code == "already_exists") | .code' "${CREATE_RESP_FILE}") + if [ "${IS_ALREADY_EXISTS}" = "already_exists" ]; then + echo "Label '${LABEL_NAME}' already exists (race condition resolved)." + else + echo "::error::Fatal 422 error creating label '${LABEL_NAME}': $(cat "${CREATE_RESP_FILE}")" + rm -f "${CREATE_RESP_FILE}" + exit 1 + fi + elif [ "${HTTP_CODE}" = "403" ]; then + echo "::error::GitHub API forbidden (HTTP 403) while creating label '${LABEL_NAME}' on ${TARGET_REPO}: $(cat "${CREATE_RESP_FILE}")" + rm -f "${CREATE_RESP_FILE}" + exit 1 + elif [ "${HTTP_CODE}" = "429" ]; then + echo "::error::GitHub API rate limit exceeded (HTTP 429) while creating label '${LABEL_NAME}' on ${TARGET_REPO}: $(cat "${CREATE_RESP_FILE}")" + rm -f "${CREATE_RESP_FILE}" + exit 1 + elif [[ "${HTTP_CODE}" =~ ^5[0-9]{2}$ ]]; then + echo "::error::GitHub API server error (HTTP ${HTTP_CODE}) while creating label '${LABEL_NAME}' on ${TARGET_REPO}: $(cat "${CREATE_RESP_FILE}")" + rm -f "${CREATE_RESP_FILE}" + exit 1 + else + echo "::error::Failed to create label '${LABEL_NAME}' (HTTP ${HTTP_CODE}): $(cat "${CREATE_RESP_FILE}")" + rm -f "${CREATE_RESP_FILE}" + exit 1 + fi + rm -f "${CREATE_RESP_FILE}" + elif [ "${LABEL_STATUS}" = "403" ]; then + echo "::error::GitHub API access forbidden (HTTP 403) while querying label '${LABEL_NAME}' on ${TARGET_REPO}" + exit 1 + elif [ "${LABEL_STATUS}" = "429" ]; then + echo "::error::GitHub API rate limit exceeded (HTTP 429) while querying label '${LABEL_NAME}' on ${TARGET_REPO}" + exit 1 + elif [[ "${LABEL_STATUS}" =~ ^5[0-9]{2}$ ]]; then + echo "::error::GitHub API server error (HTTP ${LABEL_STATUS}) while querying label '${LABEL_NAME}' on ${TARGET_REPO}" + exit 1 + else + echo "::error::GitHub API error or network failure (HTTP ${LABEL_STATUS}) while querying label '${LABEL_NAME}' on ${TARGET_REPO}" + exit 1 + fi + + # Apply label to PR + echo "Applying tracking label '${LABEL_NAME}' to PR #${PR_NUMBER}..." + if ! gh issue edit "${PR_NUMBER}" --repo "${TARGET_REPO}" --add-label "${LABEL_NAME}"; then + echo "::error::Slack award dispatched for badge '${BADGE_SLUG}' but GitHub label application failed. Manual tracking label intervention required." + exit 1 + fi + echo "Applied tracking label '${LABEL_NAME}'." + fi + done + + echo "Badge award processing completed successfully." diff --git a/.github/workflows/badge-engine-ci.yml b/.github/workflows/badge-engine-ci.yml new file mode 100644 index 0000000..377d7b0 --- /dev/null +++ b/.github/workflows/badge-engine-ci.yml @@ -0,0 +1,23 @@ +name: Badge Engine Tests + +on: + pull_request: + paths: + - 'utils/**' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/**' + +permissions: + contents: read + +jobs: + test-badge-engine: + name: Run Badge Engine Unit Tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 pinned SHA + + - name: Run badge engine tests + run: npm run test:badge-engine diff --git a/.github/workflows/test-badge-evaluator.yml b/.github/workflows/test-badge-evaluator.yml new file mode 100644 index 0000000..fe3ec57 --- /dev/null +++ b/.github/workflows/test-badge-evaluator.yml @@ -0,0 +1,145 @@ +name: Test Badge Evaluator (Dry-Run) + +on: + workflow_dispatch: + inputs: + repository: + description: "Target repository to evaluate (e.g. layer5io/sistent, meshery/meshery)" + required: true + type: string + pr_number: + description: "Merged Pull Request number in the target repository" + required: true + type: number + +permissions: + contents: read + issues: read + +jobs: + test-evaluation: + name: Dry-Run Historical PR Evaluation + runs-on: ubuntu-latest + steps: + - name: Validate authorized repository allowlist + env: + TARGET_REPO: ${{ inputs.repository }} + run: | + set -euo pipefail + NORMALIZED_REPO=$(echo "${TARGET_REPO}" | tr '[:upper:]' '[:lower:]') + case "${NORMALIZED_REPO}" in + "layer5io/sistent"|"meshery/meshery"|"meshery/meshery-operator"|"meshery/meshsync"|"layer5io/docs"|"meshery/meshery.io"|"layer5io/layer5") + echo "Repository '${TARGET_REPO}' is authorized for Track 2 badge automation." + ;; + *) + echo "::error::Repository '${TARGET_REPO}' is not an authorized Track 2 participating repository." + exit 1 + ;; + esac + + - name: Checkout recognition repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 pinned SHA + + - name: Verify PR status and collect metadata + id: collect-meta + env: + GH_TOKEN: ${{ github.token }} + TARGET_REPO: ${{ inputs.repository }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + set -euo pipefail + echo "Evaluating PR #${PR_NUMBER} from repository: ${TARGET_REPO}..." + + GH_RESP_FILE=$(mktemp) + GH_ERR_FILE=$(mktemp) + set +e + gh api --include "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > "${GH_RESP_FILE}" 2> "${GH_ERR_FILE}" + GH_EXIT_CODE=$? + set -e + + HTTP_STATUS="" + if [ -s "${GH_RESP_FILE}" ]; then + HTTP_STATUS=$(head -n 1 "${GH_RESP_FILE}" | awk '{print $2}') + fi + + if [ "${HTTP_STATUS}" = "200" ]; then + sed -e '1,/^\r\{0,1\}$/d' "${GH_RESP_FILE}" > .pr-info.json + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + elif [ "${HTTP_STATUS}" = "404" ]; then + echo "::warning::Pull Request #${PR_NUMBER} not found in ${TARGET_REPO} (HTTP 404)." + echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY" + echo "Pull Request #${PR_NUMBER} was not found in \`${TARGET_REPO}\` (HTTP 404)." >> "$GITHUB_STEP_SUMMARY" + echo "skip=true" >> "$GITHUB_OUTPUT" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 0 + elif [ "${HTTP_STATUS}" = "403" ]; then + echo "::error::GitHub API access forbidden (HTTP 403) while querying PR #${PR_NUMBER} in ${TARGET_REPO}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + elif [ "${HTTP_STATUS}" = "429" ]; then + echo "::error::GitHub API rate limit exceeded (HTTP 429) while querying PR #${PR_NUMBER} in ${TARGET_REPO}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + elif [[ "${HTTP_STATUS}" =~ ^5[0-9]{2}$ ]]; then + echo "::error::GitHub API server error (HTTP ${HTTP_STATUS}) while querying PR #${PR_NUMBER} in ${TARGET_REPO}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + elif [ -n "${HTTP_STATUS}" ]; then + echo "::error::Unexpected GitHub API HTTP status (${HTTP_STATUS}) while querying PR #${PR_NUMBER} in ${TARGET_REPO}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + else + GH_ERR_MSG=$(cat "${GH_ERR_FILE}") + echo "::error::GitHub API network/transport error while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + fi + + IS_MERGED=$(jq -r '.merged // false' .pr-info.json) + if [ "${IS_MERGED}" != "true" ]; then + echo "::warning::Pull Request #${PR_NUMBER} in ${TARGET_REPO} is not in a merged state (merged=${IS_MERGED})." + echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY" + echo "Pull Request #${PR_NUMBER} in \`${TARGET_REPO}\` is not in a merged state." >> "$GITHUB_STEP_SUMMARY" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/files" --paginate --slurp | jq 'add // []' > .pr-files.json + gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/commits" --paginate --slurp | jq 'add // []' > .pr-commits.json + gh api "repos/${TARGET_REPO}/issues/${PR_NUMBER}/labels" --paginate --slurp | jq 'add // []' > .existing-labels.json + + node -e ' + const fs = require("fs"); + const pr = JSON.parse(fs.readFileSync(".pr-info.json")); + const files = JSON.parse(fs.readFileSync(".pr-files.json")); + const commits = JSON.parse(fs.readFileSync(".pr-commits.json")); + fs.writeFileSync(".pr-metadata.json", JSON.stringify({ pr, files, commits }, null, 2)); + ' + echo "skip=false" >> "$GITHUB_OUTPUT" + + - name: Run award orchestrator in dry-run mode + if: ${{ steps.collect-meta.outputs.skip == 'false' }} + env: + TARGET_REPO: ${{ inputs.repository }} + run: | + set -euo pipefail + node utils/award-orchestrator.js \ + --metadata=".pr-metadata.json" \ + --existing-labels=".existing-labels.json" \ + --repo="${TARGET_REPO}" \ + --out=".evaluation-result.json" + + - name: Publish step summary + if: ${{ steps.collect-meta.outputs.skip == 'false' }} + run: | + node -e ' + const fs = require("fs"); + const res = JSON.parse(fs.readFileSync(".evaluation-result.json")); + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, res.summaryMarkdown + "\n"); + ' + + - name: Print sanitized dry-run report + if: ${{ steps.collect-meta.outputs.skip == 'false' }} + run: | + echo "=== SANITIZED DRY-RUN EVALUATION REPORT ===" + cat .evaluation-result.json | jq . diff --git a/package.json b/package.json index aad625e..288c094 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "start": "gatsby develop", "build": "gatsby build", "serve": "gatsby serve", - "clean": "gatsby clean" + "clean": "gatsby clean", + "test:badge-engine": "node --test utils/*.test.js" }, "proxy": "https://discuss.layer5.io/", "devDependencies": { diff --git a/utils/award-orchestrator.js b/utils/award-orchestrator.js new file mode 100644 index 0000000..fa5ecfb --- /dev/null +++ b/utils/award-orchestrator.js @@ -0,0 +1,471 @@ +const fs = require('fs'); +const path = require('path'); +const { + evaluateBadges, + normalizeLabels, + normalizeFiles, + isSupportedRepository, + SUPPORTED_REPOSITORIES +} = require('./badge-evaluator'); +const { resolveIdentity, maskEmail } = require('./identity-resolver'); + +/** + * Parses command line arguments formatted as --key=value or --key value + * @param {string[]} args + * @returns {Record} + */ +function parseArgs(args) { + const parsed = {}; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith('--')) { + const equalsIdx = arg.indexOf('='); + if (equalsIdx !== -1) { + const key = arg.slice(2, equalsIdx); + const value = arg.slice(equalsIdx + 1); + parsed[key] = value; + } else { + const key = arg.slice(2); + const next = args[i + 1]; + if (next && !next.startsWith('--')) { + parsed[key] = next; + i++; + } else { + parsed[key] = 'true'; + } + } + } + } + return parsed; +} + +/** + * Flattens slurped or paginated API response pages and handles edge cases. + * Handles: + * - Slurped array of pages: [[item1, item2], [item3]] + * - Single flattened page: [item1, item2] + * - Empty array: [] + * - Null or non-array + * + * @param {any} input + * @returns {Array} + */ +function flattenPages(input) { + if (!input) return []; + if (!Array.isArray(input)) return [input]; + if (input.length === 0) return []; + + // Check if first element is an array (slurped page array) + if (Array.isArray(input[0])) { + const flattened = []; + for (const page of input) { + if (Array.isArray(page)) { + flattened.push(...page); + } else if (page) { + flattened.push(page); + } + } + return flattened; + } + return input; +} + +/** + * Deduplicates an array of file objects or strings by filename. + * @param {any} files + * @returns {Array} + */ +function deduplicateFiles(files) { + const seen = new Set(); + const deduped = []; + for (const f of flattenPages(files)) { + const filename = (typeof f === 'string' ? f : (f && f.filename ? f.filename : '')).trim().replace(/\\/g, '/'); + if (filename && !seen.has(filename)) { + seen.add(filename); + deduped.push(f); + } + } + return deduped; +} + +/** + * Deduplicates an array of commit objects by SHA. + * @param {any} commits + * @returns {Array} + */ +function deduplicateCommits(commits) { + const seen = new Set(); + const deduped = []; + for (const c of flattenPages(commits)) { + if (!c) continue; + const sha = (c.sha || '').trim(); + if (sha) { + if (!seen.has(sha)) { + seen.add(sha); + deduped.push(c); + } + } else { + deduped.push(c); + } + } + return deduped; +} + +/** + * Deduplicates an array of labels by name. + * @param {any} labels + * @returns {Array} + */ +function deduplicateLabels(labels) { + const seen = new Set(); + const deduped = []; + for (const l of flattenPages(labels)) { + const name = (typeof l === 'string' ? l : (l && l.name ? l.name : '')).trim().toLowerCase(); + if (name && !seen.has(name)) { + seen.add(name); + deduped.push(l); + } + } + return deduped; +} + +/** + * Builds GitHub Actions step summary markdown. + * Strictly avoids logging plaintext recipient email addresses. + */ +function buildSummaryMarkdown({ + repo, + prAuthor, + maskedEmail, + dcoVerified, + dcoReason, + allEligibleBadges, + alreadyAwardedBadges, + pendingAwards, + isSupportedRepo, + isMerged +}) { + const lines = []; + lines.push(`## 🎖️ Contributor Badge Evaluation Summary`); + lines.push(''); + lines.push(`- **Target Repository**: \`${repo}\``); + lines.push(`- **PR Author**: \`@${prAuthor || 'unknown'}\``); + + if (!isSupportedRepo) { + lines.push(''); + lines.push(`> [!WARNING]`); + lines.push(`> Repository \`${repo}\` is not an authorized Track 2 participating repository. Badge evaluation rejected.`); + lines.push(''); + return lines.join('\n'); + } + + if (isMerged === false) { + lines.push(''); + lines.push(`> [!WARNING]`); + lines.push(`> Pull request is not in a merged state. Badge assignment is strictly limited to merged pull requests.`); + lines.push(''); + return lines.join('\n'); + } + + if (maskedEmail) { + lines.push(`- **Recipient Identity**: \`${maskedEmail}\` (${dcoVerified ? '✅ DCO Verified' : '⚠️ DCO Unverified'})`); + } else if (dcoVerified) { + lines.push(`- **Recipient Identity**: ⚠️ Unresolved award recipient (✅ DCO Verified)`); + } else { + lines.push(`- **Recipient Identity**: ⚠️ Unresolved email`); + } + lines.push(`- **Attribution Note**: ${dcoReason}`); + lines.push(''); + + if (allEligibleBadges.length === 0) { + lines.push(`> [!NOTE]`); + lines.push(`> No qualifying badge criteria matched for this pull request.`); + lines.push(''); + return lines.join('\n'); + } + + lines.push(`| Badge | Slug | Status | Tracking Label | Qualification Reason |`); + lines.push(`| :--- | :--- | :--- | :--- | :--- |`); + + for (const badge of allEligibleBadges) { + const isAlreadyAwarded = alreadyAwardedBadges.some(b => b.slug === badge.slug); + const trackingLabel = `\`badge-awarded:${badge.slug}\``; + + let status = '🚀 **Pending Dispatch**'; + if (isAlreadyAwarded) { + status = '✅ **Already Awarded**'; + } else if (!dcoVerified) { + status = '⚠️ **DCO Blocked**'; + } else if (!maskedEmail) { + status = '⚠️ **Recipient Unresolvable**'; + } + + lines.push(`| **${badge.name}** | \`${badge.slug}\` | ${status} | ${trackingLabel} | ${badge.reason} |`); + } + + lines.push(''); + + if (dcoVerified && !maskedEmail && allEligibleBadges.length > 0 && alreadyAwardedBadges.length === 0) { + lines.push(`> [!WARNING]`); + lines.push(`> DCO Signed-off-by trailer is verified, but recipient identity cannot be mapped to a Layer5 award recipient. Zero awards dispatched.`); + lines.push(''); + } + + if (pendingAwards.length > 0) { + lines.push(`### Planned Dispatches (${pendingAwards.length})`); + lines.push(''); + for (const award of pendingAwards) { + lines.push(`- **${award.name}** (\`${award.slug}\`) $\\rightarrow$ Tracking Label: \`${award.trackingLabel}\``); + } + lines.push(''); + } else if (alreadyAwardedBadges.length > 0 && allEligibleBadges.length === alreadyAwardedBadges.length) { + lines.push(`> [!NOTE]`); + lines.push(`> All eligible badges for this PR have already been awarded and labeled. Zero duplicate dispatches needed.`); + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * Strips all plaintext email addresses and commands to produce a sanitized public report. + * Safe for step summary, console logging, and dry-run display. + * + * @param {Object} internalResult + * @returns {Object} Sanitized report + */ +function getSanitizedReport(internalResult) { + return { + repo: internalResult.repo, + prAuthor: internalResult.prAuthor, + maskedEmail: internalResult.maskedEmail, + dcoVerified: internalResult.dcoVerified, + dcoReason: internalResult.dcoReason, + isSupportedRepo: internalResult.isSupportedRepo, + isMerged: internalResult.isMerged, + allEligibleBadges: internalResult.allEligibleBadges, + alreadyAwardedBadges: internalResult.alreadyAwardedBadges, + unawardedBadges: internalResult.unawardedBadges, + pendingAwards: (internalResult.pendingAwards || []).map(a => ({ + slug: a.slug, + name: a.name, + ruleId: a.ruleId, + reason: a.reason, + trackingLabel: a.trackingLabel + })), + summaryMarkdown: internalResult.summaryMarkdown + }; +} + +/** + * Orchestrates badge evaluation and award filtering. + * Normalizes multi-page GitHub API responses and ensures strict attribution. + * + * @param {Object} options + * @param {Object} options.prMetadata PR metadata object or file content + * @param {Array} [options.existingLabels] Existing labels on PR + * @param {string} [options.repoOverride] Explicit repository override + * @returns {Object} Structured evaluation result + */ +function orchestrateAwards({ prMetadata = {}, existingLabels = [], repoOverride = '' }) { + // Extract repository + const repo = ( + repoOverride || + prMetadata.repository || + prMetadata.repo || + (prMetadata.pr && prMetadata.pr.base && prMetadata.pr.base.repo && prMetadata.pr.base.repo.full_name) || + '' + ).trim(); + + // Validate supported repository allowlist + const isSupportedRepo = isSupportedRepository(repo); + + // Validate merged status if present in metadata + let isMerged = true; + if (prMetadata.pr && typeof prMetadata.pr.merged === 'boolean') { + isMerged = prMetadata.pr.merged; + } else if (typeof prMetadata.merged === 'boolean') { + isMerged = prMetadata.merged; + } + + // Extract author + const prAuthor = ( + prMetadata.prAuthor || + (prMetadata.pr && prMetadata.pr.user && prMetadata.pr.user.login) || + '' + ).trim(); + + // Extract and normalize labels with deduplication across pages + const rawPrLabels = prMetadata.labels || (prMetadata.pr && prMetadata.pr.labels) || []; + const dedupedPrLabels = deduplicateLabels(rawPrLabels); + const normalizedPrLabels = normalizeLabels(dedupedPrLabels); + + // Extract and normalize files with deduplication across pages + const rawFiles = prMetadata.changedFiles || prMetadata.files || []; + const dedupedFiles = deduplicateFiles(rawFiles); + const normalizedFiles = normalizeFiles(dedupedFiles); + + // Extract and deduplicate commits across pages + const rawCommits = prMetadata.commits || []; + const dedupedCommits = deduplicateCommits(rawCommits); + + // If repository is unsupported or PR is unmerged, fail closed immediately + if (!isSupportedRepo || isMerged === false) { + const summaryMarkdown = buildSummaryMarkdown({ + repo, + prAuthor, + maskedEmail: '', + dcoVerified: false, + dcoReason: !isSupportedRepo ? 'Unsupported repository' : 'PR is not merged', + allEligibleBadges: [], + alreadyAwardedBadges: [], + pendingAwards: [], + isSupportedRepo, + isMerged + }); + + return { + repo, + prAuthor, + recipientEmail: null, + maskedEmail: '', + dcoVerified: false, + dcoReason: !isSupportedRepo ? 'Unsupported repository' : 'PR is not merged', + isSupportedRepo, + isMerged, + allEligibleBadges: [], + alreadyAwardedBadges: [], + unawardedBadges: [], + pendingAwards: [], + summaryMarkdown + }; + } + + // Evaluate badge eligibility + const { eligibleBadges } = evaluateBadges({ + repository: repo, + labels: normalizedPrLabels, + changedFiles: normalizedFiles + }); + + // Resolve identity and DCO strictly to PR author + const identity = resolveIdentity(prAuthor, dedupedCommits); + const maskedRecipientEmail = maskEmail(identity.resolvedEmail); + + // Extract existing tracking labels with deduplication + const sourceExistingLabels = existingLabels.length > 0 ? existingLabels : rawPrLabels; + const dedupedExisting = deduplicateLabels(sourceExistingLabels); + const allExistingLabels = normalizeLabels(dedupedExisting); + + const existingTrackingPrefix = 'badge-awarded:'; + const alreadyAwardedSlugs = new Set( + allExistingLabels + .filter(lbl => lbl.startsWith(existingTrackingPrefix)) + .map(lbl => lbl.slice(existingTrackingPrefix.length)) + ); + + const alreadyAwardedBadges = eligibleBadges.filter(b => alreadyAwardedSlugs.has(b.slug)); + const unawardedBadges = eligibleBadges.filter(b => !alreadyAwardedSlugs.has(b.slug)); + + // Only dispatch if DCO is verified and email was resolved + const pendingAwards = []; + if (identity.dcoVerified && identity.resolvedEmail) { + for (const badge of unawardedBadges) { + pendingAwards.push({ + slug: badge.slug, + name: badge.name, + ruleId: badge.ruleId, + reason: badge.reason, + trackingLabel: `badge-awarded:${badge.slug}`, + slackCommand: `/award-badge ${identity.resolvedEmail} ${badge.slug}` + }); + } + } + + const summaryMarkdown = buildSummaryMarkdown({ + repo, + prAuthor, + maskedEmail: maskedRecipientEmail, + dcoVerified: identity.dcoVerified, + dcoReason: identity.reason, + allEligibleBadges: eligibleBadges, + alreadyAwardedBadges, + pendingAwards, + isSupportedRepo, + isMerged + }); + + return { + repo, + prAuthor, + recipientEmail: identity.resolvedEmail, + maskedEmail: maskedRecipientEmail, + dcoVerified: identity.dcoVerified, + dcoReason: identity.reason, + isSupportedRepo, + isMerged, + allEligibleBadges: eligibleBadges, + alreadyAwardedBadges, + unawardedBadges, + pendingAwards, + summaryMarkdown + }; +} + +/** + * CLI execution entrypoint + */ +function runCli() { + const args = parseArgs(process.argv.slice(2)); + + let prMetadata = {}; + if (args.metadata) { + const raw = fs.readFileSync(path.resolve(args.metadata), 'utf-8'); + prMetadata = JSON.parse(raw); + } + + let existingLabels = []; + if (args['existing-labels']) { + const raw = fs.readFileSync(path.resolve(args['existing-labels']), 'utf-8'); + existingLabels = JSON.parse(raw); + } + + const repoOverride = args.repo || ''; + const result = orchestrateAwards({ prMetadata, existingLabels, repoOverride }); + const sanitized = getSanitizedReport(result); + + // Write sanitized public output + if (args.out) { + fs.writeFileSync(path.resolve(args.out), JSON.stringify(sanitized, null, 2), 'utf-8'); + } + + // Write unlogged dispatch payload if requested (for ephemeral runner step) + if (args['dispatch-out']) { + const dispatchPayload = { + recipientEmail: result.recipientEmail, + maskedEmail: result.maskedEmail, + pendingAwards: result.pendingAwards + }; + fs.writeFileSync(path.resolve(args['dispatch-out']), JSON.stringify(dispatchPayload, null, 2), 'utf-8'); + } + + // If no output file specified, stream sanitized report to stdout + if (!args.out) { + process.stdout.write(JSON.stringify(sanitized, null, 2) + '\n'); + } +} + +if (require.main === module) { + runCli(); +} + +module.exports = { + orchestrateAwards, + parseArgs, + flattenPages, + deduplicateFiles, + deduplicateCommits, + deduplicateLabels, + buildSummaryMarkdown, + getSanitizedReport +}; diff --git a/utils/award-orchestrator.test.js b/utils/award-orchestrator.test.js new file mode 100644 index 0000000..35077c9 --- /dev/null +++ b/utils/award-orchestrator.test.js @@ -0,0 +1,356 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { + orchestrateAwards, + parseArgs, + flattenPages, + deduplicateFiles, + deduplicateCommits, + deduplicateLabels, + getSanitizedReport +} = require('./award-orchestrator'); + +test('parseArgs parses flags and key-values', () => { + const args = ['--metadata=foo.json', '--repo', 'layer5io/sistent', '--dry-run']; + const parsed = parseArgs(args); + assert.equal(parsed.metadata, 'foo.json'); + assert.equal(parsed.repo, 'layer5io/sistent'); + assert.equal(parsed['dry-run'], 'true'); +}); + +test('flattenPages and deduplicate handles single, multi, and empty pages', () => { + // Empty responses + assert.deepEqual(flattenPages([]), []); + assert.deepEqual(flattenPages([[]]), []); + assert.deepEqual(flattenPages(null), []); + + // One-page response + const onePageFiles = [{ filename: 'src/button.tsx' }]; + assert.equal(deduplicateFiles(onePageFiles).length, 1); + + // Multi-page slurped response + const multiPageFiles = [ + [{ filename: 'src/button.tsx' }], + [{ filename: 'src/modal.tsx' }] + ]; + const dedupedMulti = deduplicateFiles(multiPageFiles); + assert.equal(dedupedMulti.length, 2); + + // Overlapping duplicates across pages + const overlappingFiles = [ + [{ filename: 'src/button.tsx' }, { filename: 'src/modal.tsx' }], + [{ filename: 'src/button.tsx' }, { filename: 'src/card.tsx' }] + ]; + const dedupedOverlap = deduplicateFiles(overlappingFiles); + assert.equal(dedupedOverlap.length, 3); + + // Overlapping commits + const multiPageCommits = [ + [{ sha: 'sha1', commit: { message: 'first' } }], + [{ sha: 'sha1', commit: { message: 'first duplicate' } }, { sha: 'sha2', commit: { message: 'second' } }] + ]; + const dedupedCommits = deduplicateCommits(multiPageCommits); + assert.equal(dedupedCommits.length, 2); + + // Overlapping labels + const multiPageLabels = [ + [{ name: 'area/ui' }], + [{ name: 'AREA/UI' }, { name: 'enhancement' }] + ]; + const dedupedLabels = deduplicateLabels(multiPageLabels); + assert.equal(dedupedLabels.length, 2); +}); + +test('orchestrateAwards produces pending award on qualifying fresh PR', () => { + const prMetadata = { + repository: 'layer5io/sistent', + prAuthor: 'contributor1', + changedFiles: ['examples/sample-app/index.tsx'], + labels: ['enhancement'], + commits: [ + { + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: add button component\n\nSigned-off-by: Contributor One ' + } + } + ] + }; + + const result = orchestrateAwards({ prMetadata }); + + assert.equal(result.dcoVerified, true); + assert.equal(result.recipientEmail, 'contrib@layer5.io'); + assert.equal(result.allEligibleBadges.length, 1); + assert.equal(result.allEligibleBadges[0].slug, 'sistent-contributor'); + assert.equal(result.pendingAwards.length, 1); + assert.equal(result.pendingAwards[0].slug, 'sistent-contributor'); + assert.equal(result.pendingAwards[0].trackingLabel, 'badge-awarded:sistent-contributor'); + assert.equal(result.alreadyAwardedBadges.length, 0); + assert.ok(result.summaryMarkdown.includes('Pending Dispatch')); +}); + +test('orchestrateAwards fails closed on unauthorized / unexpected repositories', () => { + const prMetadata = { + repository: 'malicious-org/arbitrary-repo', + prAuthor: 'hacker', + changedFiles: ['src/index.ts'], + commits: [ + { + author: { login: 'hacker' }, + commit: { + author: { name: 'Hacker', email: 'hacker@example.com' }, + message: 'exploit\n\nSigned-off-by: Hacker ' + } + } + ] + }; + + const result = orchestrateAwards({ prMetadata }); + assert.equal(result.isSupportedRepo, false); + assert.equal(result.pendingAwards.length, 0); + assert.ok(result.summaryMarkdown.includes('not an authorized Track 2 participating repository')); +}); + +test('orchestrateAwards fails closed on unmerged pull requests (merged guard)', () => { + const prMetadata = { + repository: 'layer5io/sistent', + prAuthor: 'contributor1', + merged: false, // Unmerged PR + changedFiles: ['src/button.tsx'], + commits: [ + { + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contrib', email: 'contrib@layer5.io' }, + message: 'feat: button\n\nSigned-off-by: Contrib ' + } + } + ] + }; + + const result = orchestrateAwards({ prMetadata }); + assert.equal(result.isMerged, false); + assert.equal(result.pendingAwards.length, 0); + assert.ok(result.summaryMarkdown.includes('not in a merged state')); +}); + +test('orchestrateAwards filters out already awarded badges (Idempotency)', () => { + const prMetadata = { + repository: 'layer5io/sistent', + prAuthor: 'contributor1', + changedFiles: ['examples/sample-app/index.tsx'], + labels: ['enhancement'], + commits: [ + { + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: add button\n\nSigned-off-by: Contributor One ' + } + } + ] + }; + + const existingLabels = ['enhancement', 'badge-awarded:sistent-contributor']; + const result = orchestrateAwards({ prMetadata, existingLabels }); + + assert.equal(result.allEligibleBadges.length, 1); + assert.equal(result.alreadyAwardedBadges.length, 1); + assert.equal(result.alreadyAwardedBadges[0].slug, 'sistent-contributor'); + assert.equal(result.pendingAwards.length, 0, 'Must have zero pending awards when already labeled'); + assert.ok(result.summaryMarkdown.includes('Already Awarded')); + assert.ok(result.summaryMarkdown.includes('Zero duplicate dispatches needed')); +}); + +test('orchestrateAwards blocks awards when DCO is unverified', () => { + const prMetadata = { + repository: 'meshery/meshery', + prAuthor: 'author2', + changedFiles: ['server/main.go'], + labels: [], + commits: [ + { + author: { login: 'author2' }, + commit: { + author: { name: 'Author Two', email: 'author2@example.com' }, + message: 'fix: update server initialization without dco' + } + } + ] + }; + + const result = orchestrateAwards({ prMetadata }); + + assert.equal(result.dcoVerified, false); + assert.equal(result.allEligibleBadges.length, 1); + assert.equal(result.pendingAwards.length, 0, 'Cannot award badge without verified DCO'); + assert.ok(result.summaryMarkdown.includes('DCO Blocked')); +}); + +test('orchestrateAwards safely handles noreply sign-off: DCO valid but recipient unresolvable (zero dispatches)', () => { + const prMetadata = { + repository: 'meshery/meshery', + prAuthor: 'noreplydev', + changedFiles: ['server/main.go'], + labels: [], + commits: [ + { + author: { login: 'noreplydev' }, + commit: { + author: { name: 'Noreply Dev', email: '99999+noreplydev@users.noreply.github.com' }, + message: 'fix: update server\n\nSigned-off-by: Noreply Dev <99999+noreplydev@users.noreply.github.com>' + } + } + ] + }; + + const result = orchestrateAwards({ prMetadata }); + + assert.equal(result.dcoVerified, true); + assert.equal(result.recipientEmail, null); + assert.equal(result.allEligibleBadges.length, 1); + assert.equal(result.pendingAwards.length, 0, 'Cannot dispatch awards when recipient email is unresolvable'); + assert.ok(result.summaryMarkdown.includes('Recipient Unresolvable')); + assert.ok(result.summaryMarkdown.includes('Zero awards dispatched')); +}); + +test('orchestrateAwards handles multiple qualifying PRs: PR-level replay protection vs independent PR evaluation', () => { + // Contributor merges PR #100 touching meshery + const pr100 = { + repository: 'meshery/meshery', + prAuthor: 'devX', + changedFiles: ['server/main.go'], + commits: [ + { + author: { login: 'devX' }, + commit: { + author: { name: 'Dev X', email: 'devx@example.com' }, + message: 'feat: add server handler\n\nSigned-off-by: Dev X ' + } + } + ] + }; + + // Initial evaluation of PR #100 -> produces pending award + const initialRunPR100 = orchestrateAwards({ prMetadata: pr100, existingLabels: [] }); + assert.equal(initialRunPR100.pendingAwards.length, 1); + assert.equal(initialRunPR100.pendingAwards[0].slug, 'meshery'); + + // Replay of PR #100 after tracking label applied -> PR-level replay protection skips award + const replayRunPR100 = orchestrateAwards({ + prMetadata: pr100, + existingLabels: ['badge-awarded:meshery'] + }); + assert.equal(replayRunPR100.pendingAwards.length, 0, 'PR-level tracking label prevents duplicate award on same PR replay'); + assert.equal(replayRunPR100.alreadyAwardedBadges.length, 1); + + // Subsequent PR #101 by the same contributor touching meshery (no label on PR #101 yet) + const pr101 = { + repository: 'meshery/meshery', + prAuthor: 'devX', + changedFiles: ['mesheryctl/cmd/system.go'], + commits: [ + { + author: { login: 'devX' }, + commit: { + author: { name: 'Dev X', email: 'devx@example.com' }, + message: 'feat: system command\n\nSigned-off-by: Dev X ' + } + } + ] + }; + + // Track 2 evaluates PR #101 independently (relying on downstream Cloud idempotency for contributor-level deduplication) + const runPR101 = orchestrateAwards({ prMetadata: pr101, existingLabels: [] }); + assert.equal(runPR101.pendingAwards.length, 1); + assert.equal(runPR101.pendingAwards[0].slug, 'meshery'); +}); + +test('Privacy verification: sanitized report and step summary never leak plaintext email', () => { + const plaintextEmail = 'secret.contributor@privatecorp.com'; + const prMetadata = { + repository: 'layer5io/sistent', + prAuthor: 'secretdev', + changedFiles: ['src/index.ts'], + labels: [], + commits: [ + { + author: { login: 'secretdev' }, + commit: { + author: { name: 'Secret Dev', email: plaintextEmail }, + message: `feat: change\n\nSigned-off-by: Secret Dev <${plaintextEmail}>` + } + } + ] + }; + + const result = orchestrateAwards({ prMetadata }); + const sanitized = getSanitizedReport(result); + + // Stringified sanitized report check + const serialized = JSON.stringify(sanitized); + const leakedInJson = serialized.includes(plaintextEmail); + assert.equal(leakedInJson, false, 'Sanitized report must never contain plaintext email'); + assert.ok(serialized.includes(sanitized.maskedEmail), 'Sanitized report must contain masked email'); + + // Summary markdown check + const leakedInMarkdown = result.summaryMarkdown.includes(plaintextEmail); + assert.equal(leakedInMarkdown, false, 'Summary markdown must never contain plaintext email'); + assert.ok(result.summaryMarkdown.includes(sanitized.maskedEmail)); +}); + +test('orchestrateAwards CLI file integration: separates public report from internal dispatch context', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'award-test-')); + const metaFile = path.join(tmpDir, 'pr-meta.json'); + const labelsFile = path.join(tmpDir, 'labels.json'); + const publicOutFile = path.join(tmpDir, 'sanitized-out.json'); + const dispatchOutFile = path.join(tmpDir, 'dispatch-out.json'); + + const plaintextEmail = 'dev3@layer5.io'; + const prMetadata = { + repository: 'meshery/meshsync', + prAuthor: 'dev3', + changedFiles: ['internal/sync.go'], + commits: [ + { + author: { login: 'dev3' }, + commit: { + author: { name: 'Dev Three', email: plaintextEmail }, + message: `feat: sync\n\nSigned-off-by: Dev Three <${plaintextEmail}>` + } + } + ] + }; + + fs.writeFileSync(metaFile, JSON.stringify(prMetadata), 'utf-8'); + fs.writeFileSync(labelsFile, JSON.stringify(['area/sync']), 'utf-8'); + + // Run CLI + const { execFileSync } = require('child_process'); + const scriptPath = path.resolve(__dirname, 'award-orchestrator.js'); + execFileSync(process.execPath, [ + scriptPath, + `--metadata=${metaFile}`, + `--existing-labels=${labelsFile}`, + `--out=${publicOutFile}`, + `--dispatch-out=${dispatchOutFile}` + ]); + + // Public output must be sanitized + assert.ok(fs.existsSync(publicOutFile)); + const publicContent = fs.readFileSync(publicOutFile, 'utf-8'); + assert.equal(publicContent.includes(plaintextEmail), false, 'Public file must not contain raw email'); + + // Dispatch output contains recipient email for runner execution + assert.ok(fs.existsSync(dispatchOutFile)); + const dispatchContent = JSON.parse(fs.readFileSync(dispatchOutFile, 'utf-8')); + assert.equal(dispatchContent.recipientEmail, plaintextEmail); + + // Clean up + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); diff --git a/utils/badge-evaluator.js b/utils/badge-evaluator.js new file mode 100644 index 0000000..8fc5da4 --- /dev/null +++ b/utils/badge-evaluator.js @@ -0,0 +1,244 @@ +const defaultRules = require("./badge-rules.json"); + +/** + * Authoritative allowlist of participating Track 2 ecosystem repositories. + */ +const SUPPORTED_REPOSITORIES = Object.freeze([ + "layer5io/sistent", + "meshery/meshery", + "meshery/meshery-operator", + "meshery/meshsync", + "layer5io/docs", + "meshery/meshery.io", + "layer5io/layer5" +]); + +/** + * Universal Track 2 exclusions applied consistently to all Track 2 badge rules: + * - test files: tests, test.go, __tests__ + * - lockfiles: package-lock.json, yarn.lock, go.sum (at root or nested) + * - repository governance: .github/**, LICENSE, README.md, CONTRIBUTING*.md + */ +const UNIVERSAL_EXCLUSIONS = Object.freeze([ + "**/*.test.*", + "**/*_test.go", + "**/__tests__/**", + "package-lock.json", + "**/package-lock.json", + "yarn.lock", + "**/yarn.lock", + "go.sum", + "**/go.sum", + ".github/**", + "LICENSE", + "README.md", + "CONTRIBUTING*.md" +]); + +/** + * Validates whether a repository name is an authorized Track 2 participating repository. + * @param {string} repository + * @returns {boolean} + */ +function isSupportedRepository(repository) { + if (!repository || typeof repository !== "string") return false; + return SUPPORTED_REPOSITORIES.includes(repository.trim().toLowerCase()); +} + +/** + * Matches a glob pattern against a normalized relative file path. + * Supports: + * - `**` : arbitrary directories / subdirectories + * - `*` : wildcards within path segment / filename + * - exact file or path matches + * + * @param {string} pattern Glob pattern (e.g. "src/**") + * @param {string} filePath Normalized file path (e.g. "src/components/button.tsx") + * @returns {boolean} + */ +function matchGlob(pattern, filePath) { + if (!pattern || !filePath) return false; + + const normPath = filePath.replace(/\\/g, "/").replace(/^\/+/, ""); + const normPattern = pattern.replace(/\\/g, "/").replace(/^\/+/, ""); + + if (normPattern === normPath) return true; + + let regexStr = "^"; + let i = 0; + while (i < normPattern.length) { + const c = normPattern[i]; + if (c === "*" && normPattern[i + 1] === "*") { + if (normPattern[i + 2] === "/") { + regexStr += "(?:.*/)?"; + i += 3; + } else { + regexStr += ".*"; + i += 2; + } + } else if (c === "*") { + regexStr += "[^/]*"; + i += 1; + } else if (["[", "]", ".", "+", "?", "^", "$", "{", "}", "(", ")", "|"].includes(c)) { + regexStr += "\\" + c; + i += 1; + } else { + regexStr += c; + i += 1; + } + } + regexStr += "$"; + + try { + return new RegExp(regexStr).test(normPath); + } catch { + return false; + } +} + +/** + * Normalizes label inputs to lowercase string array + * @param {Array} labels + * @returns {string[]} + */ +function normalizeLabels(labels) { + if (!Array.isArray(labels)) return []; + return labels + .map(label => { + if (typeof label === "string") return label.trim().toLowerCase(); + if (label && typeof label.name === "string") return label.name.trim().toLowerCase(); + return ""; + }) + .filter(Boolean); +} + +/** + * Normalizes file paths + * @param {Array} files + * @returns {string[]} + */ +function normalizeFiles(files) { + if (!Array.isArray(files)) return []; + return files + .map(file => { + if (typeof file === "string") return file.trim().replace(/\\/g, "/"); + if (file && typeof file.filename === "string") return file.filename.trim().replace(/\\/g, "/"); + return ""; + }) + .filter(Boolean); +} + +/** + * Evaluates a pull request's metadata against badge rules. + * Pure function: (repo, labels, changedFiles, rules) -> { eligibleBadges: [ { slug, name, reason, ruleId } ], isSupportedRepo: boolean } + * Zero Git or network dependencies. + * + * @param {Object} prContext + * @param {string} prContext.repository Full repo name (e.g. "layer5io/sistent") + * @param {Array} [prContext.labels] PR labels + * @param {Array} [prContext.changedFiles] List of changed files + * @param {Array} [rules] Optional badge rules override + * @returns {{ eligibleBadges: Array<{ slug: string, name: string, reason: string, ruleId: string }>, isSupportedRepo: boolean }} + */ +function evaluateBadges(prContext = {}, rules = defaultRules) { + const repository = (prContext.repository || prContext.repo || "").trim().toLowerCase(); + const rawLabels = normalizeLabels(prContext.labels); + const rawFiles = normalizeFiles(prContext.changedFiles || prContext.files); + + if (!repository) { + return { eligibleBadges: [], isSupportedRepo: false }; + } + + const isSupported = isSupportedRepository(repository); + if (!isSupported) { + return { eligibleBadges: [], isSupportedRepo: false }; + } + + const eligibleBadges = []; + + for (const rule of rules) { + const supportedRepos = (rule.repositories || []).map(r => r.toLowerCase()); + if (!supportedRepos.includes(repository)) { + continue; + } + + // Determine applicable requiredAnyLabels for this repository + let requiredLabels = null; + if (rule.repoSpecificRequiredAnyLabels) { + for (const [repoKey, labels] of Object.entries(rule.repoSpecificRequiredAnyLabels)) { + if (repoKey.toLowerCase() === repository) { + requiredLabels = labels; + break; + } + } + } + if (requiredLabels === null && Array.isArray(rule.requiredAnyLabels)) { + requiredLabels = rule.requiredAnyLabels; + } + + if (Array.isArray(requiredLabels) && requiredLabels.length > 0) { + const requiredNormalized = requiredLabels.map(l => l.toLowerCase()); + const hasMatchingLabel = rawLabels.some(label => requiredNormalized.includes(label)); + if (!hasMatchingLabel) { + continue; + } + } + + // Determine applicable include patterns + let includePatterns = rule.includePatterns || []; + if (rule.repoSpecificIncludePatterns) { + for (const [repoKey, patterns] of Object.entries(rule.repoSpecificIncludePatterns)) { + if (repoKey.toLowerCase() === repository) { + includePatterns = includePatterns.concat(patterns); + } + } + } + + let excludePatterns = (rule.excludePatterns || []).concat(UNIVERSAL_EXCLUSIONS); + if (rule.repoSpecificExcludePatterns) { + for (const [repoKey, patterns] of Object.entries(rule.repoSpecificExcludePatterns)) { + if (repoKey.toLowerCase() === repository) { + excludePatterns = excludePatterns.concat(patterns); + } + } + } + + // Filter changed files: must match at least one include pattern, and NOT match any exclude pattern + const matchingFiles = rawFiles.filter(filePath => { + const isIncluded = includePatterns.some(pat => matchGlob(pat, filePath)); + if (!isIncluded) return false; + const isExcluded = excludePatterns.some(pat => matchGlob(pat, filePath)); + return !isExcluded; + }); + + if (matchingFiles.length > 0) { + const sampleFiles = matchingFiles.slice(0, 3).join(", "); + const moreSuffix = matchingFiles.length > 3 ? ` and ${matchingFiles.length - 3} more` : ""; + let reason = `Modified ${matchingFiles.length} file(s) matching criteria (${sampleFiles}${moreSuffix})`; + + if (Array.isArray(requiredLabels) && requiredLabels.length > 0) { + const matchedLabel = rawLabels.find(l => requiredLabels.map(r => r.toLowerCase()).includes(l)); + reason = `PR labeled '${matchedLabel}' and modified ${matchingFiles.length} file(s) (${sampleFiles}${moreSuffix})`; + } + + eligibleBadges.push({ + slug: rule.slug, + name: rule.name, + reason, + ruleId: rule.ruleId + }); + } + } + + return { eligibleBadges, isSupportedRepo: true }; +} + +module.exports = { + SUPPORTED_REPOSITORIES, + UNIVERSAL_EXCLUSIONS, + isSupportedRepository, + evaluateBadges, + matchGlob, + normalizeLabels, + normalizeFiles +}; diff --git a/utils/badge-evaluator.test.js b/utils/badge-evaluator.test.js new file mode 100644 index 0000000..20c2752 --- /dev/null +++ b/utils/badge-evaluator.test.js @@ -0,0 +1,458 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + evaluateBadges, + matchGlob, + UNIVERSAL_EXCLUSIONS, + isSupportedRepository +} = require("./badge-evaluator"); + +test("matchGlob utility handles patterns accurately", () => { + // Directory wildcards + assert.equal(matchGlob("src/**", "src/components/button.tsx"), true); + assert.equal(matchGlob("src/**", "src/index.ts"), true); + assert.equal(matchGlob("src/**", "packages/theme/index.ts"), false); + + // Test and spec exclusion globs + assert.equal(matchGlob("**/*.test.*", "ui/components/button.test.tsx"), true); + assert.equal(matchGlob("**/*.test.*", "ui/components/button.tsx"), false); + assert.equal(matchGlob("**/*_test.go", "server/handlers/patterns_test.go"), true); + assert.equal(matchGlob("**/*_test.go", "server/handlers/patterns.go"), false); + assert.equal(matchGlob("**/__tests__/**", "src/__tests__/app.test.js"), true); + assert.equal(matchGlob("**/__tests__/**", "src/components/app.js"), false); +}); + +test("sistent-contributor badge evaluation: positive & negative paths", () => { + // Qualifying files in src + const srcResult = evaluateBadges({ + repository: "layer5io/sistent", + changedFiles: ["src/components/button.tsx"] + }); + const srcSlugs = srcResult.eligibleBadges.map(b => b.slug); + assert.ok(srcSlugs.includes("sistent-contributor")); + + // Qualifying files in examples + const exampleResult = evaluateBadges({ + repository: "layer5io/sistent", + changedFiles: ["examples/nextjs-sample/pages/index.tsx"] + }); + const exampleSlugs = exampleResult.eligibleBadges.map(b => b.slug); + assert.ok(exampleSlugs.includes("sistent-contributor")); + + // system/** is docs only - does NOT qualify for sistent-contributor + const systemResult = evaluateBadges({ + repository: "layer5io/sistent", + changedFiles: ["system/docs/guidelines.md"] + }); + const systemSlugs = systemResult.eligibleBadges.map(b => b.slug); + assert.ok(!systemSlugs.includes("sistent-contributor")); + + // Config and build files (package.json, tsconfig.json, Makefile) do NOT qualify + const configResult = evaluateBadges({ + repository: "layer5io/sistent", + changedFiles: ["package.json", "tsconfig.json", "Makefile"] + }); + assert.equal(configResult.eligibleBadges.length, 0); + + // Non-existent or proposal-unsupported paths (packages/**, scripts/**) do NOT qualify + const unsupportedResult = evaluateBadges({ + repository: "layer5io/sistent", + changedFiles: ["packages/theme/index.js", "scripts/build.sh"] + }); + assert.equal(unsupportedResult.eligibleBadges.length, 0); + + // Disqualifying root metadata / non-code + const disqualified = evaluateBadges({ + repository: "layer5io/sistent", + changedFiles: [".github/workflows/ci.yml", ".gitignore", "LICENSE", "CODE_OF_CONDUCT.md", "README.md", "CONTRIBUTING.md"] + }); + assert.equal(disqualified.eligibleBadges.length, 0); + + // Case insensitive repository check + const caseInsensitive = evaluateBadges({ + repository: "Layer5IO/Sistent", + changedFiles: ["src/index.ts"] + }); + const ciSlugs = caseInsensitive.eligibleBadges.map(b => b.slug); + assert.ok(ciSlugs.includes("sistent-contributor")); +}); + +test("meshery core vs meshery-docs evaluation in meshery/meshery", () => { + // Core functional backend code modification + const coreResult = evaluateBadges({ + repository: "meshery/meshery", + changedFiles: ["server/handlers/patterns.go", "mesheryctl/cmd/system.go"] + }); + const coreSlugs = coreResult.eligibleBadges.map(b => b.slug); + assert.ok(coreSlugs.includes("meshery")); + assert.ok(!coreSlugs.includes("meshery-docs")); + + // Core functional frontend UI modification + const uiResult = evaluateBadges({ + repository: "meshery/meshery", + changedFiles: ["ui/components/Navigator.tsx"] + }); + const uiSlugs = uiResult.eligibleBadges.map(b => b.slug); + assert.ok(uiSlugs.includes("meshery"), "UI contributors must earn meshery core badge"); + + // provider-ui qualifies for meshery + const providerUiResult = evaluateBadges({ + repository: "meshery/meshery", + changedFiles: ["provider-ui/components/ProviderCard.tsx"] + }); + assert.ok(providerUiResult.eligibleBadges.some(b => b.slug === "meshery")); + + // models/** must NOT qualify for meshery (strictly reserved for meshery-catalog) + const modelsResult = evaluateBadges({ + repository: "meshery/meshery", + changedFiles: ["models/patterns/design.json"] + }); + assert.ok(!modelsResult.eligibleBadges.some(b => b.slug === "meshery"), "models/** must NOT qualify for meshery"); + + // install/** must NOT qualify for meshery + const installResult = evaluateBadges({ + repository: "meshery/meshery", + changedFiles: ["install/kubernetes/helm/values.yaml"] + }); + assert.ok(!installResult.eligibleBadges.some(b => b.slug === "meshery"), "install/** must NOT qualify for meshery"); + + // root main.go, go.mod, go.sum, Makefile do NOT qualify + const buildFilesResult = evaluateBadges({ + repository: "meshery/meshery", + changedFiles: ["main.go", "go.mod", "go.sum", "Makefile"] + }); + assert.equal(buildFilesResult.eligibleBadges.length, 0); + + // Documentation-only modification (.md / .mdx) + const docsResult = evaluateBadges({ + repository: "meshery/meshery", + changedFiles: ["docs/concepts/architecture.md", "docs/install/index.mdx"] + }); + const docsSlugs = docsResult.eligibleBadges.map(b => b.slug); + assert.ok(!docsSlugs.includes("meshery"), "Docs-only PR must not earn meshery core badge"); + assert.ok(docsSlugs.includes("meshery-docs"), "Must earn meshery-docs badge"); + + // Non-doc file under docs/** does NOT qualify for meshery-docs + const nonDocInDocs = evaluateBadges({ + repository: "meshery/meshery", + changedFiles: ["docs/assets/diagram.png", "docs/data/schema.json"] + }); + assert.ok(!nonDocInDocs.eligibleBadges.some(b => b.slug === "meshery-docs")); + + // Root markdown & governance exclusions + const metaResult = evaluateBadges({ + repository: "meshery/meshery", + changedFiles: ["README.md", "ROADMAP.md", "ADOPTERS.md", "GOVERNANCE.md", "VISION.md", "CONTRIBUTING.md", ".github/workflows/test.yml"] + }); + assert.equal(metaResult.eligibleBadges.length, 0); + + // Mixed PR modifying both server and docs + const mixedResult = evaluateBadges({ + repository: "meshery/meshery", + changedFiles: ["server/main.go", "docs/quickstart.md"] + }); + const mixedSlugs = mixedResult.eligibleBadges.map(b => b.slug); + assert.ok(mixedSlugs.includes("meshery")); + assert.ok(mixedSlugs.includes("meshery-docs")); +}); + +test("meshery-operator and meshsync badge evaluation: positive & negative paths", () => { + // Operator controllers, api, pkg, cmd qualify + const opResult = evaluateBadges({ + repository: "meshery/meshery-operator", + changedFiles: ["controllers/meshery_controller.go", "api/v1alpha1/types.go", "pkg/client.go", "cmd/main.go"] + }); + assert.equal(opResult.eligibleBadges.length, 1); + assert.equal(opResult.eligibleBadges[0].slug, "meshery-operator"); + + // Operator bundle/** and config/** do NOT qualify + const opBundle = evaluateBadges({ + repository: "meshery/meshery-operator", + changedFiles: ["bundle/manifests/meshery.csv.yaml", "config/rbac/role.yaml"] + }); + assert.equal(opBundle.eligibleBadges.length, 0); + + // Operator generated files zz_generated* do NOT qualify + const opGenerated = evaluateBadges({ + repository: "meshery/meshery-operator", + changedFiles: ["api/v1alpha1/zz_generated.deepcopy.go"] + }); + assert.equal(opGenerated.eligibleBadges.length, 0); + + // Operator go.mod, go.sum do NOT qualify + const opDeps = evaluateBadges({ + repository: "meshery/meshery-operator", + changedFiles: ["go.mod", "go.sum"] + }); + assert.equal(opDeps.eligibleBadges.length, 0); + + // MeshSync internal, pkg, meshsync qualify + const syncResult = evaluateBadges({ + repository: "meshery/meshsync", + changedFiles: ["internal/daemon/sync.go", "pkg/discovery.go", "meshsync/server.go"] + }); + assert.equal(syncResult.eligibleBadges.length, 1); + assert.equal(syncResult.eligibleBadges[0].slug, "meshsync"); + + // MeshSync plugins/** and cache/** do NOT qualify (unsupported / non-existent) + const syncUnsupported = evaluateBadges({ + repository: "meshery/meshsync", + changedFiles: ["plugins/discovery.go", "cache/store.go"] + }); + assert.equal(syncUnsupported.eligibleBadges.length, 0); + + // MeshSync integration-tests/** and go.mod, go.sum do NOT qualify + const syncExcludes = evaluateBadges({ + repository: "meshery/meshsync", + changedFiles: ["integration-tests/run.sh", "go.mod", "go.sum"] + }); + assert.equal(syncExcludes.eligibleBadges.length, 0); + + // Operator non-code metadata excluded + const opMeta = evaluateBadges({ + repository: "meshery/meshery-operator", + changedFiles: ["README.md", "LICENSE", ".github/workflows/ci.yml", "CODE_OF_CONDUCT.md"] + }); + assert.equal(opMeta.eligibleBadges.length, 0); +}); + +test("meshery-docs in layer5io/docs and meshery/meshery", () => { + // layer5io/docs: content/**/*.md and content/**/*.mdx qualify + const docsResult = evaluateBadges({ + repository: "layer5io/docs", + changedFiles: ["content/overview/index.md", "content/setup/install.mdx"] + }); + assert.equal(docsResult.eligibleBadges.length, 1); + assert.equal(docsResult.eligibleBadges[0].slug, "meshery-docs"); + + // layer5io/docs: pages/** does NOT qualify (canonical exclusion) + const pagesResult = evaluateBadges({ + repository: "layer5io/docs", + changedFiles: ["pages/getting-started.tsx", "pages/index.js"] + }); + assert.equal(pagesResult.eligibleBadges.length, 0); + + // layer5io/docs: non-markdown under content/** does NOT qualify + const nonMdContent = evaluateBadges({ + repository: "layer5io/docs", + changedFiles: ["content/assets/logo.png"] + }); + assert.equal(nonMdContent.eligibleBadges.length, 0); + + // Excluded doc directories (catalog, static, meetings, etc.) do NOT qualify + const excludedDocs = evaluateBadges({ + repository: "layer5io/docs", + changedFiles: ["catalog/item.md", "data/nav.json", "meetings/notes.md", "static/script.js"] + }); + assert.equal(excludedDocs.eligibleBadges.length, 0); +}); + +test("meshery-catalog evaluation in meshery.io and meshery", () => { + // In meshery/meshery.io: catalog/** qualifies + const catalogWeb = evaluateBadges({ + repository: "meshery/meshery.io", + changedFiles: ["catalog/kubernetes/item.yaml"] + }); + assert.equal(catalogWeb.eligibleBadges.length, 1); + assert.equal(catalogWeb.eligibleBadges[0].slug, "meshery-catalog"); + + // In meshery/meshery.io: collections/_catalog/** qualifies + const underscoreCatalog = evaluateBadges({ + repository: "meshery/meshery.io", + changedFiles: ["collections/_catalog/wasm-filter.json"] + }); + assert.equal(underscoreCatalog.eligibleBadges.length, 1); + assert.equal(underscoreCatalog.eligibleBadges[0].slug, "meshery-catalog"); + + // In meshery/meshery.io: collections/catalog/** (missing underscore) does NOT qualify + const badPathCatalog = evaluateBadges({ + repository: "meshery/meshery.io", + changedFiles: ["collections/catalog/wasm-filter.json"] + }); + assert.equal(badPathCatalog.eligibleBadges.length, 0); + + // In meshery/meshery: models/** qualifies for meshery-catalog + const catalogModels = evaluateBadges({ + repository: "meshery/meshery", + changedFiles: ["models/patterns/design.json"] + }); + assert.equal(catalogModels.eligibleBadges.length, 1); + assert.equal(catalogModels.eligibleBadges[0].slug, "meshery-catalog"); + + // CRITICAL REGRESSION: same meshery models/** must NOT qualify for meshery core + assert.ok(!catalogModels.eligibleBadges.some(b => b.slug === "meshery"), "models/** must be excluded from meshery core"); +}); + +test("landscape badge evaluation in layer5io/layer5", () => { + // Modifying landscape data strictly qualifies + const landscapeResult = evaluateBadges({ + repository: "layer5io/layer5", + changedFiles: ["src/collections/landscape/service-mesh.json"] + }); + assert.equal(landscapeResult.eligibleBadges.length, 1); + assert.equal(landscapeResult.eligibleBadges[0].slug, "landscape"); + + // Modifying blog / news / members collections must be excluded + const blogResult = evaluateBadges({ + repository: "layer5io/layer5", + changedFiles: [ + "src/collections/blog/announcement.md", + "src/collections/news/update.md", + "src/collections/members/profile.json" + ] + }); + assert.equal(blogResult.eligibleBadges.length, 0); +}); + +test("ui-ux badge evaluation with repository-specific rules", () => { + // 1. meshery/meshery: requires BOTH component/ui label AND ui/** or provider-ui/** + const mesheryUi = evaluateBadges({ + repository: "meshery/meshery", + labels: ["component/ui", "enhancement"], + changedFiles: ["ui/components/Navigator.tsx"] + }); + assert.ok(mesheryUi.eligibleBadges.some(b => b.slug === "ui-ux")); + + const mesheryProviderUi = evaluateBadges({ + repository: "meshery/meshery", + labels: [{ name: "component/ui" }], + changedFiles: ["provider-ui/components/Card.tsx"] + }); + assert.ok(mesheryProviderUi.eligibleBadges.some(b => b.slug === "ui-ux")); + + // meshery without component/ui label does NOT qualify + const mesheryNoLabel = evaluateBadges({ + repository: "meshery/meshery", + labels: ["bug"], + changedFiles: ["ui/components/Navigator.tsx"] + }); + assert.ok(!mesheryNoLabel.eligibleBadges.some(b => b.slug === "ui-ux")); + + // meshery with component/ui label but unrelated path does NOT qualify + const mesheryUnrelatedPath = evaluateBadges({ + repository: "meshery/meshery", + labels: ["component/ui"], + changedFiles: ["server/handlers/patterns.go"] + }); + assert.ok(!mesheryUnrelatedPath.eligibleBadges.some(b => b.slug === "ui-ux")); + + // meshery with legacy area/ui or area/ux labels (without component/ui) does NOT qualify + const mesheryLegacyLabel = evaluateBadges({ + repository: "meshery/meshery", + labels: ["area/ui", "area/ux"], + changedFiles: ["ui/components/Navigator.tsx"] + }); + assert.ok(!mesheryLegacyLabel.eligibleBadges.some(b => b.slug === "ui-ux")); + + // 2. layer5io/sistent: qualifies on src/** or system/** without any label required + const sistentSrc = evaluateBadges({ + repository: "layer5io/sistent", + changedFiles: ["src/components/Modal/index.tsx"] + }); + assert.ok(sistentSrc.eligibleBadges.some(b => b.slug === "ui-ux")); + + const sistentSystem = evaluateBadges({ + repository: "layer5io/sistent", + changedFiles: ["system/theme/colors.ts"] + }); + assert.ok(sistentSystem.eligibleBadges.some(b => b.slug === "ui-ux")); + + // 3. layer5io/layer5: qualifies on frontend directories without any label required + const layer5Components = evaluateBadges({ + repository: "layer5io/layer5", + changedFiles: ["src/components/Banner/index.tsx"] + }); + assert.ok(layer5Components.eligibleBadges.some(b => b.slug === "ui-ux")); + + const layer5Sections = evaluateBadges({ + repository: "layer5io/layer5", + changedFiles: ["src/sections/Home/Hero.tsx"] + }); + assert.ok(layer5Sections.eligibleBadges.some(b => b.slug === "ui-ux")); + + const layer5Templates = evaluateBadges({ + repository: "layer5io/layer5", + changedFiles: ["src/templates/blog-single.tsx"] + }); + assert.ok(layer5Templates.eligibleBadges.some(b => b.slug === "ui-ux")); + + const layer5Pages = evaluateBadges({ + repository: "layer5io/layer5", + changedFiles: ["src/pages/index.tsx"] + }); + assert.ok(layer5Pages.eligibleBadges.some(b => b.slug === "ui-ux")); + + // layer5 exclusions: src/collections/** and src/assets/** do NOT qualify for ui-ux + const layer5Excluded = evaluateBadges({ + repository: "layer5io/layer5", + changedFiles: ["src/collections/blog/post.md", "src/assets/images/logo.png"] + }); + assert.ok(!layer5Excluded.eligibleBadges.some(b => b.slug === "ui-ux")); +}); + +test("Universal exclusions consistently exclude tests, lockfiles, and repository governance", () => { + const repositories = [ + { repo: "layer5io/sistent", validPath: "src/components/button.tsx" }, + { repo: "meshery/meshery", validPath: "server/handlers/patterns.go" }, + { repo: "meshery/meshery-operator", validPath: "controllers/operator.go" }, + { repo: "meshery/meshsync", validPath: "internal/sync.go" } + ]; + + for (const { repo, validPath } of repositories) { + // Tests: *.test.*, *_test.go, __tests__/** + assert.equal( + evaluateBadges({ repository: repo, changedFiles: [validPath.replace(/\.tsx$|\.go$/, ".test.tsx")] }).eligibleBadges.length, + 0, + `${repo}: *.test.* must be excluded` + ); + assert.equal( + evaluateBadges({ repository: repo, changedFiles: [validPath.replace(/\.tsx$|\.go$/, "_test.go")] }).eligibleBadges.length, + 0, + `${repo}: *_test.go must be excluded` + ); + assert.equal( + evaluateBadges({ repository: repo, changedFiles: ["src/__tests__/unit.js"] }).eligibleBadges.length, + 0, + `${repo}: __tests__/** must be excluded` + ); + + // Lockfiles + assert.equal( + evaluateBadges({ repository: repo, changedFiles: ["package-lock.json", "ui/package-lock.json"] }).eligibleBadges.length, + 0, + `${repo}: package-lock.json must be excluded` + ); + assert.equal( + evaluateBadges({ repository: repo, changedFiles: ["yarn.lock", "nested/yarn.lock"] }).eligibleBadges.length, + 0, + `${repo}: yarn.lock must be excluded` + ); + assert.equal( + evaluateBadges({ repository: repo, changedFiles: ["go.sum", "server/go.sum"] }).eligibleBadges.length, + 0, + `${repo}: go.sum must be excluded` + ); + + // Repository governance + assert.equal( + evaluateBadges({ repository: repo, changedFiles: [".github/workflows/ci.yml", ".github/dependabot.yml"] }).eligibleBadges.length, + 0, + `${repo}: .github/** must be excluded` + ); + assert.equal( + evaluateBadges({ repository: repo, changedFiles: ["LICENSE"] }).eligibleBadges.length, + 0, + `${repo}: LICENSE must be excluded` + ); + assert.equal( + evaluateBadges({ repository: repo, changedFiles: ["README.md"] }).eligibleBadges.length, + 0, + `${repo}: README.md must be excluded` + ); + assert.equal( + evaluateBadges({ repository: repo, changedFiles: ["CONTRIBUTING.md", "CONTRIBUTING-DOCS.md"] }).eligibleBadges.length, + 0, + `${repo}: CONTRIBUTING*.md must be excluded` + ); + } +}); diff --git a/utils/badge-rules.json b/utils/badge-rules.json new file mode 100644 index 0000000..0b801ad --- /dev/null +++ b/utils/badge-rules.json @@ -0,0 +1,210 @@ +[ + { + "slug": "sistent-contributor", + "name": "Sistent Contributor", + "ruleId": "rule-sistent-contributor", + "description": "Consistent & impactful contributions to Sistent design system and components", + "repositories": [ + "layer5io/sistent" + ], + "includePatterns": [ + "src/**", + "examples/**" + ], + "excludePatterns": [ + "system/**", + "src/assets/**", + "package.json", + "tsconfig.json", + "Makefile" + ] + }, + { + "slug": "meshery", + "name": "Meshery", + "ruleId": "rule-meshery-core", + "description": "Consistent & impactful contributions to Meshery core functional codebase", + "repositories": [ + "meshery/meshery" + ], + "includePatterns": [ + "server/**", + "mesheryctl/**", + "ui/**", + "provider-ui/**" + ], + "excludePatterns": [ + "models/**", + "install/**", + "main.go", + "go.mod", + "go.sum", + "Makefile", + "docs/**", + "ui/docs/**", + "ui/public/**", + "ui/scripts/**", + "README.md", + "ROADMAP.md", + "ADOPTERS.md", + "GOVERNANCE.md", + "VISION*.md", + "CONTRIBUTING*.md", + ".github/**" + ] + }, + { + "slug": "meshery-operator", + "name": "Meshery Operator", + "ruleId": "rule-meshery-operator", + "description": "Contributions to Meshery Operator controllers, APIs, and manifests", + "repositories": [ + "meshery/meshery-operator" + ], + "includePatterns": [ + "controllers/**", + "api/**", + "pkg/**", + "cmd/**" + ], + "excludePatterns": [ + "zz_generated*", + "**/zz_generated*", + "bundle/**", + "config/**", + "main.go", + "Makefile", + "go.mod", + "go.sum" + ] + }, + { + "slug": "meshsync", + "name": "MeshSync", + "ruleId": "rule-meshsync", + "description": "Contributions to MeshSync discovery daemon logic and plugins", + "repositories": [ + "meshery/meshsync" + ], + "includePatterns": [ + "internal/**", + "pkg/**", + "meshsync/**" + ], + "excludePatterns": [ + "integration-tests/**", + "plugins/**", + "cache/**", + "main.go", + "Makefile", + "go.mod", + "go.sum" + ] + }, + { + "slug": "meshery-docs", + "name": "Meshery Docs", + "ruleId": "rule-meshery-docs", + "description": "Contributions to Meshery documentation trees", + "repositories": [ + "meshery/meshery", + "layer5io/docs" + ], + "repoSpecificIncludePatterns": { + "meshery/meshery": [ + "docs/**/*.md", + "docs/**/*.mdx" + ], + "layer5io/docs": [ + "content/**/*.md", + "content/**/*.mdx" + ] + }, + "excludePatterns": [ + "pages/**", + "catalog/**", + "integrations/**", + "data/**", + "meetings/**", + "static/**", + "archive/**", + "proposals/**", + "layouts/**", + "themes/**", + "assets/**" + ] + }, + { + "slug": "meshery-catalog", + "name": "Meshery Catalog", + "ruleId": "rule-meshery-catalog", + "description": "Contributions to Meshery Catalog items and cloud-native models", + "repositories": [ + "meshery/meshery.io", + "meshery/meshery" + ], + "repoSpecificIncludePatterns": { + "meshery/meshery.io": [ + "catalog/**", + "collections/_catalog/**" + ], + "meshery/meshery": [ + "models/**" + ] + }, + "excludePatterns": [] + }, + { + "slug": "landscape", + "name": "Landscape", + "ruleId": "rule-landscape", + "description": "Contributions to Layer5 Landscape collection", + "repositories": [ + "layer5io/layer5" + ], + "includePatterns": [ + "src/collections/landscape/**" + ], + "excludePatterns": [ + "src/collections/blog/**", + "src/collections/news/**", + "src/collections/members/**" + ] + }, + { + "slug": "ui-ux", + "name": "UI/UX", + "ruleId": "rule-ui-ux", + "description": "Creating/improving visual designs or user flows", + "repositories": [ + "meshery/meshery", + "layer5io/sistent", + "layer5io/layer5" + ], + "repoSpecificRequiredAnyLabels": { + "meshery/meshery": [ + "component/ui" + ] + }, + "repoSpecificIncludePatterns": { + "meshery/meshery": [ + "ui/**", + "provider-ui/**" + ], + "layer5io/sistent": [ + "src/**", + "system/**" + ], + "layer5io/layer5": [ + "src/components/**", + "src/sections/**", + "src/templates/**", + "src/pages/**" + ] + }, + "excludePatterns": [ + "src/collections/**", + "src/assets/**" + ] + } +] diff --git a/utils/identity-resolver.js b/utils/identity-resolver.js new file mode 100644 index 0000000..c267266 --- /dev/null +++ b/utils/identity-resolver.js @@ -0,0 +1,302 @@ +/** + * Validates an email address against a standard RFC-style pattern. + * Rejects empty strings, missing domain/user parts, missing TLDs, and malformed formats. + * + * @param {string} email + * @returns {boolean} + */ +function isValidEmail(email) { + if (!email || typeof email !== 'string') return false; + const trimmed = email.trim(); + // Standard RFC-style regex requiring valid local part, @, domain label(s), and valid TLD + const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/; + return emailRegex.test(trimmed); +} + +/** + * Masks an email address for safe public reporting in step summaries, logs, and diagnostics. + * Example: "john.doe@example.com" -> "j***e@example.com" + * + * @param {string} email + * @returns {string} + */ +function maskEmail(email) { + if (!email || typeof email !== 'string') return ''; + const trimmed = email.trim(); + const atIndex = trimmed.lastIndexOf('@'); + if (atIndex <= 0) return '***'; + + const user = trimmed.slice(0, atIndex); + const domain = trimmed.slice(atIndex + 1); + + if (user.length <= 2) { + return `${user[0]}***@${domain}`; + } + return `${user[0]}***${user[user.length - 1]}@${domain}`; +} + +/** + * Extracts all valid Signed-off-by trailers from the terminal Git trailer block of a commit message. + * Formats supported: "Signed-off-by: First Last " + * + * Terminal Trailer Block Rules (Git interpret-trailers specification): + * 1. Must be located in the terminal paragraph at the end of the commit message. + * 2. Every line in the terminal block must be a valid trailer line ("Token: Value"). + * 3. No subsequent body text may follow the trailer block. + * 4. Strictly validates trailer syntax and email format. + * + * @param {string} message Commit message + * @returns {Array<{ name: string, email: string }>} + */ +function extractDcoTrailers(message) { + if (!message || typeof message !== 'string') return []; + + // Normalize line breaks and trim trailing whitespace + const normalized = message.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd(); + if (!normalized) return []; + + // Split into paragraphs separated by one or more blank lines + const paragraphs = normalized.split(/\n[ \t]*\n+/); + const terminalParagraph = paragraphs[paragraphs.length - 1].trim(); + if (!terminalParagraph) return []; + + const lines = terminalParagraph.split('\n'); + + // Verify that EVERY line in the terminal paragraph is a valid trailer line + // (e.g. "Signed-off-by: ...", "Co-authored-by: ...", "Fixes: ...", "Token: Value") + const genericTrailerRegex = /^[ \t]*[A-Za-z0-9-_]+:[ \t]*.*$/; + for (const line of lines) { + if (!genericTrailerRegex.test(line)) { + // If there is regular body text in the terminal block, it is not a valid trailer block + return []; + } + } + + // Parse Signed-off-by trailers from the terminal trailer block + const dcoTrailerRegex = /^[ \t]*Signed-off-by:[ \t]*([^<\r\n]+)<([^>\r\n]+)>[ \t]*$/i; + const trailers = []; + + for (const line of lines) { + const match = dcoTrailerRegex.exec(line); + if (match) { + const name = match[1].trim(); + const rawEmail = match[2].trim(); + if (name && isValidEmail(rawEmail)) { + trailers.push({ name, email: rawEmail.toLowerCase() }); + } + } + } + + return trailers; +} + +/** + * Determines whether a DCO trailer is deterministically attributable to the author of a commit. + * + * Deterministic Matching Rules: + * 1. Direct email match: trailer email strictly matches git commit author email. + * 2. Noreply with name match: if git commit author email is a GitHub noreply address, + * the trailer name MUST match the git commit author's name. + * (An arbitrary DCO trailer is NEVER accepted merely because the git author used a noreply email). + * + * @param {Object} trailer { name: string, email: string } + * @param {Object} gitAuthor { name: string, email: string } + * @returns {boolean} + */ +function isTrailerAttributableToAuthor(trailer, gitAuthor) { + if (!trailer || !gitAuthor) return false; + + const tEmail = (trailer.email || '').trim().toLowerCase(); + const tName = (trailer.name || '').trim().toLowerCase(); + const gitEmail = (gitAuthor.email || '').trim().toLowerCase(); + const gitName = (gitAuthor.name || '').trim().toLowerCase(); + + // Generic @noreply.github.com is a notification address, never valid for commit or trailer attribution + if (gitEmail.endsWith('@noreply.github.com') || tEmail.endsWith('@noreply.github.com')) { + return false; + } + + // Rule 1: Direct git commit author email match + if (gitEmail && tEmail === gitEmail) { + return true; + } + + // Rule 2: GitHub noreply email requiring strict git author name match + const isNoreply = gitEmail.endsWith('@users.noreply.github.com'); + if (isNoreply && gitName && tName === gitName) { + return true; + } + + return false; +} + +/** + * Resolves contributor identity and strictly verifies DCO compliance against commit history. + * + * Attribution Contract: + * PR Author + * → Filter PR commits strictly to those whose GitHub-associated author.login matches PR author + * → DCO Signed-off-by trailer deterministically attributable to that commit author + * → verified RFC-compliant email + * + * Security & Anti-Spoofing Invariant: + * When a commit author uses a GitHub noreply email (@users.noreply.github.com), + * a real recipient email is NEVER resolved solely from matching contributor-controlled names. + * Exact noreply DCO attribution is preserved (dcoVerified: true), but resolvedEmail + * remains null. + * + * Privacy Invariant: + * The returned reason string NEVER contains plaintext contributor email addresses. + * + * @param {string} prAuthor PR author's GitHub login handle + * @param {Array} commits List of commit objects (from GitHub API pulls/commits) + * @returns {{ resolvedEmail: string|null, dcoVerified: boolean, reason: string }} + */ +function resolveIdentity(prAuthor, commits) { + if (!prAuthor || typeof prAuthor !== 'string') { + return { + resolvedEmail: null, + dcoVerified: false, + reason: 'Missing or invalid PR author login' + }; + } + + if (!Array.isArray(commits) || commits.length === 0) { + return { + resolvedEmail: null, + dcoVerified: false, + reason: 'No commits provided for evaluation' + }; + } + + const normalizedPrAuthor = prAuthor.trim().toLowerCase(); + + // Filter commits strictly to those whose GitHub-associated author matches the PR author + const authorCommits = commits.filter(item => { + if (!item || !item.author || !item.author.login) return false; + return item.author.login.trim().toLowerCase() === normalizedPrAuthor; + }); + + if (authorCommits.length === 0) { + return { + resolvedEmail: null, + dcoVerified: false, + reason: `No commits in PR matched GitHub-associated author '@${prAuthor}'` + }; + } + + const commitEmails = []; + let hasNoreplyAuthor = false; + + for (let idx = 0; idx < authorCommits.length; idx++) { + const item = authorCommits[idx]; + const sha = (item && item.sha ? item.sha.slice(0, 7) : `commit-${idx + 1}`); + + // Skip merge commits (parents > 1): these are GitHub-generated merge commits + // that never carry DCO trailers. Must be skipped before reading git author + // metadata to prevent false noreply detection. + const parents = item.parents || (item.commit && item.commit.parents) || []; + if (Array.isArray(parents) && parents.length > 1) { + continue; + } + + // Git commit author metadata + const gitAuthor = item.commit && item.commit.author ? item.commit.author : {}; + const gitEmail = (gitAuthor.email || '').trim().toLowerCase(); + if (gitEmail.endsWith('@users.noreply.github.com')) { + hasNoreplyAuthor = true; + } + + // Extract DCO trailers + const message = item.commit ? item.commit.message : (item.message || ''); + const trailers = extractDcoTrailers(message); + + if (trailers.length === 0) { + return { + resolvedEmail: null, + dcoVerified: false, + reason: `Commit ${sha} by @${prAuthor} is missing a valid DCO Signed-off-by trailer` + }; + } + + // Filter trailers to those deterministically attributable to the author + const attributable = trailers.filter(t => isTrailerAttributableToAuthor(t, gitAuthor)); + + if (attributable.length === 0) { + return { + resolvedEmail: null, + dcoVerified: false, + reason: `Commit ${sha} by @${prAuthor} has no Signed-off-by trailer attributable to author` + }; + } + + // Check if multiple attributable trailers share the same email + const distinctCommitEmails = [...new Set(attributable.map(t => t.email))]; + if (distinctCommitEmails.length > 1) { + return { + resolvedEmail: null, + dcoVerified: false, + reason: `Commit ${sha} by @${prAuthor} has conflicting Signed-off-by trailers` + }; + } + + commitEmails.push(distinctCommitEmails[0]); + } + + // Verify email consistency across all PR-author commits + const distinctEmails = [...new Set(commitEmails)]; + + // If all author commits were merge commits (all skipped), there are no + // code commits to evaluate. Fail closed. + if (distinctEmails.length === 0) { + return { + resolvedEmail: null, + dcoVerified: false, + reason: `No non-merge code commits found for @${prAuthor} (all ${authorCommits.length} commit(s) are merge commits)` + }; + } + + if (distinctEmails.length > 1) { + return { + resolvedEmail: null, + dcoVerified: false, + reason: `PR contains conflicting Signed-off-by emails across author commits (${distinctEmails.map(maskEmail).join(', ')})` + }; + } + + const verifiedEmail = distinctEmails[0]; + + // If the commit author used a GitHub noreply address (@users.noreply.github.com), + // never resolve a real recipient email solely from contributor-controlled names. + // Exact noreply DCO attribution is preserved (dcoVerified: true), but recipient + // remains unresolved (resolvedEmail: null). + if (hasNoreplyAuthor || verifiedEmail.endsWith('@users.noreply.github.com')) { + if (verifiedEmail.endsWith('@users.noreply.github.com')) { + return { + resolvedEmail: null, + dcoVerified: true, + reason: `Verified ${authorCommits.length} commit(s) by @${prAuthor} with DCO Signed-off-by trailer, but recipient uses a GitHub noreply address (@users.noreply.github.com) which cannot be mapped to a Layer5 award recipient` + }; + } + + return { + resolvedEmail: null, + dcoVerified: true, + reason: `Verified ${authorCommits.length} commit(s) by @${prAuthor} with DCO Signed-off-by trailer, but commit author uses a GitHub noreply address (@users.noreply.github.com); recipient cannot be resolved from contributor-controlled names` + }; + } + + return { + resolvedEmail: verifiedEmail, + dcoVerified: true, + reason: `Verified ${authorCommits.length} commit(s) by @${prAuthor} with attributable DCO Signed-off-by trailer` + }; +} + +module.exports = { + isValidEmail, + maskEmail, + extractDcoTrailers, + isTrailerAttributableToAuthor, + resolveIdentity +}; diff --git a/utils/identity-resolver.test.js b/utils/identity-resolver.test.js new file mode 100644 index 0000000..d37b981 --- /dev/null +++ b/utils/identity-resolver.test.js @@ -0,0 +1,542 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + resolveIdentity, + extractDcoTrailers, + maskEmail, + isValidEmail, + isTrailerAttributableToAuthor +} = require('./identity-resolver'); + +test('isValidEmail correctly enforces RFC-style structure', () => { + // Rejections + assert.equal(isValidEmail('foo'), false); + assert.equal(isValidEmail('foo@'), false); + assert.equal(isValidEmail('@example.com'), false); + assert.equal(isValidEmail('foo@bar'), false); // Missing valid TLD + assert.equal(isValidEmail('foo@.com'), false); + assert.equal(isValidEmail(''), false); + assert.equal(isValidEmail(null), false); + assert.equal(isValidEmail('user @example.com'), false); + + // Acceptances + assert.equal(isValidEmail('user@example.com'), true); + assert.equal(isValidEmail('contributor.name+tag@sub.domain.co.uk'), true); + assert.equal(isValidEmail('lee@layer5.io'), true); +}); + +test('maskEmail obfuscates email addresses correctly', () => { + assert.equal(maskEmail('john.doe@example.com'), 'j***e@example.com'); + assert.equal(maskEmail('a@layer5.io'), 'a***@layer5.io'); + assert.equal(maskEmail('lee@layer5.io'), 'l***e@layer5.io'); + assert.equal(maskEmail(''), ''); + assert.equal(maskEmail(null), ''); +}); + +test('extractDcoTrailers extracts and validates standard trailers', () => { + const msg = `feat(core): add feature\n\nSigned-off-by: Lee Calcote \nSigned-off-by: Malformed `; + const trailers = extractDcoTrailers(msg); + assert.equal(trailers.length, 1); + assert.equal(trailers[0].name, 'Lee Calcote'); + assert.equal(trailers[0].email, 'lee@layer5.io'); +}); + +test('extractDcoTrailers rejects unanchored and prefixed trailer lines', () => { + const invalidMessages = [ + 'Not-Signed-off-by: Lee Calcote ', + 'Prefix Signed-off-by: Lee Calcote ', + 'Signed-off-by: Lee Calcote Suffix text', + 'Some text before Signed-off-by: Lee Calcote and after', + 'Signed-off-by:\nLee Calcote ', + 'Signed-off-by:\r\nLee Calcote ', + 'Signed-off-by: Lee Calcote\n' + ]; + + for (const msg of invalidMessages) { + const trailers = extractDcoTrailers(msg); + assert.equal(trailers.length, 0, `Expected trailer to be rejected in: ${msg}`); + } + + // Valid with leading/trailing whitespace on its own line + const validWithWhitespace = `feat: update\n\n Signed-off-by: Lee Calcote \n`; + const trailers = extractDcoTrailers(validWithWhitespace); + assert.equal(trailers.length, 1); + assert.equal(trailers[0].email, 'lee@layer5.io'); +}); + +test('extractDcoTrailers rejects Signed-off-by followed by later body text (terminal trailer block invariant)', () => { + // 1. Later body text in a subsequent paragraph + const msgWithLaterParagraph = `feat(api): update endpoints\n\nSigned-off-by: Lee Calcote \n\nNote: This commit was later amended and should not be credited.`; + assert.equal(extractDcoTrailers(msgWithLaterParagraph).length, 0); + + // 2. Later body text in the same paragraph + const msgWithSameParagraphBody = `feat(api): update endpoints\n\nSigned-off-by: Lee Calcote \nAdditional explanatory body text here.`; + assert.equal(extractDcoTrailers(msgWithSameParagraphBody).length, 0); + + // 3. Body text before Signed-off-by in the same paragraph without blank line + const msgWithPrecedingParagraphBody = `feat(api): update endpoints\n\nSome body text without empty line separation\nSigned-off-by: Lee Calcote `; + assert.equal(extractDcoTrailers(msgWithPrecedingParagraphBody).length, 0); +}); + +test('resolveIdentity fails closed when Signed-off-by is not in terminal trailer block', () => { + const commits = [ + { + sha: 'terminal1234567', + author: { login: 'leecalcote' }, + commit: { + author: { name: 'Lee Calcote', email: 'lee@layer5.io' }, + message: 'fix: update configuration\n\nSigned-off-by: Lee Calcote \n\nLater text explaining the change' + } + } + ]; + + const result = resolveIdentity('leecalcote', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes('missing a valid DCO Signed-off-by trailer')); +}); + +test('isTrailerAttributableToAuthor handles direct matches and noreply requirements', () => { + // Direct email match + assert.equal( + isTrailerAttributableToAuthor( + { name: 'Alice Smith', email: 'alice@example.com' }, + { name: 'Alice Smith', email: 'alice@example.com' } + ), + true + ); + + // Noreply with matching name (@users.noreply.github.com) + assert.equal( + isTrailerAttributableToAuthor( + { name: 'Alice Smith', email: 'alice.personal@example.com' }, + { name: 'Alice Smith', email: '12345+alicesmith@users.noreply.github.com' } + ), + true + ); + + // Notification address (@noreply.github.com) is not a commit noreply address and must fail closed + assert.equal( + isTrailerAttributableToAuthor( + { name: 'Alice Smith', email: 'alice.personal@example.com' }, + { name: 'Alice Smith', email: 'alicesmith@noreply.github.com' } + ), + false + ); + + // Attacker domains mimicking noreply.github.com must fail closed + assert.equal( + isTrailerAttributableToAuthor( + { name: 'Alice Smith', email: 'alice.personal@example.com' }, + { name: 'Alice Smith', email: '12345+alicesmith@noreply.github.com.attacker.org' } + ), + false + ); + assert.equal( + isTrailerAttributableToAuthor( + { name: 'Alice Smith', email: 'alice.personal@example.com' }, + { name: 'Alice Smith', email: 'alicesmith@users.noreply.github.com.evil.com' } + ), + false + ); + + // Noreply with mismatched name (must fail closed; arbitrary trailers not accepted) + assert.equal( + isTrailerAttributableToAuthor( + { name: 'Bob Jones', email: 'bob@example.com' }, + { name: 'Alice Smith', email: '12345+alicesmith@users.noreply.github.com' } + ), + false + ); + + // Non-noreply with mismatched email and name + assert.equal( + isTrailerAttributableToAuthor( + { name: 'Bob Jones', email: 'bob@example.com' }, + { name: 'Alice Smith', email: 'alice@example.com' } + ), + false + ); +}); + +test('resolveIdentity: normal author + matching sign-off', () => { + const commits = [ + { + sha: 'abcdef1234567890', + author: { login: 'leecalcote' }, + commit: { + author: { name: 'Lee Calcote', email: 'lee@layer5.io' }, + message: 'fix: update configuration\n\nSigned-off-by: Lee Calcote ' + } + } + ]; + + const result = resolveIdentity('leecalcote', commits); + assert.equal(result.dcoVerified, true); + assert.equal(result.resolvedEmail, 'lee@layer5.io'); +}); + +test('resolveIdentity: GitHub noreply commit author with matching trailer name preserves DCO but keeps recipient unresolved', () => { + const commits = [ + { + sha: 'noreply12345678', + author: { login: 'octocat' }, + commit: { + author: { name: 'Mona Lisa Octocat', email: '12345+octocat@users.noreply.github.com' }, + message: 'docs: web update\n\nSigned-off-by: Mona Lisa Octocat ' + } + } + ]; + + const result = resolveIdentity('octocat', commits); + assert.equal(result.dcoVerified, true, 'DCO attribution is preserved for matching trailer name'); + assert.equal(result.resolvedEmail, null, 'Must never resolve recipient email solely from matching contributor-controlled name on noreply author'); + assert.ok(result.reason.includes('cannot be resolved from contributor-controlled names')); +}); + +test('resolveIdentity: spoofing regression - attacker using noreply author cannot claim victim email via matching trailer name', () => { + const victimEmail = 'victim@layer5.io'; + const commits = [ + { + sha: 'spoof12345678', + author: { login: 'attacker' }, + commit: { + // Attacker sets their git author name to victim's name, but author email is attacker's GitHub noreply + author: { name: 'Victim User', email: '99999+attacker@users.noreply.github.com' }, + message: `feat: malicious change\n\nSigned-off-by: Victim User <${victimEmail}>` + } + } + ]; + + const result = resolveIdentity('attacker', commits); + assert.equal(result.resolvedEmail, null, 'Must never resolve spoofed victim email from noreply commit author'); + assert.equal(result.dcoVerified, true, 'DCO trailer name match preserves DCO attribution'); + assert.ok(result.reason.includes('cannot be resolved from contributor-controlled names')); + assert.equal(result.reason.includes(victimEmail), false, 'Reason must not leak victim email'); +}); + +test('resolveIdentity: GitHub noreply commit author who signs off with noreply address (CASE B)', () => { + const sensitiveUsername = '12345+octocat'; + const noreplyEmail = `${sensitiveUsername}@users.noreply.github.com`; + const commits = [ + { + sha: 'noreplysame1234', + author: { login: 'octocat' }, + commit: { + author: { name: 'Mona Lisa Octocat', email: noreplyEmail }, + message: `docs: web update\n\nSigned-off-by: Mona Lisa Octocat <${noreplyEmail}>` + } + } + ]; + + const result = resolveIdentity('octocat', commits); + // DCO is valid according to git rules, but recipient is unresolvable for Layer5 awards + assert.equal(result.dcoVerified, true); + assert.equal(result.resolvedEmail, null, 'Must not use noreply email as award recipient'); + assert.ok(result.reason.includes('cannot be mapped to a Layer5 award recipient')); + // Privacy invariant: Reason must not contain contributor username or sensitive prefix + const leakedUsername = result.reason.includes(sensitiveUsername); + assert.equal(leakedUsername, false, 'Reason must not leak sensitive username prefix'); +}); + +test('resolveIdentity: trailer using generic @noreply.github.com fails closed (CASE C)', () => { + const genericNoreply = 'mona@noreply.github.com'; + const commits = [ + { + sha: 'genericnoreply1', + author: { login: 'octocat' }, + commit: { + author: { name: 'Mona Lisa Octocat', email: genericNoreply }, + message: `docs: web update\n\nSigned-off-by: Mona Lisa Octocat <${genericNoreply}>` + } + } + ]; + + const result = resolveIdentity('octocat', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes('has no Signed-off-by trailer attributable to author')); + const leakedEmail = result.reason.includes(genericNoreply); + assert.equal(leakedEmail, false, 'Reason must not leak email'); +}); + +test('resolveIdentity: GitHub noreply commit author with mismatched trailer name fails closed', () => { + const fakeEmail = 'impostor@example.com'; + const commits = [ + { + sha: 'noreplymismatch1', + author: { login: 'octocat' }, + commit: { + author: { name: 'Mona Lisa Octocat', email: '12345+octocat@users.noreply.github.com' }, + message: `docs: update\n\nSigned-off-by: Impostor User <${fakeEmail}>` + } + } + ]; + + const result = resolveIdentity('octocat', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes('has no Signed-off-by trailer attributable to author')); + // Ensure no plaintext email leaked in reason + const leaked = result.reason.includes(fakeEmail); + assert.equal(leaked, false, 'Reason must not leak trailer email'); +}); + +test('resolveIdentity: maintainer sign-off + contributor sign-off on same commit', () => { + const commits = [ + { + sha: 'squashed12345678', + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: add component\n\nSigned-off-by: Contributor One \nSigned-off-by: Lee Calcote ' + } + } + ]; + + const result = resolveIdentity('contributor1', commits); + assert.equal(result.dcoVerified, true); + assert.equal(result.resolvedEmail, 'contrib@layer5.io'); +}); + +test('resolveIdentity: author signed commit plus non-author / maintainer commit in PR', () => { + const commits = [ + { + sha: 'auth111111111111', + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: implement feature\n\nSigned-off-by: Contributor One ' + } + }, + { + sha: 'maint22222222222', + author: { login: 'maintainerA' }, + commit: { + author: { name: 'Maintainer A', email: 'maintainer@layer5.io' }, + message: 'chore: merge master into branch\n\nSigned-off-by: Maintainer A ' + } + } + ]; + + const result = resolveIdentity('contributor1', commits); + assert.equal(result.dcoVerified, true); + assert.equal(result.resolvedEmail, 'contrib@layer5.io'); + assert.ok(result.reason.includes('Verified 1 commit(s) by @contributor1')); +}); + +test('resolveIdentity: mismatched sign-off name and email on author commit fails closed without leaking email', () => { + const plaintextEmail = 'secret.mismatch@corporate.com'; + const commits = [ + { + sha: 'mismatch12345678', + author: { login: 'alice' }, + commit: { + author: { name: 'Alice Smith', email: 'alice@example.com' }, + message: `fix: bug\n\nSigned-off-by: Bob Jones <${plaintextEmail}>` + } + } + ]; + + const result = resolveIdentity('alice', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes('has no Signed-off-by trailer attributable to author')); + // Privacy invariant: Plaintext email must NOT appear in reason + assert.equal(result.reason.includes(plaintextEmail), false); +}); + +test('resolveIdentity: commit author mismatch when no commits belong to PR author (fails closed)', () => { + const commits = [ + { + sha: 'authormismatch12', + author: { login: 'mallory' }, + commit: { + author: { name: 'Mallory', email: 'mallory@example.com' }, + message: 'feat: patch\n\nSigned-off-by: Mallory ' + } + } + ]; + + const result = resolveIdentity('alice', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes("No commits in PR matched GitHub-associated author '@alice'")); +}); + +test('resolveIdentity: missing GitHub-associated author account (fails closed)', () => { + const commits = [ + { + sha: 'noauthor12345678', + author: null, + committer: { login: 'alice' }, + commit: { + author: { name: 'Alice', email: 'alice@example.com' }, + message: 'feat: patch\n\nSigned-off-by: Alice ' + } + } + ]; + + // Must not fall back to committer + const result = resolveIdentity('alice', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes("No commits in PR matched GitHub-associated author '@alice'")); +}); + +test('resolveIdentity: missing DCO in one of author commits (fails closed)', () => { + const commits = [ + { + sha: '1111111111111111', + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contrib', email: 'contrib@test.com' }, + message: 'first commit\n\nSigned-off-by: Contrib ' + } + }, + { + sha: '2222222222222222', + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contrib', email: 'contrib@test.com' }, + message: 'second commit without DCO' + } + } + ]; + + const result = resolveIdentity('contributor1', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes('is missing a valid DCO Signed-off-by trailer')); +}); + +test('resolveIdentity: multiple author commits with conflicting emails (fails closed without leaking plaintext)', () => { + const emailA = 'work.address@test.com'; + const emailB = 'personal.address@test.com'; + const commits = [ + { + sha: '1111111111111111', + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contrib', email: emailA }, + message: `first commit\n\nSigned-off-by: Contrib <${emailA}>` + } + }, + { + sha: '2222222222222222', + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contrib', email: emailB }, + message: `second commit\n\nSigned-off-by: Contrib <${emailB}>` + } + } + ]; + + const result = resolveIdentity('contributor1', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes('conflicting Signed-off-by emails')); + assert.equal(result.reason.includes(emailA), false); + assert.equal(result.reason.includes(emailB), false); + assert.ok(result.reason.includes(maskEmail(emailA))); +}); + +test('resolveIdentity: squashed commit with multiple sign-offs', () => { + const commits = [ + { + sha: 'squashed99999999', + author: { login: 'dev' }, + commit: { + author: { name: 'Dev User', email: 'dev@company.com' }, + message: 'Squash commit (#42)\n\n* commit 1\n* commit 2\n\nSigned-off-by: Dev User \nSigned-off-by: Reviewer ' + } + } + ]; + + const result = resolveIdentity('dev', commits); + assert.equal(result.dcoVerified, true); + assert.equal(result.resolvedEmail, 'dev@company.com'); +}); + +test('resolveIdentity: signed normal commit + unsigned merge commit => success (merge commit skipped)', () => { + const commits = [ + { + sha: 'code111111111111', + author: { login: 'contributor1' }, + parents: [{ sha: 'parent1' }], + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: add feature\n\nSigned-off-by: Contributor One ' + } + }, + { + sha: 'merge222222222222', + author: { login: 'contributor1' }, + parents: [{ sha: 'parentA' }, { sha: 'parentB' }], + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: "Merge branch 'master' into feat/my-feature" + } + } + ]; + + const result = resolveIdentity('contributor1', commits); + assert.equal(result.dcoVerified, true); + assert.equal(result.resolvedEmail, 'contrib@layer5.io'); +}); + +test('resolveIdentity: merge-only commit history => fails closed (no code commits)', () => { + const commits = [ + { + sha: 'merge333333333333', + author: { login: 'contributor1' }, + parents: [{ sha: 'parentA' }, { sha: 'parentB' }], + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: "Merge branch 'master' into feat/my-feature" + } + }, + { + sha: 'merge444444444444', + author: { login: 'contributor1' }, + parents: [{ sha: 'parentC' }, { sha: 'parentD' }], + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: "Merge branch 'develop' into feat/my-feature" + } + } + ]; + + const result = resolveIdentity('contributor1', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes('No non-merge code commits found')); +}); + +test('resolveIdentity: unsigned normal commit + merge commit => fails closed (merge skip does not rescue unsigned code)', () => { + const commits = [ + { + sha: 'unsigned55555555', + author: { login: 'contributor1' }, + parents: [{ sha: 'parent1' }], + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: add feature without DCO' + } + }, + { + sha: 'merge666666666666', + author: { login: 'contributor1' }, + parents: [{ sha: 'parentA' }, { sha: 'parentB' }], + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: "Merge branch 'master' into feat/my-feature" + } + } + ]; + + const result = resolveIdentity('contributor1', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes('missing a valid DCO Signed-off-by trailer')); +}); diff --git a/utils/workflow-integration.test.js b/utils/workflow-integration.test.js new file mode 100644 index 0000000..06c67b4 --- /dev/null +++ b/utils/workflow-integration.test.js @@ -0,0 +1,503 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +test('Integration: full pipeline with paginated API responses, multi-badge awards, and privacy isolation', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workflow-integration-')); + + const rawFilesPage1 = [ + { filename: 'src/components/Button/index.tsx' } + ]; + const rawFilesPage2 = [ + { filename: 'src/components/Button/index.tsx' }, // duplicate across pages + { filename: 'src/components/Modal/index.tsx' } + ]; + + const rawCommitsPage1 = [ + { + sha: '1111111111111111111111111111111111111111', + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: add button\n\nSigned-off-by: Contributor One ' + } + } + ]; + const rawCommitsPage2 = [ + { + sha: '2222222222222222222222222222222222222222', + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: add modal\n\nSigned-off-by: Contributor One \nSigned-off-by: Lee Calcote ' + } + } + ]; + + const rawLabelsPage1 = [{ name: 'area/ui' }]; + const rawLabelsPage2 = [{ name: 'enhancement' }]; + + // Simulate slurped jq add output + const filesSlurped = [rawFilesPage1, rawFilesPage2]; + const commitsSlurped = [rawCommitsPage1, rawCommitsPage2]; + const labelsSlurped = [rawLabelsPage1, rawLabelsPage2]; + + const metadataPath = path.join(tmpDir, 'pr-metadata.json'); + const labelsPath = path.join(tmpDir, 'existing-labels.json'); + const publicOutPath = path.join(tmpDir, 'evaluation-result.json'); + const dispatchOutPath = path.join(tmpDir, 'dispatch-context.json'); + + const prMetadata = { + repository: 'layer5io/sistent', + prAuthor: 'contributor1', + merged: true, + files: filesSlurped, + commits: commitsSlurped, + labels: labelsSlurped + }; + + fs.writeFileSync(metadataPath, JSON.stringify(prMetadata), 'utf-8'); + fs.writeFileSync(labelsPath, JSON.stringify(labelsSlurped), 'utf-8'); + + // Execute CLI + const scriptPath = path.resolve(__dirname, 'award-orchestrator.js'); + execFileSync(process.execPath, [ + scriptPath, + `--metadata=${metadataPath}`, + `--existing-labels=${labelsPath}`, + `--repo=layer5io/sistent`, + `--out=${publicOutPath}`, + `--dispatch-out=${dispatchOutPath}` + ]); + + // Verify public file + assert.ok(fs.existsSync(publicOutPath)); + const publicContent = fs.readFileSync(publicOutPath, 'utf-8'); + const publicJson = JSON.parse(publicContent); + + // 1. Privacy check: Plaintext email must NOT exist anywhere in public output + assert.equal(publicContent.includes('contrib@layer5.io'), false); + assert.ok(publicContent.includes('c***b@layer5.io')); + + // 2. Multi-badge evaluation: both sistent-contributor and ui-ux qualified + const awardedSlugs = publicJson.pendingAwards.map(a => a.slug); + assert.ok(awardedSlugs.includes('sistent-contributor'), 'Must award sistent-contributor'); + assert.ok(awardedSlugs.includes('ui-ux'), 'Must award ui-ux'); + assert.equal(publicJson.dcoVerified, true); + + // 3. Dispatch file check: runner has recipient email + assert.ok(fs.existsSync(dispatchOutPath)); + const dispatchJson = JSON.parse(fs.readFileSync(dispatchOutPath, 'utf-8')); + assert.equal(dispatchJson.recipientEmail, 'contrib@layer5.io'); + assert.equal(dispatchJson.pendingAwards.length, 2); + + // 4. Idempotency on rerun with tracking labels + const rerunLabelsPath = path.join(tmpDir, 'existing-labels-rerun.json'); + const existingWithLabels = [ + { name: 'area/ui' }, + { name: 'badge-awarded:sistent-contributor' } + ]; + fs.writeFileSync(rerunLabelsPath, JSON.stringify(existingWithLabels), 'utf-8'); + + const rerunPublicOut = path.join(tmpDir, 'rerun-evaluation-result.json'); + const rerunDispatchOut = path.join(tmpDir, 'rerun-dispatch-context.json'); + + execFileSync(process.execPath, [ + scriptPath, + `--metadata=${metadataPath}`, + `--existing-labels=${rerunLabelsPath}`, + `--repo=layer5io/sistent`, + `--out=${rerunPublicOut}`, + `--dispatch-out=${rerunDispatchOut}` + ]); + + const rerunPublicJson = JSON.parse(fs.readFileSync(rerunPublicOut, 'utf-8')); + const rerunSlugs = rerunPublicJson.pendingAwards.map(a => a.slug); + assert.ok(!rerunSlugs.includes('sistent-contributor'), 'Already awarded badge must be excluded on rerun'); + assert.ok(rerunSlugs.includes('ui-ux'), 'Unawarded badge must remain pending'); + + // Clean up + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('Integration: missing DCO blocks award dispatch in pipeline', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workflow-nodco-')); + + const prMetadata = { + repository: 'meshery/meshery', + prAuthor: 'author1', + merged: true, + files: [{ filename: 'server/main.go' }], + commits: [ + { + sha: 'abc1234', + author: { login: 'author1' }, + commit: { + author: { name: 'Author', email: 'author@test.com' }, + message: 'commit without dco' + } + } + ], + labels: [] + }; + + const metadataPath = path.join(tmpDir, 'pr-metadata.json'); + const labelsPath = path.join(tmpDir, 'existing-labels.json'); + const publicOutPath = path.join(tmpDir, 'evaluation-result.json'); + const dispatchOutPath = path.join(tmpDir, 'dispatch-context.json'); + + fs.writeFileSync(metadataPath, JSON.stringify(prMetadata), 'utf-8'); + fs.writeFileSync(labelsPath, JSON.stringify([]), 'utf-8'); + + const scriptPath = path.resolve(__dirname, 'award-orchestrator.js'); + execFileSync(process.execPath, [ + scriptPath, + `--metadata=${metadataPath}`, + `--existing-labels=${labelsPath}`, + `--repo=meshery/meshery`, + `--out=${publicOutPath}`, + `--dispatch-out=${dispatchOutPath}` + ]); + + const publicJson = JSON.parse(fs.readFileSync(publicOutPath, 'utf-8')); + assert.equal(publicJson.dcoVerified, false); + assert.equal(publicJson.pendingAwards.length, 0); + + const dispatchJson = JSON.parse(fs.readFileSync(dispatchOutPath, 'utf-8')); + assert.equal(dispatchJson.pendingAwards.length, 0); + + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('Integration: unmerged PR safely blocks badge evaluation and award dispatches', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workflow-unmerged-')); + + const prMetadata = { + repository: 'layer5io/sistent', + prAuthor: 'contributor1', + merged: false, // Unmerged PR + files: [{ filename: 'src/components/Button/index.tsx' }], + commits: [ + { + sha: 'unmerged1234', + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: add button\n\nSigned-off-by: Contributor One ' + } + } + ], + labels: [] + }; + + const metadataPath = path.join(tmpDir, 'pr-metadata.json'); + const labelsPath = path.join(tmpDir, 'existing-labels.json'); + const publicOutPath = path.join(tmpDir, 'evaluation-result.json'); + const dispatchOutPath = path.join(tmpDir, 'dispatch-context.json'); + + fs.writeFileSync(metadataPath, JSON.stringify(prMetadata), 'utf-8'); + fs.writeFileSync(labelsPath, JSON.stringify([]), 'utf-8'); + + const scriptPath = path.resolve(__dirname, 'award-orchestrator.js'); + execFileSync(process.execPath, [ + scriptPath, + `--metadata=${metadataPath}`, + `--existing-labels=${labelsPath}`, + `--repo=layer5io/sistent`, + `--out=${publicOutPath}`, + `--dispatch-out=${dispatchOutPath}` + ]); + + const publicJson = JSON.parse(fs.readFileSync(publicOutPath, 'utf-8')); + assert.equal(publicJson.isMerged, false); + assert.equal(publicJson.pendingAwards.length, 0); + assert.ok(publicJson.summaryMarkdown.includes('not in a merged state')); + + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('Integration: unauthorized repository fails closed and produces no awards', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workflow-unauthorized-')); + + const prMetadata = { + repository: 'external-org/unknown-repo', + prAuthor: 'contributor1', + merged: true, + files: [{ filename: 'src/index.ts' }], + commits: [ + { + sha: 'unauth1234', + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: code\n\nSigned-off-by: Contributor One ' + } + } + ], + labels: [] + }; + + const metadataPath = path.join(tmpDir, 'pr-metadata.json'); + const labelsPath = path.join(tmpDir, 'existing-labels.json'); + const publicOutPath = path.join(tmpDir, 'evaluation-result.json'); + const dispatchOutPath = path.join(tmpDir, 'dispatch-context.json'); + + fs.writeFileSync(metadataPath, JSON.stringify(prMetadata), 'utf-8'); + fs.writeFileSync(labelsPath, JSON.stringify([]), 'utf-8'); + + const scriptPath = path.resolve(__dirname, 'award-orchestrator.js'); + execFileSync(process.execPath, [ + scriptPath, + `--metadata=${metadataPath}`, + `--existing-labels=${labelsPath}`, + `--repo=external-org/unknown-repo`, + `--out=${publicOutPath}`, + `--dispatch-out=${dispatchOutPath}` + ]); + + const publicJson = JSON.parse(fs.readFileSync(publicOutPath, 'utf-8')); + assert.equal(publicJson.isSupportedRepo, false); + assert.equal(publicJson.pendingAwards.length, 0); + assert.ok(publicJson.summaryMarkdown.includes('not an authorized Track 2 participating repository')); + + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('Integration: GitHub noreply identity safely suppresses awards in pipeline while maintaining DCO verification', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workflow-noreply-')); + + const sensitiveHandle = '12345+noreplyuser'; + const noreplyEmail = `${sensitiveHandle}@users.noreply.github.com`; + + const prMetadata = { + repository: 'layer5io/sistent', + prAuthor: 'noreplyuser', + merged: true, + files: [{ filename: 'src/components/Button/index.tsx' }], + commits: [ + { + sha: 'noreplycommit1', + author: { login: 'noreplyuser' }, + commit: { + author: { name: 'Noreply User', email: noreplyEmail }, + message: `feat: button\n\nSigned-off-by: Noreply User <${noreplyEmail}>` + } + } + ], + labels: [] + }; + + const metadataPath = path.join(tmpDir, 'pr-metadata.json'); + const labelsPath = path.join(tmpDir, 'existing-labels.json'); + const publicOutPath = path.join(tmpDir, 'evaluation-result.json'); + const dispatchOutPath = path.join(tmpDir, 'dispatch-context.json'); + + fs.writeFileSync(metadataPath, JSON.stringify(prMetadata), 'utf-8'); + fs.writeFileSync(labelsPath, JSON.stringify([]), 'utf-8'); + + const scriptPath = path.resolve(__dirname, 'award-orchestrator.js'); + execFileSync(process.execPath, [ + scriptPath, + `--metadata=${metadataPath}`, + `--existing-labels=${labelsPath}`, + `--repo=layer5io/sistent`, + `--out=${publicOutPath}`, + `--dispatch-out=${dispatchOutPath}` + ]); + + const publicContent = fs.readFileSync(publicOutPath, 'utf-8'); + const publicJson = JSON.parse(publicContent); + + // DCO is verified, but recipient cannot be mapped -> 0 pending awards + assert.equal(publicJson.dcoVerified, true); + assert.equal(publicJson.pendingAwards.length, 0); + assert.ok(publicJson.summaryMarkdown.includes('Recipient Unresolvable')); + + // Privacy invariant: public output does not contain the sensitive handle prefix + const leakedHandle = publicContent.includes(sensitiveHandle); + assert.equal(leakedHandle, false, 'Public output must not contain sensitive handle'); + + // Dispatch context must have null recipientEmail and empty pendingAwards + const dispatchJson = JSON.parse(fs.readFileSync(dispatchOutPath, 'utf-8')); + assert.equal(dispatchJson.recipientEmail, null); + assert.equal(dispatchJson.pendingAwards.length, 0); + + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('Workflow Shell Logic: GitHub API error classification determines outcome strictly by numeric HTTP status, not response message', () => { + const evaluateApiHttpResponse = (rawHeaderAndBody, simulateTransportFailure = false) => { + const tmpResp = path.join(os.tmpdir(), `test-resp-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`); + const tmpErr = path.join(os.tmpdir(), `test-err-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`); + + if (!simulateTransportFailure) { + fs.writeFileSync(tmpResp, rawHeaderAndBody, 'utf-8'); + fs.writeFileSync(tmpErr, '', 'utf-8'); + } else { + fs.writeFileSync(tmpResp, '', 'utf-8'); + fs.writeFileSync(tmpErr, rawHeaderAndBody, 'utf-8'); + } + + const bashScript = ` + GH_RESP_FILE="$1" + GH_ERR_FILE="$2" + + HTTP_STATUS="" + if [ -s "\${GH_RESP_FILE}" ]; then + HTTP_STATUS=$(head -n 1 "\${GH_RESP_FILE}" | awk '{print $2}') + fi + + if [ "\${HTTP_STATUS}" = "200" ]; then + echo "SUCCESS_200" + exit 0 + elif [ "\${HTTP_STATUS}" = "404" ]; then + echo "SKIP_404" + exit 0 + elif [ "\${HTTP_STATUS}" = "403" ]; then + echo "FAIL_403" + exit 1 + elif [ "\${HTTP_STATUS}" = "429" ]; then + echo "FAIL_429" + exit 1 + elif [[ "\${HTTP_STATUS}" =~ ^5[0-9]{2}$ ]]; then + echo "FAIL_5XX" + exit 1 + elif [ -n "\${HTTP_STATUS}" ]; then + echo "FAIL_UNEXPECTED" + exit 1 + else + echo "FAIL_TRANSPORT" + exit 1 + fi + `; + + try { + const out = execFileSync('bash', ['-c', bashScript, 'test-sh', tmpResp, tmpErr], { encoding: 'utf-8' }); + fs.rmSync(tmpResp, { force: true }); + fs.rmSync(tmpErr, { force: true }); + return { status: 0, output: out.trim() }; + } catch (err) { + fs.rmSync(tmpResp, { force: true }); + fs.rmSync(tmpErr, { force: true }); + return { status: err.status, output: (err.stdout || '').trim() }; + } + }; + + // 1. HTTP 404 with message "Not Found" -> skip gracefully + const res404 = evaluateApiHttpResponse('HTTP/2.0 404 Not Found\r\nContent-Type: application/json\r\n\r\n{"message":"Not Found"}'); + assert.equal(res404.status, 0); + assert.equal(res404.output, 'SKIP_404'); + + // 2. HTTP 403 with message "Not Found" -> MUST fail (proves classification does NOT rely on "Not Found") + const res403NotFound = evaluateApiHttpResponse('HTTP/2.0 403 Forbidden\r\nContent-Type: application/json\r\n\r\n{"message":"Not Found"}'); + assert.equal(res403NotFound.status, 1, 'HTTP 403 containing message "Not Found" must fail, not skip'); + assert.equal(res403NotFound.output, 'FAIL_403'); + + // 3. HTTP 429 -> MUST fail + const res429 = evaluateApiHttpResponse('HTTP/2.0 429 Too Many Requests\r\nContent-Type: application/json\r\n\r\n{"message":"API rate limit exceeded"}'); + assert.equal(res429.status, 1); + assert.equal(res429.output, 'FAIL_429'); + + // 4. HTTP 500 -> MUST fail + const res500 = evaluateApiHttpResponse('HTTP/2.0 500 Internal Server Error\r\nContent-Type: application/json\r\n\r\n{"message":"Internal Server Error"}'); + assert.equal(res500.status, 1); + assert.equal(res500.output, 'FAIL_5XX'); + + // 5. Transport/network failure -> MUST fail + const resTransport = evaluateApiHttpResponse('curl: (7) Failed to connect to api.github.com port 443: Connection refused', true); + assert.equal(resTransport.status, 1); + assert.equal(resTransport.output, 'FAIL_TRANSPORT'); +}); + +test('Workflow Shell Logic: Label query status branching explicitly handles 200, 404, 403, 429, and 5xx', () => { + const evaluateLabelStatus = (statusCode) => { + const bashScript = ` + LABEL_STATUS=$1 + if [ "\${LABEL_STATUS}" = "200" ]; then + echo "EXISTS" + exit 0 + elif [ "\${LABEL_STATUS}" = "404" ]; then + echo "CREATE_LABEL" + exit 0 + elif [ "\${LABEL_STATUS}" = "403" ]; then + echo "FAIL_403" + exit 1 + elif [ "\${LABEL_STATUS}" = "429" ]; then + echo "FAIL_429" + exit 1 + elif [[ "\${LABEL_STATUS}" =~ ^5[0-9]{2}$ ]]; then + echo "FAIL_5XX" + exit 1 + else + echo "FAIL_UNEXPECTED" + exit 1 + fi + `; + try { + const out = execFileSync('bash', ['-c', bashScript, 'test-sh', statusCode], { encoding: 'utf-8' }); + return { status: 0, output: out.trim() }; + } catch (err) { + return { status: err.status, output: (err.stdout || '').trim() }; + } + }; + + assert.equal(evaluateLabelStatus('200').output, 'EXISTS'); + assert.equal(evaluateLabelStatus('404').output, 'CREATE_LABEL'); + assert.equal(evaluateLabelStatus('403').output, 'FAIL_403'); + assert.equal(evaluateLabelStatus('403').status, 1); + assert.equal(evaluateLabelStatus('429').output, 'FAIL_429'); + assert.equal(evaluateLabelStatus('429').status, 1); + assert.equal(evaluateLabelStatus('500').output, 'FAIL_5XX'); + assert.equal(evaluateLabelStatus('500').status, 1); + assert.equal(evaluateLabelStatus('000').output, 'FAIL_UNEXPECTED'); + assert.equal(evaluateLabelStatus('000').status, 1); +}); + +test('Allowlist Drift Detection: workflow shell case statements match SUPPORTED_REPOSITORIES', () => { + const { SUPPORTED_REPOSITORIES } = require('./badge-evaluator'); + + const jsSet = new Set(SUPPORTED_REPOSITORIES.map(r => r.toLowerCase())); + + // Extract repositories from shell case statements in workflow files + const workflowFiles = [ + path.join(__dirname, '..', '.github', 'workflows', 'award-project-badge.yml'), + path.join(__dirname, '..', '.github', 'workflows', 'test-badge-evaluator.yml') + ]; + + for (const workflowPath of workflowFiles) { + const basename = path.basename(workflowPath); + const content = fs.readFileSync(workflowPath, 'utf-8'); + + // Match the case pattern line: "repo1"|"repo2"|...) at the start of a case branch + const caseMatch = content.match(/"([^"]+)"(?:\|"([^"]+)")*\)/g); + assert.ok(caseMatch && caseMatch.length > 0, `No case pattern found in ${basename}`); + + // Take the first case match (the allowlist pattern) + const patternLine = caseMatch[0]; + const shellRepos = new Set( + patternLine + .replace(/\)$/, '') + .split('|') + .map(s => s.replace(/"/g, '').trim().toLowerCase()) + .filter(Boolean) + ); + + // Compare as sets: find missing and extra + const missingFromShell = [...jsSet].filter(r => !shellRepos.has(r)); + const extraInShell = [...shellRepos].filter(r => !jsSet.has(r)); + + assert.deepStrictEqual( + missingFromShell, + [], + `${basename}: repositories in SUPPORTED_REPOSITORIES but missing from shell case: ${missingFromShell.join(', ')}` + ); + assert.deepStrictEqual( + extraInShell, + [], + `${basename}: repositories in shell case but missing from SUPPORTED_REPOSITORIES: ${extraInShell.join(', ')}` + ); + } +});