Skip to content
Open
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
31 changes: 27 additions & 4 deletions src/asana/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,23 @@ def create_attachment_on_task(
attachment_content: str,
attachment_name: str,
attachment_type: Optional[str] = None,
) -> None:
self.asana_api_client.attachments.create_on_task(
) -> str:
"""
Creates an attachment on a task and returns the attachment ID.
"""
response = self.asana_api_client.attachments.create_on_task(
task_id, attachment_content, attachment_name, attachment_type
)
return response["gid"]

def delete_attachment(self, attachment_id: str) -> None:
"""
Deletes an attachment by its ID.
"""
validate_object_id(
attachment_id, "AsanaClient.delete_attachment requires an attachment_id"
)
self.asana_api_client.attachments.delete(attachment_id)


def get_task(task_id: str) -> dict:
Expand Down Expand Up @@ -212,7 +225,17 @@ def create_attachment_on_task(
attachment_content: str,
attachment_name: str,
attachment_type: Optional[str] = None,
) -> None:
AsanaClient.singleton().create_attachment_on_task(
) -> str:
"""
Creates an attachment on a task and returns the attachment ID.
"""
return AsanaClient.singleton().create_attachment_on_task(
task_id, attachment_content, attachment_name, attachment_type
)


def delete_attachment(attachment_id: str) -> None:
"""
Deletes an attachment by its ID.
"""
AsanaClient.singleton().delete_attachment(attachment_id)
5 changes: 3 additions & 2 deletions src/asana/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,6 @@ def upsert_github_comment_to_task(comment: Comment, task_id: str):
if asana_comment_id is None:
logger.info(f"Adding comment {github_comment_id} to task {task_id}")

asana_helpers.create_attachments(comment.body_html(), task_id)

asana_comment_id = asana_client.add_comment(
task_id, asana_helpers.asana_comment_from_github_comment(comment)
)
Expand All @@ -89,10 +87,13 @@ def upsert_github_comment_to_task(comment: Comment, task_id: str):
logger.info(
f"Comment {github_comment_id} already synced to task {task_id}. Updating."
)

asana_client.update_comment(
asana_comment_id, asana_helpers.asana_comment_from_github_comment(comment)
)

asana_helpers.sync_attachments(comment.body_html(), task_id, github_comment_id)

# Optionally add followers from the comment body
followers = asana_helpers.task_followers_from_comment(comment)
if len(followers) > 0:
Expand Down
115 changes: 101 additions & 14 deletions src/asana/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from src.markdown_parser import convert_github_markdown_to_asana_xml

AttachmentData = collections.namedtuple(
"AttachmentData", "file_name file_url file_type"
"AttachmentData", "file_name file_url file_type original_asset_id"
)

StatusReason = collections.namedtuple("StatusReason", "is_complete reason")
Expand Down Expand Up @@ -272,6 +272,30 @@ def _get_file_extension_from_url(url: str) -> str:
return "." + url.split("?")[0].split(".")[-1]


def _get_original_asset_id_from_url(url: str) -> str:
"""
Extract original asset ID from a URL.
For GitHub asset URLs, this extracts the UUID:
- https://api.github.com/assets/long-unique-uuid.png?token=123321
- https://github.com/user-attachments/assets/uuid-here
For non-GitHub URLs, this returns the entire URL as the asset ID.
"""
if "api.github.com/assets/" in url:
# Extract the UUID from URLs like https://api.github.com/assets/long-unique-uuid.png?token=123321
parts = url.split("/assets/")

asset_id = parts[1].split(".")[0] # Remove file extension and query params
return asset_id
elif "github.com/user-attachments/assets/" in url:
# Extract the UUID from URLs like https://github.com/user-attachments/assets/uuid-here
parts = url.split("/assets/")

return parts[1].split("/")[0] # Take the first part after /assets/

# For non-GitHub URLs, use the entire URL as the asset ID
return url


def _extract_attachments(body_html: str) -> List[AttachmentData]:
"""
Finds, but does not replace, all the image/video attachment URLs in the body_html. Handles:
Expand Down Expand Up @@ -311,29 +335,92 @@ def _extract_attachments(body_html: str) -> List[AttachmentData]:
else:
file_name = file_title + file_ext

# Extract original asset ID
original_asset_id = _get_original_asset_id_from_url(file_url_str)

attachments.append(
AttachmentData(
file_name=file_name, file_url=file_url_str, file_type=file_type
file_name=file_name,
file_url=file_url_str,
file_type=file_type,
original_asset_id=original_asset_id,
)
)

return attachments


def create_attachments(body_html: str, task_id: str) -> None:
attachments = _extract_attachments(body_html)
for attachment in attachments:
def sync_attachments(body_html: str, task_id: str, github_node_id: str) -> None:
"""
Syncs attachments between GitHub content and Asana task, tracking mappings in DynamoDB.
This will:
1. Extract current attachments from the GitHub HTML
2. Compare with existing tracked attachments for this GitHub node
3. Delete attachments that are no longer present
4. Create new attachments that aren't tracked
5. Update the DynamoDB mappings
"""
current_attachments = _extract_attachments(body_html)
existing_attachments = dynamodb_client.get_attachments_for_github_node(
github_node_id
)

# Create sets for comparison
current_asset_ids = {
att.original_asset_id for att in current_attachments if att.original_asset_id
}
existing_asset_ids = set(existing_attachments.keys())

# Find attachments to delete (exist in DynamoDB but not in current content)
assets_to_delete = existing_asset_ids - current_asset_ids
for asset_id in assets_to_delete:
asana_attachment_id = existing_attachments[asset_id]
try:
with urllib.request.urlopen(attachment.file_url) as f:
attachment_contents = f.read()
asana_client.create_attachment_on_task(
task_id,
attachment_contents,
attachment.file_name,
attachment.file_type,
asana_client.delete_attachment(asana_attachment_id)
logger.info(
f"Deleted attachment {asana_attachment_id} for asset {asset_id}"
)
except Exception as e:
logger.warning(f"Failed to delete attachment {asana_attachment_id}: {e}")

# Find attachments to create (exist in current content but not in DynamoDB)
to_be_created_asset_ids = current_asset_ids - existing_asset_ids

# Build new attachments mapping from current content only
new_attachments_map = {}

for attachment in current_attachments:
if attachment.original_asset_id in to_be_created_asset_ids:
# Create new attachment
try:
with urllib.request.urlopen(attachment.file_url) as f:
attachment_contents = f.read()
asana_attachment_id = asana_client.create_attachment_on_task(
task_id,
attachment_contents,
attachment.file_name,
attachment.file_type,
)
new_attachments_map[
attachment.original_asset_id
] = asana_attachment_id
logger.info(
f"Created attachment {asana_attachment_id} for asset {attachment.original_asset_id}"
)
except Exception as e:
logger.warning(
f"Attachment creation failed for {attachment.original_asset_id}: {e}"
)
except Exception:
logger.warning("Attachment creation failed. Creating task comment anyway.")
elif attachment.original_asset_id in existing_attachments:
# Keep existing attachment
new_attachments_map[attachment.original_asset_id] = existing_attachments[
attachment.original_asset_id
]

# Update the attachments mapping in DynamoDB
dynamodb_client.update_attachments_for_github_node(
github_node_id, new_attachments_map
)


_review_action_to_text_map: Dict[ReviewState, str] = {
Expand Down
68 changes: 66 additions & 2 deletions src/aws/dynamodb_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import boto3 # type: ignore
from typing import TypedDict, List, Optional, Tuple
import json
from typing import TypedDict, List, Optional, Tuple, Dict

from src.config import OBJECTS_TABLE, AWS_REGION
from src.logger import logger
Expand Down Expand Up @@ -78,7 +79,7 @@ def get_asana_id_from_github_node_id(self, gh_node_id: str) -> Optional[str]:
response = self.client.get_item(
TableName=OBJECTS_TABLE, Key={"github-node": {"S": gh_node_id}}
)
if "Item" in response:
if "Item" in response and "asana-id" in response["Item"]:
return response["Item"]["asana-id"]["S"]
else:
logger.warning(
Expand Down Expand Up @@ -114,6 +115,50 @@ def bulk_insert_github_node_to_asana_id_mapping(
]
return self.bulk_insert_items_in_batches(OBJECTS_TABLE, items)

# ATTACHMENT METHODS

def get_attachments_for_github_node(self, gh_node_id: str) -> Dict[str, str]:
"""
Retrieves the attachment mappings (original asset ID -> Asana attachment ID) for a GitHub node.
Returns an empty dict if no attachments are found.
"""
response = self.client.get_item(
TableName=OBJECTS_TABLE, Key={"github-node": {"S": gh_node_id}}
)
if "Item" in response and "attachments" in response["Item"]:
try:
attachments_json = response["Item"]["attachments"]["S"]
return json.loads(attachments_json)
except (json.JSONDecodeError, KeyError) as e:
logger.warning(f"Failed to parse attachments for {gh_node_id}: {e}")
return {}
return {}

def update_attachments_for_github_node(
self, gh_node_id: str, attachments: Dict[str, str]
):
"""
Updates the attachment mappings for a GitHub node. Creates the record if it doesn't exist.
"""
attachments_json = json.dumps(attachments)

response = self.client.update_item(
TableName=OBJECTS_TABLE,
Key={"github-node": {"S": gh_node_id}},
UpdateExpression="SET attachments = :attachments",
ExpressionAttributeValues={":attachments": {"S": attachments_json}},
ReturnValues="UPDATED_NEW",
)

if response["ResponseMetadata"]["HTTPStatusCode"] == 200:
logger.info(
f"Updated attachments for {gh_node_id}: {len(attachments)} mappings"
)
else:
logger.warning(
f"Error updating attachments for {gh_node_id}, response {response}"
)

@staticmethod
def _create_client():
return boto3.client("dynamodb", region_name=AWS_REGION)
Expand Down Expand Up @@ -152,3 +197,22 @@ def bulk_insert_github_node_to_asana_id_mapping(
DynamoDbClient.singleton().bulk_insert_github_node_to_asana_id_mapping(
gh_and_asana_ids
)


# Attachment convenience functions


def get_attachments_for_github_node(gh_node_id: str) -> Dict[str, str]:
"""
Retrieves the attachment mappings (original asset ID -> Asana attachment ID) for a GitHub node.
"""
return DynamoDbClient.singleton().get_attachments_for_github_node(gh_node_id)


def update_attachments_for_github_node(gh_node_id: str, attachments: Dict[str, str]):
"""
Updates the attachment mappings for a GitHub node.
"""
return DynamoDbClient.singleton().update_attachments_for_github_node(
gh_node_id, attachments
)
9 changes: 8 additions & 1 deletion src/github/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,19 @@ def upsert_pull_request(pull_request: PullRequest):

logger.info(f"Task created for pull request {pull_request_id}: {task_id}")
dynamodb_client.insert_github_node_to_asana_id_mapping(pull_request_id, task_id)
asana_helpers.create_attachments(pull_request.body_html(), task_id)
asana_helpers.sync_attachments(
pull_request.body_html(), task_id, pull_request_id
)
_add_asana_task_to_pull_request(pull_request, task_id)
else:
logger.info(
f"Task found for pull request {pull_request_id}, updating task {task_id}"
)
# Sync attachments when updating PR as well
asana_helpers.sync_attachments(
pull_request.body_html(), task_id, pull_request_id
)

asana_controller.update_task(
pull_request,
task_id,
Expand Down
Loading