|
| 1 | +import logging |
| 2 | +from typing import Any |
| 3 | + |
| 4 | +from django.db.models import Q |
| 5 | + |
| 6 | +from features.models import Feature, FeatureState |
| 7 | +from integrations.gitlab.constants import ( |
| 8 | + GITLAB_TAG_COLOUR, |
| 9 | + GitLabEventType, |
| 10 | + GitLabTag, |
| 11 | + gitlab_tag_description, |
| 12 | +) |
| 13 | +from integrations.gitlab.mappers import map_feature_states_to_dicts |
| 14 | +from integrations.gitlab.models import GitLabConfiguration |
| 15 | +from projects.tags.models import Tag, TagType |
| 16 | + |
| 17 | +logger = logging.getLogger(__name__) |
| 18 | + |
| 19 | +_tag_by_event_type: dict[str, dict[str, GitLabTag | None]] = { |
| 20 | + "merge_request": { |
| 21 | + "close": GitLabTag.MR_CLOSED, |
| 22 | + "merge": GitLabTag.MR_MERGED, |
| 23 | + "open": GitLabTag.MR_OPEN, |
| 24 | + "reopen": GitLabTag.MR_OPEN, |
| 25 | + "update": None, |
| 26 | + }, |
| 27 | + "issue": { |
| 28 | + "close": GitLabTag.ISSUE_CLOSED, |
| 29 | + "open": GitLabTag.ISSUE_OPEN, |
| 30 | + "reopen": GitLabTag.ISSUE_OPEN, |
| 31 | + }, |
| 32 | +} |
| 33 | + |
| 34 | + |
| 35 | +def get_tag_for_event( |
| 36 | + event_type: str, |
| 37 | + action: str, |
| 38 | + metadata: dict[str, Any], |
| 39 | +) -> GitLabTag | None: |
| 40 | + """Return the tag for a GitLab webhook event, or None if no tag change is needed.""" |
| 41 | + if event_type == "merge_request" and action == "update": |
| 42 | + if metadata.get("draft", False): |
| 43 | + return GitLabTag.MR_DRAFT |
| 44 | + return None |
| 45 | + |
| 46 | + event_actions = _tag_by_event_type.get(event_type, {}) |
| 47 | + return event_actions.get(action) |
| 48 | + |
| 49 | + |
| 50 | +def tag_feature_per_gitlab_event( |
| 51 | + event_type: str, |
| 52 | + action: str, |
| 53 | + metadata: dict[str, Any], |
| 54 | + project_path: str, |
| 55 | +) -> None: |
| 56 | + """Apply a tag to a feature based on a GitLab webhook event.""" |
| 57 | + web_url = metadata.get("web_url", "") |
| 58 | + |
| 59 | + # GitLab webhooks send /-/issues/N but stored URL might be /-/work_items/N |
| 60 | + url_variants = [web_url] |
| 61 | + if "/-/issues/" in web_url: |
| 62 | + url_variants.append(web_url.replace("/-/issues/", "/-/work_items/")) |
| 63 | + elif "/-/work_items/" in web_url: |
| 64 | + url_variants.append(web_url.replace("/-/work_items/", "/-/issues/")) |
| 65 | + |
| 66 | + feature = None |
| 67 | + for url in url_variants: |
| 68 | + feature = Feature.objects.filter( |
| 69 | + Q(external_resources__type="GITLAB_MR") |
| 70 | + | Q(external_resources__type="GITLAB_ISSUE"), |
| 71 | + external_resources__url=url, |
| 72 | + ).first() |
| 73 | + if feature: |
| 74 | + break |
| 75 | + |
| 76 | + if not feature: |
| 77 | + return |
| 78 | + |
| 79 | + try: |
| 80 | + gitlab_config = GitLabConfiguration.objects.get( |
| 81 | + project=feature.project, |
| 82 | + project_name=project_path, |
| 83 | + deleted_at__isnull=True, |
| 84 | + ) |
| 85 | + except GitLabConfiguration.DoesNotExist: |
| 86 | + return |
| 87 | + |
| 88 | + if not gitlab_config.tagging_enabled: |
| 89 | + return |
| 90 | + |
| 91 | + tag_enum = get_tag_for_event(event_type, action, metadata) |
| 92 | + if tag_enum is None: |
| 93 | + return |
| 94 | + |
| 95 | + gitlab_tag, _ = Tag.objects.get_or_create( |
| 96 | + color=GITLAB_TAG_COLOUR, |
| 97 | + description=gitlab_tag_description[tag_enum.value], |
| 98 | + label=tag_enum.value, |
| 99 | + project=feature.project, |
| 100 | + is_system_tag=True, |
| 101 | + type=TagType.GITLAB.value, |
| 102 | + ) |
| 103 | + |
| 104 | + tag_label_pattern = "Issue" if event_type == "issue" else "MR" |
| 105 | + feature.tags.remove( |
| 106 | + *feature.tags.filter( |
| 107 | + Q(type=TagType.GITLAB.value) & Q(label__startswith=tag_label_pattern) |
| 108 | + ) |
| 109 | + ) |
| 110 | + feature.tags.add(gitlab_tag) |
| 111 | + feature.save() |
| 112 | + |
| 113 | + |
| 114 | +def handle_gitlab_webhook_event(event_type: str, payload: dict[str, Any]) -> None: |
| 115 | + """Process a GitLab webhook payload and apply tags.""" |
| 116 | + attrs = payload.get("object_attributes", {}) |
| 117 | + action = attrs.get("action", "") |
| 118 | + project_path = payload.get("project", {}).get("path_with_namespace", "") |
| 119 | + |
| 120 | + metadata: dict[str, Any] = {"web_url": attrs.get("url", "")} |
| 121 | + if event_type == "merge_request": |
| 122 | + metadata["draft"] = attrs.get("work_in_progress", False) |
| 123 | + metadata["merged"] = attrs.get("state") == "merged" |
| 124 | + |
| 125 | + tag_feature_per_gitlab_event(event_type, action, metadata, project_path) |
| 126 | + |
| 127 | + |
| 128 | +def dispatch_gitlab_comment( |
| 129 | + project_id: int, |
| 130 | + event_type: str, |
| 131 | + feature: Feature, |
| 132 | + feature_states: list[FeatureState] | None = None, |
| 133 | + url: str | None = None, |
| 134 | + segment_name: str | None = None, |
| 135 | +) -> None: |
| 136 | + """Dispatch an async task to post a comment to linked GitLab resources. |
| 137 | +
|
| 138 | + Does NOT pass credentials through the task queue — only the project_id. |
| 139 | + The task handler fetches the GitLabConfiguration from the DB. |
| 140 | + """ |
| 141 | + from integrations.gitlab.tasks import post_gitlab_comment |
| 142 | + |
| 143 | + feature_states_data = ( |
| 144 | + map_feature_states_to_dicts(feature_states, event_type) |
| 145 | + if feature_states |
| 146 | + else [] |
| 147 | + ) |
| 148 | + |
| 149 | + post_gitlab_comment.delay( |
| 150 | + kwargs={ |
| 151 | + "project_id": project_id, |
| 152 | + "feature_id": feature.id, |
| 153 | + "feature_name": feature.name, |
| 154 | + "event_type": event_type, |
| 155 | + "feature_states": feature_states_data, |
| 156 | + "url": url if event_type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value else None, |
| 157 | + "segment_name": segment_name, |
| 158 | + }, |
| 159 | + ) |
0 commit comments