Deal with your client's feedback efficiently by creating a bunch of issues in bulk from a text file

✨ Add references support!

Closes #13

+29 -1
README.md
··· 37 37 38 38 Indentation is done with tab characters only. 39 39 40 - - **Title:** The title is made up of any word in the line that does not start with `~`, `@` or `%`. Words that start with any of these symbols will not be added to the title, except if they are in the middle (in that case, they both get added as tags/assignees/milestones and as a word in the title, without the prefix symbol) 40 + - **Title:** The title is made up of any word in the line that does not start with `~`, `@`, `%` or `#.`. Words that start with any of these symbols will not be added to the title, except if they are in the middle (in that case, they both get added as tags/assignees/milestones and as a word in the title, without the prefix symbol) 41 41 - **Tags:** Prefix a word with `~` to add a label to the issue 42 42 - **Assignees:** Prefix with `@` to add an assignee. The special assignee `@me` is supported. 43 43 - **Milestone:** Prefix with `%` to set the milestone 44 + - **References:** Prefix with `#.NUMBER` to define a reference for this issue. See [Cross-reference other issues](#cross-reference-other-issues) for more information. 44 45 - **Comments:** You can add comments by prefixing a line with `//` 45 46 - **Description:** To add a description, finish the line with `:`, and put the description on another line (or multiple), just below, indented once more than the issue's line. Exemple: 46 47 ··· 69 70 70 71 Another issue. 71 72 ``` 73 + 74 + #### Cross-reference other issues 75 + 76 + As you might know, you can link an issue to another by using `#NUMBER`, with `NUMBER` the number of the issue you want to reference. You could want to write that, to reference `First issue` in `Second issue`: 77 + 78 + ``` 79 + First issue 80 + 81 + Second issue: 82 + Needs #11 83 + ``` 84 + 85 + However, this assumes that the current latest issue, before running issurge on this file, is `#9`. It also assumes that issues get created in order (which is the case for now), and that no other issue will get created while running issurge. 86 + 87 + As managing all of this by hand can be annoying, you can create references in the issurge file: 88 + 89 + ``` 90 + #.1 First issue 91 + 92 + Second issue: 93 + Needs #.1 94 + ``` 95 + 96 + And that `#.1` in `Needs #.1` will be replaced by the actual issue number of `First issue` when the issue gets created. 97 + 98 + > [!WARNING] 99 + > For now, issues are created in order, so you need to define a reference _before_ you can use it. 72 100 73 101 ### One-shot mode 74 102
+10 -3
issurge/main.py
··· 1 1 #!/usr/bin/env python 2 2 """ 3 3 Usage: 4 - issurge [options] new <words>... 4 + issurge [options] new <words>... 5 5 issurge [options] <file> [--] [<submitter-args>...] 6 6 issurge --help 7 7 ··· 13 13 --dry-run Don't actually post the issues 14 14 --debug Print debug information 15 15 """ 16 + 16 17 import os 17 18 from pathlib import Path 18 19 ··· 33 34 if opts["new"]: 34 35 issue = interactive.create_issue(" ".join(opts["<words>"])) 35 36 debug(f"Submitting {issue.display()}") 36 - issue.submit(opts["<submitter-args>"]) 37 + number = issue.submit(opts["<submitter-args>"]) 38 + print(f"Created issue #{number}") 37 39 else: 38 40 print("Submitting issues...") 41 + references_resolutions: dict[int, int] = {} 39 42 for issue in parse(Path(opts["<file>"]).read_text()): 40 - issue.submit(opts["<submitter-args>"]) 43 + issue = issue.resolve_references(references_resolutions, strict=True) 44 + number = issue.submit(opts["<submitter-args>"]) 45 + print(f"Created issue #{number}") 46 + if issue.reference and number: 47 + references_resolutions[issue.reference] = number
+11 -1
issurge/main_test.py
··· 8 8 9 9 from issurge.utils import debugging, dry_running 10 10 11 + class MockedSubprocessOutput: 12 + def __init__(self, stdout: str, stderr: str): 13 + self.stdout = stdout.encode('utf-8') 14 + self.stderr = stderr.encode('utf-8') 11 15 12 16 @fixture 13 17 def setup(): ··· 17 21 \tAn issue to submit 18 22 Another ~issue to submit @me""" 19 23 ) 20 - subprocess.run = Mock() 24 + subprocess.run = Mock( 25 + return_value=MockedSubprocessOutput("https://github.com/gwennlbh/gh-api-playground/issues/5\n", "Some unrelated stuff haha") 26 + ) 21 27 Issue._get_remote_url = Mock( 22 28 return_value=urlparse("https://github.com/ewen-lbh/gh-api-playground") 23 29 ) ··· 84 90 "common", 85 91 "-l", 86 92 "common", 93 + "-m", 94 + "common", 87 95 ], 88 96 [ 89 97 "gh", ··· 119 127 "-a", 120 128 "common", 121 129 "-l", 130 + "common", 131 + "-m", 122 132 "common", 123 133 ], 124 134 [
+64 -9
issurge/parser.py
··· 1 1 import os 2 + import re 2 3 import subprocess 3 4 from typing import Any, Iterable, NamedTuple 4 5 from urllib.parse import urlparse ··· 57 58 labels: set[str] = set() 58 59 assignees: set[str] = set() 59 60 milestone: str = "" 61 + reference: int|None = None 60 62 61 63 def __rich_repr__(self): 62 64 yield self.title ··· 64 66 yield "labels", self.labels, set() 65 67 yield "assignees", self.assignees, set() 66 68 yield "milestone", self.milestone, "" 69 + yield "ref", self.reference, None 70 + yield "references", self.references, set() 67 71 68 72 def __str__(self) -> str: 69 - result = f"{self.title}" or "<No title>" 73 + result = "" 74 + if self.reference: 75 + result += f"<#{self.reference}> " 76 + result += f"{self.title}" or "<No title>" 70 77 if self.labels: 71 78 result += f" {' '.join(['~' + l for l in self.labels])}" 72 79 if self.milestone: ··· 78 85 return result 79 86 80 87 def display(self) -> str: 81 - result = f"[white]{self.title[:30]}[/white]" or "[red]<No title>[/red]" 88 + result = "" 89 + if self.reference: 90 + result += f"[bold blue]<#{self.reference}>[/bold blue] " 91 + result += f"[white]{self.title[:30]}[/white]" or "[red]<No title>[/red]" 82 92 if len(self.title) > 30: 83 93 result += " [white dim](...)[/white dim]" 84 94 if self.labels: ··· 95 105 result += " [white][...][/white]" 96 106 return result 97 107 108 + @property 109 + def references(self) -> set[int]: 110 + # find all \b#\.(\d+)\b in description 111 + references = set() 112 + for word in self.description.strip().split(" "): 113 + if word.startswith("#.") and word[2:].strip().isdigit(): 114 + references.add(int(word[2:].strip())) 115 + 116 + return references 117 + 118 + def resolve_references(self, resolution_map: dict[int, int], strict=False) -> 'Issue': 119 + resolved_description = self.description 120 + for reference in self.references: 121 + if (resolved := resolution_map.get(reference)): 122 + resolved_description = resolved_description.replace(f"#.{reference}", f"#{resolved}") 123 + elif strict: 124 + raise Exception(f"Could not resolve reference #.{reference}") 125 + 126 + return Issue(**(self._asdict() | {"description": resolved_description})) 127 + 128 + 98 129 def submit(self, submitter_args: list[str]): 99 130 remote_url = self._get_remote_url() 100 131 if remote_url.hostname == "github.com": 101 - self._github_submit(submitter_args) 132 + return self._github_submit(submitter_args) 102 133 else: 103 - self._gitlab_submit(submitter_args) 134 + return self._gitlab_submit(submitter_args) 104 135 105 136 def _get_remote_url(self): 106 137 try: ··· 116 147 "Could not determine remote url, make sure that you are inside of a git repository that has a remote named 'origin'" 117 148 ) from e 118 149 119 - def _gitlab_submit(self, submitter_args: list[str]): 150 + def _gitlab_submit(self, submitter_args: list[str]) -> int|None: 120 151 command = ["glab", "issue", "new"] 121 152 if self.title: 122 153 command += ["-t", self.title] ··· 128 159 if self.milestone: 129 160 command += ["-m", self.milestone] 130 161 command.extend(submitter_args) 131 - self._run(command) 162 + out = self._run(command) 163 + # parse issue number from command output url: https://.+/-/issues/(\d+) 164 + if out and (url := re.search(r"https://.+/-/issues/(\d+)", out)): 165 + return int(url.group(1)) 166 + 167 + # raise Exception(f"Could not parse issue number from {out!r}") 132 168 133 - def _github_submit(self, submitter_args: list[str]): 169 + def _github_submit(self, submitter_args: list[str]) -> int|None: 134 170 command = ["gh", "issue", "new"] 135 171 if self.title: 136 172 command += ["-t", self.title] ··· 142 178 if self.milestone: 143 179 command += ["-m", self.milestone] 144 180 command.extend(submitter_args) 145 - self._run(command) 181 + out = self._run(command) 182 + # parse issue number from command output url: https://github.com/.+/issues/(\d+) 183 + pattern = re.compile(r"https:\/\/github\.com\/.+\/issues\/(\d+)") 184 + if out and (url := pattern.search(out)): 185 + return int(url.group(1)) 186 + 187 + # raise Exception(f"Could not parse issue number from {out!r}, looked for regex {pattern}") 188 + return None 146 189 147 190 def _run(self, command): 148 191 if dry_running() or debugging(): ··· 151 194 ) 152 195 if not dry_running(): 153 196 try: 154 - subprocess.run(command, check=True, capture_output=True) 197 + out = subprocess.run(command, check=True, capture_output=True) 198 + return out.stderr.decode() + "\n" + out.stdout.decode() 155 199 except subprocess.CalledProcessError as e: 156 200 print( 157 201 f"Calling [white bold]{e.cmd}[/] failed with code [white bold]{e.returncode}[/]:\n{NEWLINE.join(TAB + line for line in e.stderr.decode().splitlines())}" ··· 159 203 160 204 @staticmethod 161 205 def _word_and_sigil(raw_word: str) -> tuple[str, str]: 206 + if raw_word.startswith("#.") and raw_word[2:].isdigit(): 207 + return "#.", raw_word[2:] 208 + 162 209 sigil = raw_word[0] 163 210 word = raw_word[1:] 164 211 if sigil not in ("~", "%", "@"): ··· 180 227 labels = set() 181 228 assignees = set() 182 229 milestone = "" 230 + reference = None 183 231 # only labels/milestones/assignees at the beginning or end of the line are not added to the title as words 184 232 add_to_title = False 185 233 remaining_words = [word.strip() for word in raw.split(" ") if word.strip()] 234 + _debug_sigils = [] 186 235 187 236 while remaining_words: 188 237 sigil, word = cls._word_and_sigil(remaining_words.pop(0)) 238 + 239 + _debug_sigils.append(sigil) 189 240 190 241 if sigil and add_to_title: 191 242 title += f" {word}" ··· 197 248 milestone = word 198 249 case "@": 199 250 assignees.add(word) 251 + case "#.": 252 + reference = int(word) 200 253 case _: 201 254 title += f" {word}" 202 255 # add to title if there are remaining regular words ··· 212 265 labels=labels, 213 266 assignees=assignees, 214 267 milestone=milestone, 268 + reference=reference 215 269 ), 216 270 expects_description, 217 271 ) ··· 271 325 labels=current_labels, 272 326 assignees=current_assignees, 273 327 milestone=current_milestone, 328 + reference=parsed.reference 274 329 ) 275 330 276 331 if current_issue.title:
+35
issurge/parser_test.py
··· 29 29 Issue(title="A label with a description following it", labels={"now"}), 30 30 True, 31 31 ), 32 + ( 33 + "#.1 An issue with a reference definition", 34 + Issue(title="An issue with a reference definition", reference=1), 35 + False, 36 + ), 32 37 ]: 33 38 34 39 @test(f"parse {fragment!r}") ··· 137 142 ), 138 143 ], 139 144 ), 145 + ( 146 + """ 147 + An issue that references another ~blocked: 148 + \tSee #.1 149 + 150 + #.1 The other one ^w^ 151 + """, 152 + [ 153 + Issue( 154 + title="An issue that references another", 155 + labels={"blocked"}, 156 + description="See #.1\n", 157 + ), 158 + Issue( 159 + title="The other one ^w^", 160 + reference=1, 161 + ), 162 + ], 163 + ), 140 164 ]: 141 165 142 166 @test(f"parse issues from {textwrap.dedent(lines)!r}") 143 167 def _(lines=lines, expected=expected): 144 168 assert list(parse(lines)) == expected 145 169 170 + 171 + @test("resolves references") 172 + def _(): 173 + [issue, *_] = list(parse("An issue that references another ~blocked:\n\tSee #.2")) 174 + assert issue.references == {2} 175 + issue = issue.resolve_references({2: 1}) 176 + assert issue.description == "See #1\n" 177 + 178 + @test("splits #. sigil") 179 + def _(): 180 + assert Issue._word_and_sigil("#.1") == ("#.", "1") 146 181 147 182 @test("parse issue with missing description fails") 148 183 def _():