├── .github └── workflows │ └── scrum.yml ├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── requirements.txt ├── test_upkeep.py ├── upkeep.py └── uptime.txt /.github/workflows/scrum.yml: -------------------------------------------------------------------------------- 1 | name: "Run the scrum bot" 2 | on: 3 | schedule: 4 | # Cron is minute | hour (UTC) | day of month | month | day of week 5 | - cron: "40 22 * * *" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | create-issues: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Run upkeep.py 14 | run: | 15 | pip install --requirement requirements.txt 16 | pytest 17 | python upkeep.py --workdays-ahead=1 --token=${{ secrets.SCRUMLORD_TOKEN }} 18 | 19 | - uses: stefanzweifel/git-auto-commit-action@v4 20 | with: 21 | commit_author: scrum-lord 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | .cache 4 | .ipynb_checkpoints 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | sudo: false 3 | language: python 4 | python: 5 | - "3.7" 6 | cache: pip 7 | before_install: 8 | - export TZ=America/New_York 9 | install: 10 | - pip install --requirement requirements.txt 11 | script: 12 | - pytest 13 | deploy: 14 | provider: script 15 | script: python upkeep.py --workdays-ahead=1 --token=$GH_TOKEN 16 | skip_cleanup: true 17 | on: 18 | branch: master 19 | condition: $TRAVIS_EVENT_TYPE = "cron" 20 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # BSD 3-Clause License 2 | 3 | _Copyright © 2017, the Greene Lab at the University of Pennsylvania_
4 | _All rights reserved._ 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Continuous administration of the Greene Lab's electronic scrum 2 | 3 | [![Build Status](https://travis-ci.org/greenelab/scrumlord.svg?branch=master)](https://travis-ci.org/greenelab/scrumlord) 4 | 5 | ## Summary 6 | 7 | This repository automates the management of GitHub issues, which must be opened and closed based on the date. 8 | 9 | ## Details 10 | 11 | The Greene Lab does an electronic [scrum](https://github.com/greenelab/onboarding/blob/master/onboarding.md#meetings) (e-scrum) where lab members create daily task lists using GitHub issues on [`greenelab/scrum`](https://github.com/greenelab/scrum) (private repository). 12 | To automate the administration of `greenelab/scrum` issues, this repository relies on Github Actions daily cron jobs and a GitHub machine user named [**@scrum-lord**](https://github.com/scrum-lord). 13 | Every day, Github Actions executes the workflow in [`scrum.yml`](./github/workflows/scrum.yml). 14 | As appropriate, **@scrum-lord** closes and opens issues to keep the scrum issues up to date. 15 | 16 | ## Github Actions Deployment Instructions 17 | 18 | 1. Fork repo 19 | 2. Create new empty repo organization/scrum 20 | 3. Get Github login token (https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/) with repo and workflow scope 21 | - The safest way to do this is to create a new machine user that doesn't have any other privileges than for the scrum repo 22 | 4. In the settings for the workflow repo: 23 | - Create environment variable named `SCRUMLORD_TOKEN` whose value is the login token from step 3. 24 | 5. Commit changes to master branch to fit your settings (see https://github.com/gentnerlab/scrumlord/network for examples) 25 | 26 | ## Travis-ci Deployment Instructions 27 | 28 | Use these instructions to deploy a new instance of the scrumlord in travis-ci to manage scrum issues for a repository 29 | 30 | 1. Fork repo 31 | 2. Create new empty repo organization/scrum 32 | 3. Get Github login token (https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/) with repo scope 33 | - The safest way to do this is to create a new machine user that doesn't have any other privileges than for the scrum repo 34 | 4. log into https://travis-ci.com using github 35 | 5. Add new repo to travis-ci: organization/scrumlord 36 | 6. In the settings for that travis-ci repo: 37 | - Add daily [cronjob](https://docs.travis-ci.com/user/cron-jobs/) to always run master branch 38 | - Create environment variable named `GH_TOKEN` whose value is the login token from step 3. 39 | 7. Commit changes to master branch to fit your settings (see https://github.com/gentnerlab/scrumlord/network for examples) 40 | 41 | ## Reuse 42 | 43 | Anyone is welcome to adapt this codebase for their use cases. 44 | The repository is openly licensed as per [`LICENSE.md`](LICENSE.md). 45 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | holidays==0.9.11 2 | PyGithub==1.57 3 | pytest>=3.3 4 | -------------------------------------------------------------------------------- /test_upkeep.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | import upkeep 6 | 7 | 8 | class Issue: 9 | """ 10 | Mock of github.Issue.Issue class 11 | """ 12 | def __init__(self, title): 13 | self.title = title 14 | 15 | 16 | @pytest.mark.parametrize("date_tuple,holiday", [ 17 | ((2016, 12, 27), True), # Special Winter Vacation 18 | ((2016, 12, 28), True), # Special Winter Vacation 19 | ((2016, 12, 29), True), # Special Winter Vacation 20 | ((2016, 12, 30), True), # Special Winter Vacation 21 | ((2016, 12, 31), True), # Special Winter Vacation 22 | ((2016, 7, 4), True), # Independence Day 23 | ((2017, 7, 4), True), # Independence Day 24 | ((2017, 1, 2), True), # New Year’s Day 25 | ((2017, 1, 3), False), 26 | ((2015, 1, 10), False), 27 | ((2019, 5, 27), True), # Memorial Day 28 | ]) 29 | def test_is_holiday(date_tuple, holiday): 30 | date = datetime.date(*date_tuple) 31 | assert upkeep.is_holiday(date) == holiday 32 | 33 | 34 | @pytest.mark.parametrize("date_tuple,workday", [ 35 | ((2017, 1, 2), False), # New Year’s Day 36 | ((2017, 4, 17), True), # Monday 37 | ((2017, 4, 18), True), # Tuesday 38 | ((2017, 4, 19), True), # Wednesday 39 | ((2017, 4, 20), True), # Thursday 40 | ((2017, 4, 21), True), # Friday 41 | ((2017, 4, 22), False), # Saturday 42 | ((2017, 4, 23), False), # Sunday 43 | ]) 44 | def test_is_workday(date_tuple, workday): 45 | date = datetime.date(*date_tuple) 46 | assert upkeep.is_workday(date) == workday 47 | 48 | 49 | @pytest.mark.parametrize("title,date_tuple", [ 50 | ('2017-04-21: e-scrum for Friday, April 21, 2017', (2017, 4, 21)), 51 | ('2017-04-11: e-scrum for Tuesday, April 11, 2017', (2017, 4, 11)), 52 | ('2016-11-08: e-scrum for Tuesday, November 8, 2016', (2016, 11, 8)), 53 | ('This is an issue from 2016-11-08, not a scrum', None), 54 | ]) 55 | def test_issue_title_to_date(title, date_tuple): 56 | date = datetime.date(*date_tuple) if date_tuple else None 57 | assert upkeep.issue_title_to_date(title) == date 58 | 59 | 60 | def test_get_future_dates_without_issues_friday(monkeypatch): 61 | today = datetime.date(2017, 4, 21) # Friday 62 | monkeypatch.setattr('upkeep.get_today', lambda: today) 63 | expected = [ 64 | datetime.date(2017, 4, 21), # Friday, today 65 | datetime.date(2017, 4, 24), # Monday 66 | datetime.date(2017, 4, 25), # Tuesday 67 | datetime.date(2017, 4, 26), # Wednesday 68 | ] 69 | issues = [] # No open issues 70 | dates = upkeep.get_future_dates_without_issues(issues, workdays_ahead=3) 71 | assert list(dates) == expected 72 | 73 | 74 | def test_get_future_dates_without_issues_saturday(monkeypatch): 75 | today = datetime.date(2017, 4, 22) # Saturday 76 | monkeypatch.setattr('upkeep.get_today', lambda: today) 77 | expected = [ 78 | datetime.date(2017, 4, 24), # Monday 79 | datetime.date(2017, 4, 25), # Tuesday 80 | ] 81 | issues = [] # No open issues 82 | dates = upkeep.get_future_dates_without_issues(issues, workdays_ahead=2) 83 | assert list(dates) == expected 84 | 85 | 86 | def test_get_future_dates_without_issues_monday(monkeypatch): 87 | today = datetime.date(2017, 4, 24) # Monday 88 | monkeypatch.setattr('upkeep.get_today', lambda: today) 89 | expected = [ 90 | datetime.date(2017, 4, 24), # Monday 91 | datetime.date(2017, 4, 25), # Tuesday 92 | datetime.date(2017, 4, 26), # Wednesday 93 | ] 94 | issues = [] # No open issues 95 | dates = upkeep.get_future_dates_without_issues(issues, workdays_ahead=2) 96 | assert list(dates) == expected 97 | 98 | 99 | def test_get_future_dates_without_issues_wednesday(monkeypatch): 100 | """ 101 | Issue already open for current workday. 102 | """ 103 | issues = [ 104 | Issue('2017-04-26: e-scrum for Wednesday, April 26, 2017'), 105 | ] 106 | today = datetime.date(2017, 4, 26) # Wednesday 107 | monkeypatch.setattr('upkeep.get_today', lambda: today) 108 | expected = [ 109 | datetime.date(2017, 4, 27), # Thursday 110 | datetime.date(2017, 4, 28), # Friday 111 | ] 112 | dates = upkeep.get_future_dates_without_issues(issues, workdays_ahead=2) 113 | assert list(dates) == expected 114 | -------------------------------------------------------------------------------- /upkeep.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import datetime 3 | import functools 4 | import re 5 | import sys 6 | import traceback 7 | import typing 8 | 9 | import holidays 10 | import github 11 | 12 | 13 | class PennHolidays(holidays.UnitedStates): 14 | 15 | def _populate(self, year): 16 | super()._populate(year) 17 | 18 | # See https://github.com/greenelab/scrum/issues/114 19 | for day in range(26, 32): 20 | self[datetime.date(year, 12, day)] = 'Special Winter Vacation' 21 | 22 | 23 | holiday_names = { 24 | 'Independence Day', 25 | 'Labor Day', 26 | 'Thanksgiving', 27 | 'Christmas Day', 28 | "New Year's Day", 29 | 'Martin Luther King, Jr. Day', 30 | 'Memorial Day', 31 | 'Special Winter Vacation', 32 | } 33 | 34 | penn_holidays = PennHolidays() 35 | 36 | 37 | def get_today() -> datetime.date: 38 | """ 39 | Returns the datetime.date for today. Needed since tests cannot mock a 40 | builtin type: http://stackoverflow.com/a/24005764/4651668 41 | """ 42 | return datetime.date.today() 43 | 44 | 45 | def is_holiday(date: datetime.date) -> bool: 46 | """ 47 | Return True or False for whether a date is a holiday 48 | """ 49 | name = penn_holidays.get(date) 50 | if not name: 51 | return False 52 | name = name.replace(' (Observed)', '') 53 | return name in holiday_names 54 | 55 | 56 | def is_workday(date) -> bool: 57 | """ 58 | Return boolean for whether a date is a workday. 59 | """ 60 | if date.weekday() in holidays.WEEKEND: 61 | return False 62 | if is_holiday(date): 63 | return False 64 | return True 65 | 66 | 67 | @functools.lru_cache() 68 | def issue_title_to_date(title: str) -> typing.Optional[datetime.date]: 69 | """ 70 | Return a datetime.date object from a Scrum issue title. 71 | """ 72 | pattern = re.compile(r'([0-9]{4})-([0-9]{2})-([0-9]{2}):') 73 | match = pattern.match(title) 74 | if not match: 75 | return None 76 | return datetime.date(*map(int, match.groups())) 77 | 78 | 79 | def close_old_issues(issues, lifespan: int): 80 | """ 81 | Close scrum issues older than the number of days specified by lifespan. 82 | """ 83 | lifespan = datetime.timedelta(days=lifespan) 84 | today = get_today() 85 | for issue in issues: 86 | if issue.state == 'closed': 87 | continue 88 | title = issue.title 89 | date = issue_title_to_date(title) 90 | if not date: 91 | continue 92 | if today - date > lifespan: 93 | print('Closing', title, file=sys.stderr) 94 | try: 95 | issue.edit(state='closed') 96 | except Exception: 97 | print('Closing issue failed:\n{}'.format(traceback.format_exc()), file=sys.stderr) 98 | 99 | 100 | def create_scrum_issue( 101 | repo: github.Repository.Repository, 102 | date: datetime.date, 103 | previous_issue: github.Issue.Issue = None, 104 | ) -> typing.Optional[github.Issue.Issue]: 105 | """ 106 | Create a scrum issue for the given date. 107 | If not None, previous_issue is used to set an issue body 108 | that refers to the previous issue. 109 | """ 110 | kwargs = {'title': f"{date}: e-scrum for {date:%A, %B %-d, %Y}"} 111 | if previous_issue: 112 | kwargs['body'] = 'Preceeding e-scrum in {}.'.format(previous_issue.html_url) 113 | print('Creating {title!r}'.format(**kwargs), file=sys.stderr) 114 | try: 115 | return repo.create_issue(**kwargs) 116 | except Exception: 117 | print('Creating issue failed:\n{}'.fomrat(traceback.format_exc()), file=sys.stderr) 118 | 119 | 120 | def get_future_dates_without_issues(issues, workdays_ahead: int = 2): 121 | """ 122 | Look through issues and yield the dates of future workdays (includes today) 123 | that don't have open issues. 124 | """ 125 | future_dates = set(get_upcoming_workdays(workdays_ahead)) 126 | future_dates -= {issue_title_to_date(x.title) for x in issues} 127 | return sorted(future_dates) 128 | 129 | 130 | def get_upcoming_workdays(workdays_ahead: int = 2) -> typing.Iterator[datetime.date]: 131 | """ 132 | Return a generator of the next number of workdays specified by 133 | workdays_ahead. The current day is yielded first, if a workday, 134 | and does not count as one of workdays_ahead. 135 | """ 136 | date = get_today() 137 | if is_workday(date): 138 | yield date 139 | i = 0 140 | while i < workdays_ahead: 141 | date += datetime.timedelta(days=1) 142 | if is_workday(date): 143 | yield date 144 | i += 1 145 | 146 | 147 | if __name__ == '__main__': 148 | parser = argparse.ArgumentParser() 149 | parser.add_argument('--username', default='scrum-lord') 150 | parser.add_argument( 151 | '--token', help='GitHub personal access token for --username') 152 | parser.add_argument('--repository', default='greenelab/scrum') 153 | parser.add_argument('--lifespan', type=int, default=7) 154 | parser.add_argument('--workdays-ahead', type=int, default=2) 155 | parser.add_argument('--upkeep-file', type=str, default='uptime.txt') 156 | args = parser.parse_args() 157 | 158 | gh = github.Github(args.username, args.token) 159 | user = gh.get_user() 160 | 161 | # Get greenelab/scrum repository. Could not find a better way 162 | repo, = [ 163 | repo for repo in user.get_repos() 164 | if repo.full_name == args.repository 165 | ] 166 | 167 | # Get open issues 168 | open_issues = list(repo.get_issues(state='open')) 169 | 170 | # Close old issues 171 | close_old_issues(open_issues, args.lifespan) 172 | 173 | # Get n most recent issues (open or closed), where n = 10 + --workdays-ahead 174 | # to help ensure the most recent existing e-scrum issue is included even when other 175 | # non e-scrum issues exist 176 | # Fetch a reasonable number of recent issues instead of relying on totalCount, which might be unreliable. 177 | # Fetching ~30 should be enough to find the last scrum issue and check upcoming dates. 178 | num_issues_to_fetch = 30 179 | issues_paginator = repo.get_issues(state='all', sort='created', direction='desc') # Sort by creation date descending 180 | issues = list(issues_paginator[:num_issues_to_fetch]) # Get up to num_issues_to_fetch items 181 | 182 | # Filter issues based on title format and sort by date 183 | date_issue_pairs = [(issue_title_to_date(issue.title), issue) for issue in issues] 184 | # Filter issues that are not scrum entries 185 | filtered_date_issue_pairs = [(date, issue) for date, issue in date_issue_pairs if date] 186 | 187 | # Issue objects are not comparable, so we need to sort by date only 188 | # Sort remaining issues by date ascending to easily find the latest one 189 | date_issue_pairs = sorted(filtered_date_issue_pairs, key=lambda x: x[0]) 190 | 191 | # Detect previous issue for creation of the first upcoming issue 192 | previous_issue = None 193 | if date_issue_pairs: 194 | _, previous_issue = date_issue_pairs[-1] 195 | 196 | # Create upcoming issues 197 | # Pass the filtered & sorted list to avoid re-filtering inside the function 198 | # Extract just the issues from the pairs relevant for checking existence 199 | existing_scrum_issues = [pair[1] for pair in date_issue_pairs] 200 | dates = get_future_dates_without_issues(existing_scrum_issues, args.workdays_ahead) 201 | for date in dates: 202 | previous_issue = create_scrum_issue(repo, date, previous_issue) 203 | 204 | # Create a small, meaningless change to keep Github Actions from disabling 205 | # the repo for inactivity 206 | with open(args.upkeep_file) as in_file: 207 | message = in_file.readline().strip() 208 | 209 | days = int(message.split(' ')[3]) 210 | days += 1 211 | 212 | new_message = "It has been " 213 | new_message += str(days) 214 | new_message += " days since I last had to tinker with the scrum bot.\n" 215 | 216 | with open(args.upkeep_file, 'w') as out_file: 217 | out_file.write(new_message) 218 | -------------------------------------------------------------------------------- /uptime.txt: -------------------------------------------------------------------------------- 1 | It has been 35 days since I last had to tinker with the scrum bot. 2 | --------------------------------------------------------------------------------