···11+# issurge
22+33+Deal with your client's feedback efficiently by creating a bunch of issues in bulk from a text file.
44+55+Only supports gitlab for now.
66+77+Requires `glab`.
88+99+## Installation
1010+1111+```
1212+pip install issurge
1313+```
1414+1515+## Usage
1616+1717+```
1818+issurge [options] <file> [--] [<glab-args>...]
1919+issurge --help
2020+```
2121+2222+- **<glab-args>** contains arguments that will be passed as-is to every `glab` command.
2323+2424+### Options
2525+2626+- **--dry-run:** Don't actually post the issues
2727+- **--debug:** Print debug information
2828+2929+### Syntax
3030+3131+Indentation is done with tab characters only.
3232+3333+- **Title:** The title is made up of any word in the line that does not start with `~`, `@` or `%`.
3434+- **Tags:** Prefix a word with `~` to add a label to the issue
3535+- **Assignees:** Prefix with `@` to add an assignee. The special assignee `@me` is supported.
3636+- **Milestone:** Prefix with `%` to set the milestone
3737+- **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:
3838+ ```
3939+ My superb issue ~some-tag:
4040+ Here is a description
4141+4242+ I can skip lines
4343+ Another issue
4444+ ```
4545+4646+ Note that you cannot have indented lines inside of the description (they will be ignored).
4747+4848+#### Add some properties to multiple issues
4949+5050+You can apply something (a tag, a milestone, an assignee) to multiple issues by indenting them below:
5151+5252+```
5353+One issue
5454+5555+~common-tag
5656+ ~tag1 This issue will have tags:
5757+ - tag1
5858+ - common-tag
5959+ @me this issue will only have common-tag as a tag.
6060+6161+Another issue.
6262+```
6363+6464+
issurge/__init__.py
This is a binary file and will not be displayed.
+226
issurge/main.py
···11+#!/usr/bin/env python
22+"""
33+Usage:
44+ issurge [options] <file> [--] [<glab-args>...]
55+ issurge --help
66+77+<glab-args> contains arguments that will be passed as-is to the end of all `glab' commands
88+99+Options:
1010+ --dry-run Don't actually post the issues
1111+ --debug Print debug information
1212+"""
1313+import json
1414+from collections.abc import Iterable
1515+from subprocess import run
1616+import subprocess
1717+from rich import print
1818+from pathlib import Path
1919+from typing import Any, Generator, NamedTuple, TypeAlias, TypeVar
2020+from docopt import docopt
2121+2222+def run():
2323+ opts = docopt(__doc__)
2424+ TAB = "\t"
2525+2626+ def debug(*args, **kwargs):
2727+ if opts["--debug"]:
2828+ print(*args, **kwargs)
2929+3030+ debug(f"Running with options: {opts}")
3131+3232+ class Node:
3333+ def __init__(self, indented_line):
3434+ self.children = []
3535+ self.level = len(indented_line) - len(indented_line.lstrip())
3636+ self.text = indented_line.strip()
3737+3838+ def add_children(self, nodes):
3939+ childlevel = nodes[0].level
4040+ while nodes:
4141+ node = nodes.pop(0)
4242+ if node.level == childlevel: # add node as a child
4343+ self.children.append(node)
4444+ elif (
4545+ node.level > childlevel
4646+ ): # add nodes as grandchildren of the last child
4747+ nodes.insert(0, node)
4848+ self.children[-1].add_children(nodes)
4949+ elif node.level <= self.level: # this node is a sibling, no more children
5050+ nodes.insert(0, node)
5151+ return
5252+5353+ def as_dict(self):
5454+ if len(self.children) > 1:
5555+ child_dicts = {}
5656+ for node in self.children:
5757+ child_dicts |= node.as_dict()
5858+ return {self.text: child_dicts}
5959+ elif len(self.children) == 1:
6060+ return {self.text: self.children[0].as_dict()}
6161+ else:
6262+ return {self.text: None}
6363+6464+6565+ def indented_to_dict(to_parse: str) -> dict[str, Any]:
6666+ root = Node("root")
6767+ root.add_children([Node(line) for line in to_parse.splitlines() if line.strip()])
6868+ return root.as_dict()["root"]
6969+7070+7171+ class Issue(NamedTuple):
7272+ title: str
7373+ description: str
7474+ labels: set[str]
7575+ assignees: set[str]
7676+ milestone: str
7777+7878+ def __str__(self) -> str:
7979+ result = f"[white]{self.title[:30]}[/white]" or "[red]<No title>[/red]"
8080+ if len(self.title) > 30:
8181+ result += " [white dim](...)[/white dim]"
8282+ if self.labels:
8383+ result += (
8484+ f" [yellow]{' '.join(['~' + l for l in self.labels][:4])}[/yellow]"
8585+ )
8686+ if len(self.labels) > 4:
8787+ result += " [yellow dim]~...[/yellow dim]"
8888+ if self.milestone:
8989+ result += f" [purple]%{self.milestone}[/purple]"
9090+ if self.assignees:
9191+ result += f" [cyan]{' '.join(['@' + a for a in self.assignees])}[/cyan]"
9292+ if self.description:
9393+ result += " [white][...][/white]"
9494+ return result
9595+9696+ def submit(self):
9797+ command = ["glab", "issue", "new"]
9898+ if self.title:
9999+ command += ["-t", self.title]
100100+ command += ["-d", self.description or ""]
101101+ for a in self.assignees:
102102+ command += ["-a", a if a != "me" else "@me"]
103103+ for l in self.labels:
104104+ command += ["-l", l]
105105+ if self.milestone:
106106+ command += ["-m", self.milestone]
107107+ command.extend(opts["<glab-args>"])
108108+ if opts['--dry-run'] or opts['--debug']:
109109+ print(
110110+ f"{'Would run' if opts['--dry-run'] else 'Running'} [white bold]{subprocess.list2cmdline(command)}[/]"
111111+ )
112112+ if not opts["--dry-run"]:
113113+ subprocess.run(command)
114114+115115+ # The boolean is true if the issue expects a description (ending ':')
116116+ @classmethod
117117+ def parse(cls, raw: str) -> tuple["Issue", bool]:
118118+ raw = raw.strip()
119119+ expects_description = False
120120+ if raw.endswith(":"):
121121+ expects_description = True
122122+ raw = raw[:-1].strip()
123123+124124+ title = ""
125125+ description = ""
126126+ labels = set()
127127+ assignees = set()
128128+ milestone = ""
129129+ for word in raw.split(" "):
130130+ if word.startswith("~"):
131131+ labels.add(word[1:])
132132+ elif word.startswith("%"):
133133+ milestone = word[1:]
134134+ elif word.startswith("@"):
135135+ assignees.add(word[1:])
136136+ else:
137137+ title += f" {word}"
138138+139139+ return (
140140+ cls(
141141+ title=title.strip(),
142142+ description=description,
143143+ labels=labels,
144144+ assignees=assignees,
145145+ milestone=milestone,
146146+ ),
147147+ expects_description,
148148+ )
149149+150150+151151+ issues: list[Issue] = []
152152+153153+154154+ def dict_to_issue(
155155+ issue_fragment: str,
156156+ children: dict[str, Any],
157157+ current_issue: Issue,
158158+ recursion_depth=0,
159159+ ) -> list[Issue]:
160160+ log = lambda *args, **kwargs: debug(
161161+ f"[white]{issue_fragment[:50]: <50}[/white]\t{TAB*recursion_depth}",
162162+ *args,
163163+ **kwargs,
164164+ )
165165+166166+ if issue_fragment.strip().startswith("//"):
167167+ log(f"[yellow bold]Skipping comment[/]")
168168+ return []
169169+ current_title = current_issue.title
170170+ current_description = current_issue.description
171171+ current_labels = set(current_issue.labels)
172172+ current_assignees = set(current_issue.assignees)
173173+ current_milestone = current_issue.milestone
174174+175175+ parsed, expecting_description = Issue.parse(issue_fragment)
176176+ if expecting_description:
177177+ log(f"[white dim]{parsed} expects a description[/]")
178178+179179+ current_title = parsed.title
180180+ current_labels |= parsed.labels
181181+ current_assignees |= parsed.assignees
182182+ current_milestone = parsed.milestone
183183+ if expecting_description:
184184+ if children is None:
185185+ raise ValueError(f"Expected a description after {issue_fragment!r}")
186186+ current_description = ""
187187+ for line, v in children.items():
188188+ if v is not None:
189189+ raise ValueError(
190190+ "Description should not have indented lines at {line!r}"
191191+ )
192192+ current_description += f"{line.strip()}\n"
193193+194194+ current_issue = Issue(
195195+ title=current_title,
196196+ description=current_description,
197197+ labels=current_labels,
198198+ assignees=current_assignees,
199199+ milestone=current_milestone,
200200+ )
201201+202202+ if current_issue.title:
203203+ log(f"Made {current_issue!s}")
204204+ return [current_issue]
205205+206206+ if not expecting_description and children is not None:
207207+ result = []
208208+ log(f"Making children from {current_issue!s}")
209209+ for child, grandchildren in children.items():
210210+ result.extend(
211211+ dict_to_issue(child, grandchildren, current_issue, recursion_depth + 1)
212212+ )
213213+ return result
214214+215215+ log(f"[red bold]Issue {issue_fragment!r} has no title and no children[/red bold]")
216216+ return []
217217+218218+219219+ for item in indented_to_dict(Path(opts["<file>"]).read_text()).items():
220220+ issue = dict_to_issue(*item, Issue("", "", set(), set(), ""))
221221+ issues.extend(issue)
222222+223223+ print("Submitting issues...")
224224+ for issue in issues:
225225+ # debug(issue)
226226+ issue.submit()