Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
jonathanalgar committed Jan 18, 2024
1 parent 8e6eafa commit 42abf2e
Show file tree
Hide file tree
Showing 7 changed files with 530 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
vale.ini
12 changes: 12 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM python:3.11-slim as base

RUN apt-get update && apt-get install -y git jq curl

COPY requirements.txt /
RUN pip install --no-cache-dir -r requirements.txt

RUN mkdir /app
WORKDIR /app
ADD . /app

CMD python activator-ghclient.py
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,83 @@
# activator-ghclient

![ci](https://github.com/jonathanalgar/activator-ghclient/actions/workflows/build-docker.yml/badge.svg)

![License: GPLv3](https://img.shields.io/badge/license-GPLv3-blue) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://makeapullrequest.com)

## Overview

Containerized GitHub action for interacting with the [activator](https://github.com/jonathanalgar/activator) service.

On a trigger comment in a pull request, the action sends the text of a supported file in a request to the [activator](https://github.com/jonathanalgar/activator) service for transformation. It then takes a response from the service, formats accordingly, and posts in-line suggestions or a block comment.

## Usage

First, create a new GitHub action workflow in your repo (eg. `.github/workflows/activator.yml`):

```yaml
name: activator

on:
issue_comment:
types:
- created

permissions:
contents: write
pull-requests: write
issues: write

jobs:
activator-ghclient:
runs-on: ubuntu-latest
if: contains(github.event.comment.body, '/activator')
container:
image: ghcr.io/jonathanalgar/activator-ghclient:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

steps:
- name: Add reaction to comment
run: |
COMMENT_ID=${{ github.event.comment.id }}
curl -X POST -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"content":"eyes"}' \
"https://api.github.com/repos/${{ github.repository }}/issues/comments/$COMMENT_ID/reactions"
- name: Set ref for checkout
id: set_ref
run: |
PR_API_URL="${{ github.event.issue.pull_request.url }}"
REF=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" $PR_API_URL | jq -r .head.ref)
echo "REF=$REF" >> $GITHUB_ENV
- name: Checkout
uses: actions/[email protected]
with:
fetch-depth: 1
ref: ${{ env.REF }}

- name: Run script
env:
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
ACTIVATOR_ENDPOINT: ${{ secrets.ACTIVATOR_ENDPOINT }}
ACTIVATOR_TOKEN: ${{ secrets.ACTIVATOR_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_ID: ${{ github.event.comment.id }}
run: python /app/activator-ghclient.py
```
You'll need to set the following repo secrets:
* `ACTIVATOR_ENDPOINT`: Endpoint URL of the running `activator` (eg. `https://activator-prod.westeurope.cloudapp.azure.com:9100/activator`)
* `ACTIVATOR_TOKEN`: Single token for service.

Once that's done you can comment `/activator /path/to/file.md` in a pull request to trigger the action.

## TODO

- [ ] Better error handling
- [ ] Unit tests
- [ ] Extend this TODO list
287 changes: 287 additions & 0 deletions activator-ghclient.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
import asyncio
import logging
import os
import re

import aiohttp

from ghutils import GitHubHandler

logging.basicConfig(level=logging.DEBUG,
format='%(levelname)s [%(asctime)s] %(message)s',
datefmt='%d-%m-%Y %H:%M:%S')


class ActivatorHandler:
"""
Handles retrieval and updating of active voice suggestions.
"""
def __init__(self):
pass

async def send_to_activator(self, session, file_path, text, activator_endpoint):
"""
Asynchronously sends text content to ACTIVATOR_ENDPOINT and retrieves suggestions.
Args:
session (ClientSession): An aiohttp client session for making HTTP requests.
text (str): The text to be sent for review.
activator_endpoint (str): Activator service URL.
Returns:
dict: A dictionary containing the response from ACTIVATOR_ENDPOINT.
"""
ACTIVATOR_TIMEOUT = 120
response_structure = {"success": False, "data": None}

logging.info(f'[{file_path}] Sending request to ACTIVATOR_ENDPOINT')
headers = {
"Content-Type": "application/json",
"X-API-Token": os.getenv('ACTIVATOR_TOKEN')
}
payload = {"text": text}
try:
async with session.post(activator_endpoint, json=payload, headers=headers, timeout=ACTIVATOR_TIMEOUT) as response:
response.raise_for_status()
response_structure["data"] = await response.json()
response_structure["success"] = True
except asyncio.TimeoutError:
logging.error(f'[{file_path}] Request to ACTIVATOR_ENDPOINT timed out')
except aiohttp.ClientResponseError as e:
logging.error(f'[{file_path}] HTTP Response Error: {e}')
except Exception as e:
logging.error(f'[{file_path}] An unexpected error occurred: {e}')

return response_structure

def format_activator_suggestions(self, violations):
"""
Formats the response from ACTIVATOR_ENDPOINT.
Takes a list of violation objects and formats them into a string that lists the original sentences and their suggested revisions along with explanations.
Args:
violations (list): A list of violation objects returned by ACTIVATOR_ENDPOINT.
Returns:
str: A formatted string representing the activator suggestions.
"""
formatted_suggestions = [
f"**Original:** {violation['original_sentence']}\n**Revised:** {violation['revised_sentence']}\n**Explanation:** {violation['clear_explanation']}\n\n"
for violation in violations
]
return '\n'.join(formatted_suggestions)

def post_review_comment_on_violation(self, file_path, violation, github_handler, pr_number):
"""
Posts a review comment on a specific line of a file in a pull request, highlighting an instance of passive voice.
Args:
file_path (str): The path of the file within the pull request.
violation (dict): A dictionary containing details about the violation, including original and suggested text.
github_handler (GitHubHandler): An instance of GitHubHandler for GitHub interaction.
pr_number (int): The number of the pull request.
"""
pr = github_handler.repo.get_pull(pr_number)
commit_obj = github_handler.repo.get_commit(pr.head.sha)

files = pr.get_files()
for file in files:
if file.filename == file_path:
file_diff = file.patch
break
else:
logging.warning(f"[{file_path}] File not found in pull request")
return

# Locating the exact line in the file diff where the original passive sentence appears.
diff_lines = file_diff.split('\n')
for i, line in enumerate(diff_lines):
if violation['original_sentence'] in line:
position = i
line_text = line[line.find('+')+1:] # Skip the diff '+'
updated_line = line_text.replace(violation['original_sentence'], violation['revised_sentence'])

review_message = f"**Suggested Change:**\n```suggestion\n{updated_line}\n```\n"
review_message += f"**Explanation:** {violation['clear_explanation']}"
pr.create_review_comment(review_message, commit_obj, file_path, position)
logging.info(f"[{file_path}] Posted a review comment for instance of passive voice on line {position}")


def parse_activator_comment(file_path, comment_body):
"""
Parses the body of the activator comment and extracts original and revised sentences.
Args:
comment_body (str): The body of the activator comment.
Returns:
List[Tuple[str, str]]: A list of tuples, each containing the original and revised sentences extracted from the comment.
"""
logging.debug(f"[{file_path}] Parsing activator command comment: {comment_body}")

pattern = re.compile(r"\*\*Original:\*\*\s(.*?)\n\*\*Revised:\*\*\s(.*?)\n\*\*Explanation:\*\*", re.DOTALL)
matches = pattern.findall(comment_body)

if not matches:
logging.warning(f"[{file_path}] No matches found in activator comment. Review regex pattern and comment format")
else:
logging.info(f"[{file_path}] Extracted tuples from activator comment: {matches}")

return matches


async def commit_edited_file(github_handler, file_path, pr_number):
"""
Commits the edited file to the repository based on the complete suggestions from the last activator review comment.
Args:
github_handler (GitHubHandler): An instance of GitHubHandler for interacting with GitHub.
file_path (str): The path of the file to be edited.
pr_number (int): The number of the pull request associated with the file.
"""
pr = github_handler.repo.get_pull(pr_number)
comments = pr.get_issue_comments()

# Convert PaginatedList to a list and reverse it to start from the latest comment
pr_comments = list(comments)
pr_comments.reverse()

latest_review_comment = next(
(
comment
for comment in pr_comments
if f"Activator suggestions for `{file_path}`" in comment.body
),
None,
)
if not latest_review_comment:
logging.error(f"[{file_path}] Failed to find activator review comment. Unable to proceed with commit")
return

suggestions = parse_activator_comment(file_path, latest_review_comment.body)
logging.info(f"[{file_path}] Extracted tuples from activator comment: {suggestions}")
with open(file_path, 'r') as file:
content = file.read()

replacements_made = 0
for original, revised in suggestions:
if original in content:
content = content.replace(original, revised)
replacements_made += 1
logging.info(f"[{file_path}] Replaced: '{original}' with '{revised}'")
else:
logging.warning(f"[{file_path}] Original sentence not found in file: '{original}'")

logging.info(f"[{file_path}] Total text replacements made in file: {replacements_made}")

if replacements_made > 0:
with open(file_path, 'w') as file:
file.write(content)

github_handler.commit_and_push([file_path], f"Posted a commit comment for file: {file_path}")
else:
logging.info(f"[{file_path}] No text replacements required. Skipping the commit process")


async def process_file(session, file_path, activator_handler, github_handler, activator_endpoint, pr_number):
"""
Processes a file to make suggestions on instances of passive voice.
Args:
session (aiohttp.ClientSession): The client session for HTTP requests.
file_path (str): The path of the file to be processed.
activator_handler (ActivatorHandler): An instance of ActivatorHandler for processing.
github_handler (GitHubHandler): An instance of GitHubHandler for interacting with GitHub.
activator_endpoint (str): Activator service URL.
pr_number (int): The number of the associated pull request.
"""
logging.info(f"[{file_path}] Starting review")

try:
with open(file_path, 'r') as file:
content = file.read()

response = await activator_handler.send_to_activator(session, file_path, content, activator_endpoint)

if not response["success"]:
logging.error(f"[{file_path}] Failed to get a response from ACTIVATOR_ENDPOINT.")
github_handler.post_comment(f"Failed to get a response from the ACTIVATOR_ENDPOINT for file `{file_path}`. Please check the logs for more details.")
return

if not response["data"].get('violations'):
logging.info(f"[{file_path}] No instances of sentences written in passive voice found")
github_handler.post_comment(f"There appear to be no instances of sentences written in passive voice in `{file_path}`.")
return

file_status = github_handler.get_file_status(file_path)
run_url = response["data"].get('run_url')
run_url_text = f"[Explore how the LLM generated them.]({run_url})" if run_url else ""

if file_status == 'added':
review_comment = f"Activator suggestions for `{file_path}` posted below."
if run_url:
review_comment += f" {run_url_text}"
github_handler.post_comment(review_comment)

for violation in response["data"]['violations']:
activator_handler.post_review_comment_on_violation(file_path, violation, github_handler, pr_number)

elif file_status == 'modified':
formatted_response = activator_handler.format_activator_suggestions(response["data"]['violations'])
final_comment = f"Activator suggestions for `{file_path}`:\n\n{formatted_response}"
if run_url:
final_comment += run_url_text

final_comment += f" Use `/activator {file_path} --commit` to commit all suggestions."
github_handler.post_comment(final_comment)

except Exception as e:
logging.error(f"[{file_path}] Error processing file: {e}")
github_handler.post_comment(f"Error processing file `{file_path}`. Please check the logs for more details.")


async def main():
"""
Main asynchronous function to run the script.
"""
repo_name = os.getenv('GITHUB_REPOSITORY')
pr_number = os.getenv('PR_NUMBER')
comment_id = os.getenv('COMMENT_ID')
comment_body = os.getenv('COMMENT_BODY', '')
activator_endpoint = os.getenv('ACTIVATOR_ENDPOINT')

pr_number = int(pr_number) if pr_number else None
comment_id = int(comment_id) if comment_id else None

logging.debug(f"Received comment body (raw): {repr(comment_body)}")

github_handler = GitHubHandler(repo_name, pr_number)
activator_handler = ActivatorHandler()

SUPPORTED_FILE_TYPES = ['.md', '.mdx', '.ipynb']
file_types_regex = r"(" + '|'.join(re.escape(ext) for ext in SUPPORTED_FILE_TYPES) + r")"

if commit_match := re.search(
rf'/activator\s+([\w/.\-]*[\w.\-]+{file_types_regex})\s+--commit', comment_body
):
file_path = commit_match[1]
logging.info(f"[{file_path}] Commit command identified")

await commit_edited_file(github_handler, file_path, pr_number)
elif file_path_match := re.search(
rf'/activator\s+([\w/.\-]*[\w.\-]+{file_types_regex})', comment_body
):
file_path = file_path_match[1]
logging.info(f"[{file_path}] File path identified")

async with aiohttp.ClientSession() as session:
await process_file(session, file_path, activator_handler, github_handler, activator_endpoint, pr_number)
else:
logging.info("No valid command found in the comment.")

supported_types_formatted = ", ".join(f"`{ext}`" for ext in SUPPORTED_FILE_TYPES)
github_handler.post_comment(f"No valid command found in the comment. Supported file types are: {supported_types_formatted}")

if __name__ == "__main__":
asyncio.run(main())
Loading

0 comments on commit 42abf2e

Please sign in to comment.