Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/pullfrog-checks.yml
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"
45 changes: 45 additions & 0 deletions .github/workflows/pullfrog.yml
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 }}
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
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.
66 changes: 66 additions & 0 deletions docs/pullfrog.md
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
```
82 changes: 82 additions & 0 deletions tools/pullfrog_command.py
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
Comment thread
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()
135 changes: 135 additions & 0 deletions tools/test_pullfrog_command.py
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()
Loading