22import json
33import os
44import re
5+ import subprocess
56import tempfile
7+ import textwrap
68import unittest
79from contextlib import redirect_stderr , redirect_stdout
810from pathlib import Path
@@ -75,6 +77,16 @@ def job(workflow: str, name: str) -> str:
7577 return "\n " .join (lines [start :end ]) + "\n "
7678
7779
80+ def step_script (workflow : str , name : str ) -> str :
81+ """The script of a job's one ``run: |`` step, dedented as the runner hands it to the shell."""
82+ lines = job (workflow , name ).splitlines ()
83+ start = next (i for i , line in enumerate (lines ) if line .strip () == "run: |" )
84+ indent = len (lines [start ]) - len (lines [start ].lstrip ())
85+ end = next ((i for i in range (start + 1 , len (lines ))
86+ if lines [i ].strip () and len (lines [i ]) - len (lines [i ].lstrip ()) <= indent ), len (lines ))
87+ return textwrap .dedent ("\n " .join (lines [start + 1 :end ])).strip ("\n " ) + "\n "
88+
89+
7890class TestNextVersion (unittest .TestCase ):
7991 def test_counts_up_from_the_tagged_version (self ):
8092 tags = ["v9.0.0+tibiash.1" , "v9.0.0+tibiash.2" ]
@@ -897,6 +909,75 @@ def test_downstream_dispatches_with_a_contents_token(self):
897909
898910class TestAlertWorkflow (WorkflowTestCase ):
899911 WORKFLOW = "alert.yml"
912+ # What the step gets through env: the watched run's fields, the repository and github.token.
913+ ENV : ClassVar [dict [str , str ]] = {
914+ "GH_TOKEN" : "ghs_alert" ,
915+ "GH_REPO" : "tibia-sh/tibiawiki-sql" ,
916+ "WORKFLOW" : "Release" ,
917+ "CONCLUSION" : "failure" ,
918+ "RUN_URL" : "https://github.com/tibia-sh/tibiawiki-sql/actions/runs/99" ,
919+ }
920+ # The open issue the alert comments on, and two it passes over: a later one with its title, and another title.
921+ ISSUES : ClassVar [list [dict ]] = [
922+ {"number" : 31 , "title" : "Automation needs a look" },
923+ {"number" : 30 , "title" : "Automation needs a look" },
924+ {"number" : 12 , "title" : "Automation needs a look again" },
925+ ]
926+ OPEN = ("gh issue create --title Automation needs a look --assignee drptbl"
927+ " --body Release failure: https://github.com/tibia-sh/tibiawiki-sql/actions/runs/99" )
928+ # A stand-in gh that logs each call to $FAKE/events. `issue list ... --jq FILTER` runs the real jq with FILTER over
929+ # $FAKE/issues, as gh does over the fields it fetched. `issue comment N` and `issue create` print the URL gh prints,
930+ # and create opens issue 40. Call N of a kind exits with line N of $FAKE/<kind>-exits, its last line once they run
931+ # out, or 0 without the file. A failed call prints a GraphQL error. A failed list still prints what it found, so a
932+ # step that went on would act on it. A create lands even when it fails, as one whose answer GitHub lost does, so
933+ # every list after it finds issue 40.
934+ FAKE_GH = r"""#!/usr/bin/env bash
935+ here="$FAKE"
936+ echo "gh $*" >> "$here/events"
937+ if [ -z "$GH_REPO" ] || [ -z "$GH_TOKEN" ]; then
938+ echo "fake gh: no GH_REPO or GH_TOKEN" >&2
939+ exit 2
940+ fi
941+ kind="$2"
942+ if [ "$#" -eq 10 ] && [ "$1 $2 $3 $4 $5" = "issue list --state open --limit" ] \
943+ && [ "$6 $7 $8 $9" = "1000 --json number,title --jq" ]; then
944+ :
945+ elif [ "$#" -eq 5 ] && [ "$1 $2" = "issue comment" ] && [ "$4" = --body ]; then
946+ case "$3" in
947+ '' | *[!0-9]*) echo "fake gh: unsupported issue $3" >&2; exit 2 ;;
948+ esac
949+ elif [ "$#" -eq 8 ] && [ "$1 $2 $3" = "issue create --title" ] && [ "$5 $6 $7" = "--assignee drptbl --body" ]; then
950+ jq -n --arg title "$4" '{number: 40, title: $title}' > "$here/opened"
951+ else
952+ echo "fake gh: unsupported command: $*" >&2
953+ exit 2
954+ fi
955+ count=$(grep -c "^gh issue $kind " "$here/events")
956+ code=0
957+ if [ -e "$here/$kind-exits" ]; then
958+ code=$(sed -n "${count}p" "$here/$kind-exits")
959+ [ -n "$code" ] || code=$(tail -n 1 "$here/$kind-exits")
960+ fi
961+ case "$kind" in
962+ list)
963+ if [ -e "$here/opened" ]; then
964+ jq -c --slurpfile opened "$here/opened" '. + $opened' "$here/issues"
965+ else
966+ cat "$here/issues"
967+ fi | jq -r "${10}"
968+ ;;
969+ comment) [ "$code" -ne 0 ] || echo "https://github.com/$GH_REPO/issues/$3#issuecomment-1" ;;
970+ create) [ "$code" -ne 0 ] || echo "https://github.com/$GH_REPO/issues/40" ;;
971+ esac
972+ if [ "$code" -ne 0 ]; then
973+ echo 'GraphQL: Something went wrong while executing your query' >&2
974+ fi
975+ exit "$code"
976+ """
977+ # A stand-in sleep that logs its seconds to $FAKE/sleeps and returns at once.
978+ FAKE_SLEEP = """#!/usr/bin/env bash
979+ echo "$*" >> "$FAKE/sleeps"
980+ """
900981
901982 def test_watches_the_automation_workflows_by_their_exact_names (self ):
902983 self .assertIn ("""\
@@ -921,7 +1002,7 @@ def test_comments_on_one_issue_with_github_token(self):
9211002 github.event.workflow_run.conclusion != 'neutral' &&
9221003 github.event.workflow_run.event != 'pull_request'
9231004 runs-on: ubuntu-latest
924- timeout-minutes: 5
1005+ timeout-minutes: 15
9251006 permissions:
9261007 issues: write
9271008 concurrency:
@@ -933,13 +1014,21 @@ def test_comments_on_one_issue_with_github_token(self):
9331014 run: |
9341015 title='Automation needs a look'
9351016 body="$WORKFLOW $CONCLUSION: $RUN_URL"
936- number=$(TITLE=$title gh issue list --state open --limit 1000 --json number,title \\
937- --jq 'map(select(.title == env.TITLE) | .number) | min // empty')
938- if [[ -n $number ]]; then
939- gh issue comment "$number" --body "$body"
940- else
941- gh issue create --title "$title" --assignee drptbl --body "$body"
942- fi
1017+ write_alert() {
1018+ number=$(TITLE=$title gh issue list --state open --limit 1000 --json number,title \\
1019+ --jq 'map(select(.title == env.TITLE) | .number) | min // empty') || return 1
1020+ if [[ -n $number ]]; then
1021+ gh issue comment "$number" --body "$body" || return 1
1022+ else
1023+ gh issue create --title "$title" --assignee drptbl --body "$body" || return 1
1024+ fi
1025+ }
1026+ for delay in 30 60 120 240; do
1027+ write_alert && exit 0
1028+ echo "::warning::Writing the alert issue failed. Trying again in $delay seconds."
1029+ sleep "$delay"
1030+ done
1031+ write_alert
9431032 env:
9441033 GH_TOKEN: ${{ github.token }}
9451034 GH_REPO: ${{ github.repository }}
@@ -948,3 +1037,77 @@ def test_comments_on_one_issue_with_github_token(self):
9481037 RUN_URL: ${{ github.event.workflow_run.html_url }}
9491038""" )
9501039 self .assertNotIn ("uses:" , self .text ())
1040+
1041+ def run_alert (self , issues : list [dict ], ** exits : list [int ]) -> dict :
1042+ """Run the step's script as the job's bash does, with the stand-in gh and sleep first on PATH.
1043+
1044+ ``issues`` are the open issues a list finds, and ``exits`` how each call of a kind (list, comment or create)
1045+ exits in turn. jq and everything else is real.
1046+ """
1047+ with tempfile .TemporaryDirectory () as tmp :
1048+ fake = Path (tmp )
1049+ for name , script in [("gh" , self .FAKE_GH ), ("sleep" , self .FAKE_SLEEP )]:
1050+ (fake / name ).write_text (script , encoding = "utf-8" )
1051+ (fake / name ).chmod (0o755 )
1052+ (fake / "issues" ).write_text (json .dumps (issues ), encoding = "utf-8" )
1053+ for kind , codes in exits .items ():
1054+ (fake / f"{ kind } -exits" ).write_text ("" .join (f"{ code } \n " for code in codes ), encoding = "utf-8" )
1055+ env = {** self .ENV , "PATH" : f"{ fake } { os .pathsep } { os .environ ['PATH' ]} " , "FAKE" : tmp }
1056+ script = step_script (self .WORKFLOW , "alert" )
1057+ # bash from PATH runs the workflow's own script, as the runner does.
1058+ run = subprocess .run (["bash" , "-e" , "-c" , script ], env = env , cwd = tmp , # noqa: S603, S607
1059+ capture_output = True , text = True , timeout = 60 , check = False )
1060+ events = (fake / "events" ).read_text (encoding = "utf-8" ).splitlines ()
1061+ sleeps = (fake / "sleeps" ).read_text (encoding = "utf-8" ).splitlines () if (fake / "sleeps" ).exists () else []
1062+ return {
1063+ "status" : run .returncode ,
1064+ "output" : run .stdout + run .stderr ,
1065+ "warnings" : [line for line in run .stdout .splitlines () if line .startswith ("::warning::" )],
1066+ "lists" : [event for event in events if event .startswith ("gh issue list " )],
1067+ "comments" : [event for event in events if event .startswith ("gh issue comment " )],
1068+ "opens" : [event for event in events if event .startswith ("gh issue create " )],
1069+ "sleeps" : sleeps ,
1070+ }
1071+
1072+ def comment (self , number : int ) -> str :
1073+ return (f"gh issue comment { number } "
1074+ " --body Release failure: https://github.com/tibia-sh/tibiawiki-sql/actions/runs/99" )
1075+
1076+ def test_lists_5_times_and_never_writes_when_it_cannot_list_the_open_issues (self ):
1077+ # Writing after a failed list would act on a list it could not trust, and could open a second issue beside
1078+ # the first. The stand-in's failed list still prints the issue it found, so a step that went on would comment.
1079+ for issues in [[], self .ISSUES ]:
1080+ with self .subTest (issues = issues ):
1081+ run = self .run_alert (issues , list = [1 ])
1082+ self .assertEqual (1 , run ["status" ], run ["output" ])
1083+ self .assertEqual (5 , len (run ["lists" ]), run ["output" ])
1084+ self .assertEqual ([], run ["comments" ])
1085+ self .assertEqual ([], run ["opens" ])
1086+ self .assertEqual (["30" , "60" , "120" , "240" ], run ["sleeps" ])
1087+ self .assertEqual ([f"::warning::Writing the alert issue failed. Trying again in { delay } seconds."
1088+ for delay in [30 , 60 , 120 , 240 ]], run ["warnings" ])
1089+
1090+ def test_comments_once_a_failed_list_finds_the_issue_on_the_next_try (self ):
1091+ run = self .run_alert (self .ISSUES , list = [1 , 0 ])
1092+ self .assertEqual (0 , run ["status" ], run ["output" ])
1093+ self .assertEqual (2 , len (run ["lists" ]))
1094+ self .assertEqual ([self .comment (30 )], run ["comments" ])
1095+ self .assertEqual ([], run ["opens" ])
1096+ self .assertEqual (["30" ], run ["sleeps" ])
1097+
1098+ def test_lists_and_comments_again_after_a_comment_fails (self ):
1099+ run = self .run_alert (self .ISSUES , comment = [1 , 0 ])
1100+ self .assertEqual (0 , run ["status" ], run ["output" ])
1101+ self .assertEqual (2 , len (run ["lists" ]))
1102+ self .assertEqual ([self .comment (30 ), self .comment (30 )], run ["comments" ])
1103+ self .assertEqual ([], run ["opens" ])
1104+ self .assertEqual (["30" ], run ["sleeps" ])
1105+
1106+ def test_comments_on_the_issue_a_failed_open_opened_and_never_opens_a_second (self ):
1107+ # GitHub can open the issue and still answer with an error. The next try lists again and finds it.
1108+ run = self .run_alert ([{"number" : 12 , "title" : "Automation needs a look again" }], create = [1 ])
1109+ self .assertEqual (0 , run ["status" ], run ["output" ])
1110+ self .assertEqual (2 , len (run ["lists" ]))
1111+ self .assertEqual ([self .OPEN ], run ["opens" ])
1112+ self .assertEqual ([self .comment (40 )], run ["comments" ])
1113+ self .assertEqual (["30" ], run ["sleeps" ])
0 commit comments