From 5107d3bb209e8499ed19e542d8f9d8b2ce433a7c Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 22 Feb 2023 18:43:06 +0100 Subject: [PATCH 01/12] sync_labels initial --- .github/sync_labels.py | 563 ++++++++++++++++++++++++++++++ .github/workflows/sync_labels.yml | 42 +++ 2 files changed, 605 insertions(+) create mode 100755 .github/sync_labels.py create mode 100644 .github/workflows/sync_labels.yml diff --git a/.github/sync_labels.py b/.github/sync_labels.py new file mode 100755 index 00000000000..f70bc58d45f --- /dev/null +++ b/.github/sync_labels.py @@ -0,0 +1,563 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +r""" +Python script to sync labels that are migrated from Trac selection lists. +""" + +############################################################################## +# Copyright (C) 2023 Sebastian Oehms +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# http://www.gnu.org/licenses/ +############################################################################## + +import os +import sys +from logging import info, warning, getLogger, INFO +from json import loads +from enum import Enum + +class SelectionList(Enum): + """ + Abstract Enum for selection lists. + """ + def succ(self): + """ + Return the successor of `self`. + """ + l = list(self.__class__) + i = l.index(self) + 1 + if i >= len(l): + return None + return l[i] + +class ReviewDecision(Enum): + """ + Enum for `gh pr view` results for `reviewDecision`. + """ + changes_requested = 'CHANGES_REQUESTED' + approved = 'APPROVED' + unclear = 'COMMENTED' + +class Priority(SelectionList): + """ + Enum for priority lables. + """ + blocker = 'p: blocker /1' + critical = 'p: critical /2' + major = 'p: major /3' + minor = 'p: minor /4' + trivial = 'p: trivial /5' + +class State(SelectionList): + """ + Enum for state lables. + """ + positive_review = 's: positive review' + needs_work = 's: needs work' + needs_review = 's: needs review' + needs_info = 's: needs info' + + +def selection_list(label): + """ + Return the selection list to which `label` belongs to. + """ + for sel_list in [Priority, State]: + for item in sel_list: + if label == item.value: + return sel_list + return None + +class GhLabelSynchronizer: + """ + Handler for access to GitHub issue via the `gh` in the bash command line + of the GitHub runner. + """ + def __init__(self, url, actor): + """ + Python constructor sets the issue / PR url and list of active labels. + """ + self._url = url + self._actor = actor + self._labels = None + self._author = None + self._draft = None + self._open = None + self._review_decision = None + self._reviews = None + self._commits = None + + number = os.path.basename(url) + self._pr = True + self._issue = 'pull request #%s' % number + if url.rfind('issue') != -1: + self._issue = 'issue #%s' % number + self._pr = False + info('Create label handler for %s and actor %s' % (self._issue, self._actor)) + + def is_pull_request(self): + """ + Return if we are treating a pull request. + """ + return self._pr + + def view(self, key): + """ + Return data obtained from `gh` command `view`. + """ + issue = 'issue' + if self._pr: + issue = 'pr' + cmd = 'gh %s view %s --json %s' % (issue, self._url, key) + from subprocess import check_output + return loads(check_output(cmd, shell=True))[key] + + def is_open(self): + """ + Return if the issue res. PR is open. + """ + if self._open is not None: + return self._open + if self.view('state') == 'OPEN': + self._open = True + else: + self._open = False + info('Issue %s is open %s' % (self._issue, self._open)) + return self._open + + def is_draft(self): + """ + Return if the PR is a draft. + """ + if self._draft is not None: + return self._draft + if self.is_pull_request(): + self._draft = self.view('isDraft') + else: + self._draft = False + info('Issue %s is draft %s' % (self._issue, self._draft)) + return self._draft + + def get_labels(self): + """ + Return the list of labels of the issue resp. PR. + """ + if self._labels is not None: + return self._labels + data = self.view('labels') + self._labels = [l['name'] for l in data] + info('List of labels for %s: %s' % (self._issue, self._labels)) + return self._labels + + def get_author(self): + """ + Return the author of the issue resp. PR. + """ + if self._author is not None: + return self._author + data = self.view('author') + self._author = self.view('author')['login'] + info('Author of %s: %s' % (self._issue, self._author)) + return self._author + + def get_review_decision(self): + """ + Return the reviewDecision of the PR. + """ + if not self.is_pull_request(): + return None + + if self._review_decision is not None: + return self._review_decision + + data = self.view('reviewDecision') + if data: + self._review_decision = ReviewDecision(data) + else: + self._review_decision = ReviewDecision.unclear + info('Review decision for %s: %s' % (self._issue, self._review_decision.value)) + return self._review_decision + + def get_reviews(self): + """ + Return the list of reviews of the PR. + """ + if not self.is_pull_request(): + return None + + if self._reviews is not None: + return self._reviews + + self._reviews = self.view('reviews') + info('Reviews for %s: %s' % (self._issue, self._reviews)) + return self._reviews + + def get_commits(self): + """ + Return the list of commits of the PR. + """ + if not self.is_pull_request(): + return None + + if self._commits is not None: + return self._commits + + self._commits = self.view('commits') + info('Commits for %s: %s' % (self._issue, self._commits)) + return self._commits + + def gh_cmd(self, cmd, arg, option): + """ + Perform a system call to `gh` for `cmd` to an isuue resp. PR. + """ + issue = 'issue' + if self._pr: + issue = 'pr' + cmd_str = 'gh %s %s %s %s "%s"' % (issue, cmd, self._url, option, arg) + os.system(cmd_str) + + def edit(self, arg, option): + """ + Perform a system call to `gh` to edit an issue resp. PR. + """ + self.gh_cmd('edit', arg, option) + + def review(self, arg, text): + """ + Perform a system call to `gh` to review a PR. + """ + self.gh_cmd('review', arg, '-b %s' % text) + + def active_partners(self, item): + """ + Return the list of other labels from the selection list + of the given one that are already present on the issue / PR. + """ + sel_list = type(item) + partners = [i for i in sel_list if i != item and i.value in self.get_labels()] + info('Active partners of %s: %s' % (item, partners)) + return partners + + def needs_work(self, only_actor=True): + """ + Return `True` if the PR needs work. This is the case if + the review decision requests changes or if there is any + review reqesting changes. + """ + ch_req = ReviewDecision.changes_requested + rev_dec = self.get_review_decision() + if rev_dec: + if rev_dec == ch_req: + info('PR %s needs work (by decision)' % self._issue) + return True + else: + info('PR %s doesn\'t need work (by decision)' % self._issue) + return False + + revs = self.get_reviews() + if only_actor: + revs = [rev for rev in revs if rev['author']['login'] == self._actor] + if any(rev['state'] == ch_req.value for rev in revs): + info('PR %s needs work' % self._issue) + return True + info('PR %s doesn\'t need work' % self._issue) + return False + + def positive_review(self, only_actor=True): + """ + Return `True` if the PR has positive review. This is the + case if the review decision is approved or if there is any + approved review but no changes requesting one. + """ + appr = ReviewDecision.approved + rev_dec = self.get_review_decision() + if rev_dec: + if rev_dec == appr: + info('PR %s has positve review (by decision)' % self._issue) + return True + else: + info('PR %s doesn\'t have positve review (by decision)' % self._issue) + return False + + if self.needs_work(): + info('PR %s doesn\'t have positve review (needs work)' % self._issue) + return False + + revs = self.get_reviews() + if only_actor: + revs = [rev for rev in revs if rev['author']['login'] == self._actor] + if any(rev['state'] == appr.value for rev in revs): + info('PR %s has positve review (by decision)' % self._issue) + return True + info('PR %s doesn\'t have positve review (needs work)' % self._issue) + return False + + def approve_allowed(self): + """ + Return if the actor has permission to approve this PR. + """ + author = self.get_author() + revs = self.get_reviews() + if not any(rev['author']['authorAssociation'] == 'MEMBER' for rev in revs): + info('PR %s can\'t be approved because of missing member review' % (self._issue)) + return False + + revs = [rev for rev in revs if rev['author']['login'] != self._actor] + ch_req = ReviewDecision.changes_requested + if any(rev['state'] == ch_req.value for rev in revs): + info('PR %s can\'t be approved by %s since others reqest changes' % (self._issue, self._actor)) + return False + + if author != self._actor: + info('PR %s can be approved by %s' % (self._issue, self._actor)) + return True + + revs = [rev for rev in revs if rev['author']['login'] != 'github-actions'] + if not revs: + info('PR %s can\'t be approved by the author %s since no other person reviewed it' % (self._issue, self._actor)) + return False + + comts = self.get_commits() + authors = sum(com['authors'] for com in comts) + authors = [auth for auth in authors if not auth['login'] in (self._actor, 'github-actions')] + if not authors: + info('PR %s can\'t be approved by the author %s since no other person commited to it' % (self._issue, self._actor)) + return False + + info('PR %s can be approved by the author %s as co-author' % (self._issue, self._actor)) + return True + + def approve(self): + """ + Approve the PR by the actor. + """ + self.review('--approve', '%s approved this PR' % self._actor) + info('PR %s approved by %s' % (self._issue, self._actor)) + + def request_changes(self): + """ + Request changes for this PR by the actor. + """ + self.review('--request-changes', '%s requested changes for this PR' % self._actor) + info('Changes requested for PR %s by %s' % (self._issue, self._actor)) + + def add_comment(self, text): + """ + Perform a system call to `gh` to add a comment to an issue or PR. + """ + + self.gh_cmd('comment', text, '-b') + info('Add comment to %s: %s' % (self._issue, text)) + + def add_label(self, label): + """ + Add the given label to the issue or PR. + """ + if not label in self.get_labels(): + self.edit(label, '--add-label') + info('Add label to %s: %s' % (self._issue, label)) + + def add_default_label(self, item): + """ + Add the given label if there is no active partner. + """ + if not self.active_partners(item): + self.add_label(item.value) + + def on_label_add(self, label): + """ + Check if the given label belongs to a selection list. If so, remove + all other labels of that list. In case of a state label reviews are + booked accordingly. + """ + sel_list = selection_list(label) + if not sel_list: + return + + item = sel_list(label) + if label not in self.get_labels(): + # this is possible if two labels of the same selection list + # have been added in one step (via multiple selection in the + # pull down menue). In this case `label` has been removed + # on the `on_label_add` of the first of the two labels + partn = self.active_partners(item) + if partn: + self.add_comment('Label *%s* can not be added due to *%s*!' % (label, partn[0].value)) + else: + warning('Label %s of %s not found!' % (label, self._issue)) + return + + if sel_list is State: + if not self.is_pull_request(): + if item != State.needs_info: + self.add_comment('Label *%s* can not be added to an issue. Please use it on the corresponding PR' % label) + self.remove_label(label) + return + + if item == State.positive_review: + if self.approve_allowed(): + self.approve() + elif self.needs_work(): + # PR still needs work + self.add_comment('Label *%s* can not be added. Please use the corresponding functionality of GitHub' % label) + self.select_label(State.needs_work) + return + else: + # PR still needs review + self.add_comment('Label *%s* can not be added. Please use the corresponding functionality of GitHub' % label) + if self.is_draft(): + self.remove_label(label) + else: + self.select_label(State.needs_review) + return + + if item == State.needs_work: + self.request_changes() + if not self.needs_work(): + # change request of actor could not be set + self.add_comment('Label *%s* can not be added. Please use the corresponding functionality of GitHub' % label) + if self.is_draft(): + self.remove_label(label) + else: + self.select_label(State.needs_review) + return + + for other in sel_list: + if other != item: + self.remove_label(other.value) + + def select_label(self, item): + """ + Add the given label and remove all others. + """ + self.add_label(item.value) + sel_list = type(item) + for other in sel_list: + if other != item: + self.remove_label(other.value) + + def remove_label(self, label): + """ + Remove the given label from the issue or PR of the handler. + """ + if label in self.get_labels(): + self.edit(label, '--remove-label') + info('Remove label from %s: %s' % (self._issue, label)) + + def on_label_remove(self, label): + """ + Check if the given label belongs to a selection list. If so, the + successor of the label is added except there is none or there + exists another label of the corresponding list. In case of a + state label reviews are booked accordingly. + """ + sel_list = selection_list(label) + if not sel_list: + return + + item = sel_list(label) + if label in self.get_labels(): + # this is possible if two labels of the same selection list + # have been removed in one step (via multiple selection in the + # pull down menue). In this case `label` has been added + # on the `on_label_remove` of the first of the two labels. + partn = self.active_partners(item) + if partn: + self.on_label_add(partn[0].value) + return + + if sel_list is State: + if not self.is_pull_request(): + return + if item == State.positive_review: + if self.positive_review(): + self.request_changes() + self.select_label(State.needs_work) + elif self.positive_review(only_actor=False): + self.add_comment('Label *%s* can not be removed. Please use the corresponding functionality of GitHub' % label) + self.select_label(item) + elif not self.is_draft(): + self.select_label(State.needs_review) + return + elif item == State.needs_work: + if self.needs_work(only_actor=False): + self.add_comment('Label *%s* can not be removed. Please use the corresponding functionality of GitHub' % label) + self.select_label(item) + elif not self.is_draft(): + self.select_label(State.needs_review) + return + + if not self.active_partners(item): + # if there is no other in the same selection list + # add the next weaker label if it exists + succ = sel_list(label).succ() + if succ: + self.select_label(succ) + + def on_converted_to_draft(self): + """ + Remove all state labels. + """ + for item in State: + self.remove_label(item.value) + + +############################################################################### +# Main +############################################################################### +cmdline_args = sys.argv[1:] + +getLogger().setLevel(INFO) +info('cmdline_args (%s) %s' % (len(cmdline_args), cmdline_args)) + +if len(cmdline_args) < 4: + print('Need 5 arguments: action, url, actor, label, rev_state' ) + exit +else: + action, url, actor, label, rev_state = cmdline_args + +info('action: %s' % action) +info('url: %s' % url) +info('label: %s' % label) +info('actor: %s' % actor) +info('rev_state: %s' % rev_state) + +gh = GhLabelSynchronizer(url, actor) + +if action in ('opened', 'reopened'): + if gh.is_pull_request(): + if not gh.is_draft(): + gh.add_default_label(State.needs_review) + +if action in ('closed', 'reopened'): + for lab in State: + gh.remove_label(lab.value) + +if action == 'labeled': + gh.on_label_add(label) + +if action == 'unlabeled': + gh.on_label_remove(label) + +if action == 'ready_for_review': + gh.select_label(State.needs_review) + +if action == 'converted_to_draft': + gh.on_converted_to_draft() + +if action == 'submitted': + if rev_state == 'approved': + if gh.positive_review(): + gh.select_label(State.positive_review) + + if rev_state == 'changes_requested': + if gh.needs_work(): + gh.select_label(State.needs_work) + +if action in ('review_requested', 'ready_for_review'): + gh.select_label(State.needs_review) diff --git a/.github/workflows/sync_labels.yml b/.github/workflows/sync_labels.yml new file mode 100644 index 00000000000..4e11a94277d --- /dev/null +++ b/.github/workflows/sync_labels.yml @@ -0,0 +1,42 @@ +# This workflow synchronizes groups of labels that correspond +# to items of selection list in Trac. It controls that in each +# such case there is just one label of the list present. +# Furthermore in the case of the state it checks the labels +# to coincide with the corresponding review state. + +name: Synchronize selection list lables + +on: + pull_request_review: + types: [submitted] + issues: + types: [opened, reopened, closed, labeled, unlabeled] + pull_request: + types: [opened, reopened, closed, ready_for_review, converted_to_draft, labeled, unlabeled] + pull_request_target: + types: [opened, reopened, closed, ready_for_review, converted_to_draft, labeled, unlabeled] + +jobs: + synchronize: + runs-on: ubuntu-latest + steps: + # Download the Python script + - name: Download script + id: download + run: | + curl -L -O "https://raw.githubusercontent.com/"$REPO"/master/.github/sync_labels.py" + chmod a+x sync_labels.py + env: + REPO: ${{ github.repository }} + + # Perform synchronization + - name: Call script + run: ./sync_labels.py $ACTION $ISSUE_URL $PR_URL $ACTOR "$LABEL" "$REV_STATE" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ACTION: ${{ github.event.action }} + ISSUE_URL: ${{ github.event.issue.html_url }} + PR_URL: ${{ github.event.pull_request.html_url }} + ACTOR: ${{ github.actor }} + LABEL: ${{ github.event.label.name }} + REV_STATE: ${{ github.event.review.state }} From 17bea0a1b2f92d4312e86319df743644dab3c1bf Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 8 Mar 2023 07:57:49 +0100 Subject: [PATCH 02/12] sync_labels refactoring and extension --- .github/sync_labels.py | 484 ++++++++++++++++++------------ .github/workflows/sync_labels.yml | 4 +- 2 files changed, 295 insertions(+), 193 deletions(-) diff --git a/.github/sync_labels.py b/.github/sync_labels.py index f70bc58d45f..7d64ca4526e 100755 --- a/.github/sync_labels.py +++ b/.github/sync_labels.py @@ -21,31 +21,46 @@ from json import loads from enum import Enum -class SelectionList(Enum): +class Action(Enum): """ - Abstract Enum for selection lists. + Enum for GitHub event ``action``. """ - def succ(self): - """ - Return the successor of `self`. - """ - l = list(self.__class__) - i = l.index(self) + 1 - if i >= len(l): - return None - return l[i] + opened = 'opened' + reopened = 'reopened' + closed = 'closed' + labeled = 'labeled' + unlabeled = 'unlabeled' + ready_for_review = 'ready_for_review' + synchronize = 'synchronize' + review_requested = 'review_requested' + converted_to_draft = 'converted_to_draft' + submitted = 'submitted' + +class RevState(Enum): + """ + Enum for GitHub event ``review_state``. + """ + commented = 'commented' + approved = 'approved' + changes_requested = 'changes_requested' class ReviewDecision(Enum): """ - Enum for `gh pr view` results for `reviewDecision`. + Enum for ``gh pr view`` results for ``reviewDecision``. """ changes_requested = 'CHANGES_REQUESTED' approved = 'APPROVED' unclear = 'COMMENTED' +class SelectionList(Enum): + """ + Abstract Enum for selection lists. + """ + pass + class Priority(SelectionList): """ - Enum for priority lables. + Enum for priority labels. """ blocker = 'p: blocker /1' critical = 'p: critical /2' @@ -55,7 +70,7 @@ class Priority(SelectionList): class State(SelectionList): """ - Enum for state lables. + Enum for state labels. """ positive_review = 's: positive review' needs_work = 's: needs work' @@ -91,6 +106,7 @@ def __init__(self, url, actor): self._review_decision = None self._reviews = None self._commits = None + self._commit_date = None number = os.path.basename(url) self._pr = True @@ -100,6 +116,9 @@ def __init__(self, url, actor): self._pr = False info('Create label handler for %s and actor %s' % (self._issue, self._actor)) + # ------------------------------------------------------------------------- + # methods to obtain properties of the issue + # ------------------------------------------------------------------------- def is_pull_request(self): """ Return if we are treating a pull request. @@ -165,6 +184,21 @@ def get_author(self): info('Author of %s: %s' % (self._issue, self._author)) return self._author + def get_commits(self): + """ + Return the list of commits of the PR. + """ + if not self.is_pull_request(): + return None + + if self._commits is not None: + return self._commits + + self._commits = self.view('commits') + self._commit_date = max( com['committedDate'] for com in self._commits ) + info('Commits until %s for %s: %s' % (self._commit_date, self._issue, self._commits)) + return self._commits + def get_review_decision(self): """ Return the reviewDecision of the PR. @@ -183,55 +217,29 @@ def get_review_decision(self): info('Review decision for %s: %s' % (self._issue, self._review_decision.value)) return self._review_decision - def get_reviews(self): + def get_reviews(self, complete=False): """ - Return the list of reviews of the PR. + Return the list of reviews of the PR. Per default only those reviews + are returned which have been submitted after the youngest commit. + Use keyword ``complete`` to get them all. """ if not self.is_pull_request(): return None - if self._reviews is not None: - return self._reviews - - self._reviews = self.view('reviews') - info('Reviews for %s: %s' % (self._issue, self._reviews)) - return self._reviews - - def get_commits(self): - """ - Return the list of commits of the PR. - """ - if not self.is_pull_request(): - return None + if self._reviews is None: + self._reviews = self.view('reviews') + info('Reviews for %s: %s' % (self._issue, self._reviews)) - if self._commits is not None: - return self._commits + if complete or not self._reviews: + return self._reviews - self._commits = self.view('commits') - info('Commits for %s: %s' % (self._issue, self._commits)) - return self._commits + if self._commit_date is None: + self.get_commits() - def gh_cmd(self, cmd, arg, option): - """ - Perform a system call to `gh` for `cmd` to an isuue resp. PR. - """ - issue = 'issue' - if self._pr: - issue = 'pr' - cmd_str = 'gh %s %s %s %s "%s"' % (issue, cmd, self._url, option, arg) - os.system(cmd_str) - - def edit(self, arg, option): - """ - Perform a system call to `gh` to edit an issue resp. PR. - """ - self.gh_cmd('edit', arg, option) - - def review(self, arg, text): - """ - Perform a system call to `gh` to review a PR. - """ - self.gh_cmd('review', arg, '-b %s' % text) + date = self._commit_date + new_revs = [rev for rev in self._reviews if rev['submittedAt'] > date] + info('Reviews for %s: %s after %s' % (self._issue, self._reviews, date)) + return new_revs def active_partners(self, item): """ @@ -243,7 +251,10 @@ def active_partners(self, item): info('Active partners of %s: %s' % (item, partners)) return partners - def needs_work(self, only_actor=True): + # ------------------------------------------------------------------------- + # methods to validate the issue state + # ------------------------------------------------------------------------- + def needs_work_valid(self): """ Return `True` if the PR needs work. This is the case if the review decision requests changes or if there is any @@ -260,15 +271,14 @@ def needs_work(self, only_actor=True): return False revs = self.get_reviews() - if only_actor: - revs = [rev for rev in revs if rev['author']['login'] == self._actor] + revs = [rev for rev in revs if rev['author']['login'] == self._actor] if any(rev['state'] == ch_req.value for rev in revs): info('PR %s needs work' % self._issue) return True info('PR %s doesn\'t need work' % self._issue) return False - def positive_review(self, only_actor=True): + def positive_review_valid(self): """ Return `True` if the PR has positive review. This is the case if the review decision is approved or if there is any @@ -284,46 +294,73 @@ def positive_review(self, only_actor=True): info('PR %s doesn\'t have positve review (by decision)' % self._issue) return False - if self.needs_work(): + if self.needs_work_valid(): info('PR %s doesn\'t have positve review (needs work)' % self._issue) return False revs = self.get_reviews() - if only_actor: - revs = [rev for rev in revs if rev['author']['login'] == self._actor] - if any(rev['state'] == appr.value for rev in revs): - info('PR %s has positve review (by decision)' % self._issue) - return True - info('PR %s doesn\'t have positve review (needs work)' % self._issue) + revs = [rev for rev in revs if rev['author']['login'] == self._actor] + if any(rev['state'] == appr.value for rev in revs): + info('PR %s has positve review' % self._issue) + return True + info('PR %s doesn\'t have positve review' % self._issue) return False + def needs_review_valid(self): + """ + Return ``True`` if the PR needs review. This is the case if + all proper reviews are older than the youngest commit. + """ + if self.is_draft(): + return False + + if self.needs_work_valid(): + info('PR %s already under review (needs work)' % self._issue) + return False + + if self.positive_review_valid(): + info('PR %s already reviewed' % self._issue) + return False + + info('PR %s needs review' % self._issue) + return True + def approve_allowed(self): """ Return if the actor has permission to approve this PR. """ - author = self.get_author() - revs = self.get_reviews() - if not any(rev['author']['authorAssociation'] == 'MEMBER' for rev in revs): + revs = self.get_reviews(complete=True) + if not any(rev['authorAssociation'] in ('MEMBER', 'OWNER') for rev in revs): info('PR %s can\'t be approved because of missing member review' % (self._issue)) return False + revs = self.get_reviews() revs = [rev for rev in revs if rev['author']['login'] != self._actor] ch_req = ReviewDecision.changes_requested if any(rev['state'] == ch_req.value for rev in revs): info('PR %s can\'t be approved by %s since others reqest changes' % (self._issue, self._actor)) return False + return self.actor_valid() + + def actor_valid(self): + """ + Return if the actor has permission to approve this PR. + """ + author = self.get_author() + if author != self._actor: info('PR %s can be approved by %s' % (self._issue, self._actor)) return True + revs = self.get_reviews() revs = [rev for rev in revs if rev['author']['login'] != 'github-actions'] if not revs: info('PR %s can\'t be approved by the author %s since no other person reviewed it' % (self._issue, self._actor)) return False - comts = self.get_commits() - authors = sum(com['authors'] for com in comts) + coms = self.get_commits() + authors = sum(com['authors'] for com in coms) authors = [auth for auth in authors if not auth['login'] in (self._actor, 'github-actions')] if not authors: info('PR %s can\'t be approved by the author %s since no other person commited to it' % (self._issue, self._actor)) @@ -332,6 +369,34 @@ def approve_allowed(self): info('PR %s can be approved by the author %s as co-author' % (self._issue, self._actor)) return True + # ------------------------------------------------------------------------- + # methods to change the issue + # ------------------------------------------------------------------------- + def gh_cmd(self, cmd, arg, option): + """ + Perform a system call to `gh` for `cmd` to an isuue resp. PR. + """ + issue = 'issue' + if self._pr: + issue = 'pr' + cmd_str = 'gh %s %s %s %s "%s"' % (issue, cmd, self._url, option, arg) + info('Execute command: %s' % cmd_str) + ex_code = os.system(cmd_str) + if ex_code: + warning('Execution of %s failed with exit code: %s' % (cmd_str, ex_code)) + + def edit(self, arg, option): + """ + Perform a system call to `gh` to edit an issue resp. PR. + """ + self.gh_cmd('edit', arg, option) + + def review(self, arg, text): + """ + Perform a system call to `gh` to review a PR. + """ + self.gh_cmd('review', arg, '-b \"%s\"' % text) + def approve(self): """ Approve the PR by the actor. @@ -369,6 +434,52 @@ def add_default_label(self, item): if not self.active_partners(item): self.add_label(item.value) + def select_label(self, item): + """ + Add the given label and remove all others. + """ + self.add_label(item.value) + sel_list = type(item) + for other in sel_list: + if other != item: + self.remove_label(other.value) + + def remove_label(self, label): + """ + Remove the given label from the issue or PR of the handler. + """ + if label in self.get_labels(): + self.edit(label, '--remove-label') + info('Remove label from %s: %s' % (self._issue, label)) + + def reject_label_addition(self, item): + """ + Post a comment that the given label can not be added and select + a corresponding other one. + """ + if self.is_pull_request(): + self.add_comment('Label *%s* can not be added. Please use the corresponding functionality of GitHub' % item.value) + else: + self.add_comment('Label *%s* can not be added to an issue. Please use it on the corresponding PR' % label) + self.remove_label(label) + return + + def reject_label_removal(self, item): + """ + Post a comment that the given label can not be removed and select + a corresponding other one. + """ + if type(item) == State: + sel_list = 'state' + else: + sel_list = 'priority' + self.add_comment('Label *%s* can not be removed. Please add the %s-label which should replace it' % (label, sel_list)) + self.add_label(item.value) + return + + # ------------------------------------------------------------------------- + # methods to act on events + # ------------------------------------------------------------------------- def on_label_add(self, label): """ Check if the given label belongs to a selection list. If so, remove @@ -395,169 +506,160 @@ def on_label_add(self, label): if sel_list is State: if not self.is_pull_request(): if item != State.needs_info: - self.add_comment('Label *%s* can not be added to an issue. Please use it on the corresponding PR' % label) - self.remove_label(label) + self.reject_label_addition(item) + return + + if item == State.needs_review: + if not self.needs_review_valid(): + self.reject_label_addition(item) return if item == State.positive_review: if self.approve_allowed(): self.approve() - elif self.needs_work(): - # PR still needs work - self.add_comment('Label *%s* can not be added. Please use the corresponding functionality of GitHub' % label) - self.select_label(State.needs_work) - return else: - # PR still needs review - self.add_comment('Label *%s* can not be added. Please use the corresponding functionality of GitHub' % label) - if self.is_draft(): - self.remove_label(label) - else: - self.select_label(State.needs_review) + self.reject_label_addition(item) return if item == State.needs_work: - self.request_changes() - if not self.needs_work(): - # change request of actor could not be set - self.add_comment('Label *%s* can not be added. Please use the corresponding functionality of GitHub' % label) - if self.is_draft(): - self.remove_label(label) - else: - self.select_label(State.needs_review) + if self.needs_review_valid(): + self.request_changes() + else: + self.reject_label_addition(item) return for other in sel_list: if other != item: self.remove_label(other.value) - def select_label(self, item): - """ - Add the given label and remove all others. - """ - self.add_label(item.value) - sel_list = type(item) - for other in sel_list: - if other != item: - self.remove_label(other.value) - - def remove_label(self, label): - """ - Remove the given label from the issue or PR of the handler. - """ - if label in self.get_labels(): - self.edit(label, '--remove-label') - info('Remove label from %s: %s' % (self._issue, label)) - - def on_label_remove(self, label): + def on_label_removal(self, label): """ Check if the given label belongs to a selection list. If so, the - successor of the label is added except there is none or there - exists another label of the corresponding list. In case of a - state label reviews are booked accordingly. + removement is rejected and a comment is posted to instead add a + replacement for ``label`` from the list. Exceptions are State labels + on issues and State.needs_info on a PR. """ sel_list = selection_list(label) if not sel_list: return item = sel_list(label) - if label in self.get_labels(): - # this is possible if two labels of the same selection list - # have been removed in one step (via multiple selection in the - # pull down menue). In this case `label` has been added - # on the `on_label_remove` of the first of the two labels. - partn = self.active_partners(item) - if partn: - self.on_label_add(partn[0].value) - return - if sel_list is State: - if not self.is_pull_request(): - return - if item == State.positive_review: - if self.positive_review(): - self.request_changes() - self.select_label(State.needs_work) - elif self.positive_review(only_actor=False): - self.add_comment('Label *%s* can not be removed. Please use the corresponding functionality of GitHub' % label) - self.select_label(item) - elif not self.is_draft(): - self.select_label(State.needs_review) - return - elif item == State.needs_work: - if self.needs_work(only_actor=False): - self.add_comment('Label *%s* can not be removed. Please use the corresponding functionality of GitHub' % label) - self.select_label(item) - elif not self.is_draft(): - self.select_label(State.needs_review) - return - - if not self.active_partners(item): - # if there is no other in the same selection list - # add the next weaker label if it exists - succ = sel_list(label).succ() - if succ: - self.select_label(succ) + if self.is_pull_request(): + if item != State.needs_info: + self.reject_label_removal(item) + elif sel_list is Priority: + self.reject_label_removal(item) + return - def on_converted_to_draft(self): + def remove_all_state_labels(self): """ Remove all state labels. """ for item in State: self.remove_label(item.value) + def run(self, action, label=None, rev_state=None): + """ + Run the given action. + """ + if action is Action.opened and self.is_pull_request(): + if not self.is_draft(): + self.add_default_label(State.needs_review) + + if action in (Action.closed, Action.reopened, Action.converted_to_draft): + self.remove_all_state_labels() + + if action is Action.labeled: + self.on_label_add(label) + + if action is Action.unlabeled: + self.on_label_removal(label) + + if action in (Action.ready_for_review, Action.synchronize): + if self.needs_review_valid(): + self.select_label(State.needs_review) + + if action is Action.review_requested: + self.select_label(State.needs_review) + + if action is Action.submitted: + if rev_state is RevState.approved: + if self.positive_review_valid(): + self.select_label(State.positive_review) + + if rev_state is RevState.changes_requested: + if self.needs_work_valid(): + self.select_label(State.needs_work) + + def run_tests(self): + """ + Simulative run over all posibble events. + + This is not intended to validate all functionality. It just + tests for bugs on invoking the methods. The result in the + issue or PR depends on timing. Note that the GitHub action runner + may run in parallel on the triggered events possibly on an other + version of the code. + """ + self.add_comment('Starting tests for sync_labels') + for action in Action: + self.add_comment('Test action %s' % action.value) + if action in (Action.labeled, Action.unlabeled): + for stat in State: + if action is Action.labeled: + self.add_label(stat.value) + else: + self.remove_label(stat.value) + self.run(action, label=stat) + for prio in Priority: + if action is Action.labeled: + self.add_label(prio.value) + else: + self.remove_label(prio.value) + self.run(action, label=prio) + elif action == Action.submitted and self.is_pull_request(): + for stat in RevState: + if stat is RevState.approved: + self.approve() + elif stat is RevState.changes_requested: + self.request_changes() + self.run(action, rev_state=stat) + elif self.is_pull_request(): + self.run(action) + ############################################################################### # Main ############################################################################### cmdline_args = sys.argv[1:] +num_args = len(cmdline_args) getLogger().setLevel(INFO) -info('cmdline_args (%s) %s' % (len(cmdline_args), cmdline_args)) +info('cmdline_args (%s) %s' % (num_args, cmdline_args)) -if len(cmdline_args) < 4: - print('Need 5 arguments: action, url, actor, label, rev_state' ) - exit -else: +if num_args == 5: action, url, actor, label, rev_state = cmdline_args + action = Action(action) -info('action: %s' % action) -info('url: %s' % url) -info('label: %s' % label) -info('actor: %s' % actor) -info('rev_state: %s' % rev_state) - -gh = GhLabelSynchronizer(url, actor) + info('action: %s' % action) + info('url: %s' % url) + info('actor: %s' % actor) + info('label: %s' % label) + info('rev_state: %s' % rev_state) -if action in ('opened', 'reopened'): - if gh.is_pull_request(): - if not gh.is_draft(): - gh.add_default_label(State.needs_review) + gh = GhLabelSynchronizer(url, actor) + gh.run(action, label=label, rev_state=rev_state) -if action in ('closed', 'reopened'): - for lab in State: - gh.remove_label(lab.value) +elif num_args == 2: + url, actor = cmdline_args -if action == 'labeled': - gh.on_label_add(label) + info('url: %s' % url) + info('actor: %s' % actor) -if action == 'unlabeled': - gh.on_label_remove(label) + gh = GhLabelSynchronizer(url, actor) + gh.run_tests() -if action == 'ready_for_review': - gh.select_label(State.needs_review) - -if action == 'converted_to_draft': - gh.on_converted_to_draft() - -if action == 'submitted': - if rev_state == 'approved': - if gh.positive_review(): - gh.select_label(State.positive_review) - - if rev_state == 'changes_requested': - if gh.needs_work(): - gh.select_label(State.needs_work) - -if action in ('review_requested', 'ready_for_review'): - gh.select_label(State.needs_review) +else: + print('Need 5 arguments: action, url, actor, label, rev_state' ) + print('Running tests is possible with 2 arguments: url, actor' ) diff --git a/.github/workflows/sync_labels.yml b/.github/workflows/sync_labels.yml index 4e11a94277d..84250589297 100644 --- a/.github/workflows/sync_labels.yml +++ b/.github/workflows/sync_labels.yml @@ -12,9 +12,9 @@ on: issues: types: [opened, reopened, closed, labeled, unlabeled] pull_request: - types: [opened, reopened, closed, ready_for_review, converted_to_draft, labeled, unlabeled] + types: [opened, reopened, closed, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] pull_request_target: - types: [opened, reopened, closed, ready_for_review, converted_to_draft, labeled, unlabeled] + types: [opened, reopened, closed, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] jobs: synchronize: From 96e8e7dd6ae752a54b50003a5ce7e1311bac378f Mon Sep 17 00:00:00 2001 From: Sebastian Oehms Date: Fri, 10 Mar 2023 08:54:23 +0100 Subject: [PATCH 03/12] sync_labels: add reset_view and typos --- .github/sync_labels.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/sync_labels.py b/.github/sync_labels.py index 7d64ca4526e..036e1147472 100755 --- a/.github/sync_labels.py +++ b/.github/sync_labels.py @@ -125,6 +125,19 @@ def is_pull_request(self): """ return self._pr + def reset_view(self): + """ + Reset cache of ``gh view`` results. + """ + self._labels = None + self._author = None + self._draft = None + self._open = None + self._review_decision = None + self._reviews = None + self._commits = None + self._commit_date = None + def view(self, key): """ Return data obtained from `gh` command `view`. @@ -460,8 +473,8 @@ def reject_label_addition(self, item): if self.is_pull_request(): self.add_comment('Label *%s* can not be added. Please use the corresponding functionality of GitHub' % item.value) else: - self.add_comment('Label *%s* can not be added to an issue. Please use it on the corresponding PR' % label) - self.remove_label(label) + self.add_comment('Label *%s* can not be added to an issue. Please use it on the corresponding PR' % item.value) + self.remove_label(item.value) return def reject_label_removal(self, item): @@ -473,7 +486,7 @@ def reject_label_removal(self, item): sel_list = 'state' else: sel_list = 'priority' - self.add_comment('Label *%s* can not be removed. Please add the %s-label which should replace it' % (label, sel_list)) + self.add_comment('Label *%s* can not be removed. Please add the %s-label which should replace it' % (item.value, sel_list)) self.add_label(item.value) return @@ -563,6 +576,8 @@ def run(self, action, label=None, rev_state=None): """ Run the given action. """ + self.reset_view() # this is just needed for run_tests + if action is Action.opened and self.is_pull_request(): if not self.is_draft(): self.add_default_label(State.needs_review) @@ -611,20 +626,20 @@ def run_tests(self): self.add_label(stat.value) else: self.remove_label(stat.value) - self.run(action, label=stat) + self.run(action, label=stat.value) for prio in Priority: if action is Action.labeled: self.add_label(prio.value) else: self.remove_label(prio.value) - self.run(action, label=prio) + self.run(action, label=prio.value) elif action == Action.submitted and self.is_pull_request(): for stat in RevState: if stat is RevState.approved: self.approve() elif stat is RevState.changes_requested: self.request_changes() - self.run(action, rev_state=stat) + self.run(action, rev_state=stat.value) elif self.is_pull_request(): self.run(action) From 9abdff3b9586f690be94dbdc2b67601f7d65fbb2 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 13 Mar 2023 08:03:02 +0100 Subject: [PATCH 04/12] sync_labels_checkout_files initial --- .github/workflows/sync_labels.yml | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/sync_labels.yml b/.github/workflows/sync_labels.yml index 84250589297..3708e1c0a4b 100644 --- a/.github/workflows/sync_labels.yml +++ b/.github/workflows/sync_labels.yml @@ -20,18 +20,17 @@ jobs: synchronize: runs-on: ubuntu-latest steps: - # Download the Python script - - name: Download script - id: download - run: | - curl -L -O "https://raw.githubusercontent.com/"$REPO"/master/.github/sync_labels.py" - chmod a+x sync_labels.py - env: - REPO: ${{ github.repository }} + # Checkout the Python script + - name: Checkout files + uses: Bhacaz/checkout-files@v2 + with: + files: .github/sync_labels.py # Perform synchronization - name: Call script - run: ./sync_labels.py $ACTION $ISSUE_URL $PR_URL $ACTOR "$LABEL" "$REV_STATE" + run: | + chmod a+x .github/sync_labels.py + .github/sync_labels.py $ACTION $ISSUE_URL $PR_URL $ACTOR "$LABEL" "$REV_STATE" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ACTION: ${{ github.event.action }} From 53999c3f81248c70320031409ccd61a0396a3db9 Mon Sep 17 00:00:00 2001 From: Sebastian Oehms Date: Mon, 13 Mar 2023 20:28:39 +0100 Subject: [PATCH 05/12] sync_labels_resolution initial --- .github/sync_labels.py | 136 ++++++++++++++++++++++------------------- 1 file changed, 73 insertions(+), 63 deletions(-) diff --git a/.github/sync_labels.py b/.github/sync_labels.py index 036e1147472..3be4cde5fff 100755 --- a/.github/sync_labels.py +++ b/.github/sync_labels.py @@ -3,6 +3,7 @@ r""" Python script to sync labels that are migrated from Trac selection lists. +For more information see https://github.com/sagemath/sage/pull/35172 """ ############################################################################## @@ -22,7 +23,7 @@ from enum import Enum class Action(Enum): - """ + r""" Enum for GitHub event ``action``. """ opened = 'opened' @@ -37,7 +38,7 @@ class Action(Enum): submitted = 'submitted' class RevState(Enum): - """ + r""" Enum for GitHub event ``review_state``. """ commented = 'commented' @@ -45,21 +46,15 @@ class RevState(Enum): changes_requested = 'changes_requested' class ReviewDecision(Enum): - """ + r""" Enum for ``gh pr view`` results for ``reviewDecision``. """ changes_requested = 'CHANGES_REQUESTED' approved = 'APPROVED' unclear = 'COMMENTED' -class SelectionList(Enum): - """ - Abstract Enum for selection lists. - """ - pass - -class Priority(SelectionList): - """ +class Priority(Enum): + r""" Enum for priority labels. """ blocker = 'p: blocker /1' @@ -68,8 +63,8 @@ class Priority(SelectionList): minor = 'p: minor /4' trivial = 'p: trivial /5' -class State(SelectionList): - """ +class State(Enum): + r""" Enum for state labels. """ positive_review = 's: positive review' @@ -77,24 +72,32 @@ class State(SelectionList): needs_review = 's: needs review' needs_info = 's: needs info' +class Resolution(Enum): + r""" + Enum for resolution labels. + """ + duplicate = 'r: duplicate' + invalid = 'r: invalid' + wontfix = 'r: wontfix' + worksforme = 'r: worksforme' def selection_list(label): + r""" + Return the selection list to which ``label`` belongs to. """ - Return the selection list to which `label` belongs to. - """ - for sel_list in [Priority, State]: + for sel_list in [Priority, State, Resolution]: for item in sel_list: if label == item.value: return sel_list return None class GhLabelSynchronizer: - """ - Handler for access to GitHub issue via the `gh` in the bash command line + r""" + Handler for access to GitHub issue via the ``gh`` in the bash command line of the GitHub runner. """ def __init__(self, url, actor): - """ + r""" Python constructor sets the issue / PR url and list of active labels. """ self._url = url @@ -120,13 +123,13 @@ def __init__(self, url, actor): # methods to obtain properties of the issue # ------------------------------------------------------------------------- def is_pull_request(self): - """ + r""" Return if we are treating a pull request. """ return self._pr def reset_view(self): - """ + r""" Reset cache of ``gh view`` results. """ self._labels = None @@ -139,8 +142,8 @@ def reset_view(self): self._commit_date = None def view(self, key): - """ - Return data obtained from `gh` command `view`. + r""" + Return data obtained from ``gh`` command ``view``. """ issue = 'issue' if self._pr: @@ -150,7 +153,7 @@ def view(self, key): return loads(check_output(cmd, shell=True))[key] def is_open(self): - """ + r""" Return if the issue res. PR is open. """ if self._open is not None: @@ -163,7 +166,7 @@ def is_open(self): return self._open def is_draft(self): - """ + r""" Return if the PR is a draft. """ if self._draft is not None: @@ -176,7 +179,7 @@ def is_draft(self): return self._draft def get_labels(self): - """ + r""" Return the list of labels of the issue resp. PR. """ if self._labels is not None: @@ -187,7 +190,7 @@ def get_labels(self): return self._labels def get_author(self): - """ + r""" Return the author of the issue resp. PR. """ if self._author is not None: @@ -198,7 +201,7 @@ def get_author(self): return self._author def get_commits(self): - """ + r""" Return the list of commits of the PR. """ if not self.is_pull_request(): @@ -213,7 +216,7 @@ def get_commits(self): return self._commits def get_review_decision(self): - """ + r""" Return the reviewDecision of the PR. """ if not self.is_pull_request(): @@ -231,7 +234,7 @@ def get_review_decision(self): return self._review_decision def get_reviews(self, complete=False): - """ + r""" Return the list of reviews of the PR. Per default only those reviews are returned which have been submitted after the youngest commit. Use keyword ``complete`` to get them all. @@ -255,7 +258,7 @@ def get_reviews(self, complete=False): return new_revs def active_partners(self, item): - """ + r""" Return the list of other labels from the selection list of the given one that are already present on the issue / PR. """ @@ -268,8 +271,8 @@ def active_partners(self, item): # methods to validate the issue state # ------------------------------------------------------------------------- def needs_work_valid(self): - """ - Return `True` if the PR needs work. This is the case if + r""" + Return ``True`` if the PR needs work. This is the case if the review decision requests changes or if there is any review reqesting changes. """ @@ -292,8 +295,8 @@ def needs_work_valid(self): return False def positive_review_valid(self): - """ - Return `True` if the PR has positive review. This is the + r""" + Return ``True`` if the PR has positive review. This is the case if the review decision is approved or if there is any approved review but no changes requesting one. """ @@ -320,7 +323,7 @@ def positive_review_valid(self): return False def needs_review_valid(self): - """ + r""" Return ``True`` if the PR needs review. This is the case if all proper reviews are older than the youngest commit. """ @@ -339,7 +342,7 @@ def needs_review_valid(self): return True def approve_allowed(self): - """ + r""" Return if the actor has permission to approve this PR. """ revs = self.get_reviews(complete=True) @@ -357,7 +360,7 @@ def approve_allowed(self): return self.actor_valid() def actor_valid(self): - """ + r""" Return if the actor has permission to approve this PR. """ author = self.get_author() @@ -386,8 +389,8 @@ def actor_valid(self): # methods to change the issue # ------------------------------------------------------------------------- def gh_cmd(self, cmd, arg, option): - """ - Perform a system call to `gh` for `cmd` to an isuue resp. PR. + r""" + Perform a system call to ``gh`` for ``cmd`` to an isuue resp. PR. """ issue = 'issue' if self._pr: @@ -399,41 +402,41 @@ def gh_cmd(self, cmd, arg, option): warning('Execution of %s failed with exit code: %s' % (cmd_str, ex_code)) def edit(self, arg, option): - """ - Perform a system call to `gh` to edit an issue resp. PR. + r""" + Perform a system call to ``gh`` to edit an issue resp. PR. """ self.gh_cmd('edit', arg, option) def review(self, arg, text): - """ - Perform a system call to `gh` to review a PR. + r""" + Perform a system call to ``gh`` to review a PR. """ self.gh_cmd('review', arg, '-b \"%s\"' % text) def approve(self): - """ + r""" Approve the PR by the actor. """ self.review('--approve', '%s approved this PR' % self._actor) info('PR %s approved by %s' % (self._issue, self._actor)) def request_changes(self): - """ + r""" Request changes for this PR by the actor. """ self.review('--request-changes', '%s requested changes for this PR' % self._actor) info('Changes requested for PR %s by %s' % (self._issue, self._actor)) def add_comment(self, text): - """ - Perform a system call to `gh` to add a comment to an issue or PR. + r""" + Perform a system call to ``gh`` to add a comment to an issue or PR. """ self.gh_cmd('comment', text, '-b') info('Add comment to %s: %s' % (self._issue, text)) def add_label(self, label): - """ + r""" Add the given label to the issue or PR. """ if not label in self.get_labels(): @@ -441,14 +444,14 @@ def add_label(self, label): info('Add label to %s: %s' % (self._issue, label)) def add_default_label(self, item): - """ + r""" Add the given label if there is no active partner. """ if not self.active_partners(item): self.add_label(item.value) def select_label(self, item): - """ + r""" Add the given label and remove all others. """ self.add_label(item.value) @@ -458,7 +461,7 @@ def select_label(self, item): self.remove_label(other.value) def remove_label(self, label): - """ + r""" Remove the given label from the issue or PR of the handler. """ if label in self.get_labels(): @@ -466,7 +469,7 @@ def remove_label(self, label): info('Remove label from %s: %s' % (self._issue, label)) def reject_label_addition(self, item): - """ + r""" Post a comment that the given label can not be added and select a corresponding other one. """ @@ -478,7 +481,7 @@ def reject_label_addition(self, item): return def reject_label_removal(self, item): - """ + r""" Post a comment that the given label can not be removed and select a corresponding other one. """ @@ -494,7 +497,7 @@ def reject_label_removal(self, item): # methods to act on events # ------------------------------------------------------------------------- def on_label_add(self, label): - """ + r""" Check if the given label belongs to a selection list. If so, remove all other labels of that list. In case of a state label reviews are booked accordingly. @@ -541,12 +544,15 @@ def on_label_add(self, label): self.reject_label_addition(item) return + if sel_list is Resolution: + self.remove_all_labels_of_sel_list(Priority) + for other in sel_list: if other != item: self.remove_label(other.value) def on_label_removal(self, label): - """ + r""" Check if the given label belongs to a selection list. If so, the removement is rejected and a comment is posted to instead add a replacement for ``label`` from the list. Exceptions are State labels @@ -565,15 +571,15 @@ def on_label_removal(self, label): self.reject_label_removal(item) return - def remove_all_state_labels(self): - """ - Remove all state labels. + def remove_all_labels_of_sel_list(self, sel_list): + r""" + Remove all labels of given selection list. """ - for item in State: + for item in sel_list: self.remove_label(item.value) def run(self, action, label=None, rev_state=None): - """ + r""" Run the given action. """ self.reset_view() # this is just needed for run_tests @@ -583,7 +589,7 @@ def run(self, action, label=None, rev_state=None): self.add_default_label(State.needs_review) if action in (Action.closed, Action.reopened, Action.converted_to_draft): - self.remove_all_state_labels() + self.remove_all_labels_of_sel_list(State) if action is Action.labeled: self.on_label_add(label) @@ -608,7 +614,7 @@ def run(self, action, label=None, rev_state=None): self.select_label(State.needs_work) def run_tests(self): - """ + r""" Simulative run over all posibble events. This is not intended to validate all functionality. It just @@ -633,6 +639,10 @@ def run_tests(self): else: self.remove_label(prio.value) self.run(action, label=prio.value) + res = Resolution.worksforme + if action is Action.labeled: + self.add_label(res.value) + self.run(action, label=prio.value) elif action == Action.submitted and self.is_pull_request(): for stat in RevState: if stat is RevState.approved: From 631daf44b5ca55a5430d32cd3a253f84d1a71d04 Mon Sep 17 00:00:00 2001 From: Sebastian Oehms <47305845+soehms@users.noreply.github.com> Date: Mon, 20 Mar 2023 20:31:33 +0100 Subject: [PATCH 06/12] sync_labels_add_on_review_comment initial (#6) --- .github/sync_labels.py | 129 ++++++++++++++++++++++++++++++++--------- 1 file changed, 100 insertions(+), 29 deletions(-) diff --git a/.github/sync_labels.py b/.github/sync_labels.py index 3be4cde5fff..2b6297723f0 100755 --- a/.github/sync_labels.py +++ b/.github/sync_labels.py @@ -235,9 +235,9 @@ def get_review_decision(self): def get_reviews(self, complete=False): r""" - Return the list of reviews of the PR. Per default only those reviews - are returned which have been submitted after the youngest commit. - Use keyword ``complete`` to get them all. + Return the list of reviews of the PR. Per default only those proper reviews + are returned which have been submitted after the most recent commit. Use + keyword ``complete`` to get them all. """ if not self.is_pull_request(): return None @@ -253,9 +253,11 @@ def get_reviews(self, complete=False): self.get_commits() date = self._commit_date + no_rev = ReviewDecision.unclear.value new_revs = [rev for rev in self._reviews if rev['submittedAt'] > date] - info('Reviews for %s: %s after %s' % (self._issue, self._reviews, date)) - return new_revs + proper_new_revs = [rev for rev in new_revs if rev['state'] != no_rev] + info('Proper reviews after %s for %s: %s' % (date, self._issue, proper_new_revs)) + return proper_new_revs def active_partners(self, item): r""" @@ -270,12 +272,34 @@ def active_partners(self, item): # ------------------------------------------------------------------------- # methods to validate the issue state # ------------------------------------------------------------------------- + def review_comment_to_state(self): + r""" + Return a State label if the most recent review comment + starts with its value. + """ + revs = self.get_reviews(complete=True) + date = max(rev['submittedAt'] for rev in revs) + + for rev in revs: + if rev['submittedAt'] == date: + for stat in State: + body = rev['body'] + if body.startswith(stat.value): + return stat + return None + def needs_work_valid(self): r""" Return ``True`` if the PR needs work. This is the case if - the review decision requests changes or if there is any - review reqesting changes. + there are reviews more recent than any commit and the review + decision requests changes or if there is any review reqesting + changes. """ + revs = self.get_reviews() + if not revs: + # no proper review since most recent commit. + return False + ch_req = ReviewDecision.changes_requested rev_dec = self.get_review_decision() if rev_dec: @@ -286,8 +310,6 @@ def needs_work_valid(self): info('PR %s doesn\'t need work (by decision)' % self._issue) return False - revs = self.get_reviews() - revs = [rev for rev in revs if rev['author']['login'] == self._actor] if any(rev['state'] == ch_req.value for rev in revs): info('PR %s needs work' % self._issue) return True @@ -297,9 +319,15 @@ def needs_work_valid(self): def positive_review_valid(self): r""" Return ``True`` if the PR has positive review. This is the - case if the review decision is approved or if there is any - approved review but no changes requesting one. + case if there are reviews more recent than any commit and the + review decision is approved or if there is any approved review + but no changes requesting one. """ + revs = self.get_reviews() + if not revs: + # no proper review since most recent commit. + return False + appr = ReviewDecision.approved rev_dec = self.get_review_decision() if rev_dec: @@ -310,13 +338,7 @@ def positive_review_valid(self): info('PR %s doesn\'t have positve review (by decision)' % self._issue) return False - if self.needs_work_valid(): - info('PR %s doesn\'t have positve review (needs work)' % self._issue) - return False - - revs = self.get_reviews() - revs = [rev for rev in revs if rev['author']['login'] == self._actor] - if any(rev['state'] == appr.value for rev in revs): + if all(rev['state'] == appr.value for rev in revs): info('PR %s has positve review' % self._issue) return True info('PR %s doesn\'t have positve review' % self._issue) @@ -407,6 +429,12 @@ def edit(self, arg, option): """ self.gh_cmd('edit', arg, option) + def mark_as_ready(self): + r""" + Perform a system call to ``gh`` to mark a PR as ready for review. + """ + self.gh_cmd('ready', '', '') + def review(self, arg, text): r""" Perform a system call to ``gh`` to review a PR. @@ -427,6 +455,13 @@ def request_changes(self): self.review('--request-changes', '%s requested changes for this PR' % self._actor) info('Changes requested for PR %s by %s' % (self._issue, self._actor)) + def review_comment(self, text): + r""" + Add a review comment. + """ + self.review('--comment', text) + info('Add review comment for PR %s: %s' % (self._issue, text)) + def add_comment(self, text): r""" Perform a system call to ``gh`` to add a comment to an issue or PR. @@ -526,20 +561,34 @@ def on_label_add(self, label): return if item == State.needs_review: - if not self.needs_review_valid(): + if self.needs_review_valid(): + # here we come for example after a sequence: + # needs review -> needs info -> needs review + pass + elif self.is_draft(): + self.mark_as_ready() + else: self.reject_label_addition(item) return - if item == State.positive_review: - if self.approve_allowed(): - self.approve() + if item == State.needs_work: + if self.needs_work_valid(): + # here we come for example after a sequence: + # needs work -> needs info -> needs work + pass + elif not self.is_draft(): + self.request_changes() else: self.reject_label_addition(item) return - if item == State.needs_work: - if self.needs_review_valid(): - self.request_changes() + if item == State.positive_review: + if self.positive_review_valid(): + # here we come for example after a sequence: + # positive review -> needs info -> positive review + pass + elif self.approve_allowed(): + self.approve() else: self.reject_label_addition(item) return @@ -570,6 +619,19 @@ def on_label_removal(self, label): elif sel_list is Priority: self.reject_label_removal(item) return + + def on_review_comment(self): + r""" + Check if the text of the most recent review begins with a + specific label name. In this case, simulate the corresponding + label addition. This feature is needed for people who don't + have permission to add labels (i.e. aren't a member of the + Triage team). + """ + rev_state = self.review_comment_to_state() + if rev_state in (State.needs_info, State.needs_review): + self.select_label(rev_state) + self.run(Action.labeled, label=rev_state.value) def remove_all_labels_of_sel_list(self, sel_list): r""" @@ -605,6 +667,7 @@ def run(self, action, label=None, rev_state=None): self.select_label(State.needs_review) if action is Action.submitted: + rev_state = RevState(rev_state) if rev_state is RevState.approved: if self.positive_review_valid(): self.select_label(State.positive_review) @@ -613,6 +676,9 @@ def run(self, action, label=None, rev_state=None): if self.needs_work_valid(): self.select_label(State.needs_work) + if rev_state is RevState.commented: + self.on_review_comment() + def run_tests(self): r""" Simulative run over all posibble events. @@ -644,12 +710,17 @@ def run_tests(self): self.add_label(res.value) self.run(action, label=prio.value) elif action == Action.submitted and self.is_pull_request(): - for stat in RevState: - if stat is RevState.approved: + for rev_stat in RevState: + if rev_stat is RevState.approved: self.approve() - elif stat is RevState.changes_requested: + self.run(action, rev_state=rev_stat.value) + elif rev_stat is RevState.changes_requested: self.request_changes() - self.run(action, rev_state=stat.value) + self.run(action, rev_state=rev_stat.value) + elif rev_stat is RevState.commented: + for stat in State: + self.review_comment(stat.value) + self.run(action, rev_state=rev_stat.value) elif self.is_pull_request(): self.run(action) From c8295256708d9fb19086e86e409ef32865ad8892 Mon Sep 17 00:00:00 2001 From: Sebastian Oehms <47305845+soehms@users.noreply.github.com> Date: Sun, 26 Mar 2023 11:58:07 +0200 Subject: [PATCH 07/12] Cleaning of warning comments (#7) * clean_warning_comments initial * clean_warning_comments remove pull_request trigger from workflow --- .github/sync_labels.py | 103 ++++++++++++++++++++++++++---- .github/workflows/sync_labels.yml | 6 +- 2 files changed, 94 insertions(+), 15 deletions(-) diff --git a/.github/sync_labels.py b/.github/sync_labels.py index 2b6297723f0..ba4b3cc743d 100755 --- a/.github/sync_labels.py +++ b/.github/sync_labels.py @@ -18,9 +18,14 @@ import os import sys -from logging import info, warning, getLogger, INFO +from logging import info, warning, debug, getLogger, INFO, DEBUG from json import loads from enum import Enum +from datetime import datetime, timedelta +from subprocess import check_output, CalledProcessError + +datetime_format = '%Y-%m-%dT%H:%M:%SZ' + class Action(Enum): r""" @@ -102,6 +107,7 @@ def __init__(self, url, actor): """ self._url = url self._actor = actor + self._warning_prefix = 'Label Sync Warning:' self._labels = None self._author = None self._draft = None @@ -118,6 +124,7 @@ def __init__(self, url, actor): self._issue = 'issue #%s' % number self._pr = False info('Create label handler for %s and actor %s' % (self._issue, self._actor)) + self.clean_warnings() # ------------------------------------------------------------------------- # methods to obtain properties of the issue @@ -141,6 +148,21 @@ def reset_view(self): self._commits = None self._commit_date = None + def rest_api(self, path_args, method=None, query=''): + r""" + Return data obtained from ``gh`` command ``api``. + """ + s = self._url.split('/') + owner = s[3] + repo = s[4] + meth = '-X GET' + if method: + meth='-X %s' % method + cmd = 'gh api %s -H \"Accept: application/vnd.github+json\" /repos/%s/%s/%s %s' % (meth, owner, repo, path_args, query) + if method: + return check_output(cmd, shell=True) + return loads(check_output(cmd, shell=True)) + def view(self, key): r""" Return data obtained from ``gh`` command ``view``. @@ -149,7 +171,6 @@ def view(self, key): if self._pr: issue = 'pr' cmd = 'gh %s view %s --json %s' % (issue, self._url, key) - from subprocess import check_output return loads(check_output(cmd, shell=True))[key] def is_open(self): @@ -178,6 +199,49 @@ def is_draft(self): info('Issue %s is draft %s' % (self._issue, self._draft)) return self._draft + def clean_warnings(self): + r""" + Remove all warnings that have been posted by ``GhLabelSynchronizer`` + more than ``warning_lifetime`` ago. + """ + warning_lifetime = timedelta(minutes=5) + time_frame = timedelta(hours=12) # timedelta to search for comments + per_page = 100 + today = datetime.today() + since = today - time_frame + query = '-F per_page=%s -F page={} -f since=%s' % (per_page, since.strftime(datetime_format)) + path = 'issues/comments' + page = 1 + comments = [] + while True: + comments_page = self.rest_api(path, query=query.format(page)) + comments += comments_page + if len(comments_page) < per_page: + break + page += 1 + + info('Cleaning warning comments since %s (total found %s)' % (since, len(comments))) + + for c in comments: + login = c['user']['login'] + body = c['body'] + comment_id = c['id'] + issue = c['issue_url'].split('/').pop() + created_at = c['created_at'] + if login.startswith('github-actions'): + debug('github-actions comment %s created at %s on issue %s found' % (comment_id, created_at, issue)) + if body.startswith(self._warning_prefix): + created = datetime.strptime(created_at, datetime_format) + lifetime = today - created + debug('github-actions %s %s is %s old' % (self._warning_prefix, comment_id, lifetime)) + if lifetime > warning_lifetime: + try: + self.rest_api('%s/%s' % (path, comment_id), method='DELETE') + info('Comment %s on issue %s deleted' % (comment_id, issue)) + except CalledProcessError: + # the comment may have been deleted by a bot running in parallel + info('Comment %s on issue %s has been deleted already' % (comment_id, issue)) + def get_labels(self): r""" Return the list of labels of the issue resp. PR. @@ -244,7 +308,7 @@ def get_reviews(self, complete=False): if self._reviews is None: self._reviews = self.view('reviews') - info('Reviews for %s: %s' % (self._issue, self._reviews)) + debug('Reviews for %s: %s' % (self._issue, self._reviews)) if complete or not self._reviews: return self._reviews @@ -418,7 +482,7 @@ def gh_cmd(self, cmd, arg, option): if self._pr: issue = 'pr' cmd_str = 'gh %s %s %s %s "%s"' % (issue, cmd, self._url, option, arg) - info('Execute command: %s' % cmd_str) + debug('Execute command: %s' % cmd_str) ex_code = os.system(cmd_str) if ex_code: warning('Execution of %s failed with exit code: %s' % (cmd_str, ex_code)) @@ -466,10 +530,15 @@ def add_comment(self, text): r""" Perform a system call to ``gh`` to add a comment to an issue or PR. """ - self.gh_cmd('comment', text, '-b') info('Add comment to %s: %s' % (self._issue, text)) + def add_warning(self, text): + r""" + Perform a system call to ``gh`` to add a warning to an issue or PR. + """ + self.add_comment('%s %s' % (self._warning_prefix, text)) + def add_label(self, label): r""" Add the given label to the issue or PR. @@ -508,10 +577,12 @@ def reject_label_addition(self, item): Post a comment that the given label can not be added and select a corresponding other one. """ - if self.is_pull_request(): - self.add_comment('Label *%s* can not be added. Please use the corresponding functionality of GitHub' % item.value) + if not self.is_pull_request(): + self.add_warning('Label *%s* can not be added to an issue. Please use it on the corresponding PR' % item.value) + elif item is State.needs_review: + self.add_warning('Label *%s* can not be added, since there are unresolved reviews' % item.value) else: - self.add_comment('Label *%s* can not be added to an issue. Please use it on the corresponding PR' % item.value) + self.add_warning('Label *%s* can not be added. Please use the GitHub review functionality' % item.value) self.remove_label(item.value) return @@ -524,7 +595,7 @@ def reject_label_removal(self, item): sel_list = 'state' else: sel_list = 'priority' - self.add_comment('Label *%s* can not be removed. Please add the %s-label which should replace it' % (item.value, sel_list)) + self.add_warning('Label *%s* can not be removed. Please add the %s-label which should replace it' % (item.value, sel_list)) self.add_label(item.value) return @@ -549,7 +620,7 @@ def on_label_add(self, label): # on the `on_label_add` of the first of the two labels partn = self.active_partners(item) if partn: - self.add_comment('Label *%s* can not be added due to *%s*!' % (label, partn[0].value)) + self.add_warning('Label *%s* can not be added due to *%s*!' % (label, partn[0].value)) else: warning('Label %s of %s not found!' % (label, self._issue)) return @@ -731,7 +802,9 @@ def run_tests(self): cmdline_args = sys.argv[1:] num_args = len(cmdline_args) -getLogger().setLevel(INFO) +# getLogger().setLevel(INFO) +getLogger().setLevel(DEBUG) + info('cmdline_args (%s) %s' % (num_args, cmdline_args)) if num_args == 5: @@ -756,6 +829,14 @@ def run_tests(self): gh = GhLabelSynchronizer(url, actor) gh.run_tests() +elif num_args == 1: + url, = cmdline_args + + info('url: %s' % url) + + gh = GhLabelSynchronizer(url, 'sagetrac-github-bot') + else: print('Need 5 arguments: action, url, actor, label, rev_state' ) print('Running tests is possible with 2 arguments: url, actor' ) + print('Cleaning warning comments is possible with 1 argument: url' ) diff --git a/.github/workflows/sync_labels.yml b/.github/workflows/sync_labels.yml index 3708e1c0a4b..71ff7eb2161 100644 --- a/.github/workflows/sync_labels.yml +++ b/.github/workflows/sync_labels.yml @@ -7,12 +7,10 @@ name: Synchronize selection list lables on: - pull_request_review: - types: [submitted] issues: types: [opened, reopened, closed, labeled, unlabeled] - pull_request: - types: [opened, reopened, closed, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] + pull_request_review: + types: [submitted] pull_request_target: types: [opened, reopened, closed, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] From aa449ef876d8ffe8f95dee9550e76ad74ace179c Mon Sep 17 00:00:00 2001 From: Sebastian Oehms <47305845+soehms@users.noreply.github.com> Date: Mon, 10 Apr 2023 07:54:31 +0200 Subject: [PATCH 08/12] sync_labels_actor_authorized initial (#8) --- .github/sync_labels.py | 119 +++++++++++++++++++++++------- .github/workflows/sync_labels.yml | 21 +++++- 2 files changed, 110 insertions(+), 30 deletions(-) diff --git a/.github/sync_labels.py b/.github/sync_labels.py index ba4b3cc743d..05fa1459b5e 100755 --- a/.github/sync_labels.py +++ b/.github/sync_labels.py @@ -18,7 +18,7 @@ import os import sys -from logging import info, warning, debug, getLogger, INFO, DEBUG +from logging import info, warning, debug, getLogger, INFO, DEBUG, WARNING from json import loads from enum import Enum from datetime import datetime, timedelta @@ -152,13 +152,11 @@ def rest_api(self, path_args, method=None, query=''): r""" Return data obtained from ``gh`` command ``api``. """ - s = self._url.split('/') - owner = s[3] - repo = s[4] meth = '-X GET' if method: meth='-X %s' % method - cmd = 'gh api %s -H \"Accept: application/vnd.github+json\" /repos/%s/%s/%s %s' % (meth, owner, repo, path_args, query) + cmd = 'gh api %s -H \"Accept: application/vnd.github+json\" %s %s' % (meth, path_args, query) + debug('Execute command: %s' % cmd) if method: return check_output(cmd, shell=True) return loads(check_output(cmd, shell=True)) @@ -171,11 +169,12 @@ def view(self, key): if self._pr: issue = 'pr' cmd = 'gh %s view %s --json %s' % (issue, self._url, key) + debug('Execute command: %s' % cmd) return loads(check_output(cmd, shell=True))[key] def is_open(self): r""" - Return if the issue res. PR is open. + Return ``True`` if the issue res. PR is open. """ if self._open is not None: return self._open @@ -188,7 +187,7 @@ def is_open(self): def is_draft(self): r""" - Return if the PR is a draft. + Return ``True`` if the PR is a draft. """ if self._draft is not None: return self._draft @@ -199,22 +198,55 @@ def is_draft(self): info('Issue %s is draft %s' % (self._issue, self._draft)) return self._draft + def is_auth_team_member(self, login): + r""" + Return ``True`` if the user with given login belongs to an authorized + team. + """ + def verify_membership(team): + path_args = '/orgs/sagemath/teams/%s/memberships/%s' % (team, login) + try: + res = self.rest_api(path_args) + if res['state'] == 'active' and res['role'] == 'member': + info('User %s is a member of %s' % (login, team)) + return True + except CalledProcessError: + pass + + info('User %s is not a member of %s' % (login, team)) + return False + + # check for the Triage team + if verify_membership('triage'): + return True + + return False + + def actor_authorized(self): + r""" + Return ``True`` if the actor belongs to an authorized team. + """ + return self.is_auth_team_member(self._actor) + def clean_warnings(self): r""" Remove all warnings that have been posted by ``GhLabelSynchronizer`` more than ``warning_lifetime`` ago. """ warning_lifetime = timedelta(minutes=5) - time_frame = timedelta(hours=12) # timedelta to search for comments + time_frame = timedelta(minutes=730) # timedelta to search for comments including 10 minutes overlap with cron-cycle per_page = 100 today = datetime.today() since = today - time_frame query = '-F per_page=%s -F page={} -f since=%s' % (per_page, since.strftime(datetime_format)) - path = 'issues/comments' + s = self._url.split('/') + owner = s[3] + repo = s[4] + path_args = '/repos/%s/%s/issues/comments' % (owner, repo) page = 1 comments = [] while True: - comments_page = self.rest_api(path, query=query.format(page)) + comments_page = self.rest_api(path_args, query=query.format(page)) comments += comments_page if len(comments_page) < per_page: break @@ -236,7 +268,7 @@ def clean_warnings(self): debug('github-actions %s %s is %s old' % (self._warning_prefix, comment_id, lifetime)) if lifetime > warning_lifetime: try: - self.rest_api('%s/%s' % (path, comment_id), method='DELETE') + self.rest_api('%s/%s' % (path_args, comment_id), method='DELETE') info('Comment %s on issue %s deleted' % (comment_id, issue)) except CalledProcessError: # the comment may have been deleted by a bot running in parallel @@ -740,7 +772,7 @@ def run(self, action, label=None, rev_state=None): if action is Action.submitted: rev_state = RevState(rev_state) if rev_state is RevState.approved: - if self.positive_review_valid(): + if self.actor_authorized() and self.positive_review_valid(): self.select_label(State.positive_review) if rev_state is RevState.changes_requested: @@ -799,44 +831,75 @@ def run_tests(self): ############################################################################### # Main ############################################################################### +last_arg = None +run_tests = False +default_actor = 'sagetrac-github-bot' cmdline_args = sys.argv[1:] num_args = len(cmdline_args) -# getLogger().setLevel(INFO) -getLogger().setLevel(DEBUG) +if num_args: + last_arg = cmdline_args[num_args-1] + +if last_arg in ('-t', '--test'): + getLogger().setLevel(DEBUG) + cmdline_args.pop() + run_tests = True +elif last_arg in ('-d', '--debug'): + getLogger().setLevel(DEBUG) + cmdline_args.pop() +elif last_arg in ('-i', '--info'): + getLogger().setLevel(INFO) + cmdline_args.pop() +elif last_arg in ('-w', '--warning'): + getLogger().setLevel(INFO) + info('cmdline_args (%s) %s' % (num_args, cmdline_args)) + getLogger().setLevel(WARNING) + cmdline_args.pop() +else: + getLogger().setLevel(DEBUG) +num_args = len(cmdline_args) info('cmdline_args (%s) %s' % (num_args, cmdline_args)) -if num_args == 5: - action, url, actor, label, rev_state = cmdline_args - action = Action(action) +if run_tests and num_args in (1,2): + if num_args == 2: + url, actor = cmdline_args + else: + url, = cmdline_args + actor = default_actor - info('action: %s' % action) info('url: %s' % url) info('actor: %s' % actor) - info('label: %s' % label) - info('rev_state: %s' % rev_state) gh = GhLabelSynchronizer(url, actor) - gh.run(action, label=label, rev_state=rev_state) + gh.run_tests() -elif num_args == 2: - url, actor = cmdline_args +elif num_args == 5: + action, url, actor, label, rev_state = cmdline_args + action = Action(action) + info('action: %s' % action) info('url: %s' % url) info('actor: %s' % actor) + info('label: %s' % label) + info('rev_state: %s' % rev_state) gh = GhLabelSynchronizer(url, actor) - gh.run_tests() + gh.run(action, label=label, rev_state=rev_state) elif num_args == 1: url, = cmdline_args info('url: %s' % url) - gh = GhLabelSynchronizer(url, 'sagetrac-github-bot') + gh = GhLabelSynchronizer(url, default_actor) else: - print('Need 5 arguments: action, url, actor, label, rev_state' ) - print('Running tests is possible with 2 arguments: url, actor' ) - print('Cleaning warning comments is possible with 1 argument: url' ) + print('Need 5 arguments to synchronize: action, url, actor, label, rev_state') + print('Need 1 argument to clean warning comments: url') + print('Need 1 argument to run tests: url') + print('The following options may be appended:') + print(' -t --test to run the test suite') + print(' -i --info to set the log-level to INFO') + print(' -d --debug to set the log-level to DEBUG (default)') + print(' -w --warning to set the log-level to WARNING') diff --git a/.github/workflows/sync_labels.yml b/.github/workflows/sync_labels.yml index 71ff7eb2161..4a406a5b72f 100644 --- a/.github/workflows/sync_labels.yml +++ b/.github/workflows/sync_labels.yml @@ -13,9 +13,13 @@ on: types: [submitted] pull_request_target: types: [opened, reopened, closed, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] + schedule: + # run cleaning of warning comments twice a day + - cron: '00 6,18 * * *' jobs: synchronize: + if: vars.SYNC_LABELS_ACTIVE == 'yes' # variable from repository settings to suspend the job runs-on: ubuntu-latest steps: # Checkout the Python script @@ -25,10 +29,11 @@ jobs: files: .github/sync_labels.py # Perform synchronization - - name: Call script + - name: Call script for synchronization + if: github.event.schedule == '' run: | chmod a+x .github/sync_labels.py - .github/sync_labels.py $ACTION $ISSUE_URL $PR_URL $ACTOR "$LABEL" "$REV_STATE" + .github/sync_labels.py $ACTION $ISSUE_URL $PR_URL $ACTOR "$LABEL" "$REV_STATE" $LOG_LEVEL env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ACTION: ${{ github.event.action }} @@ -37,3 +42,15 @@ jobs: ACTOR: ${{ github.actor }} LABEL: ${{ github.event.label.name }} REV_STATE: ${{ github.event.review.state }} + LOG_LEVEL: ${{ vars.SYNC_LABELS_LOG_LEVEL }} # variable from repository settings, values can be "--debug", "--info" or "--warning" + + # Perform cleaning + - name: Call script for cleaning + if: github.event.schedule != '' + run: | + chmod a+x .github/sync_labels.py + .github/sync_labels.py $REPO_URL $LOG_LEVEL + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO_URL: ${{ github.event.repository.html_url }} + LOG_LEVEL: ${{ vars.SYNC_LABELS_LOG_LEVEL }} # variable from repository settings, values can be "--debug", "--info" or "--warning" From 9246bb1471c0acb1513bccbfc1c304ff893ff724 Mon Sep 17 00:00:00 2001 From: Sebastian Oehms Date: Mon, 10 Apr 2023 22:44:04 +0200 Subject: [PATCH 09/12] sync_labels: introduce vars.SYNC_LABELS_BIDIRECT --- .github/workflows/sync_labels.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync_labels.yml b/.github/workflows/sync_labels.yml index 4a406a5b72f..0171fdb427b 100644 --- a/.github/workflows/sync_labels.yml +++ b/.github/workflows/sync_labels.yml @@ -19,7 +19,11 @@ on: jobs: synchronize: - if: vars.SYNC_LABELS_ACTIVE == 'yes' # variable from repository settings to suspend the job + if: | # check variables from repository settings to suspend the job + vars.SYNC_LABELS_ACTIVE == 'yes' && ( + vars.SYNC_LABELS_BIDIRECT == 'yes' || + github.event.action != 'labeled' && + github.event.action != 'unlabeled') runs-on: ubuntu-latest steps: # Checkout the Python script From 1a2e9f5ecbbf8c94b3cac35402f5d3ef262578d8 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 31 May 2023 08:13:17 +0200 Subject: [PATCH 10/12] 35172: vars.SYNC_LABELS_BIDIRECT -> vars.SYNC_LABELS_IGNORE_EVENTS --- .github/workflows/sync_labels.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/sync_labels.yml b/.github/workflows/sync_labels.yml index 0171fdb427b..acc02862657 100644 --- a/.github/workflows/sync_labels.yml +++ b/.github/workflows/sync_labels.yml @@ -20,10 +20,7 @@ on: jobs: synchronize: if: | # check variables from repository settings to suspend the job - vars.SYNC_LABELS_ACTIVE == 'yes' && ( - vars.SYNC_LABELS_BIDIRECT == 'yes' || - github.event.action != 'labeled' && - github.event.action != 'unlabeled') + vars.SYNC_LABELS_ACTIVE == 'yes' && ! contains(vars.SYNC_LABELS_IGNORE_EVENTS, github.event.action) runs-on: ubuntu-latest steps: # Checkout the Python script From 8eb6854650a79782a475c5903277c089d76eeedc Mon Sep 17 00:00:00 2001 From: Sebastian Oehms Date: Fri, 2 Jun 2023 09:00:54 +0200 Subject: [PATCH 11/12] 35172: use Json array for SYNC_LABELS_IGNORE_EVENTS --- .github/workflows/sync_labels.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync_labels.yml b/.github/workflows/sync_labels.yml index acc02862657..ec2f61ab53f 100644 --- a/.github/workflows/sync_labels.yml +++ b/.github/workflows/sync_labels.yml @@ -20,7 +20,7 @@ on: jobs: synchronize: if: | # check variables from repository settings to suspend the job - vars.SYNC_LABELS_ACTIVE == 'yes' && ! contains(vars.SYNC_LABELS_IGNORE_EVENTS, github.event.action) + vars.SYNC_LABELS_ACTIVE == 'yes' && (! vars.SYNC_LABELS_IGNORE_EVENTS || ! contains(fromJSON(vars.SYNC_LABELS_IGNORE_EVENTS), github.event.action)) runs-on: ubuntu-latest steps: # Checkout the Python script From 54002f2d298c8125570fab7f31f19a86a3bafb61 Mon Sep 17 00:00:00 2001 From: Sebastian Oehms Date: Fri, 2 Jun 2023 17:59:38 +0200 Subject: [PATCH 12/12] 36172: Title corrections according to review --- .github/sync_labels.py | 2 +- .github/workflows/sync_labels.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/sync_labels.py b/.github/sync_labels.py index 05fa1459b5e..c920d21fca4 100755 --- a/.github/sync_labels.py +++ b/.github/sync_labels.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- r""" -Python script to sync labels that are migrated from Trac selection lists. +Python script to sync status and priority labels. For more information see https://github.com/sagemath/sage/pull/35172 """ diff --git a/.github/workflows/sync_labels.yml b/.github/workflows/sync_labels.yml index ec2f61ab53f..f9378d1fe9d 100644 --- a/.github/workflows/sync_labels.yml +++ b/.github/workflows/sync_labels.yml @@ -4,7 +4,7 @@ # Furthermore in the case of the state it checks the labels # to coincide with the corresponding review state. -name: Synchronize selection list lables +name: Synchronize labels on: issues: