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
-
-
-
-[](https://community.finos.org/docs/governance/lifecycle-stages/graduated)
-[](https://www.npmjs.com/package/@finos/git-proxy)
-[](https://github.com/finos/git-proxy/actions/workflows/ci.yml)
-[](https://codecov.io/gh/finos/git-proxy)
-[](https://git-proxy.finos.org)
-
-[](https://github.com/finos/git-proxy/blob/main/LICENSE)
-[](https://github.com/finos/git-proxy/graphs/contributors)
-[](https://app.slack.com/client/T01E7QRQH97/C06LXNW0W76)
-[](https://api.securityscorecards.dev/projects/github.com/finos/git-proxy)
-[](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()