diff --git a/.github/workflows/extend-awards.yml b/.github/workflows/extend-awards.yml new file mode 100644 index 00000000..1f2806fc --- /dev/null +++ b/.github/workflows/extend-awards.yml @@ -0,0 +1,32 @@ +name: extend-awards +run-name: Extending awards +on: + pull_request: + types: [ closed ] + branches: + - master +jobs: + if_merged: + if: | + github.event_name == 'pull_request' && + github.event.action == 'closed' && + github.event.pull_request.merged == true && + github.event.pull_request.head.ref != 'extend-awards/patch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + - run: pip install requests + - run: python extend-awards.py + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_CONTEXT: ${{ toJson(github) }} + - uses: peter-evans/create-pull-request@v7 + with: + add-paths: awards.csv + branch: extend-awards/patch + commit-message: Extending awards.csv + title: Extending awards.csv + body: A PR was merged that solves an issue and awards.csv should be extended. diff --git a/docs/dev/extend-awards.md b/docs/dev/extend-awards.md new file mode 100644 index 00000000..51caf563 --- /dev/null +++ b/docs/dev/extend-awards.md @@ -0,0 +1,54 @@ +# Automatically extend awards.csv + +## Overview + +Whenever a pull request (PR) is merged in the [stacker.news](https://github.com/stackernews/stacker.news) repository, a [GitHub Action](https://docs.github.com/en/actions) is triggered: + +If the merged PR solves an issue with [award tags](https://github.com/stackernews/stacker.news?tab=readme-ov-file#contributing), +the amounts due to the PR and issue authors are calculated and corresponding lines are added to the [awards.csv](https://github.com/stackernews/stacker.news/blob/master/awards.csv) file, +and a PR is opened for this change. + +## Action + +The action is defined in [.github/workflows/extend-awards.yml](.github/workflows/extend-awards.yml). + +Filters on the event type and parameters ensure the action is [triggered only on merged PRs](https://stackoverflow.com/questions/60710209/trigger-github-actions-only-when-pr-is-merged). + +The primary job consists of several steps: + - [checkout](https://github.com/actions/checkout) checks out the repository + - [setup-python](https://github.com/actions/setup-python) installs [Python](https://en.wikipedia.org/wiki/Python_(programming_language)) + - [pip](https://en.wikipedia.org/wiki/Pip_%28package_manager%29) installs the [requests](https://docs.python-requests.org/en/latest/index.html) module + - a script (see below) is executed, which appends lines to [awards.csv](awards.csv) if needed + - [create-pull-request](https://github.com/peter-evans/create-pull-request) looks for modified files and creates (or updates) a PR + +## Script + +The script is [extend-awards.py](extend-awards.py). + +The script extracts from the [environment](https://en.wikipedia.org/wiki/Environment_variable) an authentication token needed for the [GitHub REST API](https://docs.github.com/en/rest/about-the-rest-api/about-the-rest-api) and the [context](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs) containing the event details including the merged PR (formatted in [JSON](https://en.wikipedia.org/wiki/JSON)). + +In the merged PR's title and body it searches for the first [GitHub issue URL](https://github.com/stackernews/stacker.news/issues/) or any number with a hash symbol (#) prefix, and takes this as the issue being solved by the PR. + +Using the GitHub REST API it fetches the issue and analyzes its tags for difficulty and priority. + +It fetches the issue's timeline and counts the number of reviews completed with status 'changes requested' to calculate the amount reduction. + +It calculates the amounts due to the PR author and the issue author. + +It reads the existing awards.csv file to suppress appending redundant lines (same user, PR, and issue) and fill known receive methods (same user). + +Finally, it appends zero, one, or two lines to the awards.csv file. + +## Diagnostics + +In the GitHub web interface under 'Actions' each invokation of the action can be viewed, including environment and [output and errors](https://en.wikipedia.org/wiki/Standard_streams) of the script. First, the specific invokation is selected, then the job 'if_merged', then the step 'Run python extend-awards.py'. The environment is found by expanding the inner 'Run python extended-awards.py' on the first line. + +The normal output includes details about the issue number found, the amount calculation, or the reason for not appending lines. + +The error output may include a [Python traceback](https://realpython.com/python-traceback/) which helps to explain the error. + +The environment contains in GITHUB_CONTEXT the event details, which may be required to understand the error. + +## Security considerations + +The create-pull-request step requires [workflow permissions](https://github.com/peter-evans/create-pull-request#workflow-permissions). diff --git a/extend-awards.py b/extend-awards.py new file mode 100644 index 00000000..13284791 --- /dev/null +++ b/extend-awards.py @@ -0,0 +1,104 @@ +import json, os, re, requests + +difficulties = {'good-first-issue':20000,'easy':100000,'medium':250000,'medium-hard':500000,'hard':1000000} +priorities = {'low':0.5,'medium':1.5,'high':2,'urgent':3} +ignored = ['huumn', 'ekzyis'] +fn = 'awards.csv' + +sess = requests.Session() +headers = {'Authorization':'Bearer %s' % os.getenv('GITHUB_TOKEN') } +awards = [] + +def getIssue(n): + url = 'https://api.github.com/repos/stackernews/stacker.news/issues/' + n + r = sess.get(url, headers=headers) + j = json.loads(r.text) + return j + +def findIssueInPR(j): + p = re.compile('(#|https://github.com/stackernews/stacker.news/issues/)([0-9]+)') + for m in p.finditer(j['title']): + return m.group(2) + if not 'body' in j or j['body'] is None: + return + for s in j['body'].split('\n'): + for m in p.finditer(s): + return m.group(2) + +def addAward(user, kind, pr, issue, difficulty, priority, count, amount): + if amount >= 1000000 and amount % 1000000 == 0: + amount = str(int(amount / 1000000)) + 'm' + elif amount >= 1000 and amount % 1000 == 0: + amount = str(int(amount / 1000)) + 'k' + for a in awards: + if a[0] == user and a[1] == kind and a[2] == pr: + print('found existing entry %s' % a) + if a[8] != amount: + print('warning: amount %s != %s' % (a[8], amount)) + return + if count < 1: + count = '' + addr = '???' + for a in awards: + if a[0] == user and a[9] != '???': + addr = a[9] + print('adding %s,%s,%s,%s,%s,%s,%s,,%s,%s,???' % (user, kind, pr, issue, difficulty, priority, count, amount, addr)) + with open(fn, 'a') as f: + print('%s,%s,%s,%s,%s,%s,%s,,%s,%s,???' % (user, kind, pr, issue, difficulty, priority, count, amount, addr), file=f) + +def countReviews(pr): + url = 'https://api.github.com/repos/stackernews/stacker.news/issues/%s/timeline' % pr + r = sess.get(url, headers=headers) + j = json.loads(r.text) + count = 0 + for e in j: + if e['event'] == 'reviewed' and e['state'] == 'changes_requested': + count += 1 + return count + +def checkPR(i): + pr = str(i['number']) + print('pr %s' % pr) + n = findIssueInPR(i) + if not n: + print('pr %s does not solve an issue' % pr) + return + print('solves issue %s' % n) + j = getIssue(n) + difficulty = '' + amount = 0 + priority = '' + multiplier = 1 + for l in j['labels']: + for d in difficulties: + if l['name'] == 'difficulty:' + d: + difficulty = d + amount = difficulties[d] + for p in priorities: + if l['name'] == 'priority:' + p: + priority = p + multiplier = priorities[p] + if amount * multiplier <= 0: + print('issue gives no award') + return + count = countReviews(pr) + if count >= 10: + print('too many reviews, no award') + return + if count > 0: + print('%d reviews, %d%% reduction' % (count, count * 10)) + award = amount * multiplier * (10 - count) / 10 + print('award is %d' % award) + if i['user']['login'] not in ignored: + addAward(i['user']['login'], 'pr', '#' + pr, '#' + n, difficulty, priority, count, award) + if j['user']['login'] not in ignored: + count = 0 + addAward(j['user']['login'], 'issue', '#' + pr, '#' + n, difficulty, priority, count, int(award / 10)) + +with open(fn, 'r') as f: + for s in f: + s = s.split('\n')[0] + awards.append(s.split(',')) + +j = json.loads(os.getenv('GITHUB_CONTEXT')) +checkPR(j['event']['pull_request'])