Skip to content

Commit 9bcaf06

Browse files
committed
Implement dynamodb based attachments update
1 parent 6d7f286 commit 9bcaf06

8 files changed

Lines changed: 412 additions & 24 deletions

File tree

src/asana/client.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,23 @@ def create_attachment_on_task(
140140
attachment_content: str,
141141
attachment_name: str,
142142
attachment_type: Optional[str] = None,
143-
) -> None:
144-
self.asana_api_client.attachments.create_on_task(
143+
) -> str:
144+
"""
145+
Creates an attachment on a task and returns the attachment ID.
146+
"""
147+
response = self.asana_api_client.attachments.create_on_task(
145148
task_id, attachment_content, attachment_name, attachment_type
146149
)
150+
return response["gid"]
151+
152+
def delete_attachment(self, attachment_id: str) -> None:
153+
"""
154+
Deletes an attachment by its ID.
155+
"""
156+
validate_object_id(
157+
attachment_id, "AsanaClient.delete_attachment requires an attachment_id"
158+
)
159+
self.asana_api_client.attachments.delete(attachment_id)
147160

148161

149162
def get_task(task_id: str) -> dict:
@@ -212,7 +225,17 @@ def create_attachment_on_task(
212225
attachment_content: str,
213226
attachment_name: str,
214227
attachment_type: Optional[str] = None,
215-
) -> None:
216-
AsanaClient.singleton().create_attachment_on_task(
228+
) -> str:
229+
"""
230+
Creates an attachment on a task and returns the attachment ID.
231+
"""
232+
return AsanaClient.singleton().create_attachment_on_task(
217233
task_id, attachment_content, attachment_name, attachment_type
218234
)
235+
236+
237+
def delete_attachment(attachment_id: str) -> None:
238+
"""
239+
Deletes an attachment by its ID.
240+
"""
241+
AsanaClient.singleton().delete_attachment(attachment_id)

src/asana/controller.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def upsert_github_comment_to_task(comment: Comment, task_id: str):
7777
if asana_comment_id is None:
7878
logger.info(f"Adding comment {github_comment_id} to task {task_id}")
7979

80-
asana_helpers.create_attachments(comment.body_html(), task_id)
80+
asana_helpers.sync_attachments(comment.body_html(), task_id, github_comment_id)
8181

8282
asana_comment_id = asana_client.add_comment(
8383
task_id, asana_helpers.asana_comment_from_github_comment(comment)
@@ -89,6 +89,10 @@ def upsert_github_comment_to_task(comment: Comment, task_id: str):
8989
logger.info(
9090
f"Comment {github_comment_id} already synced to task {task_id}. Updating."
9191
)
92+
93+
# Sync attachments when updating comment as well
94+
asana_helpers.sync_attachments(comment.body_html(), task_id, github_comment_id)
95+
9296
asana_client.update_comment(
9397
asana_comment_id, asana_helpers.asana_comment_from_github_comment(comment)
9498
)

src/asana/helpers.py

Lines changed: 98 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from src.markdown_parser import convert_github_markdown_to_asana_xml
2626

2727
AttachmentData = collections.namedtuple(
28-
"AttachmentData", "file_name file_url file_type"
28+
"AttachmentData", "file_name file_url file_type original_asset_id"
2929
)
3030

3131
StatusReason = collections.namedtuple("StatusReason", "is_complete reason")
@@ -272,6 +272,31 @@ def _get_file_extension_from_url(url: str) -> str:
272272
return "." + url.split("?")[0].split(".")[-1]
273273

274274

275+
def _get_original_asset_id_from_url(url: str) -> Optional[str]:
276+
"""
277+
Extract original asset ID from a URL.
278+
For GitHub asset URLs, this extracts the UUID:
279+
- https://api.github.com/assets/long-unique-uuid.png?token=123321
280+
- https://github.com/user-attachments/assets/uuid-here
281+
For non-GitHub URLs, this returns the entire URL as the asset ID.
282+
"""
283+
if "api.github.com/assets/" in url:
284+
# Extract the UUID from URLs like https://api.github.com/assets/long-unique-uuid.png?token=123321
285+
parts = url.split("/assets/")
286+
if len(parts) >= 2:
287+
asset_part = parts[1].split("?")[0] # Remove query parameters
288+
asset_id = asset_part.split(".")[0] # Remove file extension
289+
return asset_id
290+
elif "github.com/user-attachments/assets/" in url:
291+
# Extract the UUID from URLs like https://github.com/user-attachments/assets/uuid-here
292+
parts = url.split("/assets/")
293+
if len(parts) >= 2:
294+
return parts[1].split("/")[0] # Take the first part after /assets/
295+
296+
# For non-GitHub URLs, use the entire URL as the asset ID
297+
return url
298+
299+
275300
def _extract_attachments(body_html: str) -> List[AttachmentData]:
276301
"""
277302
Finds, but does not replace, all the image/video attachment URLs in the body_html. Handles:
@@ -311,29 +336,88 @@ def _extract_attachments(body_html: str) -> List[AttachmentData]:
311336
else:
312337
file_name = file_title + file_ext
313338

339+
# Extract original asset ID
340+
original_asset_id = _get_original_asset_id_from_url(file_url_str)
341+
314342
attachments.append(
315343
AttachmentData(
316-
file_name=file_name, file_url=file_url_str, file_type=file_type
344+
file_name=file_name,
345+
file_url=file_url_str,
346+
file_type=file_type,
347+
original_asset_id=original_asset_id,
317348
)
318349
)
319350

320351
return attachments
321352

322353

323-
def create_attachments(body_html: str, task_id: str) -> None:
324-
attachments = _extract_attachments(body_html)
325-
for attachment in attachments:
354+
def sync_attachments(body_html: str, task_id: str, github_node_id: str) -> None:
355+
"""
356+
Syncs attachments between GitHub content and Asana task, tracking mappings in DynamoDB.
357+
This will:
358+
1. Extract current attachments from the GitHub HTML
359+
2. Compare with existing tracked attachments for this GitHub node
360+
3. Delete attachments that are no longer present
361+
4. Create new attachments that aren't tracked
362+
5. Update the DynamoDB mappings
363+
"""
364+
current_attachments = _extract_attachments(body_html)
365+
existing_attachments = dynamodb_client.get_attachments_for_github_node(
366+
github_node_id
367+
)
368+
369+
# Create sets for comparison
370+
current_asset_ids = {
371+
att.original_asset_id for att in current_attachments if att.original_asset_id
372+
}
373+
existing_asset_ids = set(existing_attachments.keys())
374+
375+
# Find attachments to delete (exist in DynamoDB but not in current content)
376+
assets_to_delete = existing_asset_ids - current_asset_ids
377+
for asset_id in assets_to_delete:
378+
asana_attachment_id = existing_attachments[asset_id]
326379
try:
327-
with urllib.request.urlopen(attachment.file_url) as f:
328-
attachment_contents = f.read()
329-
asana_client.create_attachment_on_task(
330-
task_id,
331-
attachment_contents,
332-
attachment.file_name,
333-
attachment.file_type,
380+
asana_client.delete_attachment(asana_attachment_id)
381+
logger.info(
382+
f"Deleted attachment {asana_attachment_id} for asset {asset_id}"
383+
)
384+
except Exception as e:
385+
logger.warning(f"Failed to delete attachment {asana_attachment_id}: {e}")
386+
387+
# Find attachments to create (exist in current content but not in DynamoDB)
388+
assets_to_create = current_asset_ids - existing_asset_ids
389+
390+
# Build new attachments mapping from current content only
391+
new_attachments = {}
392+
393+
for attachment in current_attachments:
394+
if attachment.original_asset_id in assets_to_create:
395+
# Create new attachment
396+
try:
397+
with urllib.request.urlopen(attachment.file_url) as f:
398+
attachment_contents = f.read()
399+
asana_attachment_id = asana_client.create_attachment_on_task(
400+
task_id,
401+
attachment_contents,
402+
attachment.file_name,
403+
attachment.file_type,
404+
)
405+
new_attachments[attachment.original_asset_id] = asana_attachment_id
406+
logger.info(
407+
f"Created attachment {asana_attachment_id} for asset {attachment.original_asset_id}"
408+
)
409+
except Exception as e:
410+
logger.warning(
411+
f"Attachment creation failed for {attachment.original_asset_id}: {e}"
334412
)
335-
except Exception:
336-
logger.warning("Attachment creation failed. Creating task comment anyway.")
413+
elif attachment.original_asset_id in existing_attachments:
414+
# Keep existing attachment
415+
new_attachments[attachment.original_asset_id] = existing_attachments[
416+
attachment.original_asset_id
417+
]
418+
419+
# Update the attachments mapping in DynamoDB
420+
dynamodb_client.update_attachments_for_github_node(github_node_id, new_attachments)
337421

338422

339423
_review_action_to_text_map: Dict[ReviewState, str] = {

src/aws/dynamodb_client.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import boto3 # type: ignore
2-
from typing import TypedDict, List, Optional, Tuple
2+
import json
3+
from typing import TypedDict, List, Optional, Tuple, Dict
34

45
from src.config import OBJECTS_TABLE, AWS_REGION
56
from src.logger import logger
@@ -78,7 +79,7 @@ def get_asana_id_from_github_node_id(self, gh_node_id: str) -> Optional[str]:
7879
response = self.client.get_item(
7980
TableName=OBJECTS_TABLE, Key={"github-node": {"S": gh_node_id}}
8081
)
81-
if "Item" in response:
82+
if "Item" in response and "asana-id" in response["Item"]:
8283
return response["Item"]["asana-id"]["S"]
8384
else:
8485
logger.warning(
@@ -114,6 +115,50 @@ def bulk_insert_github_node_to_asana_id_mapping(
114115
]
115116
return self.bulk_insert_items_in_batches(OBJECTS_TABLE, items)
116117

118+
# ATTACHMENT METHODS
119+
120+
def get_attachments_for_github_node(self, gh_node_id: str) -> Dict[str, str]:
121+
"""
122+
Retrieves the attachment mappings (original asset ID -> Asana attachment ID) for a GitHub node.
123+
Returns an empty dict if no attachments are found.
124+
"""
125+
response = self.client.get_item(
126+
TableName=OBJECTS_TABLE, Key={"github-node": {"S": gh_node_id}}
127+
)
128+
if "Item" in response and "attachments" in response["Item"]:
129+
try:
130+
attachments_json = response["Item"]["attachments"]["S"]
131+
return json.loads(attachments_json)
132+
except (json.JSONDecodeError, KeyError) as e:
133+
logger.warning(f"Failed to parse attachments for {gh_node_id}: {e}")
134+
return {}
135+
return {}
136+
137+
def update_attachments_for_github_node(
138+
self, gh_node_id: str, attachments: Dict[str, str]
139+
):
140+
"""
141+
Updates the attachment mappings for a GitHub node. Creates the record if it doesn't exist.
142+
"""
143+
attachments_json = json.dumps(attachments)
144+
145+
response = self.client.update_item(
146+
TableName=OBJECTS_TABLE,
147+
Key={"github-node": {"S": gh_node_id}},
148+
UpdateExpression="SET attachments = :attachments",
149+
ExpressionAttributeValues={":attachments": {"S": attachments_json}},
150+
ReturnValues="UPDATED_NEW",
151+
)
152+
153+
if response["ResponseMetadata"]["HTTPStatusCode"] == 200:
154+
logger.info(
155+
f"Updated attachments for {gh_node_id}: {len(attachments)} mappings"
156+
)
157+
else:
158+
logger.warning(
159+
f"Error updating attachments for {gh_node_id}, response {response}"
160+
)
161+
117162
@staticmethod
118163
def _create_client():
119164
return boto3.client("dynamodb", region_name=AWS_REGION)
@@ -152,3 +197,22 @@ def bulk_insert_github_node_to_asana_id_mapping(
152197
DynamoDbClient.singleton().bulk_insert_github_node_to_asana_id_mapping(
153198
gh_and_asana_ids
154199
)
200+
201+
202+
# Attachment convenience functions
203+
204+
205+
def get_attachments_for_github_node(gh_node_id: str) -> Dict[str, str]:
206+
"""
207+
Retrieves the attachment mappings (original asset ID -> Asana attachment ID) for a GitHub node.
208+
"""
209+
return DynamoDbClient.singleton().get_attachments_for_github_node(gh_node_id)
210+
211+
212+
def update_attachments_for_github_node(gh_node_id: str, attachments: Dict[str, str]):
213+
"""
214+
Updates the attachment mappings for a GitHub node.
215+
"""
216+
return DynamoDbClient.singleton().update_attachments_for_github_node(
217+
gh_node_id, attachments
218+
)

src/github/controller.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,19 @@ def upsert_pull_request(pull_request: PullRequest):
2222

2323
logger.info(f"Task created for pull request {pull_request_id}: {task_id}")
2424
dynamodb_client.insert_github_node_to_asana_id_mapping(pull_request_id, task_id)
25-
asana_helpers.create_attachments(pull_request.body_html(), task_id)
25+
asana_helpers.sync_attachments(
26+
pull_request.body_html(), task_id, pull_request_id
27+
)
2628
_add_asana_task_to_pull_request(pull_request, task_id)
2729
else:
2830
logger.info(
2931
f"Task found for pull request {pull_request_id}, updating task {task_id}"
3032
)
33+
# Sync attachments when updating PR as well
34+
asana_helpers.sync_attachments(
35+
pull_request.body_html(), task_id, pull_request_id
36+
)
37+
3138
asana_controller.update_task(
3239
pull_request,
3340
task_id,

0 commit comments

Comments
 (0)