feat: enhance agent notifications for issue comments and assignments
All checks were successful
Lint and Syntax Check / build (pull_request) Successful in 5s
All checks were successful
Lint and Syntax Check / build (pull_request) Successful in 5s
This commit is contained in:
18
README.md
18
README.md
@@ -1,13 +1,14 @@
|
|||||||
# Assign Me Openclaw (<a href="https://github.com/Space-Banane/shsf"><img src="https://github.com/Space-Banane/Space-Banane/blob/main/WORKS%20WITH%20SHSF%20-%20WHITE%20TEXT.png?raw=true" height="30" alt="Works with SHSF" style="vertical-align: middle;"></a>)
|
# Assign Me Openclaw (<a href="https://github.com/Space-Banane/shsf"><img src="https://github.com/Space-Banane/Space-Banane/blob/main/WORKS%20WITH%20SHSF%20-%20WHITE%20TEXT.png?raw=true" height="30" alt="Works with SHSF" style="vertical-align: middle;"></a>)
|
||||||
|
|
||||||
This project is a serverless function (designed for SHSF) that handles Gitea webhooks for issue and pull request assignments. It notifies an AI agent (via OpenClaw) when they are assigned or unassigned, even if the unassignment event lacks assignee data. The function uses a database to track assignees across events, ensuring accurate notifications.
|
This project is a serverless function (designed for SHSF) that handles Gitea webhooks for issue and pull request assignments and comments. It notifies an AI agent (via OpenClaw) when they are assigned, unassigned, or mentioned in a comment. The function uses a database to track assignees across events, ensuring accurate notifications.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Multiple Assignee Support**: Correctly handles Gitea events with multiple assignees.
|
- **Multiple Assignee Support**: Correctly handles Gitea events with multiple assignees.
|
||||||
- **State Persistence**: Uses a database to track assignees across events, ensuring "unassigned" events have the necessary context even when the payload is sparse.
|
- **State Persistence**: Uses a database to track assignees across events, ensuring "unassigned" events have the necessary context even when the payload is sparse.
|
||||||
- **AI Integration**: Sends formatted Markdown notifications to an OpenClaw-compatible API.
|
- **AI Integration**: Sends formatted Markdown notifications to an OpenClaw-compatible API.
|
||||||
- **Assignment Logic**: Specifically identifies when the configured `AGENT_USERNAME` is involved in an assignment change.
|
- **Assignment & Mention Logic**: Specifically identifies when the configured `AGENT_USERNAME` is involved in an assignment change or mentioned in a comment (e.g., `@youragent`).
|
||||||
|
- **Comment Notifications**: Notifies the agent about new comments on issues or PRs they are assigned to, or when they are tagged.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
@@ -25,14 +26,21 @@ The following environment variables are required for the service to function:
|
|||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
1. **Webhook Reception**: The `main(args)` function receives a POST request from Gitea.
|
1. **Webhook Reception**: The `main(args)` function receives a POST request from Gitea.
|
||||||
2. **Event Filtering**: It filters for `issues` and `pull_request` events with `assigned` or `unassigned` actions.
|
2. **Event Filtering**: It filters for `issues`, `pull_request`, and `issue_comment` events.
|
||||||
3. **Database Lookups**: If an `unassigned` event lacks assignee data, the script retrieves the last known assignees from the database.
|
3. **Database Lookups**: If an `unassigned` or `issue_comment` event lacks assignee data in the payload, the script retrieves the last known assignees from the database.
|
||||||
4. **Agent Notification**: If the configured `AGENT_USERNAME` is found in the assignee list, a detailed Markdown message is constructed and sent to OpenClaw.
|
4. **Agent Notification**: If the configured `AGENT_USERNAME` is found in the assignee list or is mentioned (case-insensitive) in a comment, a detailed Markdown message is constructed and sent to OpenClaw.
|
||||||
5. **Database Sync**: The current state of assignees for the specific issue/PR is updated in the database for future reference.
|
5. **Database Sync**: The current state of assignees for the specific issue/PR is updated in the database for future reference.
|
||||||
|
|
||||||
## Webhook Pointers
|
## Webhook Pointers
|
||||||
Ensure you Webhook is pointed from YOUR and YOUR AGENTS Gitea account to the Function, or have an Gitea Wide Webhook pointed to your Function, the code will filter events that don't belong to your agent.
|
Ensure you Webhook is pointed from YOUR and YOUR AGENTS Gitea account to the Function, or have an Gitea Wide Webhook pointed to your Function, the code will filter events that don't belong to your agent.
|
||||||
|
|
||||||
|
### Webhook Event Checklist
|
||||||
|
To use all features, ensure your Gitea webhook is configured to send:
|
||||||
|
- Issues (Assignment changes)
|
||||||
|
- Pull Request (Assignment changes)
|
||||||
|
- Issue Comment (Created) - *Note: Gitea sends both Issue and PR comments as `issue_comment` events.*
|
||||||
|
- Pull Request Comment (Created) - *Note: Handled as `issue_comment` events in Gitea.*
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
The project consists of:
|
The project consists of:
|
||||||
|
|||||||
99
main.py
99
main.py
@@ -11,8 +11,8 @@ DATABASE_STORAGE = os.getenv("DATABASE_STORAGE_NAME")
|
|||||||
AGENT_USERNAME = os.getenv("AGENT_USERNAME")
|
AGENT_USERNAME = os.getenv("AGENT_USERNAME")
|
||||||
AGENT_PROMPT_FILE = os.getenv("AGENT_PROMPT_FILE")
|
AGENT_PROMPT_FILE = os.getenv("AGENT_PROMPT_FILE")
|
||||||
|
|
||||||
eventsToHandle = ["pull_request", "issues"]
|
eventsToHandle = ["pull_request", "issues", "issue_comment"]
|
||||||
actionsToHandle = ["assigned", "unassigned"]
|
actionsToHandle = ["assigned", "created"]
|
||||||
|
|
||||||
# Template Fields:
|
# Template Fields:
|
||||||
DEFAULT_PROMPT_TEMPLATE = """# GITEA STATUS UPDATE
|
DEFAULT_PROMPT_TEMPLATE = """# GITEA STATUS UPDATE
|
||||||
@@ -31,7 +31,7 @@ The Goal is to solve this issue or PR as quickly and with as little back and for
|
|||||||
|
|
||||||
|
|
||||||
def get_assignee_key(event_type, repository_id, number):
|
def get_assignee_key(event_type, repository_id, number):
|
||||||
if event_type == "issues" or event_type == "issue":
|
if event_type == "issues" or event_type == "issue" or event_type == "issue_comment":
|
||||||
return f"assignees_issue_{repository_id}_{number}"
|
return f"assignees_issue_{repository_id}_{number}"
|
||||||
elif event_type == "pull_request":
|
elif event_type == "pull_request":
|
||||||
return f"assignees_pr_{repository_id}_{number}"
|
return f"assignees_pr_{repository_id}_{number}"
|
||||||
@@ -89,6 +89,11 @@ def fill_template(template, event_object, action_str, type_str):
|
|||||||
|
|
||||||
|
|
||||||
def build_message(event_object, action_str, type_str):
|
def build_message(event_object, action_str, type_str):
|
||||||
|
if type_str == "comment":
|
||||||
|
action_summary = f"There is a new comment on a {event_object['target_type']} you are assigned to in {event_object['repository']} by {event_object['sender']}."
|
||||||
|
else:
|
||||||
|
action_summary = f"You were {action_str} to an {type_str} in {event_object['repository']} by {event_object['sender']}."
|
||||||
|
|
||||||
if AGENT_PROMPT_FILE:
|
if AGENT_PROMPT_FILE:
|
||||||
AGENT_PROMPT_FILE_PATH = "/app/" + AGENT_PROMPT_FILE
|
AGENT_PROMPT_FILE_PATH = "/app/" + AGENT_PROMPT_FILE
|
||||||
|
|
||||||
@@ -98,32 +103,45 @@ def build_message(event_object, action_str, type_str):
|
|||||||
f"Custom prompt file not found at {AGENT_PROMPT_FILE_PATH}. Using default message."
|
f"Custom prompt file not found at {AGENT_PROMPT_FILE_PATH}. Using default message."
|
||||||
)
|
)
|
||||||
message = fill_template(DEFAULT_PROMPT_TEMPLATE, event_object, action_str, type_str)
|
message = fill_template(DEFAULT_PROMPT_TEMPLATE, event_object, action_str, type_str)
|
||||||
|
message = message.replace("You were [action_str] to an [type_str]", action_summary)
|
||||||
return message
|
return message
|
||||||
|
|
||||||
with open(AGENT_PROMPT_FILE_PATH, "r") as f:
|
with open(AGENT_PROMPT_FILE_PATH, "r") as f:
|
||||||
custom_prompt = f.read()
|
custom_prompt = f.read()
|
||||||
message = fill_template(custom_prompt, event_object, action_str, type_str)
|
message = fill_template(custom_prompt, event_object, action_str, type_str)
|
||||||
|
message = message.replace("You were [action_str] to an [type_str]", action_summary)
|
||||||
return message
|
return message
|
||||||
else:
|
else:
|
||||||
print("No custom prompt file specified. Using default message.")
|
print("No custom prompt file specified. Using default message.")
|
||||||
message = fill_template(DEFAULT_PROMPT_TEMPLATE, event_object, action_str, type_str)
|
message = fill_template(DEFAULT_PROMPT_TEMPLATE, event_object, action_str, type_str)
|
||||||
|
message = message.replace("You were [action_str] to an [type_str]", action_summary)
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
|
||||||
def sendToAgent(event_object):
|
def sendToAgent(event_object):
|
||||||
headers = {"x-openclaw-token": OPENCLAW_TOKEN, "Content-Type": "application/json"}
|
headers = {"x-openclaw-token": OPENCLAW_TOKEN, "Content-Type": "application/json"}
|
||||||
action_str = "assigned" if event_object["action"] == "assigned" else "unassigned"
|
|
||||||
type_str = "issue" if event_object["type"] == "issue" else "pull request"
|
|
||||||
|
|
||||||
assignees = event_object.get("assignees", [event_object.get("assignee", "Unknown")])
|
if event_object.get("type") == "issue_comment":
|
||||||
|
action_str = "created"
|
||||||
if AGENT_USERNAME in assignees:
|
type_str = "comment"
|
||||||
message = build_message(event_object, action_str, type_str)
|
mention = f"@{AGENT_USERNAME}".lower()
|
||||||
|
comment_body = event_object.get("comment_body", "").lower()
|
||||||
|
if mention not in comment_body:
|
||||||
|
print(
|
||||||
|
f"Agent {AGENT_USERNAME} was not mentioned in comment body. Skipping notification."
|
||||||
|
)
|
||||||
|
return
|
||||||
else:
|
else:
|
||||||
print(
|
action_str = "assigned"
|
||||||
f"Agent {AGENT_USERNAME} is not among the assignees for this event. Skipping notification."
|
type_str = "issue" if event_object["type"] == "issue" else "pull request"
|
||||||
)
|
assignees = event_object.get("assignees", [event_object.get("assignee", "Unknown")])
|
||||||
return
|
if AGENT_USERNAME not in assignees:
|
||||||
|
print(
|
||||||
|
f"Agent {AGENT_USERNAME} is not among the assignees for this event. Skipping notification."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
message = build_message(event_object, action_str, type_str)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if OPENCLAW_PROXY_AUTH:
|
if OPENCLAW_PROXY_AUTH:
|
||||||
@@ -239,7 +257,7 @@ def main(args):
|
|||||||
"_headers": {"Content-Type": "application/json"},
|
"_headers": {"Content-Type": "application/json"},
|
||||||
}
|
}
|
||||||
|
|
||||||
if action in ["assigned", "unassigned"]:
|
if action == "assigned" and event_type in ["issues", "pull_request"]:
|
||||||
assignee_data = data.get("assignee")
|
assignee_data = data.get("assignee")
|
||||||
assignees_data = data.get("assignees")
|
assignees_data = data.get("assignees")
|
||||||
|
|
||||||
@@ -315,28 +333,12 @@ def main(args):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if event_object:
|
if event_object:
|
||||||
# Use helpers to store/delete assignees
|
# Store assignees for later comment events.
|
||||||
if action == "assigned":
|
if action == "assigned":
|
||||||
set_stored_assignees(
|
set_stored_assignees(
|
||||||
db, event_type, repo_id, event_object["number"], assignees_list
|
db, event_type, repo_id, event_object["number"], assignees_list
|
||||||
)
|
)
|
||||||
|
|
||||||
if action == "unassigned":
|
|
||||||
# If the payload STILL has assignees, it means someone is still assigned
|
|
||||||
# If it's empty (or only contains "Unknown"), delete it.
|
|
||||||
if not assignees_data or len(assignees_data) == 0:
|
|
||||||
delete_stored_assignees(
|
|
||||||
db, event_type, repo_id, event_object["number"]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
set_stored_assignees(
|
|
||||||
db,
|
|
||||||
event_type,
|
|
||||||
repo_id,
|
|
||||||
event_object["number"],
|
|
||||||
assignees_list,
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"--- {event_object['type'].upper()} {action.capitalize()} ---")
|
print(f"--- {event_object['type'].upper()} {action.capitalize()} ---")
|
||||||
print(f"Title: {event_object['title']}")
|
print(f"Title: {event_object['title']}")
|
||||||
print(f"Assignees: {', '.join(event_object['assignees'])}")
|
print(f"Assignees: {', '.join(event_object['assignees'])}")
|
||||||
@@ -344,8 +346,39 @@ def main(args):
|
|||||||
print(f"By: {event_object['sender']}")
|
print(f"By: {event_object['sender']}")
|
||||||
|
|
||||||
# Send to OpenClaw
|
# Send to OpenClaw
|
||||||
# sendToAgent(event_object)
|
if action == "assigned":
|
||||||
print("Not sending event to agent, unassinging is too noisy for now, will only send assigned events")
|
sendToAgent(event_object)
|
||||||
|
else:
|
||||||
|
print(f"Action {action} is not configured to send to agent")
|
||||||
|
|
||||||
|
elif action == "created" and event_type == "issue_comment":
|
||||||
|
comment_data = data.get("comment", {})
|
||||||
|
is_pull = data.get("is_pull", False)
|
||||||
|
target_data = data.get("issue") or data.get("pull_request") or {}
|
||||||
|
|
||||||
|
repository = data.get("repository", {}).get("full_name", "Unknown")
|
||||||
|
repo_id = data.get("repository", {}).get("id")
|
||||||
|
sender = data.get("sender", {}).get("login", "Unknown")
|
||||||
|
issue_number = target_data.get("number")
|
||||||
|
|
||||||
|
event_object = {
|
||||||
|
"type": "issue_comment",
|
||||||
|
"target_type": "pull_request" if is_pull else "issue",
|
||||||
|
"action": action,
|
||||||
|
"repository": repository,
|
||||||
|
"number": issue_number,
|
||||||
|
"title": target_data.get("title", "Unknown"),
|
||||||
|
"sender": sender,
|
||||||
|
"comment_body": comment_data.get("body", ""),
|
||||||
|
"url": comment_data.get("html_url"),
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"--- {event_object['type'].upper()} {action.capitalize()} ---")
|
||||||
|
print(f"Title: {event_object['title']}")
|
||||||
|
print(f"Repo: {event_object['repository']}")
|
||||||
|
print(f"By: {event_object['sender']}")
|
||||||
|
|
||||||
|
sendToAgent(event_object)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"_shsf": "v2",
|
"_shsf": "v2",
|
||||||
|
|||||||
Reference in New Issue
Block a user