+29
-1
README.md
+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
+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
+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
+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
+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 _():