···33333434Indentation is done with tab characters only.
35353636-- **Title:** The title is made up of any word in the line that does not start with `~`, `@` or `%`.
3636+- **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)
3737- **Tags:** Prefix a word with `~` to add a label to the issue
3838- **Assignees:** Prefix with `@` to add an assignee. The special assignee `@me` is supported.
3939- **Milestone:** Prefix with `%` to set the milestone
-4
issurge/main.py
···1010 --dry-run Don't actually post the issues
1111 --debug Print debug information
1212"""
1313-import json
1413import os
1515-from collections.abc import Iterable
1614from pathlib import Path
1717-from subprocess import run
1818-from typing import Generator, TypeAlias, TypeVar
19152016from docopt import docopt
2117from rich import print
+75-21
issurge/parser.py
···505051515252class Issue(NamedTuple):
5353- title: str
5454- description: str
5555- labels: set[str]
5656- assignees: set[str]
5757- milestone: str
5353+ title: str = ""
5454+ description: str = ""
5555+ labels: set[str] = set()
5656+ assignees: set[str] = set()
5757+ milestone: str = ""
5858+5959+ def __rich_repr__(self):
6060+ yield self.title
6161+ yield "description", self.description, ""
6262+ yield "labels", self.labels, set()
6363+ yield "assignees", self.assignees, set()
6464+ yield "milestone", self.milestone, ""
58655966 def __str__(self) -> str:
6767+ result = f"{self.title}" or "<No title>"
6868+ if self.labels:
6969+ result += f" {' '.join(['~' + l for l in self.labels])}"
7070+ if self.milestone:
7171+ result += f" %{self.milestone}"
7272+ if self.assignees:
7373+ result += f" {' '.join(['@' + a for a in self.assignees])}"
7474+ if self.description:
7575+ result += f": {self.description}"
7676+ return result
7777+7878+ def display(self) -> str:
6079 result = f"[white]{self.title[:30]}[/white]" or "[red]<No title>[/red]"
6180 if len(self.title) > 30:
6281 result += " [white dim](...)[/white dim]"
···7493 result += " [white][...][/white]"
7594 return result
76957777- def submit(self):
7878- remote_url = urlparse(
7979- subprocess.run(["git", "remote", "get-url", "origin"]).stdout.decode()
8080- )
9696+ def submit(self, submitter_args: list[str]):
9797+ remote_url = self._get_remote_url()
8198 if remote_url.hostname == "github.com":
8299 self._github_submit(submitter_args)
83100 else:
84101 self._gitlab_submit(submitter_args)
85102103103+ def _get_remote_url(self):
104104+ try:
105105+ return urlparse(
106106+ subprocess.run(
107107+ ["git", "remote", "get-url", "origin"], capture_output=True
108108+ ).stdout.decode()
109109+ )
110110+ except subprocess.CalledProcessError as e:
111111+ raise ValueError(
112112+ "Could not determine remote url, make sure that you are inside of a git repository that has a remote named 'origin'"
113113+ ) from e
114114+86115 def _gitlab_submit(self, submitter_args: list[str]):
87116 command = ["glab", "issue", "new"]
88117 if self.title:
···101130 command = ["gh", "issue", "new"]
102131 if self.title:
103132 command += ["-t", self.title]
104104- command += ["-d", self.description or ""]
133133+ command += ["-b", self.description or ""]
105134 for a in self.assignees:
106135 command += ["-a", a if a != "me" else "@me"]
107136 for l in self.labels:
···124153 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())}"
125154 )
126155156156+ @staticmethod
157157+ def _word_and_sigil(raw_word: str) -> tuple[str, str]:
158158+ sigil = raw_word[0]
159159+ word = raw_word[1:]
160160+ if sigil not in ("~", "%", "@"):
161161+ sigil = ""
162162+ word = raw_word
163163+ return sigil, word
164164+127165 # The boolean is true if the issue expects a description (ending ':')
128166 @classmethod
129167 def parse(cls, raw: str) -> tuple["Issue", bool]:
···138176 labels = set()
139177 assignees = set()
140178 milestone = ""
141141- for word in raw.split(" "):
142142- if word.startswith("~"):
143143- labels.add(word[1:])
144144- elif word.startswith("%"):
145145- milestone = word[1:]
146146- elif word.startswith("@"):
147147- assignees.add(word[1:])
148148- else:
179179+ # only labels/milestones/assignees at the beginning or end of the line are not added to the title as words
180180+ add_to_title = False
181181+ remaining_words = [word.strip() for word in raw.split(" ") if word.strip()]
182182+183183+ while remaining_words:
184184+ sigil, word = cls._word_and_sigil(remaining_words.pop(0))
185185+186186+ if sigil and add_to_title:
149187 title += f" {word}"
188188+189189+ match sigil:
190190+ case "~":
191191+ labels.add(word)
192192+ case "%":
193193+ milestone = word
194194+ case "@":
195195+ assignees.add(word)
196196+ case _:
197197+ title += f" {word}"
198198+ # add to title if there are remaining regular words
199199+ add_to_title = any(
200200+ not sigil
201201+ for (sigil, _) in map(cls._word_and_sigil, remaining_words)
202202+ )
150203151204 return (
152205 cls(
···212265 )
213266214267 if current_issue.title:
215215- log(f"Made {current_issue!s}")
268268+ log(f"Made {current_issue.display()}")
216269 return [current_issue]
217270218271 if not expecting_description and children is not None:
219272 result = []
220220- log(f"Making children from {current_issue!s}")
273273+ log(f"Making children from {current_issue.display()}")
221274 for child, grandchildren in children.items():
222275 result.extend(
223276 parse_issue_fragment(
···236289237290def parse(raw: str) -> Iterable[Issue]:
238291 for item in Node.to_dict(raw).items():
239239- yield parse_issue_fragment(*item, Issue("", "", set(), set(), ""))
292292+ for issue in parse_issue_fragment(*item, Issue("", "", set(), set(), "")):
293293+ yield issue
+38
issurge/parser_test.py
···11+from ward import test
22+from .parser import Issue
33+44+for fragment, expected, description_expected in [
55+ ("", Issue(), False),
66+ ("a simple test right there", Issue(title="a simple test right there"), False),
77+ (
88+ "@me some ~labels to ~organize issues ~bug",
99+ Issue(
1010+ title="some labels to organize issues",
1111+ labels={"labels", "organize", "bug"},
1212+ assignees={"me"},
1313+ ),
1414+ False,
1515+ ),
1616+ (
1717+ "a %milestone to keep ~track of stuff",
1818+ Issue(
1919+ title="a milestone to keep track of stuff",
2020+ labels={"track"},
2121+ milestone="milestone",
2222+ ),
2323+ False,
2424+ ),
2525+ (
2626+ "A label with a description following it ~now:",
2727+ Issue(title="A label with a description following it", labels={"now"}),
2828+ True,
2929+ ),
3030+]:
3131+3232+ @test(f"parse {fragment!r}")
3333+ def _(
3434+ fragment=fragment, expected=expected, description_expected=description_expected
3535+ ):
3636+ actual, expecting_description = Issue.parse(fragment)
3737+ assert expecting_description == description_expected
3838+ assert actual == expected