Skip to content
Merged
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
11 changes: 11 additions & 0 deletions cards/merge_request.yaml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ body:
- details
- showMore
- showLess
- threadsCollapsed
columns:
- type: Column
verticalContentAlignment: Center
Expand Down Expand Up @@ -58,6 +59,15 @@ body:
- details
- showMore
- showLess
- threadsCollapsed
{% if precalc.discussion_stats and precalc.discussion_stats.threads_unresolved > 0 %}
- type: TextBlock
id: threadsCollapsed
isVisible: {{ collapsed }}
spacing: Small
text: '**Threads** {{ precalc.discussion_stats.threads_resolved }}/{{ precalc.discussion_stats.threads_total }} resolved'
color: Warning
{% endif %}
- type: RichTextBlock
id: showMore
isVisible: {{ collapsed }}
Expand All @@ -72,6 +82,7 @@ body:
- details
- showMore
- showLess
- threadsCollapsed
{% endif %}

- type: Container
Expand Down
1 change: 1 addition & 0 deletions cards/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ def render(
and mri.merge_request_payload.object_attributes.action not in ("close", "merge")
):
icon_color = Teams_Color.WARNING
icon_name = "CommentError"

precalc = {
"path_with_namespace": mri.merge_request_payload.project.path_with_namespace,
Expand Down
14 changes: 14 additions & 0 deletions db.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ class MergeRequestExtraState(BaseModel):
discussion_stats: DiscussionStats | None = None


def has_unresolved_threads(extra_state: MergeRequestExtraState) -> bool:
"""Check if extra_state has unresolved threads."""
return extra_state.discussion_stats is not None and extra_state.discussion_stats.threads_unresolved > 0


class MergeRequestInfos(BaseModel):
merge_request_ref_id: int
merge_request_payload: MergeRequestPayload
Expand All @@ -144,6 +149,15 @@ def compute_mri_fingerprint(mri: MergeRequestInfos) -> str:
return hashlib.sha256(json.dumps(datasource, sort_keys=True, default=str).encode()).hexdigest()


def make_mr_summary(mri: MergeRequestInfos) -> str:
"""Create a summary string for Teams message fallback."""
return (
f"MR {mri.merge_request_payload.object_attributes.state}:"
f" {mri.merge_request_payload.object_attributes.title}\n"
f"on {mri.merge_request_payload.project.path_with_namespace}"
)


class DBHelper:
def __init__(self, database: DatabaseLifecycleHandler):
self.db: DatabaseLifecycleHandler = database
Expand Down
53 changes: 40 additions & 13 deletions periodic_cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from db import MergeRequestInfos
from db import compute_mri_fingerprint
from db import dbh
from db import has_unresolved_threads
from db import make_mr_summary
from gitlab_api import fetch_and_persist_discussion_stats
from webhook.messaging import update_all_messages_transactional

Expand Down Expand Up @@ -44,6 +46,8 @@ async def _process_pending_refreshes() -> int:
head_pipeline_id=row["head_pipeline_id"],
)

had_unresolved_threads = has_unresolved_threads(mri.merge_request_extra_state)

updated_extra_state = await fetch_and_persist_discussion_stats(
merge_request_ref_id=mri.merge_request_ref_id,
project_url=mri.merge_request_payload.project.web_url,
Expand All @@ -53,29 +57,52 @@ async def _process_pending_refreshes() -> int:
if updated_extra_state is not None:
mri.merge_request_extra_state = updated_extra_state

should_be_collapsed: bool = (
mri.merge_request_payload.object_attributes.draft
or mri.merge_request_payload.object_attributes.work_in_progress
or mri.merge_request_payload.object_attributes.state in ("closed", "merged")
)
card = render(mri, collapsed=should_be_collapsed, show_collapsible=should_be_collapsed)
datasource_fingerprint = compute_mri_fingerprint(mri)
summary = (
f"MR {mri.merge_request_payload.object_attributes.state}:"
f" {mri.merge_request_payload.object_attributes.title}\n"
f"on {mri.merge_request_payload.project.path_with_namespace}"
)
now_has_unresolved_threads = has_unresolved_threads(mri.merge_request_extra_state)

is_closing_state = mri.merge_request_payload.object_attributes.state in ("closed", "merged")

# Use GitLab's updated_at from stored payload (not local last_event_at)
# to keep timestamps comparable for OOO detection
payload_updated_at = datetime.datetime.fromisoformat(
mri.merge_request_payload.object_attributes.updated_at.replace(" UTC", "+00:00")
)

if had_unresolved_threads and not now_has_unresolved_threads and not is_closing_state:
datasource_fingerprint = compute_mri_fingerprint(mri)
temp_card = render(mri, collapsed=False, show_collapsible=False)
await update_all_messages_transactional(
mri,
temp_card,
make_mr_summary(mri),
datasource_fingerprint,
payload_updated_at,
"threads-resolved",
schedule_deletion=True,
deletion_delay=datetime.timedelta(seconds=0),
)
await dbh.delete_pending_refresh(mri.merge_request_ref_id)
processed += 1
logger.info(
"pending refresh processed - threads resolved, message re-emitted",
merge_request_ref_id=mri.merge_request_ref_id,
payload_type=row["payload_type"],
fingerprint=datasource_fingerprint[:16],
)
continue

should_be_collapsed: bool = (
mri.merge_request_payload.object_attributes.draft
or mri.merge_request_payload.object_attributes.work_in_progress
or is_closing_state
or now_has_unresolved_threads
)
card = render(mri, collapsed=should_be_collapsed, show_collapsible=should_be_collapsed)
datasource_fingerprint = compute_mri_fingerprint(mri)

messages_updated = await update_all_messages_transactional(
mri,
card,
summary,
make_mr_summary(mri),
datasource_fingerprint,
payload_updated_at,
row["payload_type"],
Expand Down
84 changes: 70 additions & 14 deletions tests/test_card_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,53 +236,109 @@ def find_icon_color(card: dict[str, Any]) -> str | None:
return None


class TestIconColor:
"""Tests for icon color based on MR state and unresolved threads."""
def find_icon_name(card: dict[str, Any]) -> str | None:
"""Find the icon name in the adaptive card body."""
for item in card.get("body", []):
if item.get("type") == "ColumnSet":
for col in item.get("columns", []):
for inner in col.get("items", []):
if inner.get("type") == "Icon":
name = inner.get("name")
return str(name) if name is not None else None
return None

def test_default_icon_color_no_discussion_stats(self):
"""Icon should be accent when no discussion stats."""

class TestIconColorAndName:
"""Tests for icon color and name based on MR state and unresolved threads."""

def test_default_icon_no_discussion_stats(self):
"""Icon should be BranchRequest with accent color when no discussion stats."""
mri = make_mri()
result = render(mri)
assert find_icon_color(result) == "accent"
assert find_icon_name(result) == "BranchRequest"

def test_default_icon_color_no_unresolved_threads(self):
"""Icon should be accent when all threads resolved."""
def test_default_icon_no_unresolved_threads(self):
"""Icon should be BranchRequest with accent color when all threads resolved."""
stats = DiscussionStats(threads_total=3, threads_resolved=3, threads_unresolved=0)
mri = make_mri(discussion_stats=stats)
result = render(mri)
assert find_icon_color(result) == "accent"
assert find_icon_name(result) == "BranchRequest"

def test_warning_icon_color_with_unresolved_threads(self):
"""Icon should be warning (orange) when there are unresolved threads."""
def test_chatbubbles_icon_with_unresolved_threads(self):
"""Icon should be CommentError with warning color when unresolved threads."""
stats = DiscussionStats(threads_total=3, threads_resolved=1, threads_unresolved=2)
mri = make_mri(discussion_stats=stats)
result = render(mri)
assert find_icon_color(result) == "warning"
assert find_icon_name(result) == "CommentError"

def test_closed_mr_keeps_attention_regardless_of_threads(self):
"""Closed MR should keep attention color, not be overridden by thread status."""
"""Closed MR should keep CodeTextOff icon with attention color."""
stats = DiscussionStats(threads_total=3, threads_resolved=1, threads_unresolved=2)
mri = make_mri(action="close", discussion_stats=stats)
result = render(mri)
assert find_icon_color(result) == "attention"
assert find_icon_name(result) == "CodeTextOff"

def test_merged_mr_keeps_good_regardless_of_threads(self):
"""Merged MR should keep good color, not be overridden by thread status."""
"""Merged MR should keep Merge icon with good color."""
stats = DiscussionStats(threads_total=3, threads_resolved=1, threads_unresolved=2)
mri = make_mri(action="merge", discussion_stats=stats)
result = render(mri)
assert find_icon_color(result) == "good"
assert find_icon_name(result) == "Merge"

def test_draft_mr_with_unresolved_threads_shows_warning(self):
"""Draft MR with unresolved threads should show warning color."""
def test_draft_mr_with_unresolved_threads_shows_chatbubbles(self):
"""Draft MR with unresolved threads should show CommentError with warning."""
stats = DiscussionStats(threads_total=2, threads_resolved=0, threads_unresolved=2)
mri = make_mri(draft=True, discussion_stats=stats)
result = render(mri)
assert find_icon_color(result) == "warning"
assert find_icon_name(result) == "CommentError"

def test_draft_mr_without_unresolved_threads_shows_default(self):
"""Draft MR without unresolved threads should show default color."""
def test_draft_mr_without_unresolved_threads_shows_drafts(self):
"""Draft MR without unresolved threads should show Drafts icon with default color."""
stats = DiscussionStats(threads_total=2, threads_resolved=2, threads_unresolved=0)
mri = make_mri(draft=True, discussion_stats=stats)
result = render(mri)
assert find_icon_color(result) == "default"
assert find_icon_name(result) == "Drafts"


def find_thread_count_block(card: dict[str, Any]) -> dict[str, Any] | None:
"""Find the threadsCollapsed TextBlock in the card body."""
for item in card.get("body", []):
if item.get("type") == "TextBlock" and item.get("id") == "threadsCollapsed":
return dict(item)
return None


class TestCollapsedWithThreads:
"""Tests for collapsed state showing thread count."""

def test_collapsed_with_threads_shows_thread_count(self):
"""Collapsed card with unresolved threads should show 'Threads X/Y resolved' on separate line."""
stats = DiscussionStats(threads_total=5, threads_resolved=2, threads_unresolved=3)
mri = make_mri(discussion_stats=stats)
result = render(mri, collapsed=True, show_collapsible=True)
thread_block = find_thread_count_block(result)
assert thread_block is not None
assert "2/5 resolved" in thread_block.get("text", "")
assert thread_block.get("color") == "Warning"

def test_collapsed_without_threads_no_thread_count(self):
"""Collapsed card without unresolved threads should not show thread count block."""
stats = DiscussionStats(threads_total=3, threads_resolved=3, threads_unresolved=0)
mri = make_mri(discussion_stats=stats)
result = render(mri, collapsed=True, show_collapsible=True)
thread_block = find_thread_count_block(result)
assert thread_block is None

def test_collapsed_draft_no_thread_count(self):
"""Collapsed draft without threads should not show thread count block."""
mri = make_mri(draft=True)
result = render(mri, collapsed=True, show_collapsible=True)
thread_block = find_thread_count_block(result)
assert thread_block is None
5 changes: 5 additions & 0 deletions tests/test_integration_webhook_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ async def test_webhook_endpoint_merge_request_open(
merge_request_extra_state=MagicMock(
opener=MagicMock(id=1),
approvers={},
discussion_stats=None,
),
)

Expand Down Expand Up @@ -180,6 +181,7 @@ async def test_webhook_endpoint_merge_request_merge(
merge_request_extra_state=MagicMock(
opener=MagicMock(id=1),
approvers={},
discussion_stats=None,
),
)

Expand Down Expand Up @@ -268,6 +270,7 @@ async def test_webhook_endpoint_invalid_conversation_token(
merge_request_extra_state=MagicMock(
opener=MagicMock(id=1),
approvers={},
discussion_stats=None,
),
)

Expand Down Expand Up @@ -316,6 +319,7 @@ async def test_webhook_endpoint_multiple_conversation_tokens(
merge_request_extra_state=MagicMock(
opener=MagicMock(id=1),
approvers={},
discussion_stats=None,
),
)

Expand Down Expand Up @@ -365,6 +369,7 @@ async def test_webhook_endpoint_activity_api_error(
merge_request_extra_state=MagicMock(
opener=MagicMock(id=1),
approvers={},
discussion_stats=None,
),
)

Expand Down
11 changes: 6 additions & 5 deletions tests/test_merge_request_deletion.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ async def test_merge_close_uses_transactional_update(mock_database, sample_merge
sample_mri = AsyncMock(
merge_request_ref_id=merge_request_ref_id,
merge_request_payload=sample_merge_request_payload,
merge_request_extra_state=AsyncMock(opener=sample_merge_request_payload.user),
merge_request_extra_state=AsyncMock(opener=sample_merge_request_payload.user, discussion_stats=None),
)

with (
Expand Down Expand Up @@ -82,7 +82,7 @@ async def test_merge_close_transactional_rollback_on_failure(mock_database, samp
sample_mri = AsyncMock(
merge_request_ref_id=merge_request_ref_id,
merge_request_payload=sample_merge_request_payload,
merge_request_extra_state=AsyncMock(opener=sample_merge_request_payload.user),
merge_request_extra_state=AsyncMock(opener=sample_merge_request_payload.user, discussion_stats=None),
)

with (
Expand Down Expand Up @@ -179,7 +179,7 @@ async def test_multiple_messages_all_deleted_transactionally(mock_database, samp
sample_mri = AsyncMock(
merge_request_ref_id=merge_request_ref_id,
merge_request_payload=sample_merge_request_payload,
merge_request_extra_state=AsyncMock(opener=sample_merge_request_payload.user),
merge_request_extra_state=AsyncMock(opener=sample_merge_request_payload.user, discussion_stats=None),
)

with (
Expand Down Expand Up @@ -223,7 +223,7 @@ async def test_draft_to_ready_uses_transaction(mock_database, sample_merge_reque
sample_merge_request_payload.changes = {"draft": {"previous": True, "current": False}}

connection.fetch.return_value = [{"merge_request_message_ref_id": 1, "message_id": uuid.uuid4()}]
connection.fetchrow.return_value = {"merge_request_extra_state": {}}
connection.fetchrow.return_value = {"merge_request_extra_state": MagicMock(discussion_stats=None)}

mock_ref = MRMessRef(
merge_request_message_ref_id=1,
Expand All @@ -237,6 +237,7 @@ async def test_draft_to_ready_uses_transaction(mock_database, sample_merge_reque
merge_request_extra_state=AsyncMock(
opener=sample_merge_request_payload.user,
approvers={},
discussion_stats=None,
),
)

Expand Down Expand Up @@ -286,7 +287,7 @@ async def test_merge_with_no_messages_doesnt_delete_mr_ref(mock_database, sample
sample_mri = AsyncMock(
merge_request_ref_id=100,
merge_request_payload=sample_merge_request_payload,
merge_request_extra_state=AsyncMock(opener=sample_merge_request_payload.user),
merge_request_extra_state=AsyncMock(opener=sample_merge_request_payload.user, discussion_stats=None),
)

with (
Expand Down
Loading