this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

๐ŸŽ‰ Initial commit

Ewen Le Bihan e4d41756

+397
+2
.gitignore
··· 1 + dist 2 + issurge/__pycache__
+64
README.md
··· 1 + # issurge 2 + 3 + Deal with your client's feedback efficiently by creating a bunch of issues in bulk from a text file. 4 + 5 + Only supports gitlab for now. 6 + 7 + Requires `glab`. 8 + 9 + ## Installation 10 + 11 + ``` 12 + pip install issurge 13 + ``` 14 + 15 + ## Usage 16 + 17 + ``` 18 + issurge [options] <file> [--] [<glab-args>...] 19 + issurge --help 20 + ``` 21 + 22 + - **<glab-args>** contains arguments that will be passed as-is to every `glab` command. 23 + 24 + ### Options 25 + 26 + - **--dry-run:** Don't actually post the issues 27 + - **--debug:** Print debug information 28 + 29 + ### Syntax 30 + 31 + Indentation is done with tab characters only. 32 + 33 + - **Title:** The title is made up of any word in the line that does not start with `~`, `@` or `%`. 34 + - **Tags:** Prefix a word with `~` to add a label to the issue 35 + - **Assignees:** Prefix with `@` to add an assignee. The special assignee `@me` is supported. 36 + - **Milestone:** Prefix with `%` to set the milestone 37 + - **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: 38 + ``` 39 + My superb issue ~some-tag: 40 + Here is a description 41 + 42 + I can skip lines 43 + Another issue 44 + ``` 45 + 46 + Note that you cannot have indented lines inside of the description (they will be ignored). 47 + 48 + #### Add some properties to multiple issues 49 + 50 + You can apply something (a tag, a milestone, an assignee) to multiple issues by indenting them below: 51 + 52 + ``` 53 + One issue 54 + 55 + ~common-tag 56 + ~tag1 This issue will have tags: 57 + - tag1 58 + - common-tag 59 + @me this issue will only have common-tag as a tag. 60 + 61 + Another issue. 62 + ``` 63 + 64 +
issurge/__init__.py

This is a binary file and will not be displayed.

+226
issurge/main.py
··· 1 + #!/usr/bin/env python 2 + """ 3 + Usage: 4 + issurge [options] <file> [--] [<glab-args>...] 5 + issurge --help 6 + 7 + <glab-args> contains arguments that will be passed as-is to the end of all `glab' commands 8 + 9 + Options: 10 + --dry-run Don't actually post the issues 11 + --debug Print debug information 12 + """ 13 + import json 14 + from collections.abc import Iterable 15 + from subprocess import run 16 + import subprocess 17 + from rich import print 18 + from pathlib import Path 19 + from typing import Any, Generator, NamedTuple, TypeAlias, TypeVar 20 + from docopt import docopt 21 + 22 + def run(): 23 + opts = docopt(__doc__) 24 + TAB = "\t" 25 + 26 + def debug(*args, **kwargs): 27 + if opts["--debug"]: 28 + print(*args, **kwargs) 29 + 30 + debug(f"Running with options: {opts}") 31 + 32 + class Node: 33 + def __init__(self, indented_line): 34 + self.children = [] 35 + self.level = len(indented_line) - len(indented_line.lstrip()) 36 + self.text = indented_line.strip() 37 + 38 + def add_children(self, nodes): 39 + childlevel = nodes[0].level 40 + while nodes: 41 + node = nodes.pop(0) 42 + if node.level == childlevel: # add node as a child 43 + self.children.append(node) 44 + elif ( 45 + node.level > childlevel 46 + ): # add nodes as grandchildren of the last child 47 + nodes.insert(0, node) 48 + self.children[-1].add_children(nodes) 49 + elif node.level <= self.level: # this node is a sibling, no more children 50 + nodes.insert(0, node) 51 + return 52 + 53 + def as_dict(self): 54 + if len(self.children) > 1: 55 + child_dicts = {} 56 + for node in self.children: 57 + child_dicts |= node.as_dict() 58 + return {self.text: child_dicts} 59 + elif len(self.children) == 1: 60 + return {self.text: self.children[0].as_dict()} 61 + else: 62 + return {self.text: None} 63 + 64 + 65 + def indented_to_dict(to_parse: str) -> dict[str, Any]: 66 + root = Node("root") 67 + root.add_children([Node(line) for line in to_parse.splitlines() if line.strip()]) 68 + return root.as_dict()["root"] 69 + 70 + 71 + class Issue(NamedTuple): 72 + title: str 73 + description: str 74 + labels: set[str] 75 + assignees: set[str] 76 + milestone: str 77 + 78 + def __str__(self) -> str: 79 + result = f"[white]{self.title[:30]}[/white]" or "[red]<No title>[/red]" 80 + if len(self.title) > 30: 81 + result += " [white dim](...)[/white dim]" 82 + if self.labels: 83 + result += ( 84 + f" [yellow]{' '.join(['~' + l for l in self.labels][:4])}[/yellow]" 85 + ) 86 + if len(self.labels) > 4: 87 + result += " [yellow dim]~...[/yellow dim]" 88 + if self.milestone: 89 + result += f" [purple]%{self.milestone}[/purple]" 90 + if self.assignees: 91 + result += f" [cyan]{' '.join(['@' + a for a in self.assignees])}[/cyan]" 92 + if self.description: 93 + result += " [white][...][/white]" 94 + return result 95 + 96 + def submit(self): 97 + command = ["glab", "issue", "new"] 98 + if self.title: 99 + command += ["-t", self.title] 100 + command += ["-d", self.description or ""] 101 + for a in self.assignees: 102 + command += ["-a", a if a != "me" else "@me"] 103 + for l in self.labels: 104 + command += ["-l", l] 105 + if self.milestone: 106 + command += ["-m", self.milestone] 107 + command.extend(opts["<glab-args>"]) 108 + if opts['--dry-run'] or opts['--debug']: 109 + print( 110 + f"{'Would run' if opts['--dry-run'] else 'Running'} [white bold]{subprocess.list2cmdline(command)}[/]" 111 + ) 112 + if not opts["--dry-run"]: 113 + subprocess.run(command) 114 + 115 + # The boolean is true if the issue expects a description (ending ':') 116 + @classmethod 117 + def parse(cls, raw: str) -> tuple["Issue", bool]: 118 + raw = raw.strip() 119 + expects_description = False 120 + if raw.endswith(":"): 121 + expects_description = True 122 + raw = raw[:-1].strip() 123 + 124 + title = "" 125 + description = "" 126 + labels = set() 127 + assignees = set() 128 + milestone = "" 129 + for word in raw.split(" "): 130 + if word.startswith("~"): 131 + labels.add(word[1:]) 132 + elif word.startswith("%"): 133 + milestone = word[1:] 134 + elif word.startswith("@"): 135 + assignees.add(word[1:]) 136 + else: 137 + title += f" {word}" 138 + 139 + return ( 140 + cls( 141 + title=title.strip(), 142 + description=description, 143 + labels=labels, 144 + assignees=assignees, 145 + milestone=milestone, 146 + ), 147 + expects_description, 148 + ) 149 + 150 + 151 + issues: list[Issue] = [] 152 + 153 + 154 + def dict_to_issue( 155 + issue_fragment: str, 156 + children: dict[str, Any], 157 + current_issue: Issue, 158 + recursion_depth=0, 159 + ) -> list[Issue]: 160 + log = lambda *args, **kwargs: debug( 161 + f"[white]{issue_fragment[:50]: <50}[/white]\t{TAB*recursion_depth}", 162 + *args, 163 + **kwargs, 164 + ) 165 + 166 + if issue_fragment.strip().startswith("//"): 167 + log(f"[yellow bold]Skipping comment[/]") 168 + return [] 169 + current_title = current_issue.title 170 + current_description = current_issue.description 171 + current_labels = set(current_issue.labels) 172 + current_assignees = set(current_issue.assignees) 173 + current_milestone = current_issue.milestone 174 + 175 + parsed, expecting_description = Issue.parse(issue_fragment) 176 + if expecting_description: 177 + log(f"[white dim]{parsed} expects a description[/]") 178 + 179 + current_title = parsed.title 180 + current_labels |= parsed.labels 181 + current_assignees |= parsed.assignees 182 + current_milestone = parsed.milestone 183 + if expecting_description: 184 + if children is None: 185 + raise ValueError(f"Expected a description after {issue_fragment!r}") 186 + current_description = "" 187 + for line, v in children.items(): 188 + if v is not None: 189 + raise ValueError( 190 + "Description should not have indented lines at {line!r}" 191 + ) 192 + current_description += f"{line.strip()}\n" 193 + 194 + current_issue = Issue( 195 + title=current_title, 196 + description=current_description, 197 + labels=current_labels, 198 + assignees=current_assignees, 199 + milestone=current_milestone, 200 + ) 201 + 202 + if current_issue.title: 203 + log(f"Made {current_issue!s}") 204 + return [current_issue] 205 + 206 + if not expecting_description and children is not None: 207 + result = [] 208 + log(f"Making children from {current_issue!s}") 209 + for child, grandchildren in children.items(): 210 + result.extend( 211 + dict_to_issue(child, grandchildren, current_issue, recursion_depth + 1) 212 + ) 213 + return result 214 + 215 + log(f"[red bold]Issue {issue_fragment!r} has no title and no children[/red bold]") 216 + return [] 217 + 218 + 219 + for item in indented_to_dict(Path(opts["<file>"]).read_text()).items(): 220 + issue = dict_to_issue(*item, Issue("", "", set(), set(), "")) 221 + issues.extend(issue) 222 + 223 + print("Submitting issues...") 224 + for issue in issues: 225 + # debug(issue) 226 + issue.submit()
+88
poetry.lock
··· 1 + # This file is automatically @generated by Poetry and should not be changed by hand. 2 + 3 + [[package]] 4 + name = "docopt" 5 + version = "0.6.2" 6 + description = "Pythonic argument parser, that will make you smile" 7 + category = "main" 8 + optional = false 9 + python-versions = "*" 10 + files = [ 11 + {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, 12 + ] 13 + 14 + [[package]] 15 + name = "markdown-it-py" 16 + version = "2.2.0" 17 + description = "Python port of markdown-it. Markdown parsing, done right!" 18 + category = "main" 19 + optional = false 20 + python-versions = ">=3.7" 21 + files = [ 22 + {file = "markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1"}, 23 + {file = "markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30"}, 24 + ] 25 + 26 + [package.dependencies] 27 + mdurl = ">=0.1,<1.0" 28 + 29 + [package.extras] 30 + benchmarking = ["psutil", "pytest", "pytest-benchmark"] 31 + code-style = ["pre-commit (>=3.0,<4.0)"] 32 + compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] 33 + linkify = ["linkify-it-py (>=1,<3)"] 34 + plugins = ["mdit-py-plugins"] 35 + profiling = ["gprof2dot"] 36 + rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] 37 + testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] 38 + 39 + [[package]] 40 + name = "mdurl" 41 + version = "0.1.2" 42 + description = "Markdown URL utilities" 43 + category = "main" 44 + optional = false 45 + python-versions = ">=3.7" 46 + files = [ 47 + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, 48 + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, 49 + ] 50 + 51 + [[package]] 52 + name = "pygments" 53 + version = "2.14.0" 54 + description = "Pygments is a syntax highlighting package written in Python." 55 + category = "main" 56 + optional = false 57 + python-versions = ">=3.6" 58 + files = [ 59 + {file = "Pygments-2.14.0-py3-none-any.whl", hash = "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717"}, 60 + {file = "Pygments-2.14.0.tar.gz", hash = "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297"}, 61 + ] 62 + 63 + [package.extras] 64 + plugins = ["importlib-metadata"] 65 + 66 + [[package]] 67 + name = "rich" 68 + version = "13.3.3" 69 + description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" 70 + category = "main" 71 + optional = false 72 + python-versions = ">=3.7.0" 73 + files = [ 74 + {file = "rich-13.3.3-py3-none-any.whl", hash = "sha256:540c7d6d26a1178e8e8b37e9ba44573a3cd1464ff6348b99ee7061b95d1c6333"}, 75 + {file = "rich-13.3.3.tar.gz", hash = "sha256:dc84400a9d842b3a9c5ff74addd8eb798d155f36c1c91303888e0a66850d2a15"}, 76 + ] 77 + 78 + [package.dependencies] 79 + markdown-it-py = ">=2.2.0,<3.0.0" 80 + pygments = ">=2.13.0,<3.0.0" 81 + 82 + [package.extras] 83 + jupyter = ["ipywidgets (>=7.5.1,<9)"] 84 + 85 + [metadata] 86 + lock-version = "2.0" 87 + python-versions = "^3.10" 88 + content-hash = "e9cb0bf1a5b56042fca97b6ee7080855364a369ff6a81b56c1c912cb4d49ab4c"
+17
pyproject.toml
··· 1 + [tool.poetry] 2 + name = "issurge" 3 + version = "0.1.0" 4 + description = "Deal with your client's feedback efficiently by creating a bunch of issues in bulk from a text file." 5 + authors = ["Ewen Le Bihan <hey@ewen.works>"] 6 + readme = "README.md" 7 + scripts = { issurge = "issurge.main:run" } 8 + 9 + [tool.poetry.dependencies] 10 + python = "^3.10" 11 + rich = "^13.3.3" 12 + docopt = "^0.6.2" 13 + 14 + 15 + [build-system] 16 + requires = ["poetry-core"] 17 + build-backend = "poetry.core.masonry.api"
tests/__init__.py

This is a binary file and will not be displayed.