3 # backport-create-issue
5 # Standalone version of the "backport-create-issue" subcommand of
6 # "ceph-workbench" by Loic Dachary.
8 # This script scans Redmine (tracker.ceph.com) for issues in "Pending Backport"
9 # status and creates backport issues for them, based on the contents of the
10 # "Backport" field while trying to avoid creating duplicate backport issues.
12 # Copyright (C) 2015 <contact@redhat.com>
13 # Copyright (C) 2018, SUSE LLC
15 # Author: Loic Dachary <loic@dachary.org>
16 # Author: Nathan Cutler <ncutler@suse.com>
18 # This program is free software: you can redistribute it and/or modify
19 # it under the terms of the GNU Affero General Public License as
20 # published by the Free Software Foundation, either version 3 of the
21 # License, or (at your option) any later version.
23 # This program is distributed in the hope that it will be useful,
24 # but WITHOUT ANY WARRANTY; without even the implied warranty of
25 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
26 # GNU Affero General Public License for more details.
28 # You should have received a copy of the GNU Affero General Public License
29 # along with this program. If not, see http://www.gnu.org/licenses/>
36 from redminelib
import Redmine
# https://pypi.org/project/python-redmine/
38 redmine_endpoint
= "https://tracker.ceph.com"
42 redmine_key_file
="~/.redmine_key"
44 # NOTE: release_id is hard-coded because
45 # http://www.redmine.org/projects/redmine/wiki/Rest_CustomFields
46 # requires administrative permissions. If and when
47 # https://www.redmine.org/issues/18875
48 # is resolved, it could maybe be replaced by the following code:
50 # for field in redmine.custom_field.all():
51 # if field.name == 'Release':
52 # release_id = field.id
55 project_id2project
= {}
56 tracker2tracker_id
= {}
57 version2version_id
= {}
61 logging
.error("Redmine credentials are required to perform this operation. "
62 "Please provide either a Redmine key (via %s) "
63 "or a Redmine username and password (via --user and --password). "
64 "Optionally, one or more issue numbers can be given via positional "
65 "argument(s). In the absence of positional arguments, the script "
66 "will loop through all issues in Pending Backport status." % redmine_key_file
)
69 def parse_arguments():
70 parser
= argparse
.ArgumentParser()
71 parser
.add_argument("issue_numbers", nargs
='*', help="Issue number")
72 parser
.add_argument("--user", help="Redmine user")
73 parser
.add_argument("--password", help="Redmine password")
74 parser
.add_argument("--resolve-parent", help="Resolve parent issue if all backports resolved/rejected",
76 parser
.add_argument("--debug", help="Show debug-level messages",
78 parser
.add_argument("--dry-run", help="Do not write anything to Redmine",
80 parser
.add_argument("--force", help="Create backport issues even if status not Pending Backport",
82 return parser
.parse_args()
84 def set_logging_level(a
):
86 logging
.basicConfig(level
=logging
.DEBUG
)
88 logging
.basicConfig(level
=logging
.INFO
)
91 def report_dry_run(a
):
93 logging
.info("Dry run: nothing will be written to Redmine")
95 logging
.warning("Missing issues will be created in Backport tracker "
96 "of the relevant Redmine project")
98 def process_resolve_parent_option(a
):
100 resolve_parent
= a
.resolve_parent
102 logging
.warning("Parent issues with all backports resolved/rejected will be marked Resolved")
104 def connect_to_redmine(a
):
105 full_path
=os
.path
.expanduser(redmine_key_file
)
108 with
open(full_path
, "r") as f
:
109 redmine_key
= f
.read().strip()
110 except FileNotFoundError
:
113 if a
.user
and a
.password
:
114 logging
.info("Redmine username and password were provided; using them")
115 return Redmine(redmine_endpoint
, username
=a
.user
, password
=a
.password
)
117 logging
.info("Redmine key was read from '%s'; using it" % redmine_key_file
)
118 return Redmine(redmine_endpoint
, key
=redmine_key
)
123 return ('argonaut', 'bobtail', 'cuttlefish', 'dumpling', 'emperor',
124 'firefly', 'giant', 'hammer', 'infernalis', 'jewel', 'kraken',
125 'luminous', 'mimic', 'nautilus')
127 def populate_status_dict(r
):
128 for status
in r
.issue_status
.all():
129 status2status_id
[status
.name
] = status
.id
130 logging
.debug("Statuses {}".format(status2status_id
))
133 # not used currently, but might be useful
134 def populate_version_dict(r
, p_id
):
135 versions
= r
.version
.filter(project_id
=p_id
)
136 for version
in versions
:
137 version2version_id
[version
.name
] = version
.id
138 #logging.debug("Versions {}".format(version2version_id))
141 def populate_tracker_dict(r
):
142 for tracker
in r
.tracker
.all():
143 tracker2tracker_id
[tracker
.name
] = tracker
.id
144 logging
.debug("Trackers {}".format(tracker2tracker_id
))
147 def has_tracker(r
, p_id
, tracker_name
):
148 for tracker
in get_project(r
, p_id
).trackers
:
149 if tracker
['name'] == tracker_name
:
153 def get_project(r
, p_id
):
154 if p_id
not in project_id2project
:
155 p_obj
= r
.project
.get(p_id
, include
='trackers')
156 project_id2project
[p_id
] = p_obj
157 return project_id2project
[p_id
]
160 return redmine_endpoint
+ "/issues/" + str(issue
['id'])
162 def set_backport(issue
):
163 for field
in issue
['custom_fields']:
164 if field
['name'] == 'Backport' and field
['value'] != 0:
165 issue
['backports'] = set(re
.findall('\w+', field
['value']))
166 logging
.debug("backports for " + str(issue
['id']) +
167 " is " + str(field
['value']) + " " +
168 str(issue
['backports']))
172 def get_release(issue
):
173 for field
in issue
.custom_fields
:
174 if field
['name'] == 'Release':
175 return field
['value']
177 def update_relations(r
, issue
, dry_run
):
178 global resolve_parent
179 relations
= r
.issue_relation
.filter(issue_id
=issue
['id'])
180 existing_backports
= set()
181 existing_backports_dict
= {}
182 for relation
in relations
:
183 other
= r
.issue
.get(relation
['issue_to_id'])
184 if other
['tracker']['name'] != 'Backport':
185 logging
.debug(url(issue
) + " ignore relation to " +
186 url(other
) + " because it is not in the Backport " +
189 if relation
['relation_type'] != 'copied_to':
190 logging
.error(url(issue
) + " unexpected relation '" +
191 relation
['relation_type'] + "' to " + url(other
))
193 release
= get_release(other
)
194 if release
in existing_backports
:
195 logging
.error(url(issue
) + " duplicate " + release
+
196 " backport issue detected")
198 existing_backports
.add(release
)
199 existing_backports_dict
[release
] = relation
['issue_to_id']
200 logging
.debug(url(issue
) + " backport to " + release
+ " is " +
201 redmine_endpoint
+ "/issues/" + str(relation
['issue_to_id']))
202 if existing_backports
== issue
['backports']:
203 logging
.debug(url(issue
) + " has all the required backport issues")
205 maybe_resolve(issue
, existing_backports_dict
, dry_run
)
207 if existing_backports
.issuperset(issue
['backports']):
208 logging
.error(url(issue
) + " has more backport issues (" +
209 ",".join(sorted(existing_backports
)) + ") than expected (" +
210 ",".join(sorted(issue
['backports'])) + ")")
212 backport_tracker_id
= tracker2tracker_id
['Backport']
213 for release
in issue
['backports'] - existing_backports
:
214 if release
not in releases():
215 logging
.error(url(issue
) + " requires backport to " +
216 "unknown release " + release
)
218 subject
= (release
+ ": " + issue
['subject'])[:255]
220 logging
.info(url(issue
) + " add backport to " + release
)
222 other
= r
.issue
.create(project_id
=issue
['project']['id'],
223 tracker_id
=backport_tracker_id
,
231 logging
.debug("Rate-limiting to avoid seeming like a spammer")
232 time
.sleep(delay_seconds
)
233 r
.issue_relation
.create(issue_id
=issue
['id'],
234 issue_to_id
=other
['id'],
235 relation_type
='copied_to')
236 logging
.info(url(issue
) + " added backport to " +
237 release
+ " " + url(other
))
240 def maybe_resolve(issue
, backports
, dry_run
):
242 issue is a parent issue in Pending Backports status, and backports is a dict
243 like, e.g., { "luminous": 25345, "mimic": 32134 }.
244 If all the backport issues are Resolved/Rejected, set the parent issue to Resolved, too.
248 global status2status_id
251 pending_backport_status_id
= status2status_id
["Pending Backport"]
252 resolved_status_id
= status2status_id
["Resolved"]
253 rejected_status_id
= status2status_id
["Rejected"]
254 logging
.debug("entering maybe_resolve with parent issue ->{}<- backports ->{}<-"
255 .format(issue
.id, backports
))
256 assert issue
.status
.id == pending_backport_status_id
, \
257 "Parent Redmine issue ->{}<- has status ->{}<- (expected Pending Backport)".format(issue
.id, issue
.status
)
259 resolved_equiv_statuses
= [resolved_status_id
, rejected_status_id
]
260 for backport
in backports
.keys():
261 tracker_issue_id
= backports
[backport
]
262 backport_issue
= redmine
.issue
.get(tracker_issue_id
)
263 logging
.debug("{} backport is in status {}".format(backport
, backport_issue
.status
.name
))
264 if backport_issue
.status
.id not in resolved_equiv_statuses
:
268 logging
.debug("Parent ->{}<- all backport issues in status Resolved".format(url(issue
)))
269 note
= ("While running with --resolve-parent, the script \"backport-create-issue\" "
270 "noticed that all backports of this issue are in status \"Resolved\" or \"Rejected\".")
272 logging
.info("Set status of parent ->{}<- to Resolved".format(url(issue
)))
274 redmine
.issue
.update(issue
.id, status_id
=resolved_status_id
, notes
=note
)
275 logging
.info("Parent ->{}<- status changed from Pending Backport to Resolved".format(url(issue
)))
276 logging
.debug("Rate-limiting to avoid seeming like a spammer")
277 time
.sleep(delay_seconds
)
279 logging
.debug("Some backport issues are still unresolved: leaving parent issue open")
281 def iterate_over_backports(r
, issues
, dry_run
=False):
285 logging
.debug("{} ({}) {}".format(issue
.id, issue
.project
,
287 print('Examining issue#{} ({}/{})\r'.format(issue
.id, counter
, len(issues
)), end
='', flush
=True)
288 if not has_tracker(r
, issue
['project']['id'], 'Backport'):
289 logging
.info("{} skipped because the project {} does not "
290 "have a Backport tracker".format(url(issue
),
291 issue
['project']['name']))
293 if not set_backport(issue
):
294 logging
.error(url(issue
) + " no backport field")
296 if len(issue
['backports']) == 0:
297 logging
.error(url(issue
) + " the backport field is empty")
298 update_relations(r
, issue
, dry_run
)
299 print(' \r', end
='', flush
=True)
300 logging
.info("Processed {} issues".format(counter
))
304 if __name__
== '__main__':
305 args
= parse_arguments()
306 set_logging_level(args
)
307 process_resolve_parent_option(args
)
309 redmine
= connect_to_redmine(args
)
310 project
= redmine
.project
.get(project_name
)
311 ceph_project_id
= project
.id
312 logging
.debug("Project {} has ID {}".format(project_name
, ceph_project_id
))
313 populate_status_dict(redmine
)
314 pending_backport_status_id
= status2status_id
["Pending Backport"]
315 logging
.debug("Pending Backport status has ID {}"
316 .format(pending_backport_status_id
))
317 populate_tracker_dict(redmine
)
319 if args
.issue_numbers
:
320 issue_list
= ','.join(args
.issue_numbers
)
321 logging
.info("Processing issue list ->{}<-".format(issue_list
))
324 logging
.warn("--force option was given: ignoring issue status!")
325 issues
= redmine
.issue
.filter(project_id
=ceph_project_id
,
329 issues
= redmine
.issue
.filter(project_id
=ceph_project_id
,
331 status_id
=pending_backport_status_id
)
334 logging
.warn("ignoring --force option, which can only be used with an explicit issue list")
335 issues
= redmine
.issue
.filter(project_id
=ceph_project_id
,
336 status_id
=pending_backport_status_id
)
338 logging
.info("Processing {} issues regardless of status"
339 .format(len(issues
)))
341 logging
.info("Processing {} issues with status Pending Backport"
342 .format(len(issues
)))
343 iterate_over_backports(redmine
, issues
, dry_run
=args
.dry_run
)