From 666a83609e0110c3ec7cea91b517349392a81161 Mon Sep 17 00:00:00 2001 From: Hector Martinez Date: Fri, 27 Mar 2026 12:42:45 +0100 Subject: [PATCH] Add experiment #67 Signed-off-by: Hector Martinez --- .pre-commit-config.yaml | 8 +- .../67-claude-github-app-auth/README.md | 65 +++++++++ experiments/67-claude-github-app-auth/main.py | 129 ++++++++++++++++++ .../requirements.txt | 2 + 4 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 experiments/67-claude-github-app-auth/README.md create mode 100644 experiments/67-claude-github-app-auth/main.py create mode 100644 experiments/67-claude-github-app-auth/requirements.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f4f645f1a1..da905172e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,12 +3,12 @@ repos: rev: v6.0.0 hooks: - id: check-yaml - args: ['--unsafe'] + args: ["--unsafe"] - id: end-of-file-fixer - id: trailing-whitespace - id: detect-private-key - id: check-added-large-files - args: ['--maxkb=1000'] + args: ["--maxkb=1000"] - id: check-merge-conflict - id: check-json - id: check-toml @@ -25,7 +25,7 @@ repos: hooks: - id: ty name: ty check - entry: uvx ty check + entry: uvx ty check . --ignore unresolved-import --ignore unresolved-attribute language: system types: [python] pass_filenames: false @@ -34,7 +34,7 @@ repos: rev: "1.9.4" hooks: - id: bandit - args: ['-r', 'hack/', 'experiments/', '--skip', 'B101,B404,B603'] + args: ["-r", "hack/", "experiments/", "--skip", "B101,B404,B603"] pass_filenames: false - repo: https://github.com/zricethezav/gitleaks diff --git a/experiments/67-claude-github-app-auth/README.md b/experiments/67-claude-github-app-auth/README.md new file mode 100644 index 0000000000..82286b0fd9 --- /dev/null +++ b/experiments/67-claude-github-app-auth/README.md @@ -0,0 +1,65 @@ +# PoC FullSend #67 + +This experiment folder addresses: https://github.com/konflux-ci/fullsend/issues/67 + +1. Create an app with: contents, pull requests, issues. Make it public so it can be installed on any org. +2. Go to the public URL of the app (available on the setting app page after creating it) +3. Install the app on the org of your chosing. +4. Generate a private PEM key for the app (in its settings page). +5. Generate a JWT using the PEM and the App Client ID. +6. Use the JWT to list installations, get the installation id you want. +7. Generate a short lived access token scoped to the repository that you want using the JWT and the installation ID. +8. Export the token and execute claude. + +## Results + +Passing GH_TOKEN to the environment when executing the agent be enough. + +## Example Output + +``` +Found 2 installation(s): + +Installation ID: 119153102 + Account: rh-hemartin-konflux (Organization) + Target type: Organization + Token: ghs_4FRsI532... (expires 2026-03-27T11:14:29Z) + Repositories (2): + - rh-hemartin-konflux/konflux-test-app (id: 945843843) + Scoped token: ghs_S9ANYTn2... (expires 2026-03-27T11:14:29Z) + Launching Claude agent for rh-hemartin-konflux/konflux-test-app... + Agent output: + Done! Here's a summary: + + - **Issue created:** https://github.com/rh-hemartin-konflux/konflux-test-app/issues/4 + - **PR opened:** https://github.com/rh-hemartin-konflux/konflux-test-app/pull/5 + + The PR adds a description line to the README and references the issue with `Closes #4`, so merging the PR will automatically close the issue. + - rh-hemartin-konflux/testrepo (id: 945993967) + Scoped token: ghs_655syxbA... (expires 2026-03-27T11:15:18Z) + Launching Claude agent for rh-hemartin-konflux/testrepo... + Agent output: + All done. Here's what was created: + + - **Issue:** https://github.com/rh-hemartin-konflux/testrepo/issues/20 + - **PR:** https://github.com/rh-hemartin-konflux/testrepo/pull/21 (closes issue #20) + + The PR adds a small "Claude Agent Test" section to the README on branch `claude-agent-test-20`. + +Installation ID: 119149070 + Account: rh-hemartin (User) + Target type: User + Token: ghs_h0W4j49l... (expires 2026-03-27T11:16:11Z) + Repositories (1): + - rh-hemartin/nonflux-integration-service (id: 1191345201) + Scoped token: ghs_KlpL3FSB... (expires 2026-03-27T11:16:11Z) + Launching Claude agent for rh-hemartin/nonflux-integration-service... + Agent output: + All done. Here's a summary: + + 1. **Issue created:** https://github.com/rh-hemartin/nonflux-integration-service/issues/7 — "Testing Claude Agent" + 2. **Branch pushed:** `testing-claude-agent-2` with a dummy change adding a "Contributing" section to the README + 3. **PR opened:** https://github.com/nonflux/integration-service/pull/6 — "Add contributing section to README" (references `Closes #7`) + + Note: The PR was opened against the upstream `nonflux/integration-service` repo since your repo is a fork. The `Closes #7` reference points to the issue in your fork. If you'd prefer the PR to target your fork instead, let me know. +``` diff --git a/experiments/67-claude-github-app-auth/main.py b/experiments/67-claude-github-app-auth/main.py new file mode 100644 index 0000000000..a0040b1ceb --- /dev/null +++ b/experiments/67-claude-github-app-auth/main.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +import os +import subprocess +import sys +import time + +import jwt +import requests + +pem_path = "..." +client_id = "..." + +prompt = """ +Use gh to list the available repositories. Create an issue in them called 'Testing Claude Agent', +then clone the repo, introduce a dummy change in the README, create a branch, push the change and +open a PR that solves the issue. +""" + +with open(pem_path, "rb") as pem_file: + signing_key = pem_file.read() + +payload = { + "iat": int(time.time()), + "exp": int(time.time()) + 600, # 10 minute expiration + "iss": client_id, +} + +encoded_jwt = jwt.encode(payload, signing_key, algorithm="RS256") + +response = requests.get( + "https://api.github.com/app/installations", + headers={ + "Authorization": f"Bearer {encoded_jwt}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + timeout=60, +) + +if response.status_code != 200: + print(f"Error fetching installations: {response.status_code} {response.text}", file=sys.stderr) + sys.exit(1) + +installations = response.json() +print(f"Found {len(installations)} installation(s):\n") + +gh_headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", +} + +for inst in installations: + installation_id = inst["id"] + account = inst.get("account", {}) + print(f"Installation ID: {installation_id}") + print(f" Account: {account.get('login', 'N/A')} ({account.get('type', 'N/A')})") + print(f" Target type: {inst.get('target_type', 'N/A')}") + + # Create an installation access token + token_resp = requests.post( + f"https://api.github.com/app/installations/{installation_id}/access_tokens", + headers={**gh_headers, "Authorization": f"Bearer {encoded_jwt}"}, + timeout=60, + ) + if token_resp.status_code != 201: + print(f" ERROR creating token: {token_resp.status_code} {token_resp.text}") + print() + continue + + token_data = token_resp.json() + install_token = token_data["token"] + print(f" Token: {install_token[:12]}... (expires {token_data.get('expires_at', 'N/A')})") + + # List repositories accessible to this installation token + repos_resp = requests.get( + "https://api.github.com/installation/repositories", + headers={**gh_headers, "Authorization": f"Bearer {install_token}"}, + timeout=60, + ) + if repos_resp.status_code != 200: + print(f" ERROR listing repos: {repos_resp.status_code} {repos_resp.text}") + print() + continue + + repos = repos_resp.json().get("repositories", []) + print(f" Repositories ({len(repos)}):") + for repo in repos: + # Create a repo-scoped token (limited to just this repository) + repo_token_resp = requests.post( + f"https://api.github.com/app/installations/{installation_id}/access_tokens", + headers={**gh_headers, "Authorization": f"Bearer {encoded_jwt}"}, + json={"repository_ids": [repo["id"]]}, + timeout=60, + ) + if repo_token_resp.status_code == 201: + repo_token_data = repo_token_resp.json() + repo_token = repo_token_data["token"] + expires_at = repo_token_data.get("expires_at", "N/A") + print(f" - {repo['full_name']} (id: {repo['id']})") + print(f" Scoped token: {repo_token[:12]}... (expires {expires_at})") + + # Launch a Claude agent with the repo-scoped GH_TOKEN + print(f" Launching Claude agent for {repo['full_name']}...") + agent_env = {**os.environ, "GH_TOKEN": repo_token} + result = subprocess.run( + [ + "claude", # nosec + "--print", + "--dangerously-skip-permissions", + prompt, + ], + env=agent_env, + capture_output=True, + text=True, + timeout=120, + ) + print(" Agent output:") + for line in result.stdout.strip().splitlines(): + print(f" {line}") + if result.returncode != 0 and result.stderr: + print(" Agent stderr:") + for line in result.stderr.strip().splitlines(): + print(f" {line}") + else: + status_code = repo_token_resp.status_code + text = repo_token_resp.text + print(f" - {repo['full_name']} (id: {repo['id']})") + print(f" ERROR creating scoped token: {status_code} {text}") + print() diff --git a/experiments/67-claude-github-app-auth/requirements.txt b/experiments/67-claude-github-app-auth/requirements.txt new file mode 100644 index 0000000000..c9fc200df2 --- /dev/null +++ b/experiments/67-claude-github-app-auth/requirements.txt @@ -0,0 +1,2 @@ +requests==2.33.0 +pyjwt==2.12.1