-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathgh_sync_issues.py
318 lines (238 loc) · 8.5 KB
/
gh_sync_issues.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
import dataclasses
import json
from pathlib import Path
import subprocess
import click
import github
import github.Repository as ghrepository
import github.Issue as ghissue
from ruamel.yaml import YAML
from ruamel.yaml.scalarstring import LiteralScalarString, PlainScalarString
from ruamel.yaml.comments import CommentedSeq
yaml = YAML(typ=["rt", "string"])
def comp_newline(a, b):
"""Compare a and b while normalising newlines."""
if isinstance(a, str):
a = a.replace("\r\n", "\n")
if isinstance(b, str):
b = b.replace("\r\n", "\n")
return a == b
@dataclasses.dataclass(kw_only=True)
class Issue:
"""A limited representation of a Github issue."""
number: int | None = None
title: str | None = None
body: str | None = None
assignees: list[str] | None = None
labels: list[str] | None = None
def __post_init__(self):
self.dirty: list = []
def to_dict(self, yaml: bool = False, skip_missing: bool = False) -> dict:
"""Output the results to a dictionary.
Parameters
----------
yaml
Wrap internal types for better serialisation by `ruamel.yaml`.
Returns
-------
d
The serialised data.
"""
d = dataclasses.asdict(self)
if skip_missing:
d = {k: v for k, v in d.items() if v is not None}
if yaml:
# TODO: munge body
for k, v in d.items():
if isinstance(v, str):
if "\n" in v or len(v) > 80:
d[k] = LiteralScalarString(v)
else:
d[k] = PlainScalarString(v)
if isinstance(v, list):
d[k] = CommentedSeq(v)
d[k].fa.set_flow_style()
return d
def _dirty_dict(self):
"""A dict of the dirty values."""
return {k: v for k, v in self.to_dict().items() if k in self.dirty}
def update(self, **kwargs) -> None:
"""Update the issue with the given fields and mark them dirty.
Parameters
----------
kwargs
Updated values of the fields.
"""
field_names = [f.name for f in dataclasses.fields(self)]
for k, v in kwargs.items():
if k not in field_names:
continue
existing_val = getattr(self, k, None)
# See if the value has changed.
# NOTE: we need to be careful with newlines here as Github sends \r\n
if comp_newline(existing_val, v):
# Nothing to update
continue
setattr(self, k, v)
self.dirty.append(k)
@classmethod
def from_github(cls, issue: ghissue.Issue) -> "Issue":
"""Create the Issue from a github API issue.
Parameters
----------
issue
The Github API representation.
Returns
-------
Issue
The converted issue.
"""
assignees = [a.login for a in issue.assignees]
labels = [l.name for l in issue.labels]
return cls(
number=issue.number,
title=issue.title,
body=issue.body,
assignees=assignees,
labels=labels,
)
@classmethod
def list_to_yaml(cls, issues: list["Issue"]) -> CommentedSeq:
"""Convert a list of issues into a nicely formatted representation."""
s = [issue.to_dict(yaml=True) for issue in issues]
s = CommentedSeq(s)
# Improve the formatting by adding newlines between issues
for i in range(1, len(s)):
s.yaml_set_comment_before_after_key(i, before="\n")
return s
_gh: github.Github | None = None
def gh() -> github.Github:
"""Get a Github API handle."""
global _gh
if _gh is None:
# Try and get an access token from the gh client
token = subprocess.check_output("gh auth token", shell=True).decode()[:-1]
_gh = github.Github(token)
return _gh
def current_repo() -> str:
"""Get the current repo"""
repo_name = subprocess.check_output("gh repo view --json nameWithOwner", shell=True)
repo_name = json.loads(repo_name)["nameWithOwner"]
return repo_name
def resolve_repo(repo: str | None) -> ghrepository.Repository:
"""Resolve a repo string to an API object.
Parameters
----------
repo
Repository name to resolve. If not set, then try to find one from the current
directory.
Returns
-------
ghrepo
Github API repository object.
"""
if repo is None:
repo = current_repo()
return gh().get_repo(repo)
@click.group
def cli():
"""A command for synchronizing issues to and from a local YAML file."""
pass
@click.argument("output", type=click.Path(dir_okay=False, path_type=Path))
@click.option(
"--repo",
help=(
"The name of the repo as <owner>/<reponame>. "
"If not given the current directory is mapped to an enclosing repository."
),
default=None,
type=str,
)
@cli.command()
def pull(output: Path, repo: str):
"""Fetch the issues and save as yaml into OUTPUT."""
repo = resolve_repo(repo)
gh_issues = repo.get_issues()
issues = [Issue.from_github(issue) for issue in gh_issues]
with open(output, "w") as fh:
yaml_issue_list = Issue.list_to_yaml(issues)
yaml.dump(yaml_issue_list, stream=fh)
@click.argument("input", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--repo",
help=(
"The name of the repo as <owner>/<reponame>. "
"If not given the current directory is mapped to an enclosing repository."
),
default=None,
type=str,
)
@click.option(
"-n",
"--dry-run",
help="Don't apply changes just simulate the actions.",
is_flag=True,
)
@click.option(
"--update-input/--no-update-input",
help="Update the input file with the created issue numbers.",
default=True,
)
@cli.command()
def push(input: Path, repo: str, dry_run: bool, update_input: bool):
"""Add and update issues from the INPUT file into a Github repository.
INPUT must be a yaml formatted file.
Any issue without a `number` field are presumed to be new and will be added to the
repository. Unless overridden with the `--no-update-input` option the input file
will be updated with the new issue numbers.
The issue `title` is a required field. An optional text `body` can given for the
main issue body, as can lists of names of `assignees` and string `labels` (which
will be created if they don't exist already).
"""
with open(input, "r") as fh:
issues = yaml.load(fh)
repo = resolve_repo(repo)
new_issues_added = False
for issue in issues:
# If number is in there the issue currently exists
if "number" in issue:
gh_issue = repo.get_issue(int(issue["number"]))
existing_issue = Issue.from_github(gh_issue)
existing_issue.update(**issue)
if not existing_issue.dirty:
continue
click.echo(f"=== Updating existing issue (#{existing_issue.number}) ===")
click.echo("Changes:")
click.echo(yaml.dumps(existing_issue._dirty_dict()))
click.echo("")
if not dry_run:
gh_issue.edit(**existing_issue._dirty_dict())
click.echo("Updated.")
else:
click.echo("Not updated (dry run).")
click.echo("")
else:
new_issue = Issue(**issue)
if new_issue.title is None:
raise click.UsageError("All issues must have a title.")
click.echo(f"=== Adding new issue ===")
click.echo(yaml.dumps(new_issue.to_dict(yaml=True, skip_missing=True)))
click.echo("")
if not dry_run:
gh_issue = repo.create_issue(**new_issue.to_dict(skip_missing=True))
issue_number = gh_issue.number
issue.insert(0, "number", issue_number)
click.echo(f"Added (#{issue_number}).")
new_issues_added = True
else:
click.echo(f"Not added (dry run).")
click.echo("")
if new_issues_added:
if update_input:
click.echo("=== Updating input file ===")
with open(input, "w") as fh:
yaml.dump(issues, stream=fh)
else:
click.echo("=== Not updating input file. UPDATE MANUALLY ===")
if __name__ == "__main__":
cli()