Skip to content

Commit

Permalink
feat: manage Project and Group Labels via gitlabform (#751)
Browse files Browse the repository at this point in the history
feat: manage Project and Group Labels via gitlabform

---------

Signed-off-by: Tim Knight <tim.knight1@engineering.digital.dwp.gov.uk>
  • Loading branch information
TimKnight-DWP committed May 15, 2024
1 parent a362d8e commit f1be42b
Show file tree
Hide file tree
Showing 9 changed files with 516 additions and 0 deletions.
66 changes: 66 additions & 0 deletions docs/reference/labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Labels

This section purpose is to manage labels both [project](https://docs.gitlab.com/ee/api/labels.html) and [group](https://docs.gitlab.com/ee/api/group_labels.html)

## Project Labels

The keys name are as in the endpoints described in the [GitLab Labels API docs](https://docs.gitlab.com/ee/api/labels.html), f.e. `description`, `color` etc.

```yaml
projects_and_groups:
group_1/project_1:
labels:
my_label:
color: red
description: hello world
```

`enforce` is used to determine whether labels present in GitLab but not the configuration should be deleted or not. Without enabled `enforce: true` we retain any labels not present in the configuration, to support automated tooling which may apply labels based on user's rulesets and work practices, such as for Compliance Frameworks.

```yaml
projects_and_groups:
group_1/project_1:
labels:
enforce: true
my_label:
color: red
description: hello world
```

The same project labels can be applied to all projects in a group using the following syntax:

```yaml
projects_and_groups:
group_1/*:
labels:
my_label:
color: red
description: hello world
```

## Group Labels

The keys name are as in the endpoints described in the [GitLab Group Labels API docs](https://docs.gitlab.com/ee/api/group_labels.html), f.e. `description`, `color` etc.

We use `group_labels` as the key within the configuration to disambiguate from labels being applied to the Group and labels being applied to **all Projects** in a Group.

```yaml
projects_and_groups:
group_1/*:
group_labels:
my_label:
color: red
description: hello world
```

`enforce` is used to determine whether labels present in GitLab but not the configuration should be deleted or not. Without enabled `enforce: true` we retain any labels not present in the configuration, to support automated tooling which may apply labels based on user's rulesets and work practices, such as for Compliance Frameworks.

```yaml
projects_and_groups:
group_1/*:
group_labels:
enforce: true
my_label:
color: red
description: hello world
```
4 changes: 4 additions & 0 deletions gitlabform/processors/group/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
from gitlabform.processors.group.group_settings_processor import (
GroupSettingsProcessor,
)
from gitlabform.processors.group.group_labels_processor import (
GroupLabelsProcessor,
)


class GroupProcessors(AbstractProcessors):
Expand All @@ -28,4 +31,5 @@ def __init__(self, gitlab: GitLab, config: Configuration, strict: bool):
GroupMembersProcessor(gitlab),
GroupLDAPLinksProcessor(gitlab),
GroupBadgesProcessor(gitlab),
GroupLabelsProcessor(gitlab),
]
30 changes: 30 additions & 0 deletions gitlabform/processors/group/group_labels_processor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from logging import info
from typing import Dict, List


from gitlabform.gitlab import GitLab
from gitlabform.processors.abstract_processor import AbstractProcessor
from gitlabform.processors.util.labels_processor import LabelsProcessor

from gitlab.v4.objects import Group


class GroupLabelsProcessor(AbstractProcessor):
def __init__(self, gitlab: GitLab):
super().__init__("group_labels", gitlab)
self._labels_processor = LabelsProcessor()

def _process_configuration(self, group_path_and_name: str, configuration: Dict):
configured_labels = configuration.get("group_labels", {})

enforce = configuration.get("group_labels|enforce", False)

# Remove 'enforce' key from the config so that it's not treated as a "label"
if enforce:
configured_labels.pop("enforce")

group: Group = self.gl.get_group_by_path_cached(group_path_and_name)

self._labels_processor.process_labels(
configured_labels, enforce, group, self._needs_update
)
4 changes: 4 additions & 0 deletions gitlabform/processors/project/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
from gitlabform.processors.project.project_settings_processor import (
ProjectSettingsProcessor,
)
from gitlabform.processors.project.project_labels_processor import (
ProjectLabelsProcessor,
)
from gitlabform.processors.shared.protected_environments_processor import (
ProtectedEnvironmentsProcessor,
)
Expand Down Expand Up @@ -52,6 +55,7 @@ def __init__(self, gitlab: GitLab, config: Configuration, strict: bool):
ProjectProcessor(gitlab),
ProjectSettingsProcessor(gitlab),
ProjectPushRulesProcessor(gitlab),
ProjectLabelsProcessor(gitlab),
DeployKeysProcessor(gitlab),
VariablesProcessor(gitlab),
BranchesProcessor(gitlab, strict),
Expand Down
29 changes: 29 additions & 0 deletions gitlabform/processors/project/project_labels_processor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from typing import Dict

from gitlabform.gitlab import GitLab
from gitlabform.processors.abstract_processor import AbstractProcessor

from gitlab.v4.objects import Project

from gitlabform.processors.util.labels_processor import LabelsProcessor


class ProjectLabelsProcessor(AbstractProcessor):
def __init__(self, gitlab: GitLab):
super().__init__("labels", gitlab)
self._labels_processor = LabelsProcessor()

def _process_configuration(self, project_and_group: str, configuration: Dict):
configured_labels = configuration.get("labels", {})

enforce = configuration.get("labels|enforce", False)

# Remove 'enforce' key from the config so that it's not treated as a "label"
if enforce:
configured_labels.pop("enforce")

project: Project = self.gl.get_project_by_path_cached(project_and_group)

self._labels_processor.process_labels(
configured_labels, enforce, project, self._needs_update
)
84 changes: 84 additions & 0 deletions gitlabform/processors/util/labels_processor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from logging import debug, info
from typing import Dict, List, Callable, Union

from gitlab.base import RESTObject
from gitlab.v4.objects import Group, Project, ProjectLabel, GroupLabel


class LabelsProcessor:

# Groups and Projects share the same API for .labels within python-gitlab
def process_labels(
self,
configured_labels: Dict,
enforce: bool,
group_or_project: Union[
Group, Project
], # Group | Project -> |: operand not supported in 3.8/3.9
needs_update: Callable, # self._needs_update passed from AbstractProcessor called process_labels
):
# python-gitlab/python-gitlab#2843
existing_labels = group_or_project.labels.list(get_all=True)
existing_label_names: List = []

if isinstance(group_or_project, Group):
parent_object_type = "Group"
else:
parent_object_type = "Project"

if existing_labels:
for listed_label in existing_labels:
full_label = group_or_project.labels.get(listed_label.id)
label_name = full_label.name

if label_name not in configured_labels.keys():
debug(f"{label_name} not in configured labels")
# only delete labels when enforce is true, because user's maybe automatically applying labels based
# on Repo state, for example: Compliance Framework labels based on language or CI-template status
if enforce:
info(f"Removing {label_name} from {parent_object_type}")
full_label.delete()
else:
configured_label = configured_labels.get(label_name)
existing_label_names.append(label_name)

if needs_update(full_label.asdict(), configured_label):
self.update_existing_label(
configured_label,
full_label,
parent_object_type,
)
else:
debug(f"No update required for label: {label_name}")

# add new labels
for label_name in configured_labels.keys():
if label_name not in existing_label_names:
self.create_new_label(
configured_labels, group_or_project, label_name, parent_object_type
)

@staticmethod
def update_existing_label(
configured_label,
full_label: Union[GroupLabel, ProjectLabel], # GroupLabel | ProjectLabel
parent_object_type: str,
):
info(f"Updating {full_label.name} on {parent_object_type}")

# label APIs in python-gitlab do not supply an update() method
for key in configured_label:
full_label.__setattr__(key, configured_label[key])

full_label.save()

@staticmethod
def create_new_label(
configured_labels,
group_or_project: Union[Group, Project], # Group | Project
label_name: str,
parent_object_type: str,
):
label = configured_labels.get(label_name)
info(f"Adding {label_name} to {parent_object_type}")
group_or_project.labels.create({"name": label_name, **label})
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ nav:
- Files: reference/files.md
- Group LDAP links: reference/group_ldap_links.md
- Integrations: reference/integrations.md
- Labels: reference/labels.md
- Members: reference/members.md
- Merge Requests: reference/merge_requests.md
- Pipeline schedules: reference/pipeline_schedules.md
Expand Down
125 changes: 125 additions & 0 deletions tests/acceptance/standard/test_group_labels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
from tests.acceptance import run_gitlabform


class TestGroupLabels:
def test__can_add_a_label_to_group(self, gl, group_for_function):
group = gl.groups.get(group_for_function.id)
labels = group.labels.list()
assert len(labels) == 0

config_for_labels = f"""
projects_and_groups:
{group.full_path}/*:
group_labels:
test_label:
color: red
description: this is a label
"""

run_gitlabform(config_for_labels, group_for_function)

updated_group = gl.groups.get(group.id)
updated_labels = updated_group.labels.list()
assert len(updated_labels) == 1

updated_label = updated_labels[0]
assert updated_label.name == "test_label"
assert updated_label.description == "this is a label"

# text color gets converted to HexCode by GitLab ie red -> #FF0000
assert updated_label.color == "#FF0000"

def test__removes_existing_label_when_enforce_is_true(self, gl, group_for_function):
group = gl.groups.get(group_for_function.id)
group.labels.create({"name": "delete_this", "color": "red"})
labels = group.labels.list()
assert len(labels) == 1

config_for_labels = f"""
projects_and_groups:
{group.full_path}/*:
group_labels:
enforce: true
test_label:
color: red
description: this is a label
"""

run_gitlabform(config_for_labels, group_for_function)

updated_group = gl.groups.get(group.id)
updated_labels = updated_group.labels.list()
assert len(updated_labels) == 1

updated_label = updated_labels[0]
assert updated_label.name == "test_label"
assert updated_label.description == "this is a label"

# text color gets converted to HexCode by GitLab ie red -> #FF0000
assert updated_label.color == "#FF0000"

def test__leaves_existing_label_when_enforce_is_false(self, gl, group_for_function):
group = gl.groups.get(group_for_function.id)
group.labels.create({"name": "delete_this", "color": "red"})
labels = group.labels.list()
assert len(labels) == 1

config_for_labels = f"""
projects_and_groups:
{group.full_path}/*:
group_labels:
test_label:
color: red
description: this is a label
"""

run_gitlabform(config_for_labels, group_for_function)

updated_group = gl.groups.get(group.id)
updated_labels = updated_group.labels.list()
assert len(updated_labels) == 2

existing_label = updated_labels[0]
assert existing_label.name == "delete_this"
assert existing_label.color == "#FF0000"

new_label = updated_labels[1]
assert new_label.name == "test_label"
assert new_label.description == "this is a label"

# text color gets converted to HexCode by GitLab ie red -> #FF0000
assert new_label.color == "#FF0000"

def test__updates_existing_label(self, gl, group_for_function):
group = gl.groups.get(group_for_function.id)
created_label = group.labels.create(
{"name": "update_this", "color": "red", "description": "hello world"}
)

labels = group.labels.list()
assert len(labels) == 1

config_for_labels = f"""
projects_and_groups:
{group.full_path}/*:
group_labels:
update_this:
color: blue
description: this is a label
"""

run_gitlabform(config_for_labels, group_for_function)

updated_group = gl.groups.get(group.id)
updated_labels = updated_group.labels.list()
assert len(updated_labels) == 1

new_label = updated_labels[0]
assert new_label.name == "update_this"
assert new_label.description == "this is a label"

# text color gets converted to HexCode by GitLab ie blue -> #0000FF
assert new_label.color == "#0000FF"

# validate same id is being used
assert created_label.id == new_label.id
Loading

0 comments on commit f1be42b

Please sign in to comment.