diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml index 1033a44f6..278e6dc5c 100644 --- a/.github/workflows/npm.yml +++ b/.github/workflows/npm.yml @@ -5,6 +5,7 @@ on: workflow_dispatch: inputs: dry_run: + description: 'Dry run only (no actual publish)' required: false default: 'true' type: choice @@ -72,7 +73,7 @@ jobs: # Otherwise, this is a maintenance release on an older line # Tag as v. so users can pin to it MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2) - DIST_TAG="release-${MAJOR_MINOR}" + DIST_TAG="v${MAJOR_MINOR}" echo "Maintenance release detected, publishing under '$DIST_TAG' tag" npm publish "${PUBLISH_FLAGS[@]}" --tag "$DIST_TAG" fi diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index dd6115037..93fee1d64 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -4,11 +4,7 @@ name: 'PR' on: pull_request_target: - types: - - opened - - reopened - - edited - - synchronize + types: [opened, reopened, edited, synchronize] permissions: contents: read diff --git a/.github/workflows/pr-quality-check.yml b/.github/workflows/pr-quality-check.yml new file mode 100644 index 000000000..c7b4d2526 --- /dev/null +++ b/.github/workflows/pr-quality-check.yml @@ -0,0 +1,37 @@ +name: PR Quality Check +on: + pull_request: + types: [opened, reopened] + +jobs: + pr_quality_check: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: pip install litellm PyGithub + - name: Run PR quality check agent + env: + # e.g: "claude-sonnet-4-6", "gpt-4o", etc. + MODEL: ${{ vars.MODEL }} + # Enable token/cost logging in job logs + DEBUG_AI_WORKFLOWS: ${{ vars.DEBUG_AI_WORKFLOWS }} + MAX_OUTPUT_TOKENS: ${{ vars.MAX_OUTPUT_TOKENS }} + TOKEN_BUDGET: ${{ vars.TOKEN_BUDGET }} + # Only API key for the chosen model is required + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + # Obtained automatically by GH Actions + AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} + AUTHOR_USERNAME: ${{ github.event.pull_request.user.login }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + REPO_NAME: ${{ github.repository }} + run: python scripts/agents/pr_checker_agent.py diff --git a/.github/workflows/security-review.yml b/.github/workflows/security-review.yml new file mode 100644 index 000000000..dc0619f91 --- /dev/null +++ b/.github/workflows/security-review.yml @@ -0,0 +1,50 @@ +name: Security Review + +on: + pull_request: + types: [opened, reopened] + issue_comment: + types: [created] + +jobs: + security-review: + runs-on: ubuntu-latest + # Always runs on PR creation + # Also runs if comment on PR contains "/security-review" + if: > + github.event_name == 'pull_request' || + ( + github.event_name == 'issue_comment' && + github.event.issue.pull_request != null && + contains(github.event.comment.body, '/security-review') + ) + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: pip install litellm PyGithub + - name: Run security review agent + env: + IGNORED_EXTENSIONS: .lock,.sum + IGNORED_FILENAMES: package-lock.json,yarn.lock,poetry.lock,Gemfile.lock,Cargo.lock,composer.lock,pnpm-lock.yaml,pip.lock + MAX_PATCH_CHARS_PER_FILE: 3000 + # e.g: "claude-sonnet-4-6", "gpt-4o", etc. + MODEL: ${{ vars.MODEL }} + # Enable token/cost logging in job logs + DEBUG_AI_WORKFLOWS: ${{ vars.DEBUG_AI_WORKFLOWS }} + MAX_OUTPUT_TOKENS: ${{ vars.MAX_OUTPUT_TOKENS }} + TOKEN_BUDGET: ${{ vars.TOKEN_BUDGET }} + # Only API key for the chosen model is required + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + # Obtained automatically by GH Actions + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + REPO_NAME: ${{ github.repository }} + TRIGGER: ${{ github.event_name }} + run: python scripts/agents/security_review_agent.py diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml new file mode 100644 index 000000000..a52ea703c --- /dev/null +++ b/.github/workflows/triage.yml @@ -0,0 +1,37 @@ +name: Issue Triage +on: + issues: + types: [opened, reopened] + +jobs: + triage: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: pip install litellm PyGithub + - name: Run triage agent + env: + AVAILABLE_LABELS: automation,bug,dependencies,documentation,enhancement,good-first-issue,meeting,needs-info,plugins,protocol,question,security,tech-debt,testing + LATEST_ISSUES_LIMIT: 100 + # e.g: "claude-sonnet-4-6", "gpt-4o", etc. + MODEL: ${{ vars.MODEL }} + # Enable token/cost logging in job logs + DEBUG_AI_WORKFLOWS: ${{ vars.DEBUG_AI_WORKFLOWS }} + MAX_OUTPUT_TOKENS: ${{ vars.MAX_OUTPUT_TOKENS }} + TOKEN_BUDGET: ${{ vars.TOKEN_BUDGET }} + # Only API key for the chosen model is required + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + # Obtained automatically by GH Actions + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + REPO_NAME: ${{ github.repository }} + run: python scripts/agents/triage_agent.py diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml new file mode 100644 index 000000000..3298566ad --- /dev/null +++ b/.github/workflows/version-bump.yml @@ -0,0 +1,110 @@ +name: Bump package versions + +# Can be triggered by closing a milestone or manually +on: + milestone: + types: [closed] + workflow_dispatch: + inputs: + version: + description: 'Version to bump to, e.g. 2.2.0' + required: true + +permissions: + contents: write + pull-requests: write + +concurrency: + group: version-bump-${{ github.event.milestone.number || github.run_id }} + cancel-in-progress: false + +jobs: + bump-version: + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout main + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: main + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: '24' + + - name: Resolve version + id: version + env: + MILESTONE_TITLE: ${{ github.event.milestone.title }} + MANUAL_VERSION: ${{ inputs.version }} + run: | + RAW="${MANUAL_VERSION:-$MILESTONE_TITLE}" + RAW="$(printf '%s' "$RAW" | tr -d '[:space:]')" + + if [[ ! "$RAW" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::'$RAW' isn't a plain semver version (for example '2.2.0'). Not bumping." + exit 1 + fi + + VERSION="${RAW#v}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved version: $VERSION" + + - name: Sanity check against current version + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + CURRENT=$(node -p "require('./package.json').version") + NEWEST=$(printf '%s\n%s\n' "$CURRENT" "$VERSION" | sort -V | tail -n1) + if [ "$VERSION" = "$CURRENT" ] || [ "$NEWEST" != "$VERSION" ]; then + echo "::error::$VERSION is not newer than the current package.json version ($CURRENT)." + exit 1 + fi + + - name: Bump root package + env: + VERSION: ${{ steps.version.outputs.version }} + run: npm version "$VERSION" --no-git-tag-version --allow-same-version + + - name: Bump git-proxy-cli package + working-directory: packages/git-proxy-cli + env: + VERSION: ${{ steps.version.outputs.version }} + run: npm version "$VERSION" --no-git-tag-version --allow-same-version + + - name: Open version bump PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + MILESTONE_TITLE: ${{ github.event.milestone.title }} + MILESTONE_URL: ${{ github.event.milestone.html_url }} + run: | + BRANCH="chore/bump-version-$VERSION" + RELEASE_BRANCH="release/${VERSION%.*}" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git checkout -b "$BRANCH" + git add package.json package-lock.json packages/git-proxy-cli/package.json + git commit -m "chore: bump version to $VERSION" + git push -u origin "$BRANCH" --force + + if [ -n "$MILESTONE_URL" ]; then + SOURCE_LINE="Triggered by closing milestone **$MILESTONE_TITLE** ($MILESTONE_URL)." + else + SOURCE_LINE="Triggered manually for version $VERSION." + fi + + BODY=$(printf '%s\n\nBumps:\n- `package.json` / `package-lock.json`\n- `packages / `packages/git-proxy-cli/package.json`\n\nOnce merged, cut `%s` from `main` per the release process.\n' "$SOURCE_LINE" "$RELEASE_BRANCH") + + if gh pr view "$BRANCH" >/dev/null 2>&1; then + echo "PR for $BRANCH already exists. Branch updated, no new PR opened." + else + gh pr create --base main --head "$BRANCH" --title "chore: bump version to $VERSION" --body "$BODY" + fi diff --git a/README.md b/README.md index 3ba0ec7e8..7373e22ed 100644 --- a/README.md +++ b/README.md @@ -20,21 +20,6 @@ · Suggest a new feature

- -
- -[![FINOS - Graduated](https://cdn.jsdelivr.net/gh/finos/contrib-toolbox@master/images/badge-graduated.svg)](https://community.finos.org/docs/governance/lifecycle-stages/graduated) -[![NPM](https://img.shields.io/npm/v/@finos/git-proxy?colorA=00C586&colorB=000000)](https://www.npmjs.com/package/@finos/git-proxy) -[![Build](https://img.shields.io/github/actions/workflow/status/finos/git-proxy/ci.yml?branch=main&label=CI&logo=github&colorA=00C586&colorB=000000)](https://github.com/finos/git-proxy/actions/workflows/ci.yml) -[![codecov](https://codecov.io/gh/finos/git-proxy/branch/main/graph/badge.svg)](https://codecov.io/gh/finos/git-proxy) -[![Documentation](https://img.shields.io/badge/_-documentation-000000?colorA=00C586&logo=docusaurus&logoColor=FFFFFF&)](https://git-proxy.finos.org) -
-[![License](https://img.shields.io/github/license/finos/git-proxy?colorA=00C586&colorB=000000)](https://github.com/finos/git-proxy/blob/main/LICENSE) -[![Contributors](https://img.shields.io/github/contributors/finos/git-proxy?colorA=00C586&colorB=000000)](https://github.com/finos/git-proxy/graphs/contributors) -[![Slack](https://img.shields.io/badge/_-Chat_on_Slack-000000.svg?logo=slack&colorA=00C586)](https://app.slack.com/client/T01E7QRQH97/C06LXNW0W76) -[![git-proxy](https://api.securityscorecards.dev/projects/github.com/finos/git-proxy/badge)](https://api.securityscorecards.dev/projects/github.com/finos/git-proxy) -[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/10520/badge)](https://www.bestpractices.dev/projects/10520) -
diff --git a/experimental/li-cli/package.json b/experimental/li-cli/package.json index 82f12c34f..eac643fc5 100644 --- a/experimental/li-cli/package.json +++ b/experimental/li-cli/package.json @@ -1,5 +1,5 @@ { - "name": "@finos/git-proxy-li-cli", + "name": "@jescalada/git-proxy-li-cli", "version": "0.0.1", "author": "git-proxy contributors", "license": "Apache-2.0", diff --git a/experimental/license-inventory/package.json b/experimental/license-inventory/package.json index f37a331c9..ac69e0000 100644 --- a/experimental/license-inventory/package.json +++ b/experimental/license-inventory/package.json @@ -1,5 +1,5 @@ { - "name": "@finos/git-proxy-license-inventory", + "name": "@jescalada/git-proxy-license-inventory", "version": "0.0.2", "author": "git-proxy contributors", "license": "Apache-2.0", diff --git a/package-lock.json b/package-lock.json index 7a377225d..249c266c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2604,13 +2604,229 @@ } }, "node_modules/@finos/git-proxy": { - "resolved": "", - "link": true + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@finos/git-proxy/-/git-proxy-2.0.0.tgz", + "integrity": "sha512-55UPvUZeZ6Z4TEz5P2ADLQKD1+4ZCzPeaLSXzukpfn2Do8+ughHHSRtHDXuWR5u9GlZFzZVxhW/P0LTr3G3aBw==", + "license": "Apache-2.0", + "workspaces": [ + "./packages/git-proxy-cli" + ], + "dependencies": { + "@aws-sdk/credential-providers": "^3.980.0", + "@fontsource/roboto": "^5.2.9", + "@material-ui/core": "^4.12.4", + "@material-ui/icons": "4.11.3", + "@primer/octicons-react": "^19.21.2", + "@seald-io/nedb": "^4.1.2", + "axios": "^1.13.4", + "bcryptjs": "^3.0.3", + "clsx": "^2.1.1", + "concurrently": "^9.2.1", + "connect-mongo": "^5.1.0", + "cors": "^2.8.6", + "diff2html": "^3.4.56", + "env-paths": "^3.0.0", + "escape-string-regexp": "^5.0.0", + "express": "^5.2.1", + "express-http-proxy": "^2.1.2", + "express-rate-limit": "^8.2.1", + "express-session": "^1.19.0", + "font-awesome": "^4.7.0", + "history": "5.3.0", + "isomorphic-git": "^1.36.3", + "jsonwebtoken": "^9.0.3", + "load-plugin": "^6.0.3", + "lodash": "^4.17.23", + "lusca": "^1.7.0", + "material-design-icons": "^3.0.1", + "moment": "^2.30.1", + "mongodb": "^5.9.2", + "openid-client": "^6.8.1", + "parse-diff": "^0.11.1", + "passport": "^0.7.0", + "passport-activedirectory": "^1.4.0", + "passport-local": "^1.0.0", + "perfect-scrollbar": "^1.5.6", + "react": "^16.14.0", + "react-dom": "^16.14.0", + "react-html-parser": "^2.0.2", + "react-router-dom": "6.30.3", + "simple-git": "^3.30.0", + "uuid": "^13.0.0", + "validator": "^13.15.26", + "yargs": "^17.7.2" + }, + "bin": { + "git-proxy": "dist/index.js", + "git-proxy-all": "concurrently 'npm run server' 'npm run client'" + }, + "engines": { + "node": ">=22.13.1 || >=24.0.0" + }, + "optionalDependencies": { + "@esbuild/darwin-arm64": "^0.27.2", + "@esbuild/darwin-x64": "^0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } }, "node_modules/@finos/git-proxy-cli": { "resolved": "packages/git-proxy-cli", "link": true }, + "node_modules/@finos/git-proxy/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@finos/git-proxy/node_modules/concurrently": { + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz", + "integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==", + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.4", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/@finos/git-proxy/node_modules/concurrently/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@finos/git-proxy/node_modules/connect-mongo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/connect-mongo/-/connect-mongo-5.1.0.tgz", + "integrity": "sha512-xT0vxQLqyqoUTxPLzlP9a/u+vir0zNkhiy9uAdHjSCcUUf7TS5b55Icw8lVyYFxfemP3Mf9gdwUOgeF3cxCAhw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "kruptein": "^3.0.0" + }, + "engines": { + "node": ">=12.9.0" + }, + "peerDependencies": { + "express-session": "^1.17.1", + "mongodb": ">= 5.1.0 < 7" + } + }, + "node_modules/@finos/git-proxy/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/@finos/git-proxy/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@finos/git-proxy/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@finos/git-proxy/node_modules/uuid": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/@finos/git-proxy/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@finos/git-proxy/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@fontsource/roboto": { "version": "5.2.9", "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.9.tgz", @@ -14596,6 +14812,37 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -15758,6 +16005,19 @@ "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitest": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", diff --git a/plugins/git-proxy-plugin-samples/package.json b/plugins/git-proxy-plugin-samples/package.json index e571da7d9..fd1ff13e1 100644 --- a/plugins/git-proxy-plugin-samples/package.json +++ b/plugins/git-proxy-plugin-samples/package.json @@ -1,5 +1,5 @@ { - "name": "@finos/git-proxy-plugin-samples", + "name": "@jescalada/git-proxy-plugin-samples", "version": "0.1.2", "description": "A set of sample (dummy) plugins for GitProxy to demonstrate how plugins are authored.", "scripts": { diff --git a/scripts/agents/helpers.py b/scripts/agents/helpers.py new file mode 100644 index 000000000..353b5797e --- /dev/null +++ b/scripts/agents/helpers.py @@ -0,0 +1,144 @@ +import os +import json +import litellm + +def validate_api_keys(): + valid_api_keys = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY"] + if not any(os.environ.get(k) for k in valid_api_keys): + raise ValueError("No API key is set") + + +def validate_env_vars(env_vars: list[str]): + for env_var in env_vars: + if not os.environ.get(env_var): + raise ValueError(f"{env_var} is not set") + + +def _debug_mode_enabled(): + return os.environ.get("DEBUG_AI_WORKFLOWS", "").strip().lower() in ("true", "1", "yes") + + +def run_agent( + messages: list, + tools: list, + handle_tool_call, + model: str, + terminal_tools: set | frozenset = frozenset(), + max_turns: int = 10, + max_output_tokens: int = 500000, + token_budget: int = 1000000, +): + """ + Runs the agent loop until the model stops, calls no tools, or calls a tool + listed in `terminal_tools`. Terminal tools end the run immediately, to + prevent further model calls (and wasted tokens). + """ + debug = _debug_mode_enabled() + total_prompt_tokens = 0 + total_completion_tokens = 0 + total_tokens = 0 + total_cost = 0.0 + truncated = False + + for turn in range(1, max_turns + 1): + response = litellm.completion( + model=model, + messages=messages, + tools=tools, + max_tokens=max_output_tokens, + ) + + # Accounting always runs; only the printing is gated on debug. + usage = getattr(response, "usage", None) + prompt_tokens = (getattr(usage, "prompt_tokens", 0) if usage else 0) or 0 + completion_tokens = (getattr(usage, "completion_tokens", 0) if usage else 0) or 0 + tokens = (getattr(usage, "total_tokens", 0) if usage else 0) or 0 + total_prompt_tokens += prompt_tokens + total_completion_tokens += completion_tokens + total_tokens += tokens or (prompt_tokens + completion_tokens) + + try: + total_cost += litellm.completion_cost(completion_response=response) + except Exception: + pass + + if debug: + print( + f"[debug] turn={turn} tokens prompt={prompt_tokens} " + f"completion={completion_tokens} total={tokens} " + f"running_total={total_tokens}" + ) + + choice = response.choices[0] + message = choice.message + + if choice.finish_reason == "length": + truncated = True + print( + f"[agent] WARNING: output hit max_tokens={max_output_tokens} on turn {turn}. " + "Any tool call from this turn is likely malformed." + ) + + if message.content: + print(f"[agent] {message.content}") + messages.append(message.model_dump(exclude_none=True)) + + if choice.finish_reason == "stop" or not message.tool_calls: + break + + finished = False + tool_results = [] + for tool_call in message.tool_calls: + name = tool_call.function.name + try: + inputs = json.loads(tool_call.function.arguments) + except json.JSONDecodeError as e: + print(f"[agent] Malformed arguments for {name}: {e}") + tool_results.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": f"Error: arguments were not valid JSON ({e}). Please retry.", + }) + continue + + result = handle_tool_call(name, inputs) + tool_results.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": result, + }) + if name in terminal_tools: + finished = True + + messages.extend(tool_results) + + if finished: + print("[agent] Terminal tool called, ending run.") + break + + if token_budget is not None and total_tokens >= token_budget: + print( + f"[agent] Token budget exhausted " + f"({total_tokens} >= {token_budget}), stopping before next call." + ) + break + else: + print(f"[agent] Hit max_turns={max_turns} without finishing.") + + if debug: + print( + f"[debug] summary prompt={total_prompt_tokens} " + f"completion={total_completion_tokens} total={total_tokens} " + f"estimated_cost=${total_cost:.6f}" + f"model={model}" + f"max_output_tokens={max_output_tokens}" + f"token_budget={token_budget}" + ) + + return { + "prompt_tokens": total_prompt_tokens, + "completion_tokens": total_completion_tokens, + "total_tokens": total_tokens, + "estimated_cost": total_cost, + "truncated": truncated, + } diff --git a/scripts/agents/pr_checker_agent.py b/scripts/agents/pr_checker_agent.py new file mode 100644 index 000000000..a2e1c265d --- /dev/null +++ b/scripts/agents/pr_checker_agent.py @@ -0,0 +1,132 @@ +import os +from github import Github, Auth +from helpers import validate_env_vars, validate_api_keys, run_agent + +# Setup + +gh = Github(auth=Auth.Token(os.environ["GITHUB_TOKEN"])) +repo = gh.get_repo(os.environ["REPO_NAME"]) +pr = repo.get_pull(int(os.environ["PR_NUMBER"])) +author = os.environ["AUTHOR_USERNAME"] + +MODEL = os.environ["MODEL"] +validate_env_vars(["GITHUB_TOKEN", "REPO_NAME", "PR_NUMBER", "AUTHOR_USERNAME", "MODEL"]) +validate_api_keys() + +# Tools + +TOOLS = [ + { + "type": "function", + "function": { + "name": "post_comment", + "description": ( + "Post a comment on the PR. Use this to welcome a first-time contributor, " + "ask for a clearer description, request an issue link, or flag non-compliance " + "with CONTRIBUTING.md. Combine multiple concerns into a single comment where " + "possible rather than posting several separate ones." + ), + "parameters": { + "type": "object", + "properties": { + "body": {"type": "string", "description": "The comment text (markdown supported)."} + }, + "required": ["body"], + }, + }, + }, +] + +# System prompt + +SYSTEM_PROMPT = """You are a PR review assistant for an open-source GitHub repository. +Check the following in order, then post at most one comment combining all concerns. If nothing needs flagging, stay silent. + +Checks: +1. FIRST CONTRIBUTION: Welcome first-time contributors and link any getting-started resources from CONTRIBUTING.md. +2. DESCRIPTION: If missing or too vague to explain what changed and why, ask for clarification. +3. LINKED ISSUE: If no "Fixes/Closes/Resolves/Related to #N" link exists, ask the author to add one. +4. CONTRIBUTING.md: If the PR doesn't follow the required structure, quote the specific rule that is violated. + +Rules: +- One comment maximum. Combine all concerns. +- Silence if everything is fine. +- Be constructive, not demanding. +- No emojis. + +When posting a comment, always use this exact structure (omit sections that don't apply): + +Thanks for the contribution! + + + + + + +... (repeat for each rule that is violated)""" + +# GitHub helpers + +def get_contributing_md() -> str: + """Fetches CONTRIBUTING.md from the repo root, or returns a notice if absent.""" + try: + contents = repo.get_contents("CONTRIBUTING.md") + return contents.decoded_content.decode("utf-8") + except Exception: + return "(No CONTRIBUTING.md found in this repository.)" + + +def is_first_contribution() -> bool: + """Returns True if the author has no previously merged PRs in this repo.""" + first_contribution_list = ['FIRST_TIMER', 'FIRST_TIME_CONTRIBUTOR', 'NONE'] + return os.environ["AUTHOR_ASSOCIATION"] in first_contribution_list + + +def post_comment(body: str) -> str: + pr.create_issue_comment(body) + return "Comment posted." + +# Tool dispatch + +def handle_tool_call(name: str, inputs: dict) -> str: + if name == "post_comment": + result = post_comment(inputs["body"]) + else: + result = f"Unknown tool: {name}" + + print(f"[tool] {name}: {result}") + return result + +# Agentic loop + +def build_initial_message() -> str: + first_contribution = is_first_contribution() + contributing_md = get_contributing_md() + + return ( + f"Please review this newly opened PR:\n\n" + f"Title: {os.environ['PR_TITLE']}\n" + f"Author: {author} ({'first-time contributor' if first_contribution else 'returning contributor'})\n" + f"Description:\n{os.environ.get('PR_BODY') or '(no description provided)'}\n\n" + f"---\n" + f"CONTRIBUTING.md contents:\n\n" + f"{contributing_md}" + ) + + +def run_pr_review_agent(): + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": build_initial_message()}, + ] + stats = run_agent(messages, TOOLS, handle_tool_call, MODEL, + terminal_tools={"post_comment"}, + max_output_tokens=int(os.environ.get("MAX_OUTPUT_TOKENS", 5000)), + token_budget=int(os.environ.get("TOKEN_BUDGET", 1000000)), + ) + if stats["truncated"]: + raise SystemExit("PR review output was truncated: results may be incomplete.") + + +if __name__ == "__main__": + run_pr_review_agent() diff --git a/scripts/agents/security_review_agent.py b/scripts/agents/security_review_agent.py new file mode 100644 index 000000000..3afa1644e --- /dev/null +++ b/scripts/agents/security_review_agent.py @@ -0,0 +1,195 @@ +import os +from github import Github, Auth +from helpers import validate_env_vars, validate_api_keys, run_agent + +# Setup + +gh = Github(auth=Auth.Token(os.environ["GITHUB_TOKEN"])) +repo = gh.get_repo(os.environ["REPO_NAME"]) +pr = repo.get_pull(int(os.environ["PR_NUMBER"])) + +MODEL = os.environ["MODEL"] +validate_env_vars(["GITHUB_TOKEN", "REPO_NAME", "PR_NUMBER", "MODEL"]) +validate_api_keys() + +IGNORED_FILENAMES = set(os.environ.get( + "IGNORED_FILENAMES", + "package-lock.json,yarn.lock,poetry.lock,Gemfile.lock,Cargo.lock,composer.lock,pnpm-lock.yaml,pip.lock" +).split(",")) + +# Extensions must include a leading dot +IGNORED_EXTENSIONS = set(os.environ.get( + "IGNORED_EXTENSIONS", + ".lock,.sum" +).split(",")) + +# Truncate very large diffs like generated files to prevent bloating the prompt +MAX_PATCH_CHARS_PER_FILE = int(os.environ.get("MAX_PATCH_CHARS_PER_FILE", 3000)) + +# System prompt + +SYSTEM_PROMPT = f"""You are a security analysis assistant for a GitHub repository. +You are given a pull request diff and must identify potential security issues. + +Flag only: hardcoded secrets or credentials, injection vulnerabilities (SQL, shell, template), insecure cryptography or hashing, unsafe deserialization, path traversal, missing input validation on user-controlled data, known-vulnerable dependency versions, overly permissive file or network access. + +Do not comment on style, performance, test coverage, or best practices unless directly tied to a security risk. + +Always call post_security_review once when done, even if there are no findings. +No emojis. + +Use this exact format: + +### Summary + + +### Findings (omit section if none) + +**** + + + + + +... (repeat for each finding) + +**Disclaimer:** This review is AI-generated. Please validate the findings before fixing. + +Reviewed by {MODEL}. Re-run by commenting `/security-review` on this PR. +""" + +if os.environ["DEBUG_AI_WORKFLOWS"]: + SYSTEM_PROMPT += f"\n\n**Model:** {MODEL}" + SYSTEM_PROMPT += f"\n\n**Max output tokens:** {os.environ.get('MAX_OUTPUT_TOKENS', 5000)}" + SYSTEM_PROMPT += f"\n\n**Token budget:** {os.environ.get('TOKEN_BUDGET', 1000000)}" + +# GitHub helpers + +def get_pr_diff() -> str: + """ + Fetches changed files and their patches, filtering out lockfiles and + other noise. Returns a formatted string ready to be included in the prompt. + """ + sections = [] + for f in pr.get_files(): + filename = os.path.basename(f.filename) + _, ext = os.path.splitext(filename) + + if filename in IGNORED_FILENAMES or ext in IGNORED_EXTENSIONS: + print(f"[diff] Skipping {f.filename} (ignored file type)") + continue + + if not f.patch: + print(f"[diff] Skipping {f.filename} (no patch — binary or too large)") + continue + + patch = f.patch[:MAX_PATCH_CHARS_PER_FILE] + truncated = len(f.patch) > MAX_PATCH_CHARS_PER_FILE + sections.append( + f"### {f.filename}\n```diff\n{patch}" + + ("\n... (truncated)" if truncated else "") + + "\n```" + ) + + return "\n\n".join(sections) if sections else "(no reviewable changes found)" + + +def find_previous_security_comment() -> object | None: + """ + Looks for an existing security review comment posted by github-actions[bot] + so we can replace it rather than stacking multiple comments on updated reviews. + """ + for comment in pr.get_issue_comments(): + if ( + comment.user.login == "github-actions[bot]" + and "Automated Security Review" in comment.body + ): + return comment + return None + + +def post_or_update_comment(body: str): + """ + If a previous security review comment exists, edit it in place. + Otherwise post a new one to keep the PR timeline clean. + """ + existing = find_previous_security_comment() + if existing: + existing.edit(body) + print("[comment] Updated existing security review comment.") + else: + pr.create_issue_comment(body) + print("[comment] Posted new security review comment.") + +# Tools + +TOOLS = [ + { + "type": "function", + "function": { + "name": "post_security_review", + "description": ( + "Post the security review findings as a comment on the PR. " + "Call this once when your analysis is complete. " + "If there are no findings, still call this to confirm the review ran." + ), + "parameters": { + "type": "object", + "properties": { + "body": { + "type": "string", + "description": "The full markdown comment body to post on the PR.", + } + }, + "required": ["body"], + }, + }, + } +] + +# Tool dispatch + +def handle_tool_call(name: str, inputs: dict) -> str: + if name == "post_security_review": + # Prepend a header to identify review comments across runs + body = f"## Automated Security Review\n\n{inputs['body']}" + post_or_update_comment(body) + return "Security review comment posted." + return f"Unknown tool: {name}" + +# Agentic loop + +def build_initial_message() -> str: + trigger = os.environ.get("TRIGGER", "pull_request") + trigger_note = ( + "This review was requested manually via `/security-review`." + if trigger == "issue_comment" + else "This review was triggered automatically on PR creation." + ) + + return ( + f"Please perform a security review of this pull request.\n\n" + f"**PR #{pr.number}:** {pr.title}\n" + f"_{trigger_note}_\n\n" + f"---\n\n" + f"{get_pr_diff()}" + ) + + +def run_security_review_agent(): + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": build_initial_message()}, + ] + stats = run_agent(messages, TOOLS, handle_tool_call, MODEL, + terminal_tools={"post_security_review"}, + max_output_tokens=int(os.environ.get("MAX_OUTPUT_TOKENS", 5000)), + token_budget=int(os.environ.get("TOKEN_BUDGET", 1000000)), + ) + + if stats["truncated"]: + raise SystemExit("Security review output was truncated: results may be incomplete.") + + +if __name__ == "__main__": + run_security_review_agent() diff --git a/scripts/agents/triage_agent.py b/scripts/agents/triage_agent.py new file mode 100644 index 000000000..f7d074dbc --- /dev/null +++ b/scripts/agents/triage_agent.py @@ -0,0 +1,231 @@ +import os +from github import Github, Auth +from helpers import validate_env_vars, validate_api_keys, run_agent + +# Setup + +gh = Github(auth=Auth.Token(os.environ["GITHUB_TOKEN"])) +repo = gh.get_repo(os.environ["REPO_NAME"]) +issue = repo.get_issue(int(os.environ["ISSUE_NUMBER"])) + +LATEST_ISSUES_LIMIT = int(os.environ["LATEST_ISSUES_LIMIT"], 100) +AVAILABLE_LABELS = os.environ.get("AVAILABLE_LABELS", "bug,enhancement,question,documentation,needs-info") +MODEL = os.environ["MODEL"] + +validate_env_vars(["GITHUB_TOKEN", "REPO_NAME", "ISSUE_NUMBER", "ISSUE_TITLE", "ISSUE_BODY", "MODEL"]) +validate_api_keys() + +# Tools + +TOOLS = [ + { + "type": "function", + "function": { + "name": "apply_label", + "description": ( + "Apply one or more labels to the issue. " + "Use labels like: " + AVAILABLE_LABELS + ), + "parameters": { + "type": "object", + "properties": { + "labels": { + "type": "array", + "items": {"type": "string"}, + "description": "List of labels to apply.", + } + }, + "required": ["labels"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "post_comment", + "description": "Post a comment on the issue, e.g. to ask for clarification or acknowledge receipt.", + "parameters": { + "type": "object", + "properties": { + "body": {"type": "string", "description": "The comment text (markdown supported)."} + }, + "required": ["body"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "mark_duplicate", + "description": ( + "Mark this issue as a duplicate of an existing one. " + "Use this when the issue is clearly asking about the same thing as an open issue. " + "Post a comment pointing to the original issue without closing anything." + ), + "parameters": { + "type": "object", + "properties": { + "original_issue_number": { + "type": "integer", + "description": "The issue number this is a duplicate of.", + }, + "reason": { + "type": "string", + "description": "Brief explanation of why these issues are duplicates.", + }, + }, + "required": ["original_issue_number", "reason"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "suggest_possible_duplicate", + "description": ( + "Use when an existing issue is related but not clearly the same thing. " + "Posts a comment pointing to the similar issue without closing anything." + "Continue triage normally after posting the comment." + ), + "parameters": { + "type": "object", + "properties": { + "related_issue_number": { + "type": "integer", + "description": "The issue number that might be related.", + }, + "reason": { + "type": "string", + "description": "Brief explanation of why these issues seem related.", + }, + }, + "required": ["related_issue_number", "reason"], + }, + }, + }, +] + +# System prompt + +SYSTEM_PROMPT = """You are an issue triage assistant for a GitHub repository. +Given a new issue and a list of existing open issues, follow these steps in order. +No emojis. + +1. DUPLICATE CHECK: If the issue clearly duplicates an existing one, call mark_duplicate and stop. + If it seems related but distinct, call suggest_possible_duplicate and continue triage. +2. LABEL: Apply appropriate labels (bug, enhancement, question, documentation, needs-info, good-first-issue, etc.). +3. NEEDS INFO: If the issue lacks key details (reproduction steps for bugs, use case for features), post a comment asking for them using this format: + +Thanks for opening this issue. To help us investigate, please provide: +- +... (repeat for each missing detail) + +4. ACKNOWLEDGE: If no duplicate was flagged and no needs-info comment was posted, acknowledge receipt with this format: + +Thanks for the report. We will take a look. + +Do not post acknowledgments on administrative issues such as meeting minutes or roadmaps.""" + +# GitHub helpers + +def get_existing_issues(limit: int = LATEST_ISSUES_LIMIT) -> str: + """ + Fetches the most recent open issues (excluding the current one) + and formats them into a string for the prompt. + """ + open_issues = repo.get_issues(state="open") + lines = [] + count = 0 + for existing in open_issues: + if existing.number == issue.number: + continue + lines.append( + f"- #{existing.number}: {existing.title}\n" + f" {(existing.body or '').strip()[:200]}" # truncate long bodies + ) + count += 1 + if count >= limit: + break + return "\n".join(lines) if lines else "(no other open issues)" + + +def apply_label(labels: list[str]) -> str: + existing_label_names = [l.name for l in repo.get_labels()] + for label in labels: + if label not in existing_label_names: + repo.create_label(label, "ededed") + issue.add_to_labels(*labels) + return f"Applied labels: {labels}" + + +def post_comment(body: str) -> str: + issue.create_comment(body) + return "Comment posted." + + +def mark_duplicate(original_issue_number: int, reason: str) -> str: + original = repo.get_issue(original_issue_number) + issue.create_comment( + f"This looks like a duplicate of #{original_issue_number} " + f"({original.html_url}).\n\n> {reason}\n\n" + f"If you believe it is distinct, please edit this issue with any additional details." + ) + issue.add_to_labels("duplicate") + return f"Marked as duplicate of #{original_issue_number}." + + +def suggest_possible_duplicate(related_issue_number: int, reason: str) -> str: + related = repo.get_issue(related_issue_number) + issue.create_comment( + f"This may be related to #{related_issue_number} " + f"({related.html_url}): {reason}\n\n" + f"Please check if that issue already covers what you are reporting." + ) + return f"Flagged as possibly related to #{related_issue_number}." + + +# Tool dispatch + +def handle_tool_call(name: str, inputs: dict) -> str: + if name == "apply_label": + result = apply_label(inputs["labels"]) + elif name == "post_comment": + result = post_comment(inputs["body"]) + elif name == "mark_duplicate": + result = mark_duplicate(inputs["original_issue_number"], inputs["reason"]) + elif name == "suggest_possible_duplicate": + result = suggest_possible_duplicate(inputs["related_issue_number"], inputs["reason"]) + else: + result = f"Unknown tool: {name}" + print(f"Tool {name}: {result}") + return result + +# Agentic loop + +def build_initial_message() -> str: + return ( + f"Please triage this new GitHub issue:\n\n" + f"Title: {os.environ['ISSUE_TITLE']}\n" + f"Body:\n{os.environ.get('ISSUE_BODY') or '(no description provided)'}\n\n" + f"---\n" + f"Here are the currently open issues for duplicate detection:\n\n" + f"{get_existing_issues()}" + ) + + +def run_triage_agent(): + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": build_initial_message()}, + ] + stats = run_agent(messages, TOOLS, handle_tool_call, MODEL, + terminal_tools={"post_comment"}, + max_output_tokens=int(os.environ.get("MAX_OUTPUT_TOKENS", 5000)), + token_budget=int(os.environ.get("TOKEN_BUDGET", 1000000)), + ) + if stats["truncated"]: + raise SystemExit("Triage output was truncated: results may be incomplete.") + + +if __name__ == "__main__": + run_triage_agent()