-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
8e6eafa
commit 42abf2e
Showing
7 changed files
with
530 additions
and
0 deletions.
There are no files selected for viewing
This file contains 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 @@ | ||
vale.ini |
This file contains 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,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 |
This file contains 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 |
---|---|---|
@@ -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 |
This file contains 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,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()) |
Oops, something went wrong.