Example #1
0
def cmd_blocked_by(config: Config, args: List[str]) -> None:
    # Args
    blocked_id: IssueID
    blocking_id: IssueID

    # Parse args
    blocked_id = _arg_issue_id(args, config, restrict_to_own=True)
    blocking_id = _arg_issue_id(args, config)
    cli.arg_disallow_trailing(args)
    if blocked_id == blocking_id:
        error("an issue can't block itself")

    # Run command
    issue = Issue.load_from_id(blocked_id)
    other_issue = Issue.load_from_id(blocking_id)
    s_blocked_id = f'#{blocked_id.shorten(config)}'
    s_blocking_id = f'#{blocking_id.shorten(config)}'
    if blocking_id in issue.blocked_by:
        print(f'Issue {s_blocked_id} is already blocked by {s_blocking_id}, '
              f'no changes were made.')
    elif blocked_id in other_issue.blocked_by:
        error(f'blocking loop detected! Issue {s_blocking_id} is already '
              f'blocked by {s_blocked_id}!')
    else:
        issue.blocked_by.add(blocking_id)
        issue.save()
        print(f'Issue {s_blocked_id} marked as blocked by {s_blocking_id}.')
Example #2
0
 def pre_process(func: Callable[[Config, List[str]], None],
                 conf: Config,
                 args: List[str]) -> None:
     if not ROOT.exists() and func != cmd_init:
         error(
             'no .ishu directory found! '
             'Please run the init command first.'
         )
     else:
         func(conf, args)
Example #3
0
def cmd_configure(config: Config, args: List[str]) -> None:
    # Args
    list_settings = False
    get_setting: Optional[str] = None
    set_setting: Optional[Tuple[str, str]] = None
    # Parse args
    if not args:
        list_settings = True
    else:
        arg = args.pop(0)
        cli.arg_disallow_positional(arg)
        if arg in {'-l', '--list'}:
            list_settings = True
        elif arg in {'-g', '--get'}:
            try:
                get_setting = args.pop(0)
            except IndexError:
                error('--get needs an argument')
        elif arg in {'-s', '--set'}:
            try:
                set_setting = (args.pop(0), args.pop(0))
            except IndexError:
                error('--set needs two arguments')
        else:
            cli.arg_unknown_optional(arg)
    cli.arg_disallow_trailing(args)
    # List settings
    if list_settings:
        print('Settings:')
        for key in sorted(Config.editable_settings):
            print(f'  {key} = {config[key] if config else ""}')
    # Get settings
    elif get_setting:
        if get_setting not in Config.editable_settings:
            error(f'unknown setting: {get_setting}')
        else:
            print(f'{get_setting} = {config[get_setting]}')
    # Set settings
    elif set_setting:
        key, value = set_setting
        if key not in Config.editable_settings:
            error(f'unknown setting: {key}')
        try:
            updated_config: Config
            config[key] = value
            updated_config = config
        except InvalidConfigException as e:
            print(f'error in config value: {e!r}')
        else:
            print(f'{key} -> {value}')
            updated_config.save()
            print('Config saved')
Example #4
0
def cmd_unblock(config: Config, args: List[str]) -> None:
    # Args
    blocked_id: IssueID
    blocking_id: IssueID

    # Parse args
    blocked_id = _arg_issue_id(args, config, restrict_to_own=True)
    blocking_id = _arg_issue_id(args, config)
    cli.arg_disallow_trailing(args)
    if blocked_id == blocking_id:
        error("an issue can't block itself")

    # Run command
    issue = Issue.load_from_id(blocked_id)
    s_blocked_id = f'#{blocked_id.shorten(config)}'
    s_blocking_id = f'#{blocking_id.shorten(config)}'
    if blocking_id not in issue.blocked_by:
        print(f'Issue {s_blocked_id} is not blocked by {s_blocking_id}, '
              f'no changes were made.')
    else:
        issue.blocked_by.remove(blocking_id)
        issue.save()
        print(f'Issue #{blocked_id.shorten(config)} no longer marked as '
              f'blocked by #{blocking_id.shorten(config)}.')
Example #5
0
def _arg_issue_id(args: List[str], config: Config,
                  specify_id: bool = False,
                  restrict_to_own: bool = False) -> IssueID:
    try:
        raw_issue_id = args.pop(0)
    except IndexError:
        error('issue ID required')
    try:
        issue_id = IssueID.load(config, raw_issue_id,
                                restrict_to_own=restrict_to_own)
    except Exception as e:
        if specify_id:
            error(f'failed to parse issue ID {raw_issue_id}: {e}')
        else:
            error(str(e))
    return issue_id
Example #6
0
def cmd_edit(config: Config, args: List[str]) -> None:
    # Args
    issue_id: IssueID
    description: Optional[str] = None
    add_tags: Optional[Set[str]] = None
    remove_tags: Optional[Set[str]] = None
    # Parse args
    issue_id = _arg_issue_id(args, config)
    while args:
        arg = args.pop(0)
        if not arg.startswith('-'):
            error(f'unknown positional argument: {arg}')
        elif arg in {'-d', '--description'}:
            try:
                description = args.pop(0)
            except IndexError:
                error('no description provided')
        elif arg in {'-t', '--add-tags'}:
            add_tags = cli.arg_tags(args, '--add-tags')
        elif arg in {'-T', '--remove-tags'}:
            remove_tags = cli.arg_tags(args, '--remove-tags')
        else:
            error(f'unknown argument: {arg}')
    # Run command
    issue = Issue.load_from_id(issue_id)
    changed = False
    if description and description != issue.description:
        issue = issue._replace(description=description)
        changed = True
    if add_tags and not add_tags.issubset(issue.tags):
        issue.tags.update(add_tags)
        changed = True
    if remove_tags and remove_tags.intersection(issue.tags):
        issue.tags.difference_update(remove_tags)
        changed = True
    if changed:
        issue.save()
        print('Issue edited')
    else:
        print('Nothing to update')
Example #7
0
def cmd_tag(config: Config, args: List[str]) -> None:
    # Args
    list_tags = False
    sort_by_usage = False
    add_tags: Optional[Set[str]] = None
    remove_tags: Optional[Set[str]] = None
    edit_tag: Optional[Tuple[str, str]] = None

    # Parse args
    if not args:
        list_tags = True
    else:
        arg = args.pop(0)
        cli.arg_disallow_positional(arg)
        if arg == '-lu':
            list_tags = True
            sort_by_usage = True
        elif arg in {'-l', '--list'}:
            list_tags = True
            if args and args[0] in {'-u', '--usage'}:
                args.pop(0)
                sort_by_usage = True
        elif arg in {'-a', '--add'}:
            add_tags = cli.arg_tags(args, '--add')
        elif arg in {'-r', '--remove'}:
            remove_tags = cli.arg_tags(args, '--remove')
        elif arg in {'-e', '--edit'}:
            edit_tag = (cli.arg_positional(args, 'old tag'),
                        cli.arg_positional(args, 'new tag'))
        else:
            cli.arg_unknown_optional(arg)
    cli.arg_disallow_trailing(args)

    # Run command
    tag_registry: Set[str]
    if not TAGS_PATH.exists():
        TAGS_PATH.write_text('[]')
        tag_registry = set()
    else:
        tag_registry = set(json.loads(TAGS_PATH.read_text()))
    old_tag_registry = frozenset(tag_registry)
    issues = load_issues()
    if list_tags:
        issue_tags = Counter(t for issue in issues for t in issue.tags)
        issue_tags.update({t: 0 for t in tag_registry if t not in issue_tags})
        tag_list = [(name, str(count))
                    for name, count in sorted(sorted(issue_tags.most_common()),
                                              key=itemgetter(1), reverse=True)]
        if not sort_by_usage:
            tag_list.sort()
        unregistered_lines: Dict[int, Tuple[Union[str, Color], Union[str, Color]]] = {
            n: (RED, RESET)
            for n, (name, _) in enumerate(tag_list)
            if name not in tag_registry
        }
        if tag_list:
            print('\n'.join(format_table(tag_list,
                                         titles=('Tag name', 'Use count'),
                                         surround_rows=unregistered_lines)))
        unregistered_tags = set(issue_tags.keys()) - tag_registry
        if unregistered_tags:
            print(f'\n{RED}{len(unregistered_tags)} '
                  f'unregistered tags!{RESET}')
    elif add_tags:
        existing_tags = add_tags.intersection(tag_registry)
        new_tags = add_tags - tag_registry
        if existing_tags:
            print('Existing tags that weren\'t added:',
                  ', '.join(sorted(existing_tags)))
        if new_tags:
            print('Added tags:', ', '.join(sorted(new_tags)))
            tag_registry.update(add_tags)
    elif remove_tags:
        matched_tags = remove_tags.intersection(tag_registry)
        unknown_tags = remove_tags - tag_registry
        # TODO: remove/add unregistered tags?
        if unknown_tags:
            print('Unknown tags that weren\'t removed:',
                  ', '.join(sorted(unknown_tags)))
        if matched_tags:
            print('Tags to remove:', ', '.join(sorted(matched_tags)))
            for tag in matched_tags:
                matched_issues = [i for i in issues if tag in i.tags]
                if matched_issues:
                    response = input(f'Tag {tag!r} is used in '
                                     f'{len(matched_issues)} issues. '
                                     f'Remove it from all of them? [y/N] ')
                    if response.lower() not in {'y', 'yes'}:
                        print('Aborted tag removal, nothing was changed.')
                        break
            else:
                tag_registry.difference_update(matched_tags)
                count = 0
                for issue in issues:
                    if matched_tags.intersection(issue.tags):
                        issue.tags.difference_update(matched_tags)
                        issue.save()
                        count += 1
                print(f'Tags removed, {count} issues were modified.')
    elif edit_tag:
        old_name, new_name = edit_tag
        if old_name == new_name:
            error('old name and new name are identical')
        if old_name not in tag_registry:
            error(f'unknown tag: {old_name}')
        if new_name in tag_registry:
            error(f'new tag already exist: {new_name}')
        matched_issues = [i for i in issues if old_name in i.tags]
        if matched_issues:
            response = input(f'Tag {old_name!r} is used in '
                             f'{len(matched_issues)} issues. '
                             f'Rename it to {new_name!r} '
                             f'in all of them? [y/N] ')
            if response.lower() not in {'y', 'yes'}:
                print('Aborted tag edit, nothing was changed.')
                return
            else:
                for issue in matched_issues:
                    issue.tags.remove(old_name)
                    issue.tags.add(new_name)
                    issue.save()
        tag_registry.remove(old_name)
        tag_registry.add(new_name)
        print(f'Tag {old_name!r} renamed to {new_name!r}.')
        if matched_issues:
            print(f'{len(matched_issues)} issues were modified.')
    # Save changes if needed
    if tag_registry != old_tag_registry:
        TAGS_PATH.write_text(json.dumps(sorted(tag_registry), indent=2))
Example #8
0
def cmd_alias(config: Config, args: List[str]) -> None:
    # Args
    list_aliases = False
    get_alias: Optional[str] = None
    set_alias: Optional[Tuple[str, str]] = None
    remove_alias: Optional[str] = None
    # Parse args
    if not args:
        list_aliases = True
    else:
        arg = args.pop(0)
        cli.arg_disallow_positional(arg)
        if arg in {'-l', '--list'}:
            list_aliases = True
        elif arg in {'-g', '--get'}:
            try:
                get_alias = args.pop(0)
            except IndexError:
                error('--get needs an argument')
        elif arg in {'-r', '--remove'}:
            try:
                remove_alias = args.pop(0)
            except IndexError:
                error('--remove needs an argument')
        elif arg in {'-s', '--set'}:
            try:
                set_alias = (args.pop(0), args.pop(0))
            except IndexError:
                error('--set needs two arguments')
        else:
            cli.arg_unknown_optional(arg)
    cli.arg_disallow_trailing(args)
    # List aliases
    if list_aliases:
        if config.aliases:
            print('Aliases:')
            for key, value in sorted(config.aliases.items()):
                print(f'  {key} = {value}')
        else:
            print('No aliases')
    # Get aliases
    elif get_alias:
        if get_alias not in config.aliases:
            error(f'unknown alias: {get_alias}')
        else:
            print(f'{get_alias} = {config.aliases[get_alias]}')
    # Remove alias
    elif remove_alias:
        if remove_alias not in config.aliases:
            error(f'unknown alias: {remove_alias}')
        else:
            del config.aliases[remove_alias]
            config.save()
            print(f'Alias {remove_alias} removed')
    # Set alias
    elif set_alias:
        key, value = set_alias
        is_new = key not in config.aliases
        config.aliases[key] = value
        print(f'{key} -> {value}')
        config.save()
        if is_new:
            print('Alias created')
        else:
            print('Alias updated')
Example #9
0
def cmd_list(config: Config, args: List[str]) -> None:
    # Arguments
    status: Optional[IssueStatus] = None
    tags: Optional[Set[str]] = None
    without_tags: Optional[Set[str]] = None
    blocked = False
    blocking = False
    no_blocks = False
    show_icons = not bool(os.environ.get('ISHU_NO_ICONS'))
    show_dates = True
    list_abc = False

    # Parse the arguments
    while args:
        arg = args.pop(0)
        cli.arg_disallow_positional(arg)
        if arg in {'-s', '--status'}:
            try:
                raw_status = args.pop(0)
            except IndexError:
                error('--status needs an argument')
            else:
                try:
                    status = IssueStatus(raw_status)
                except ValueError:
                    error('invalid status: {raw_status}')
        elif arg in {'-t', '--tags'}:
            tags = cli.arg_tags(args, '--tags')
        elif arg in {'-T', '--without-tags'}:
            without_tags = cli.arg_tags(args, '--without-tags')
        elif arg in {'-b', '--blocked'}:
            blocked = True
        elif arg in {'-B', '--blocking'}:
            blocking = True
        elif arg in {'-n', '--no-blocks'}:
            no_blocks = True
        elif arg in {'-I', '--no-icons'}:
            show_icons = False
        elif arg in {'-D', '--no-dates'}:
            show_dates = False
        elif arg in {'-l', '--list-abc'}:
            list_abc = True
        else:
            cli.arg_unknown_optional(arg)
    if no_blocks and (blocked or blocking):
        error('--blocked or --blocking can\'t be used with --no-blocks')

    # Run command
    all_issues = load_issues()
    issues: List[Issue] = []
    is_blocking = set()
    for issue in all_issues:
        # Only see issues as blocking if they are open
        if issue.status == IssueStatus.OPEN:
            blocking_issues = [i for i in all_issues
                               if i.id_ != issue.id_ and issue.id_ in i.blocked_by]
        else:
            blocking_issues = []
        if blocking_issues:
            is_blocking.add(issue.id_)
        if tags and not tags.issubset(issue.tags):
            continue
        if without_tags and without_tags.intersection(issue.tags):
            continue
        if blocking and not any(blocking_issues):
            continue
        if blocked and not issue.blocked_by:
            continue
        if no_blocks and (issue.blocked_by or any(blocking_issues)):
            continue
        if status:
            if status == IssueStatus.CLOSED \
                    and issue.status == IssueStatus.OPEN:
                continue
            elif status != IssueStatus.CLOSED and status != issue.status:
                continue
        issues.append(issue)

    date_fmt = '%Y-%m-%d'
    time_fmt = '%H:%M'
    datetime_fmt = f'{date_fmt} {time_fmt}'
    one_day_ago = datetime.now(timezone.utc) - timedelta(days=1)

    def _date_or_time_fmt(dt: datetime) -> str:
        return dt.strftime(time_fmt if dt > one_day_ago else date_fmt)

    status_icon = {
        IssueStatus.FIXED: GREEN + ('' if show_icons else 'F'),
        IssueStatus.OPEN: CYAN + ('' if show_icons else ' '),
        IssueStatus.CLOSED: GREEN + ('' if show_icons else 'C'),
        IssueStatus.WONTFIX: RED + ('' if show_icons else 'W'),
    }

    def cull_empty(items: Iterable[Optional[str]]) -> Iterable[str]:
        for item in items:
            if item is not None:
                yield item

    def generate_row(i: Issue, short: bool = False) -> Tuple[str, ...]:
        status = status_icon[i.status] + RESET
        blocks = (('b' if i.blocked_by else '')
                  + ('B' if i.id_ in is_blocking else ''))
        comments = str(len(i.comments))
        tags = ', '.join(f'#{tag}' for tag in sorted(i.tags))
        row: List[Optional[str]]
        if short:
            created = _date_or_time_fmt(i.created.astimezone())
            updated = (_date_or_time_fmt(i.updated.astimezone())
                       if i.updated > i.created else '')
            row = [
                i.id_.shorten(None),
                status,
                blocks,
                created if show_dates else None,
                updated if show_dates else None,
                comments,
                tags,
                i.description,
            ]
        else:
            created = i.created.astimezone().strftime(datetime_fmt)
            updated = (i.updated.astimezone().strftime(datetime_fmt)
                       if i.updated > i.created else '')
            row = [
                str(i.id_.num),
                i.id_.user,
                status,
                blocks,
                created if show_dates else None,
                updated if show_dates else None,
                comments,
                tags,
                i.description,
            ]
        return tuple(cull_empty(row))

    titles = tuple(cull_empty([
        'ID', 'User', 'S', (' ' if show_icons else 'Blocks'),
        ('Created' if show_dates else None),
        ('Updated' if show_dates else None),
        (' ' if show_icons else 'Comments'),
        'Tags', 'Description'
    ]))

    def sorter(issue: Issue) -> Union[str, int]:
        if list_abc:
            return issue.description
        else:
            return issue.id_.num

    table = [generate_row(i) for i in sorted(issues, key=sorter)]
    try:
        for line in format_table(table, wrap_columns={-1, -2}, titles=titles,
                                 require_min_widths=frozenset({(-1, 30)})):
            print(line)
    except cli.TooNarrowColumn:
        shorter_titles = tuple(cull_empty([
            'ID', 'S', (' ' if show_icons else 'Blocks'),
            ('Created' if show_dates else None),
            ('Updated' if show_dates else None),
            (' ' if show_icons else 'Cmnt'), 'Tags',
            'Description'
        ]))
        shorter_table = [generate_row(i, short=True) for i in sorted(issues, key=sorter)]
        for line in format_table(shorter_table, wrap_columns={-1, -2},
                                 titles=shorter_titles):
            print(line)