backlogops-cli 0.2__py3-none-any.whl → 0.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backlogops_cli/_command_io.py +43 -9
- backlogops_cli/_wizard_io.py +50 -16
- backlogops_cli/add_releases_to_jira.py +144 -0
- backlogops_cli/add_to_jira.py +151 -0
- backlogops_cli/config_wizard.py +16 -5
- backlogops_cli/encrypt_token_file.py +120 -0
- backlogops_cli/jira_fields.py +91 -0
- backlogops_cli/order_releases_in_jira.py +128 -0
- backlogops_cli/preset_wizard.py +20 -5
- backlogops_cli/rank_in_jira.py +120 -0
- backlogops_cli/read_jira.py +92 -0
- backlogops_cli/rename_releases_in_jira.py +120 -0
- backlogops_cli/update_backlog_in_jira.py +177 -0
- backlogops_cli/update_releases_in_jira.py +135 -0
- {backlogops_cli-0.2.dist-info → backlogops_cli-0.4.dist-info}/METADATA +85 -18
- {backlogops_cli-0.2.dist-info → backlogops_cli-0.4.dist-info}/RECORD +19 -9
- {backlogops_cli-0.2.dist-info → backlogops_cli-0.4.dist-info}/WHEEL +1 -1
- {backlogops_cli-0.2.dist-info → backlogops_cli-0.4.dist-info}/licenses/LICENSE.txt +0 -0
- {backlogops_cli-0.2.dist-info → backlogops_cli-0.4.dist-info}/top_level.txt +0 -0
backlogops_cli/_command_io.py
CHANGED
|
@@ -20,11 +20,39 @@ from backlogops_cli._migrate_warn import (
|
|
|
20
20
|
CliMigrateWarnHook, CliPresetMigrateWarnHook)
|
|
21
21
|
from backlogops_cli.bloc_version_reporter import BloCliVersionReporter
|
|
22
22
|
from backlogops import (
|
|
23
|
-
BacklogOpsConfig, BacklogReleases, FileExistsCb, FormatRules,
|
|
24
|
-
ReleaseChanges, ReleaseDateChanges,
|
|
25
|
-
format_content_changes, format_date_changes,
|
|
26
|
-
read_backlog_releases, resolve_input_config,
|
|
27
|
-
write_backlog_releases, write_content_changes,
|
|
23
|
+
BacklogOpsConfig, BacklogReleases, FileExistsCb, FormatRules,
|
|
24
|
+
JiraRankAnchor, Levels, ReleaseChanges, ReleaseDateChanges,
|
|
25
|
+
allow_overwrite, format_content_changes, format_date_changes,
|
|
26
|
+
get_backlog_ops_config, read_backlog_releases, resolve_input_config,
|
|
27
|
+
resolve_output_config, write_backlog_releases, write_content_changes,
|
|
28
|
+
write_date_changes)
|
|
29
|
+
|
|
30
|
+
RANK_ANCHOR_CHOICES = {
|
|
31
|
+
'backlog-top': JiraRankAnchor.BACKLOG_TOP,
|
|
32
|
+
'backlog-bottom': JiraRankAnchor.BACKLOG_BOTTOM,
|
|
33
|
+
'first-key': JiraRankAnchor.FIRST_KEY,
|
|
34
|
+
'last-key': JiraRankAnchor.LAST_KEY}
|
|
35
|
+
"""Command-line anchor names mapped to :class:`JiraRankAnchor` members."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def rank_anchor(value: Optional[str]) -> Optional[JiraRankAnchor]:
|
|
39
|
+
"""Return the anchor for a CLI anchor name, or None for a None value."""
|
|
40
|
+
return None if value is None else RANK_ANCHOR_CHOICES[value]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def add_rank_arg(parser: argparse.ArgumentParser) -> None:
|
|
44
|
+
"""Add the optional ``--rank`` flag choosing a Jira rank anchor.
|
|
45
|
+
|
|
46
|
+
Shared by the add and update commands, where ranking is opt-in: without
|
|
47
|
+
``--rank`` the Jira rank order is left unchanged.
|
|
48
|
+
"""
|
|
49
|
+
parser.add_argument('--rank', dest='rank', default=None,
|
|
50
|
+
choices=sorted(RANK_ANCHOR_CHOICES),
|
|
51
|
+
help='Also set the Jira rank order to match the '
|
|
52
|
+
'supplied backlog, placing the items at this anchor: '
|
|
53
|
+
'backlog-top, backlog-bottom, first-key (keep the '
|
|
54
|
+
'first item fixed) or last-key (keep the last item '
|
|
55
|
+
'fixed). Omit to leave the Jira rank order unchanged.')
|
|
28
56
|
|
|
29
57
|
|
|
30
58
|
def overwrite_callback(force: bool, in_stream: Optional[TextIO] = None,
|
|
@@ -255,11 +283,14 @@ def _write_output(parsed: argparse.Namespace,
|
|
|
255
283
|
|
|
256
284
|
def run_write(parsed: argparse.Namespace,
|
|
257
285
|
data_source: Callable[[Optional[BacklogOpsConfig]],
|
|
258
|
-
BacklogReleases]
|
|
286
|
+
BacklogReleases],
|
|
287
|
+
require_config: bool = False) -> int:
|
|
259
288
|
"""Build the data, write it to the output file, and report the result.
|
|
260
289
|
|
|
261
|
-
The configuration is resolved once from ``-c`` or by discovery
|
|
262
|
-
back to the built-in defaults when none is found
|
|
290
|
+
The configuration is resolved once from ``-c`` or by discovery. It
|
|
291
|
+
falls back to the built-in defaults when none is found, unless
|
|
292
|
+
``require_config`` is True, in which case a missing configuration is
|
|
293
|
+
reported as an error.
|
|
263
294
|
|
|
264
295
|
Args:
|
|
265
296
|
parsed: Parsed command line arguments holding the output options
|
|
@@ -269,12 +300,15 @@ def run_write(parsed: argparse.Namespace,
|
|
|
269
300
|
None) and returns the backlog and releases to write. It is
|
|
270
301
|
called inside the error handling so that reading failures are
|
|
271
302
|
reported like writing failures.
|
|
303
|
+
require_config: When True a missing configuration is reported as an
|
|
304
|
+
error instead of falling back to the built-in defaults.
|
|
272
305
|
|
|
273
306
|
Returns:
|
|
274
307
|
``0`` on success, ``1`` when the data cannot be built or written.
|
|
275
308
|
"""
|
|
276
309
|
try:
|
|
277
|
-
config =
|
|
310
|
+
config = (required_config(parsed) if require_config
|
|
311
|
+
else optional_config(parsed))
|
|
278
312
|
_write_output(parsed, config, data_source(config))
|
|
279
313
|
except (ValueError, TypeError, KeyError, OSError) as error:
|
|
280
314
|
print(f'Could not write {parsed.output}: {error}', file=sys.stderr)
|
backlogops_cli/_wizard_io.py
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
Both wizard commands write a JSON configuration file built interactively
|
|
5
5
|
through a ``WizardUiBridge``. They share the same command line shape (an
|
|
6
|
-
output file,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
output file, an optional input file whose contents pre-fill the wizard, a
|
|
7
|
+
switch forcing the plain console interface, and a force flag), the same
|
|
8
|
+
overwrite check, the same read-run-write-report flow, and the same
|
|
9
|
+
crash-safe write. That shared logic lives here; the leading underscore in
|
|
10
|
+
the module name keeps it out of the command listing.
|
|
10
11
|
"""
|
|
11
12
|
# PYTHON_ARGCOMPLETE_OK
|
|
12
13
|
|
|
@@ -17,21 +18,32 @@ import argparse
|
|
|
17
18
|
import sys
|
|
18
19
|
from collections.abc import Callable
|
|
19
20
|
from pathlib import Path
|
|
21
|
+
from typing import Optional, TypeVar
|
|
20
22
|
from config_as_json import Config
|
|
21
23
|
from config_as_json.file_extension import fix_file_extension
|
|
22
24
|
from tableio_cfg_json import WizardUiBridge, WizardUiBridgeConsole, \
|
|
23
25
|
make_text_ui_bridge
|
|
26
|
+
from backlogops import safe_write_config
|
|
24
27
|
from backlogops_cli._command_io import add_force_arg, overwrite_callback
|
|
25
28
|
|
|
26
29
|
CONFIG_EXTENSION = '.cfg'
|
|
30
|
+
_ConfigT = TypeVar('_ConfigT', bound=Config)
|
|
27
31
|
|
|
28
32
|
|
|
29
33
|
def build_wizard_parser(description: str) -> argparse.ArgumentParser:
|
|
30
|
-
"""Build a wizard parser with output, no-textual and force
|
|
34
|
+
"""Build a wizard parser with output, input, no-textual and force."""
|
|
31
35
|
parser = argparse.ArgumentParser(description=description)
|
|
32
36
|
parser.add_argument('-o', '--output', dest='output', required=True,
|
|
33
37
|
help='Configuration file to write; the '
|
|
34
38
|
f'{CONFIG_EXTENSION} extension is added if missing.')
|
|
39
|
+
parser.add_argument('-i', '--input', dest='input',
|
|
40
|
+
help='Existing configuration file whose contents '
|
|
41
|
+
'pre-fill the wizard as the starting answers, so you '
|
|
42
|
+
'edit them instead of starting empty. Use the same '
|
|
43
|
+
'path as -o to edit that file in place; you are then '
|
|
44
|
+
f'asked to confirm overwriting it. The '
|
|
45
|
+
f'{CONFIG_EXTENSION} extension is assumed when the '
|
|
46
|
+
'file is not found as named.')
|
|
35
47
|
parser.add_argument('--no-textual', dest='no_textual', action='store_true',
|
|
36
48
|
help='Force the plain console interface instead of '
|
|
37
49
|
'the Textual full-screen interface.')
|
|
@@ -61,30 +73,52 @@ def _make_bridge(no_textual: bool) -> WizardUiBridge:
|
|
|
61
73
|
return make_text_ui_bridge(sys.stdout, sys.stdin, sys.stderr)
|
|
62
74
|
|
|
63
75
|
|
|
76
|
+
def _read_default(input_file: Optional[str],
|
|
77
|
+
reader: Callable[[str], _ConfigT]) -> Optional[_ConfigT]:
|
|
78
|
+
"""Read the pre-fill file, or return None when none is requested.
|
|
79
|
+
|
|
80
|
+
The ``.cfg`` extension is assumed when the named file is not found as
|
|
81
|
+
given, matching how the output filename is completed.
|
|
82
|
+
"""
|
|
83
|
+
if input_file is None:
|
|
84
|
+
return None
|
|
85
|
+
path = fix_file_extension(input_file, CONFIG_EXTENSION, for_reading=True)
|
|
86
|
+
if not Path(path).is_file():
|
|
87
|
+
raise ValueError(f'File to read initial values from not found: {path}')
|
|
88
|
+
return reader(path)
|
|
89
|
+
|
|
90
|
+
|
|
64
91
|
def run_wizard_to_file(parsed: argparse.Namespace,
|
|
65
|
-
wizard: Callable[
|
|
66
|
-
label: str) -> int:
|
|
92
|
+
wizard: Callable[..., _ConfigT],
|
|
93
|
+
reader: Callable[[str], _ConfigT], label: str) -> int:
|
|
67
94
|
"""Run a wizard, write its configuration to the output file, report.
|
|
68
95
|
|
|
69
|
-
|
|
70
|
-
|
|
96
|
+
When ``parsed.input`` names an existing file it is read first and used
|
|
97
|
+
to pre-fill the wizard, so the user edits those values instead of
|
|
98
|
+
starting from scratch. The output filename receives the ``.cfg``
|
|
99
|
+
extension when it is not already present, an existing output file is
|
|
100
|
+
only overwritten after confirmation (or with ``--force``), and the
|
|
101
|
+
result is written crash-safely through a ``.in_progress`` sibling file.
|
|
71
102
|
|
|
72
103
|
Args:
|
|
73
|
-
parsed: Parsed arguments holding ``output``, ``force``
|
|
74
|
-
``no_textual``.
|
|
75
|
-
wizard: Wizard called with the chosen UI bridge
|
|
76
|
-
configuration object that knows how to
|
|
104
|
+
parsed: Parsed arguments holding ``output``, ``input``, ``force``
|
|
105
|
+
and ``no_textual``.
|
|
106
|
+
wizard: Wizard called with the chosen UI bridge and the pre-fill
|
|
107
|
+
default; it returns a configuration object that knows how to
|
|
108
|
+
write itself.
|
|
109
|
+
reader: Reads the ``input`` file into a default for the wizard.
|
|
77
110
|
label: Human-readable name of what was written, for the message.
|
|
78
111
|
|
|
79
112
|
Returns:
|
|
80
113
|
``0`` on success, ``1`` when the wizard is abandoned or the
|
|
81
|
-
configuration is rejected or cannot be written.
|
|
114
|
+
configuration is rejected or cannot be read or written.
|
|
82
115
|
"""
|
|
83
116
|
output = fix_file_extension(parsed.output, CONFIG_EXTENSION)
|
|
84
117
|
try:
|
|
118
|
+
default = _read_default(parsed.input, reader)
|
|
85
119
|
_check_overwrite(output, parsed.force)
|
|
86
|
-
config = wizard(_make_bridge(parsed.no_textual))
|
|
87
|
-
config
|
|
120
|
+
config = wizard(_make_bridge(parsed.no_textual), default=default)
|
|
121
|
+
safe_write_config(config, output, sys.stderr)
|
|
88
122
|
except (ValueError, TypeError, KeyError, EOFError, OSError) as error:
|
|
89
123
|
print(f'Could not create the configuration: {error}', file=sys.stderr)
|
|
90
124
|
return 1
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Add releases to Jira from an input file, creating one version per name.
|
|
3
|
+
|
|
4
|
+
The command reads a backlog and its releases from the input file, then
|
|
5
|
+
adds the releases to Jira using a named preset of the backlog-ops
|
|
6
|
+
configuration. By default it stops with an error when a release name
|
|
7
|
+
already exists in Jira; ``--skip-existing`` skips those releases instead.
|
|
8
|
+
|
|
9
|
+
The added releases and the releases already in Jira are printed to stdout
|
|
10
|
+
as two labelled lists, unless ``-q``/``--quiet`` is given. Each list is
|
|
11
|
+
also written, together with the unchanged input backlog, to a file when
|
|
12
|
+
``--added-file`` or ``--existing-file`` names one; without a file name the
|
|
13
|
+
list is not written. An encrypted Jira token is unlocked by a pass phrase
|
|
14
|
+
asked on the terminal only when it is needed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
18
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
19
|
+
# MIT License
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import sys
|
|
23
|
+
from getpass import getpass
|
|
24
|
+
from typing import Optional
|
|
25
|
+
from backlogops import (
|
|
26
|
+
AddedReleasesToJira, BacklogOpsConfig, BacklogReleases, FormatRules,
|
|
27
|
+
JiraConnections, OnExistingKey, ReleaseExistsError, add_releases_to_jira,
|
|
28
|
+
format_release_result, resolve_output_config, write_backlog_releases)
|
|
29
|
+
from backlogops_cli._command_io import (
|
|
30
|
+
add_config_arg, add_input_args, overwrite_callback, parsed_args,
|
|
31
|
+
read_input, required_config)
|
|
32
|
+
from backlogops_cli._migrate_warn import CliPresetMigrateWarnHook
|
|
33
|
+
|
|
34
|
+
DESCRIPTION = 'Add releases to Jira, creating a new version per release'
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
38
|
+
"""Build the command line parser for the add-releases command."""
|
|
39
|
+
parser = argparse.ArgumentParser(description=DESCRIPTION)
|
|
40
|
+
add_input_args(parser)
|
|
41
|
+
add_config_arg(parser)
|
|
42
|
+
parser.add_argument('-p', '--preset', dest='preset', required=True,
|
|
43
|
+
help='Name of the Jira preset in the configuration.')
|
|
44
|
+
parser.add_argument('--skip-existing', dest='skip_existing',
|
|
45
|
+
action='store_true',
|
|
46
|
+
help='Skip releases whose name already exists in Jira '
|
|
47
|
+
'(the default stops with an error instead).')
|
|
48
|
+
parser.add_argument('--added-file', dest='added_file', metavar='FILE',
|
|
49
|
+
help='Write the added releases, with the input '
|
|
50
|
+
'backlog, to this file. Omit to not write it.')
|
|
51
|
+
parser.add_argument('--existing-file', dest='existing_file',
|
|
52
|
+
metavar='FILE',
|
|
53
|
+
help='Write the releases already in Jira, with the '
|
|
54
|
+
'input backlog, to this file. Omit to not write it.')
|
|
55
|
+
parser.add_argument('-q', '--quiet', dest='quiet', action='store_true',
|
|
56
|
+
help='Do not print the added and already-present '
|
|
57
|
+
'lists to stdout.')
|
|
58
|
+
parser.add_argument('-f', '--force', dest='force', action='store_true',
|
|
59
|
+
help='Overwrite existing output files without '
|
|
60
|
+
'asking.')
|
|
61
|
+
return parser
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _passphrase() -> str:
|
|
65
|
+
"""Ask for the Jira token pass phrase on the terminal."""
|
|
66
|
+
return getpass('Jira API token pass phrase: ')
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _add(parsed: argparse.Namespace, config: BacklogOpsConfig,
|
|
70
|
+
data: BacklogReleases) -> AddedReleasesToJira:
|
|
71
|
+
"""Add the input releases to Jira using the named preset."""
|
|
72
|
+
print(f"Adding releases to Jira using preset '{parsed.preset}'...",
|
|
73
|
+
file=sys.stderr)
|
|
74
|
+
connections = JiraConnections(config.get_jira_config(), _passphrase)
|
|
75
|
+
mode = (OnExistingKey.SKIP if parsed.skip_existing
|
|
76
|
+
else OnExistingKey.RAISE)
|
|
77
|
+
result = add_releases_to_jira(connections, parsed.preset, data.releases,
|
|
78
|
+
on_existing_key=mode)
|
|
79
|
+
print(f'Added {len(result.stored)} releases to Jira; '
|
|
80
|
+
f'{len(result.already_present)} already present; '
|
|
81
|
+
f'{len(result.failed)} failed.', file=sys.stderr)
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _write_file(config: BacklogOpsConfig, path: str, data: BacklogReleases,
|
|
86
|
+
force: bool) -> None:
|
|
87
|
+
"""Write one backlog-and-releases result to a file."""
|
|
88
|
+
out_config = resolve_output_config(None, data_file=path,
|
|
89
|
+
presets=config.output_configs,
|
|
90
|
+
auto_ch_hook=CliPresetMigrateWarnHook())
|
|
91
|
+
write_backlog_releases(data, path, out_config, FormatRules(),
|
|
92
|
+
levels=config.get_levels(),
|
|
93
|
+
file_exists_callback=overwrite_callback(force))
|
|
94
|
+
print(f'Wrote {path}')
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _write_result_files(parsed: argparse.Namespace, config: BacklogOpsConfig,
|
|
98
|
+
data: BacklogReleases,
|
|
99
|
+
result: AddedReleasesToJira) -> None:
|
|
100
|
+
"""Write the added and already-present releases to any named files."""
|
|
101
|
+
backlog = list(data.backlog)
|
|
102
|
+
if parsed.added_file is not None:
|
|
103
|
+
added = BacklogReleases(backlog=backlog, releases=result.stored)
|
|
104
|
+
_write_file(config, parsed.added_file, added, parsed.force)
|
|
105
|
+
if parsed.existing_file is not None:
|
|
106
|
+
present = BacklogReleases(backlog=backlog,
|
|
107
|
+
releases=result.already_present)
|
|
108
|
+
_write_file(config, parsed.existing_file, present, parsed.force)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _run(parsed: argparse.Namespace) -> int:
|
|
112
|
+
"""Read the input, add the releases, write files and print the lists."""
|
|
113
|
+
try:
|
|
114
|
+
config = required_config(parsed)
|
|
115
|
+
data = read_input(parsed, config)
|
|
116
|
+
result = _add(parsed, config, data)
|
|
117
|
+
_write_result_files(parsed, config, data, result)
|
|
118
|
+
except ReleaseExistsError:
|
|
119
|
+
print('Nothing added to Jira.', file=sys.stderr)
|
|
120
|
+
return 1
|
|
121
|
+
except (ValueError, TypeError, KeyError, OSError) as error:
|
|
122
|
+
print(f'Could not add releases to Jira: {error}', file=sys.stderr)
|
|
123
|
+
return 1
|
|
124
|
+
if not parsed.quiet:
|
|
125
|
+
print(format_release_result(result))
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main(args: Optional[list[str]] = None) -> int:
|
|
130
|
+
"""Add releases to Jira and report the added and present releases.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
args: Optional replacement for ``sys.argv[1:]``, mainly for tests.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
``0`` on success, ``1`` when the releases cannot be added or a name
|
|
137
|
+
already exists in Jira without ``--skip-existing``.
|
|
138
|
+
"""
|
|
139
|
+
parsed = parsed_args(build_parser(), args)
|
|
140
|
+
return _run(parsed)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == '__main__': # pragma: no cover
|
|
144
|
+
sys.exit(main())
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Add a backlog to Jira from an input file, creating one issue per item.
|
|
3
|
+
|
|
4
|
+
The command reads a backlog (or a backlog and its releases) from the input
|
|
5
|
+
file, then adds the backlog items to Jira using a named preset of the
|
|
6
|
+
backlog-ops configuration. By default it stops with an error when an
|
|
7
|
+
item's key already exists in Jira; ``--skip-existing`` skips those items
|
|
8
|
+
instead. With ``--rank`` the items are also ranked in Jira to match the
|
|
9
|
+
supplied backlog order, at the chosen anchor.
|
|
10
|
+
|
|
11
|
+
The added items (carrying their new Jira keys) and the items already in
|
|
12
|
+
Jira are printed to stdout as two labelled lists, unless ``-q``/``--quiet``
|
|
13
|
+
is given. Each list is also written to a file when ``--added-file`` or
|
|
14
|
+
``--existing-file`` names one; without a file name the list is not written.
|
|
15
|
+
An encrypted Jira token is unlocked by a pass phrase asked on the terminal
|
|
16
|
+
only when it is needed.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
20
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
21
|
+
# MIT License
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import sys
|
|
25
|
+
from getpass import getpass
|
|
26
|
+
from typing import Optional
|
|
27
|
+
from backlogops import (
|
|
28
|
+
AddedToJira, BacklogOpsConfig, BacklogReleases, ExistsInJiraError,
|
|
29
|
+
FormatRules, JiraConnections, OnExistingKey, add_backlog_to_jira,
|
|
30
|
+
format_add_result, resolve_output_config, write_backlog_releases)
|
|
31
|
+
from backlogops_cli._command_io import (
|
|
32
|
+
add_config_arg, add_input_args, add_rank_arg, overwrite_callback,
|
|
33
|
+
parsed_args, rank_anchor, read_input, required_config)
|
|
34
|
+
from backlogops_cli._migrate_warn import CliPresetMigrateWarnHook
|
|
35
|
+
|
|
36
|
+
DESCRIPTION = 'Add a backlog to Jira, creating a new issue per item'
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
40
|
+
"""Build the command line parser for the add-to-Jira command."""
|
|
41
|
+
parser = argparse.ArgumentParser(description=DESCRIPTION)
|
|
42
|
+
add_input_args(parser)
|
|
43
|
+
add_config_arg(parser)
|
|
44
|
+
parser.add_argument('-p', '--preset', dest='preset', required=True,
|
|
45
|
+
help='Name of the Jira preset in the configuration.')
|
|
46
|
+
parser.add_argument('--skip-existing', dest='skip_existing',
|
|
47
|
+
action='store_true',
|
|
48
|
+
help='Skip items whose key already exists in Jira '
|
|
49
|
+
'(the default stops with an error instead).')
|
|
50
|
+
add_rank_arg(parser)
|
|
51
|
+
parser.add_argument('--added-file', dest='added_file', metavar='FILE',
|
|
52
|
+
help='Write the added items, with their new Jira '
|
|
53
|
+
'keys, to this file. Omit to not write it.')
|
|
54
|
+
parser.add_argument('--existing-file', dest='existing_file',
|
|
55
|
+
metavar='FILE',
|
|
56
|
+
help='Write the items already in Jira to this file. '
|
|
57
|
+
'Omit to not write it.')
|
|
58
|
+
parser.add_argument('-q', '--quiet', dest='quiet', action='store_true',
|
|
59
|
+
help='Do not print the added and already-present '
|
|
60
|
+
'lists to stdout.')
|
|
61
|
+
parser.add_argument('-f', '--force', dest='force', action='store_true',
|
|
62
|
+
help='Overwrite existing output files without '
|
|
63
|
+
'asking.')
|
|
64
|
+
return parser
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _passphrase() -> str:
|
|
68
|
+
"""Ask for the Jira token pass phrase on the terminal."""
|
|
69
|
+
return getpass('Jira API token pass phrase: ')
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _add(parsed: argparse.Namespace, config: BacklogOpsConfig,
|
|
73
|
+
data: BacklogReleases) -> AddedToJira:
|
|
74
|
+
"""Add the input backlog to Jira using the named write preset."""
|
|
75
|
+
print(f"Adding backlog to Jira using preset '{parsed.preset}'...",
|
|
76
|
+
file=sys.stderr)
|
|
77
|
+
connections = JiraConnections(config.get_jira_config(), _passphrase)
|
|
78
|
+
mode = (OnExistingKey.SKIP if parsed.skip_existing
|
|
79
|
+
else OnExistingKey.RAISE)
|
|
80
|
+
result = add_backlog_to_jira(connections, parsed.preset, data.backlog,
|
|
81
|
+
on_existing_key=mode,
|
|
82
|
+
rank_anchor=rank_anchor(parsed.rank),
|
|
83
|
+
levels=config.get_levels(),
|
|
84
|
+
status_map=config.get_status_input_map())
|
|
85
|
+
print(f'Added {len(result.stored)} items to Jira; '
|
|
86
|
+
f'{len(result.already_present)} already present; '
|
|
87
|
+
f'{len(result.failed)} failed; '
|
|
88
|
+
f'{len(result.failed_links)} links not written.', file=sys.stderr)
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _write_backlog_file(config: BacklogOpsConfig, path: str,
|
|
93
|
+
data: BacklogReleases, force: bool) -> None:
|
|
94
|
+
"""Write one returned backlog and the input releases to a file."""
|
|
95
|
+
out_config = resolve_output_config(None, data_file=path,
|
|
96
|
+
presets=config.output_configs,
|
|
97
|
+
auto_ch_hook=CliPresetMigrateWarnHook())
|
|
98
|
+
write_backlog_releases(data, path, out_config, FormatRules(),
|
|
99
|
+
levels=config.get_levels(),
|
|
100
|
+
file_exists_callback=overwrite_callback(force))
|
|
101
|
+
print(f'Wrote {path}')
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _write_result_files(parsed: argparse.Namespace, config: BacklogOpsConfig,
|
|
105
|
+
data: BacklogReleases, result: AddedToJira) -> None:
|
|
106
|
+
"""Write the added and already-present backlogs to any named files."""
|
|
107
|
+
releases = list(data.releases)
|
|
108
|
+
if parsed.added_file is not None:
|
|
109
|
+
added = BacklogReleases(backlog=result.stored, releases=releases)
|
|
110
|
+
_write_backlog_file(config, parsed.added_file, added, parsed.force)
|
|
111
|
+
if parsed.existing_file is not None:
|
|
112
|
+
present = BacklogReleases(backlog=result.already_present,
|
|
113
|
+
releases=releases)
|
|
114
|
+
_write_backlog_file(config, parsed.existing_file, present,
|
|
115
|
+
parsed.force)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _run(parsed: argparse.Namespace) -> int:
|
|
119
|
+
"""Read the input, add it to Jira, write files and print the lists."""
|
|
120
|
+
try:
|
|
121
|
+
config = required_config(parsed)
|
|
122
|
+
data = read_input(parsed, config)
|
|
123
|
+
result = _add(parsed, config, data)
|
|
124
|
+
_write_result_files(parsed, config, data, result)
|
|
125
|
+
except ExistsInJiraError:
|
|
126
|
+
print('Nothing added to Jira.', file=sys.stderr)
|
|
127
|
+
return 1
|
|
128
|
+
except (ValueError, TypeError, KeyError, OSError) as error:
|
|
129
|
+
print(f'Could not add to Jira: {error}', file=sys.stderr)
|
|
130
|
+
return 1
|
|
131
|
+
if not parsed.quiet:
|
|
132
|
+
print(format_add_result(result))
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main(args: Optional[list[str]] = None) -> int:
|
|
137
|
+
"""Add a backlog to Jira and report the added and present items.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
args: Optional replacement for ``sys.argv[1:]``, mainly for tests.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
``0`` on success, ``1`` when the backlog cannot be added or a key
|
|
144
|
+
already exists in Jira without ``--skip-existing``.
|
|
145
|
+
"""
|
|
146
|
+
parsed = parsed_args(build_parser(), args)
|
|
147
|
+
return _run(parsed)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == '__main__': # pragma: no cover
|
|
151
|
+
sys.exit(main())
|
backlogops_cli/config_wizard.py
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
import argparse
|
|
9
9
|
import sys
|
|
10
10
|
from typing import Optional
|
|
11
|
-
from backlogops import backlog_ops_wizard
|
|
11
|
+
from backlogops import BacklogOpsConfig, backlog_ops_wizard, \
|
|
12
|
+
read_backlog_ops_config
|
|
12
13
|
from backlogops_cli._command_io import parsed_args
|
|
14
|
+
from backlogops_cli._migrate_warn import CliMigrateWarnHook
|
|
13
15
|
from backlogops_cli._wizard_io import build_wizard_parser, run_wizard_to_file
|
|
14
16
|
|
|
15
17
|
DESCRIPTION = 'Create a backlog-ops configuration file via a wizard'
|
|
@@ -20,21 +22,30 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
20
22
|
return build_wizard_parser(DESCRIPTION)
|
|
21
23
|
|
|
22
24
|
|
|
25
|
+
def _read_config(filename: str) -> BacklogOpsConfig:
|
|
26
|
+
"""Read an existing backlog-ops configuration to pre-fill the wizard."""
|
|
27
|
+
return read_backlog_ops_config(filename, sys.stderr,
|
|
28
|
+
auto_ch_hook=CliMigrateWarnHook())
|
|
29
|
+
|
|
30
|
+
|
|
23
31
|
def main(args: Optional[list[str]] = None) -> int:
|
|
24
32
|
"""Run the interactive wizard and write the backlog-ops configuration.
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
With ``-i`` an existing configuration file is read first and used to
|
|
35
|
+
pre-fill the wizard, so the user edits those values; pointing ``-i`` at
|
|
36
|
+
the same file as ``-o`` edits it in place after confirming the
|
|
37
|
+
overwrite. The output filename receives the ``.cfg`` extension when it
|
|
38
|
+
is not already present.
|
|
28
39
|
|
|
29
40
|
Args:
|
|
30
41
|
args: Optional replacement for ``sys.argv[1:]``, mainly for tests.
|
|
31
42
|
|
|
32
43
|
Returns:
|
|
33
44
|
``0`` on success, ``1`` when the entered configuration is rejected
|
|
34
|
-
or cannot be written.
|
|
45
|
+
or cannot be read or written.
|
|
35
46
|
"""
|
|
36
47
|
parsed = parsed_args(build_parser(), args)
|
|
37
|
-
return run_wizard_to_file(parsed, backlog_ops_wizard,
|
|
48
|
+
return run_wizard_to_file(parsed, backlog_ops_wizard, _read_config,
|
|
38
49
|
'Backlog-ops configuration')
|
|
39
50
|
|
|
40
51
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/local/bin/python3
|
|
2
|
+
"""Encrypt a Jira API token to a file with a pass phrase.
|
|
3
|
+
|
|
4
|
+
Read a clear text API token from a file or from standard input, encrypt it
|
|
5
|
+
with a pass phrase and write the encrypted token to a file. The pass phrase
|
|
6
|
+
is requested on the terminal and is not echoed. The clear text token file
|
|
7
|
+
is not modified, unless the the encrypted token file is the same as the
|
|
8
|
+
clear text token file, in which case the clear text token file is overwritten
|
|
9
|
+
with the encrypted token. The encrypted token file is written atomically,
|
|
10
|
+
so that it is either the old version or the new version, and never a
|
|
11
|
+
half-written file.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
15
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
16
|
+
# MIT License
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import sys
|
|
20
|
+
from getpass import getpass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
from backlogops import encrypt_token_to_file, encrypt_token_file
|
|
24
|
+
from backlogops_cli._command_io import parsed_args
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DESCRIPTION = 'Encrypt a Jira API token file with a pass phrase.'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ok_to_overwrite(filename: Path) -> bool:
|
|
31
|
+
"""Return True if the file can be overwritten, False otherwise."""
|
|
32
|
+
txt = f'The output file {filename} already exists. Overwrite? [y/n] '
|
|
33
|
+
while True:
|
|
34
|
+
answer = input(txt)
|
|
35
|
+
if answer.lower().strip() in ('y', 'ye', 'yes'):
|
|
36
|
+
return True
|
|
37
|
+
if answer.lower().strip() in ('n', 'no'):
|
|
38
|
+
return False
|
|
39
|
+
print('Please answer yes or no.')
|
|
40
|
+
return False # pragma: no cover
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
44
|
+
"""Build the command line parser for the encrypt-token-file command."""
|
|
45
|
+
descr = DESCRIPTION + '. The pass phrase is requested on the terminal.'
|
|
46
|
+
descr += ' The clear text token file is not modified, unless the output'
|
|
47
|
+
descr += ' file is the same as the input file, in which case the clear'
|
|
48
|
+
descr += ' text token file is overwritten with the encrypted token.'
|
|
49
|
+
parser = argparse.ArgumentParser(description=descr)
|
|
50
|
+
inhelp = 'The clear text token file to read from. Omit to read from stdin.'
|
|
51
|
+
parser.add_argument('-i', '--infile', required=False, help=inhelp)
|
|
52
|
+
parser.add_argument('-o', '--outfile', required=True,
|
|
53
|
+
help='The encrypted token file to write to.')
|
|
54
|
+
return parser
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
EXCEPTIONS = (FileNotFoundError, ValueError, FileExistsError,
|
|
58
|
+
NotADirectoryError, OSError)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _run(parsed: argparse.Namespace) -> int:
|
|
62
|
+
"""Run the encrypt-token-file command with the given parsed arguments.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
parsed: The parsed command line arguments.
|
|
66
|
+
Returns:
|
|
67
|
+
0 if the command was successful, 1 if there was an error
|
|
68
|
+
"""
|
|
69
|
+
outfile = Path(parsed.outfile)
|
|
70
|
+
token: Optional[str] = None
|
|
71
|
+
pphrase1 = 'a'
|
|
72
|
+
phrase2 = 'b'
|
|
73
|
+
while pphrase1 != phrase2 or not pphrase1:
|
|
74
|
+
pphrase1 = getpass('Enter the pass phrase to encrypt the token with: ')
|
|
75
|
+
if not pphrase1:
|
|
76
|
+
print('No pass phrase entered. Exiting.')
|
|
77
|
+
return 1
|
|
78
|
+
phrase2 = getpass('Re-enter the pass phrase to confirm: ')
|
|
79
|
+
if pphrase1 != phrase2:
|
|
80
|
+
print('Pass phrases do not match. Please try again.')
|
|
81
|
+
if not parsed.infile:
|
|
82
|
+
print('Enter the clear text token on a single line, then press Enter.')
|
|
83
|
+
token = input().strip()
|
|
84
|
+
if not token:
|
|
85
|
+
print('No token entered. Exiting.')
|
|
86
|
+
return 1
|
|
87
|
+
try:
|
|
88
|
+
encrypt_token_to_file(token, passphrase=pphrase1, filename=outfile,
|
|
89
|
+
ok_to_overwrite=_ok_to_overwrite)
|
|
90
|
+
return 0
|
|
91
|
+
except EXCEPTIONS as e:
|
|
92
|
+
print(f'Error encrypting token: {e}')
|
|
93
|
+
return 1
|
|
94
|
+
infile = Path(parsed.infile)
|
|
95
|
+
try:
|
|
96
|
+
encrypt_token_file(clear_file=infile, encrypted_file=outfile,
|
|
97
|
+
passphrase=pphrase1,
|
|
98
|
+
ok_to_overwrite=_ok_to_overwrite)
|
|
99
|
+
return 0
|
|
100
|
+
except EXCEPTIONS as e:
|
|
101
|
+
print(f'Error encrypting token file: {e}')
|
|
102
|
+
return 1
|
|
103
|
+
return 2 # pragma: no cover # this should never be reached
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(arguments: Optional[list[str]] = None) -> int:
|
|
107
|
+
"""Encrypt a Jira API token file with a pass phrase.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
arguments: The command line arguments to parse. If None, the arguments
|
|
111
|
+
are taken from sys.argv.
|
|
112
|
+
Returns:
|
|
113
|
+
0 if the command was successful, 1 if there was an error.
|
|
114
|
+
"""
|
|
115
|
+
parsed = parsed_args(build_parser(), arguments)
|
|
116
|
+
return _run(parsed)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == '__main__': # pragma: no cover
|
|
120
|
+
sys.exit(main())
|