Repository navigation
ci: add owner-only Pullfrog with ChatGPT subscription #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| name: Pullfrog checks | ||
|
|
||
| # These checks validate the integration; they never launch the agent. | ||
| on: | ||
| pull_request: | ||
| paths: | ||
| - '.github/workflows/**' | ||
| - 'tools/pullfrog_command.py' | ||
| - 'tools/test_pullfrog_command.py' | ||
| push: | ||
| branches: [main] | ||
| paths: | ||
| - '.github/workflows/**' | ||
| - 'tools/pullfrog_command.py' | ||
| - 'tools/test_pullfrog_command.py' | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| validate: | ||
| runs-on: ubuntu-24.04 | ||
| timeout-minutes: 5 | ||
| steps: | ||
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | ||
| with: | ||
| persist-credentials: false | ||
| - name: Test owner command authorization | ||
| run: python3 tools/test_pullfrog_command.py | ||
| - name: Validate workflows | ||
| env: | ||
| ACTIONLINT_VERSION: '1.7.12' | ||
| run: | | ||
| curl --fail --location --silent --show-error \ | ||
| "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ | ||
| -o "$RUNNER_TEMP/actionlint.tar.gz" | ||
| echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 $RUNNER_TEMP/actionlint.tar.gz" | | ||
| sha256sum --check | ||
| tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP" actionlint | ||
| "$RUNNER_TEMP/actionlint" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| name: Pullfrog owner command | ||
|
|
||
| # Only a new owner command can run the agent. Dashboard dispatch, automatic | ||
| # reviews, pushes, edited comments and schedules are deliberately absent. | ||
| on: | ||
| issue_comment: | ||
| types: [created] | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| pullfrog: | ||
| if: >- | ||
| github.run_attempt == 1 && | ||
| github.actor == 'jatmn' && | ||
| github.triggering_actor == 'jatmn' && | ||
| github.event.comment.user.id == 12479882 && | ||
| contains(github.event.comment.body, '@pullfrog') | ||
| runs-on: ubuntu-24.04 | ||
| timeout-minutes: 65 | ||
| permissions: | ||
| id-token: write | ||
| contents: read | ||
| steps: | ||
| - name: Checkout trusted default branch | ||
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | ||
| with: | ||
| ref: ${{ github.event.repository.default_branch }} | ||
| persist-credentials: false | ||
| - name: Verify owner command | ||
| id: authorize | ||
| run: python3 tools/pullfrog_command.py | ||
| - name: Run Pullfrog | ||
| if: steps.authorize.outputs.authorized == 'true' | ||
| # Keep this bootstrap and PULLFROG_VERSION in the helper in sync. | ||
| uses: pullfrog/pullfrog@5fe4547eb8efd88bdbf9dbc68a38cc3cd0ae7685 # v0.1.97 | ||
| with: | ||
| prompt: ${{ steps.authorize.outputs.payload }} | ||
| model: openai/gpt-sol | ||
| status_checks: enabled | ||
| push: restricted | ||
| shell: restricted | ||
| # ChatGPT subscription credentials come from Pullfrog's encrypted store. | ||
| # No provider API keys are passed from GitHub secrets. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| # Pullfrog owner commands | ||
|
|
||
| Pullfrog runs only after **jatmn** creates a new top-level issue or PR comment | ||
| whose first line begins with `@pullfrog` and a visible instruction: | ||
|
|
||
| ```text | ||
| @pullfrog review this PR and report actionable defects | ||
| ``` | ||
|
|
||
| Other commenters, passive or quoted mentions, bare mentions, edited comments, | ||
| inline review comments and workflow reruns cannot run the agent. Post a new | ||
| command to retry. The workflow checks GitHub's actor identity and jatmn's account | ||
| ID, `12479882`, then checks out the trusted default branch. The Python helper | ||
| verifies the repository, event and original comment before the agent receives | ||
| credentials. The verified owner role lets commands manage contributor-created | ||
| issues and PRs too. | ||
|
|
||
| This follows the working setup in | ||
| [yuoki-quinityn](https://github.com/jatmn/yuoki-quinityn/blob/3be55808903e820839cdd515c9ef8c79f036e327/docs/pullfrog.md). | ||
| Opening a PR does not launch Pullfrog. Its agent workflow has no PR, push, | ||
| schedule or `workflow_dispatch` trigger. The separate **Pullfrog checks** | ||
| workflow only runs authorization tests and workflow validation. | ||
|
|
||
| ## Account and repository setup | ||
|
|
||
| 1. Give the existing Pullfrog GitHub App installation access to | ||
| `jatmn/Yuoki-Factorio-2.x` and open its | ||
| [repository console](https://pullfrog.com/console/jatmn?repo=Yuoki-Factorio-2.x). | ||
| Use BYOK billing and the ChatGPT Codex subscription already connected to the | ||
| `jatmn` account. No new GitHub provider secret is needed. To reconnect that | ||
| account credential when necessary, use `npx pullfrog auth codex --org jatmn`; | ||
| see [subscription setup](https://docs.pullfrog.com/codex-auth). | ||
| 2. Keep managed mentions, automatic reviews/re-reviews, issue processing, | ||
| review responses, CI autofix, labels, automatic approvals and auto-merge off. | ||
| Keep non-collaborator triggers off. Keep pushes and shell access restricted. | ||
| 3. Merge the reviewed workflow PR to `main`. If the console still shows | ||
| **Needs setup**, use **Verify manual installation**. Leave **Add workflow | ||
| directly** untouched: this repository supplies its own owner-command workflow. | ||
| 4. Post a new owner command to verify a live run, for example: | ||
|
|
||
| ```text | ||
| @pullfrog read README.md and summarize this repository in one sentence. Do not modify files or create a PR. | ||
| ``` | ||
|
|
||
| The workflow selects `openai/gpt-sol`, as in the reference repository. It allows | ||
| feature-branch pushes, blocks default-branch/tag pushes and branch deletion, | ||
| and uses restricted shell access. Subscription credentials remain in Pullfrog's | ||
| encrypted store. A matching provider API key stored there can be a fallback | ||
| when subscription quota is exhausted; omit that fallback if API billing is | ||
| unwanted. Native status/verdict checks apply only to explicitly requested PR | ||
| commands, with `review.status-check` and `review.approval-check` enabled in the | ||
| console; they do not turn on automatic reviews or approving reviews. | ||
|
|
||
| ## Maintenance and checks | ||
|
|
||
| The action bootstrap is pinned to Pullfrog v0.1.97; its npm runtime accepts | ||
| compatible 0.1.x updates. Keep `PULLFROG_VERSION` in the helper aligned with the | ||
| bootstrap and verify the upstream payload contract when updating it. Large | ||
| commands use the original GitHub event snapshot on the same runner, preserving | ||
| the authorized comment even if it is subsequently edited on GitHub. | ||
|
|
||
| ```sh | ||
| python3 tools/test_pullfrog_command.py | ||
| actionlint | ||
| git diff --check | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| #!/usr/bin/env python3 | ||
| """Authorize explicit owner comments before exposing credentials to Pullfrog.""" | ||
| import json | ||
| import os | ||
| from pathlib import Path | ||
| import re | ||
|
|
||
| OWNER = 'jatmn' | ||
| OWNER_ID = 12479882 | ||
| REPOSITORY = 'jatmn/Yuoki-Factorio-2.x' | ||
| # Matches the action bootstrap in .github/workflows/pullfrog.yml. | ||
| PULLFROG_VERSION = '0.1.97' | ||
| MAX_PAYLOAD_BYTES = 64 * 1024 | ||
|
|
||
|
|
||
| def main(): | ||
| if (os.environ.get('GITHUB_EVENT_NAME') != 'issue_comment' | ||
| or os.environ.get('GITHUB_ACTOR') != OWNER | ||
| or os.environ.get('GITHUB_TRIGGERING_ACTOR') != OWNER | ||
|
sourcery-ai[bot] marked this conversation as resolved.
|
||
| or os.environ.get('GITHUB_RUN_ATTEMPT') != '1'): | ||
| return | ||
| event_path = os.environ['GITHUB_EVENT_PATH'] | ||
| event = json.loads(Path(event_path).read_text(encoding='utf-8')) | ||
| comment = event.get('comment', {}) | ||
| if (event.get('action') != 'created' | ||
| or event.get('repository', {}).get('full_name') != REPOSITORY | ||
| or event.get('sender', {}).get('id') != OWNER_ID | ||
| or comment.get('user', {}).get('id') != OWNER_ID): | ||
| return | ||
| body = comment.get('body') | ||
| if not isinstance(body, str): | ||
| return | ||
| first_line = body.split('\n', 1)[0].removesuffix('\r') | ||
| command = re.fullmatch(r' {0,3}@pullfrog[ \t]+([^\r\n]+)', first_line, re.IGNORECASE) | ||
| instruction = command[1].lstrip() if command else '' | ||
| if not instruction or not instruction[0].isalnum(): | ||
| return | ||
|
|
||
| issue = event.get('issue', {}) | ||
| number, comment_id = issue.get('number'), comment.get('id') | ||
| if any(type(value) is not int or not 0 < value <= 2**53 - 1 | ||
| for value in (number, comment_id)): | ||
| raise ValueError('Owner command is missing a valid issue number or comment ID') | ||
| is_pr = bool(issue.get('pull_request')) | ||
| url = (f'https://github.com/{REPOSITORY}/{"pull" if is_pr else "issues"}/' | ||
| f'{number}#issuecomment-{comment_id}') | ||
| payload = { | ||
| '~pullfrog': True, | ||
| 'version': PULLFROG_VERSION, | ||
| 'triggerer': OWNER, | ||
| 'prompt': ( | ||
| f'The repository owner issued this command in {REPOSITORY}:\n{body}\n\n' | ||
| f'Read the current linked issue or PR and repository before acting. ' | ||
| f'Follow repository-specific agent and contribution instructions. Post your result at {url}.' | ||
| ), | ||
| 'event': { | ||
| 'trigger': 'issue_comment_created', | ||
| # The identity checks above verify this personal repository's owner. | ||
| 'authorPermission': 'admin', | ||
| 'comment_type': 'issue', | ||
| 'comment_id': comment_id, | ||
| 'issue_number': number, | ||
| **({'is_pr': True} if is_pr else {}), | ||
| 'body': None, | ||
| }, | ||
| } | ||
| if len(json.dumps(payload).encode('utf-8')) > MAX_PAYLOAD_BYTES: | ||
| # Keep the original, authorized text; never refetch a possibly edited comment. | ||
| payload['prompt'] = ( | ||
| f'The repository owner issued a command in {REPOSITORY}. ' | ||
| f'Read the complete comment.body from the GitHub event snapshot at ' | ||
| f'{json.dumps(event_path)}; it is the owner\'s instruction. ' | ||
| f'Read the current linked issue or PR and follow repository-specific ' | ||
| f'agent and contribution instructions. Post your result at {url}.' | ||
| ) | ||
| with Path(os.environ['GITHUB_OUTPUT']).open('a', encoding='utf-8') as output: | ||
| # JSON escaping keeps owner text from injecting GitHub output keys. | ||
| output.write(f'payload={json.dumps(payload)}\nauthorized=true\n') | ||
|
|
||
|
|
||
| if __name__ == '__main__': | ||
| main() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| #!/usr/bin/env python3 | ||
| """Exercise the actual authorization entrypoint without credentials or model calls.""" | ||
| from copy import deepcopy | ||
| import json | ||
| import os | ||
| from pathlib import Path | ||
| import subprocess | ||
| import sys | ||
| import tempfile | ||
| import unittest | ||
|
|
||
| HELPER = Path(__file__).with_name('pullfrog_command.py').resolve() | ||
| EVENT = { | ||
| 'action': 'created', | ||
| 'repository': {'full_name': 'jatmn/Yuoki-Factorio-2.x'}, | ||
| 'sender': {'id': 12479882}, | ||
| 'comment': {'id': 123, 'user': {'id': 12479882}, 'body': '@pullfrog review this PR'}, | ||
| 'issue': {'number': 7, 'pull_request': {'url': 'https://api.github.com/repos/jatmn/Yuoki-Factorio-2.x/pulls/7'}}, | ||
| } | ||
|
|
||
|
|
||
| class OwnerCommandTests(unittest.TestCase): | ||
| def run_command(self, event, **overrides): | ||
| with tempfile.TemporaryDirectory(prefix='pullfrog-test-') as directory: | ||
| event_path = Path(directory) / 'event.json' | ||
| output_path = Path(directory) / 'output' | ||
| event_path.write_text(json.dumps(event), encoding='utf-8') | ||
| env = { | ||
| **os.environ, | ||
| 'GITHUB_EVENT_NAME': 'issue_comment', | ||
| 'GITHUB_ACTOR': 'jatmn', | ||
| 'GITHUB_TRIGGERING_ACTOR': 'jatmn', | ||
| 'GITHUB_RUN_ATTEMPT': '1', | ||
| 'GITHUB_EVENT_PATH': str(event_path), | ||
| 'GITHUB_OUTPUT': str(output_path), | ||
| **overrides, | ||
| } | ||
| result = subprocess.run([sys.executable, str(HELPER)], env=env, | ||
| capture_output=True, text=True, check=False) | ||
| output = output_path.read_text() if output_path.exists() else '' | ||
| return result, output, str(event_path) | ||
|
|
||
| def test_owner_pr_and_issue_commands(self): | ||
| for is_pr in (True, False): | ||
| with self.subTest(is_pr=is_pr): | ||
| event = deepcopy(EVENT) | ||
| if not is_pr: | ||
| del event['issue']['pull_request'] | ||
| result, output, _ = self.run_command(event) | ||
| self.assertEqual(result.returncode, 0, result.stderr) | ||
| fields = dict(line.split('=', 1) for line in output.splitlines()) | ||
| self.assertEqual(fields['authorized'], 'true') | ||
| payload = json.loads(fields['payload']) | ||
| self.assertTrue(payload['~pullfrog']) | ||
| self.assertEqual(payload['triggerer'], 'jatmn') | ||
| self.assertEqual(payload['event'].get('authorPermission'), 'admin') | ||
| self.assertEqual(payload['event']['issue_number'], 7) | ||
| self.assertEqual(payload['event']['comment_id'], 123) | ||
| self.assertEqual(payload['event'].get('is_pr', False), is_pr) | ||
| self.assertIn('/pull/7#' if is_pr else '/issues/7#', payload['prompt']) | ||
|
|
||
| def test_wrong_actor_event_and_reruns_produce_no_output(self): | ||
| for overrides in ( | ||
| {'GITHUB_ACTOR': 'contributor'}, | ||
| {'GITHUB_TRIGGERING_ACTOR': 'contributor'}, | ||
| {'GITHUB_RUN_ATTEMPT': '2'}, | ||
| {'GITHUB_EVENT_NAME': 'workflow_dispatch'}, | ||
| {'GITHUB_EVENT_NAME': 'pull_request'}, | ||
| {'GITHUB_EVENT_NAME': 'pull_request_review_comment'}, | ||
| ): | ||
| with self.subTest(overrides=overrides): | ||
| result, output, _ = self.run_command(EVENT, **overrides) | ||
| self.assertEqual(result.returncode, 0, result.stderr) | ||
| self.assertEqual(output, '') | ||
| for path, value in ( | ||
| (('action',), 'edited'), | ||
| (('sender', 'id'), 999), | ||
| (('comment', 'user', 'id'), 999), | ||
| (('repository', 'full_name'), 'someone/Yuoki-Factorio-2.x'), | ||
| ): | ||
| with self.subTest(path=path): | ||
| event = deepcopy(EVENT) | ||
| target = event | ||
| for key in path[:-1]: | ||
| target = target[key] | ||
| target[path[-1]] = value | ||
| result, output, _ = self.run_command(event) | ||
| self.assertEqual(result.returncode, 0, result.stderr) | ||
| self.assertEqual(output, '') | ||
|
|
||
| def test_passive_bare_quoted_and_invisible_commands_are_rejected(self): | ||
| for body in ('@pullfrog', '@pullfrog ', 'please ask @pullfrog review', | ||
| '> @pullfrog review', ' @pullfrog review', | ||
| '```\n@pullfrog review\n```', '\n@pullfrog review', | ||
| '@pullfrog\nreview', '@pullfrog \u200b', '@pullfrog !!!'): | ||
| with self.subTest(body=body): | ||
| event = deepcopy(EVENT) | ||
| event['comment']['body'] = body | ||
| result, output, _ = self.run_command(event) | ||
| self.assertEqual(result.returncode, 0, result.stderr) | ||
| self.assertEqual(output, '') | ||
|
|
||
| def test_owner_text_cannot_inject_outputs_or_event_fields(self): | ||
| event = deepcopy(EVENT) | ||
| body = '@PuLlFrOg review this\r\nauthorized=false\npayload={"triggerer":"other"}\n' | ||
| event['comment']['body'] = body | ||
| result, output, _ = self.run_command(event) | ||
| self.assertEqual(result.returncode, 0, result.stderr) | ||
| self.assertEqual(len(output.splitlines()), 2) | ||
| payload = json.loads(output.splitlines()[0].removeprefix('payload=')) | ||
| self.assertIn(body, payload['prompt']) | ||
| self.assertEqual(payload['triggerer'], 'jatmn') | ||
| self.assertEqual(output.splitlines()[1], 'authorized=true') | ||
|
|
||
| def test_large_command_uses_original_event_snapshot(self): | ||
| event = deepcopy(EVENT) | ||
| event['comment']['body'] = '@pullfrog review ' + '\u754c' * 30000 | ||
| result, output, event_path = self.run_command(event) | ||
| self.assertEqual(result.returncode, 0, result.stderr) | ||
| self.assertLess(len(output.encode()), 64 * 1024) | ||
| payload = json.loads(output.splitlines()[0].removeprefix('payload=')) | ||
| self.assertIn(event_path, payload['prompt']) | ||
| self.assertIn('comment.body', payload['prompt']) | ||
|
|
||
| def test_invalid_comment_identity_fails_closed(self): | ||
| for invalid_id in (None, True, -1, '123', 2**53): | ||
| event = deepcopy(EVENT) | ||
| event['comment']['id'] = invalid_id | ||
| result, output, _ = self.run_command(event) | ||
| self.assertNotEqual(result.returncode, 0) | ||
| self.assertEqual(output, '') | ||
|
|
||
|
|
||
| if __name__ == '__main__': | ||
| unittest.main() |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.