#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (c) 2023, Gabriele Pongelli (gabriele.pongelli@gmail.com) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import absolute_import, division, print_function __metaclass__ = type DOCUMENTATION = ''' module: gitlab_milestone short_description: Creates/updates/deletes GitLab Milestones belonging to project or group version_added: 8.3.0 description: - When a milestone does not exist, it will be created. - When a milestone does exist, its value will be updated when the values are different. - Milestones can be purged. author: - "Gabriele Pongelli (@gpongelli)" requirements: - python-gitlab python module extends_documentation_fragment: - community.general.auth_basic - community.general.gitlab - community.general.attributes attributes: check_mode: support: full diff_mode: support: none options: state: description: - Create or delete milestone. default: present type: str choices: ["present", "absent"] purge: description: - When set to V(true), delete all milestone which are not mentioned in the task. default: false type: bool required: false project: description: - The path and name of the project. Either this or O(group) is required. required: false type: str group: description: - The path of the group. Either this or O(project) is required. required: false type: str milestones: description: - A list of dictionaries that represents gitlab project's or group's milestones. type: list elements: dict required: false default: [] suboptions: title: description: - The name of the milestone. type: str required: true due_date: description: - Milestone due date in YYYY-MM-DD format. type: str required: false default: null start_date: description: - Milestone start date in YYYY-MM-DD format. type: str required: false default: null description: description: - Milestone's description. type: str default: null ''' EXAMPLES = ''' # same project's task can be executed for group - name: Create one milestone community.general.gitlab_milestone: api_url: https://gitlab.com api_token: secret_access_token project: "group1/project1" milestones: - title: milestone_one start_date: "2024-01-04" state: present - name: Create many group milestones community.general.gitlab_milestone: api_url: https://gitlab.com api_token: secret_access_token group: "group1" milestones: - title: milestone_one start_date: "2024-01-04" description: this is a milestone due_date: "2024-02-04" - title: milestone_two state: present - name: Create many project milestones community.general.gitlab_milestone: api_url: https://gitlab.com api_token: secret_access_token project: "group1/project1" milestones: - title: milestone_one start_date: "2024-01-04" description: this is a milestone due_date: "2024-02-04" - title: milestone_two state: present - name: Set or update some milestones community.general.gitlab_milestone: api_url: https://gitlab.com api_token: secret_access_token project: "group1/project1" milestones: - title: milestone_one start_date: "2024-05-04" state: present - name: Add milestone in check mode community.general.gitlab_milestone: api_url: https://gitlab.com api_token: secret_access_token project: "group1/project1" milestones: - title: milestone_one start_date: "2024-05-04" check_mode: true - name: Delete milestone community.general.gitlab_milestone: api_url: https://gitlab.com api_token: secret_access_token project: "group1/project1" milestones: - title: milestone_one state: absent - name: Purge all milestones community.general.gitlab_milestone: api_url: https://gitlab.com api_token: secret_access_token project: "group1/project1" purge: true - name: Delete many milestones community.general.gitlab_milestone: api_url: https://gitlab.com api_token: secret_access_token project: "group1/project1" state: absent milestones: - title: milestone-abc123 - title: milestone-two ''' RETURN = ''' milestones: description: Four lists of the milestones which were added, updated, removed or exist. returned: success type: dict contains: added: description: A list of milestones which were created. returned: always type: list sample: ['abcd', 'milestone-one'] untouched: description: A list of milestones which exist. returned: always type: list sample: ['defg', 'new-milestone'] removed: description: A list of milestones which were deleted. returned: always type: list sample: ['defg', 'new-milestone'] updated: description: A list pre-existing milestones whose values have been set. returned: always type: list sample: ['defg', 'new-milestone'] milestones_obj: description: API object. returned: success type: dict ''' from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.api import basic_auth_argument_spec from ansible_collections.community.general.plugins.module_utils.version import LooseVersion from ansible_collections.community.general.plugins.module_utils.gitlab import ( auth_argument_spec, gitlab_authentication, ensure_gitlab_package, find_group, find_project, gitlab ) from datetime import datetime class GitlabMilestones(object): def __init__(self, module, gitlab_instance, group_id, project_id): self._gitlab = gitlab_instance self.gitlab_object = group_id if group_id else project_id self.is_group_milestone = True if group_id else False self._module = module def list_all_milestones(self): page_nb = 1 milestones = [] vars_page = self.gitlab_object.milestones.list(page=page_nb) while len(vars_page) > 0: milestones += vars_page page_nb += 1 vars_page = self.gitlab_object.milestones.list(page=page_nb) return milestones def create_milestone(self, var_obj): if self._module.check_mode: return True, True var = { "title": var_obj.get('title'), } if var_obj.get('description') is not None: var["description"] = var_obj.get('description') if var_obj.get('start_date') is not None: var["start_date"] = self.check_date(var_obj.get('start_date')) if var_obj.get('due_date') is not None: var["due_date"] = self.check_date(var_obj.get('due_date')) _obj = self.gitlab_object.milestones.create(var) return True, _obj.asdict() def update_milestone(self, var_obj): if self._module.check_mode: return True, True _milestone = self.gitlab_object.milestones.get(self.get_milestone_id(var_obj.get('title'))) if var_obj.get('description') is not None: _milestone.description = var_obj.get('description') if var_obj.get('start_date') is not None: _milestone.start_date = var_obj.get('start_date') if var_obj.get('due_date') is not None: _milestone.due_date = var_obj.get('due_date') # save returns None _milestone.save() return True, _milestone.asdict() def get_milestone_id(self, _title): _milestone_list = self.gitlab_object.milestones.list() _found = list(filter(lambda x: x.title == _title, _milestone_list)) if _found: return _found[0].id else: self._module.fail_json(msg="milestone '%s' not found." % _title) def check_date(self, _date): try: datetime.strptime(_date, '%Y-%m-%d') except ValueError: self._module.fail_json(msg="milestone's date '%s' not in correct format." % _date) return _date def delete_milestone(self, var_obj): if self._module.check_mode: return True, True _milestone = self.gitlab_object.milestones.get(self.get_milestone_id(var_obj.get('title'))) # delete returns None _milestone.delete() return True, _milestone.asdict() def compare(requested_milestones, existing_milestones, state): # we need to do this, because it was determined in a previous version - more or less buggy # basically it is not necessary and might result in more/other bugs! # but it is required and only relevant for check mode!! # logic represents state 'present' when not purge. all other can be derived from that # untouched => equal in both # updated => title are equal # added => title does not exist untouched = list() updated = list() added = list() if state == 'present': _existing_milestones = list() for item in existing_milestones: _existing_milestones.append({'title': item.get('title')}) for var in requested_milestones: if var in existing_milestones: untouched.append(var) else: compare_item = {'title': var.get('title')} if compare_item in _existing_milestones: updated.append(var) else: added.append(var) return untouched, updated, added def native_python_main(this_gitlab, purge, requested_milestones, state, module): change = False return_value = dict(added=[], updated=[], removed=[], untouched=[]) return_obj = dict(added=[], updated=[], removed=[]) milestones_before = [x.asdict() for x in this_gitlab.list_all_milestones()] # filter out and enrich before compare for item in requested_milestones: # add defaults when not present if item.get('description') is None: item['description'] = "" if item.get('due_date') is None: item['due_date'] = None if item.get('start_date') is None: item['start_date'] = None for item in milestones_before: # remove field only from server item.pop('id') item.pop('iid') item.pop('created_at') item.pop('expired') item.pop('state') item.pop('updated_at') item.pop('web_url') # group milestone has group_id, while project has project_id if 'group_id' in item: item.pop('group_id') if 'project_id' in item: item.pop('project_id') if state == 'present': add_or_update = [x for x in requested_milestones if x not in milestones_before] for item in add_or_update: try: _rv, _obj = this_gitlab.create_milestone(item) if _rv: return_value['added'].append(item) return_obj['added'].append(_obj) except Exception: # create raises exception with following error message when milestone already exists _rv, _obj = this_gitlab.update_milestone(item) if _rv: return_value['updated'].append(item) return_obj['updated'].append(_obj) if purge: # re-fetch _milestones = this_gitlab.list_all_milestones() for item in milestones_before: _rv, _obj = this_gitlab.delete_milestone(item) if _rv: return_value['removed'].append(item) return_obj['removed'].append(_obj) elif state == 'absent': if not purge: _milestone_titles_requested = [x['title'] for x in requested_milestones] remove_requested = [x for x in milestones_before if x['title'] in _milestone_titles_requested] for item in remove_requested: _rv, _obj = this_gitlab.delete_milestone(item) if _rv: return_value['removed'].append(item) return_obj['removed'].append(_obj) else: for item in milestones_before: _rv, _obj = this_gitlab.delete_milestone(item) if _rv: return_value['removed'].append(item) return_obj['removed'].append(_obj) if module.check_mode: _untouched, _updated, _added = compare(requested_milestones, milestones_before, state) return_value = dict(added=_added, updated=_updated, removed=return_value['removed'], untouched=_untouched) if any(return_value[x] for x in ['added', 'removed', 'updated']): change = True milestones_after = [x.asdict() for x in this_gitlab.list_all_milestones()] return change, return_value, milestones_before, milestones_after, return_obj def main(): argument_spec = basic_auth_argument_spec() argument_spec.update(auth_argument_spec()) argument_spec.update( project=dict(type='str', required=False, default=None), group=dict(type='str', required=False, default=None), purge=dict(type='bool', required=False, default=False), milestones=dict(type='list', elements='dict', required=False, default=list(), options=dict( title=dict(type='str', required=True), description=dict(type='str', required=False), due_date=dict(type='str', required=False), start_date=dict(type='str', required=False),) ), state=dict(type='str', default="present", choices=["absent", "present"]), ) module = AnsibleModule( argument_spec=argument_spec, mutually_exclusive=[ ['api_username', 'api_token'], ['api_username', 'api_oauth_token'], ['api_username', 'api_job_token'], ['api_token', 'api_oauth_token'], ['api_token', 'api_job_token'], ['project', 'group'], ], required_together=[ ['api_username', 'api_password'], ], required_one_of=[ ['api_username', 'api_token', 'api_oauth_token', 'api_job_token'], ['project', 'group'] ], supports_check_mode=True ) ensure_gitlab_package(module) gitlab_project = module.params['project'] gitlab_group = module.params['group'] purge = module.params['purge'] milestone_list = module.params['milestones'] state = module.params['state'] gitlab_version = gitlab.__version__ _min_gitlab = '3.2.0' if LooseVersion(gitlab_version) < LooseVersion(_min_gitlab): module.fail_json(msg="community.general.gitlab_milestone requires python-gitlab Python module >= %s " "(installed version: [%s]). Please upgrade " "python-gitlab to version %s or above." % (_min_gitlab, gitlab_version, _min_gitlab)) gitlab_instance = gitlab_authentication(module) # find_project can return None, but the other must exist gitlab_project_id = find_project(gitlab_instance, gitlab_project) # find_group can return None, but the other must exist gitlab_group_id = find_group(gitlab_instance, gitlab_group) # if both not found, module must exist if not gitlab_project_id and not gitlab_group_id: if gitlab_project and not gitlab_project_id: module.fail_json(msg="project '%s' not found." % gitlab_project) if gitlab_group and not gitlab_group_id: module.fail_json(msg="group '%s' not found." % gitlab_group) this_gitlab = GitlabMilestones(module=module, gitlab_instance=gitlab_instance, group_id=gitlab_group_id, project_id=gitlab_project_id) change, raw_return_value, before, after, _obj = native_python_main(this_gitlab, purge, milestone_list, state, module) if not module.check_mode: raw_return_value['untouched'] = [x for x in before if x in after] added = [x.get('title') for x in raw_return_value['added']] updated = [x.get('title') for x in raw_return_value['updated']] removed = [x.get('title') for x in raw_return_value['removed']] untouched = [x.get('title') for x in raw_return_value['untouched']] return_value = dict(added=added, updated=updated, removed=removed, untouched=untouched) module.exit_json(changed=change, milestones=return_value, milestones_obj=_obj) if __name__ == '__main__': main()