Skip to content

gitea.cli.utils.issue

issue

Helpers for addressing issues from the project CLI commands.

Gitea identifies an issue in two ways: the number shown in the web UI, which is local to a repository, and the global ID, which the project endpoints expect. The helpers here let the project commands take the number whenever the repository holding the issue is known, and turn the endpoints' rejections into errors that say what to do next.

The repository holding the issue is not the same thing as the repository holding the project: a repository project takes its issues from its own repository, while an organization project takes them from any repository of the organization, which is why the commands let that one be named separately.

One endpoint's rejection has to be manufactured here, because it does not reject: moving an issue that is not on a project moves the row relating the two, of which there is none, and Gitea answers that with a success and an empty body. run_project_issue_move therefore finds the card before moving it, and reports its absence itself - a success that moved nothing is the one failure a caller cannot see.

The same walk answers a different question for run_project_issue_remove. The removal endpoint takes the column the card is in, which is not a column the caller is choosing but one the board already knows, so --column-id is optional there and the walk supplies it - and an issue with no card is reported as having none rather than removed from a column picked for it. This is what makes the option mean two things across the commands: a destination for add and move, the card's present whereabouts for remove.

Looking before the move is what makes the failure legible; reading the card back afterwards is what makes the success true. The status code says only that the request was accepted, so the move is followed by a listing of the target column, and the command exits zero having seen the card there rather than having assumed it. A removal whose column the walk supplied is read back as well, and against the whole board rather than that column: the column named holds no card whether the removal took it off or the card had already moved elsewhere, so only a walk finding none anywhere tells the two apart. What that walk establishes is the card's absence rather than the request's fate - an instance may refuse a removal naming a column that does not hold the card, and one tried by hand answers it with a 404, but a status code is an answer about the call and never about the card. Neither half makes either pair atomic - Gitea has no conditional move and no conditional delete, so a card taken off the board, or put back on it, between the two reads is still a card the command has reported on - and the messages say which of "no card", "not where it was sent", "still on the board" and "could not be confirmed" happened rather than collapsing them into one.

Classes

Functions:

gitea.cli.utils.issue.resolve_issue_id

resolve_issue_id(
    *,
    client: Gitea,
    owner: str,
    repository: str | None,
    issue_number: int,
) -> int

Resolve a repository issue number to the global issue ID.

When the repository holding the issue is known, issue_number is the number shown in the web UI and is looked up against that repository. When it is not, there is nothing to look the number up in, so the value is taken to be the global ID already and returned unchanged.

Parameters:

Name Type Description Default
client Gitea

The Gitea client used for the lookup.

required
owner str

The owner of the repository.

required
repository str | None

The name of the repository holding the issue, or None when it is not known.

required
issue_number int

The issue number of the repository, or the global issue ID when repository is None.

required

Returns:

Type Description
int

The global issue ID that the project endpoints expect.

Raises:

Type Description
CommandError

If the repository has no issue with that number, the lookup was refused, the instance could not be reached, or the request failed without reaching a response.

Source code in src/gitea/cli/utils/issue.py
def resolve_issue_id(*, client: Gitea, owner: str, repository: str | None, issue_number: int) -> int:
    """Resolve a repository issue number to the global issue ID.

    When the repository holding the issue is known, `issue_number` is the number
    shown in the web UI and is looked up against that repository. When it is
    not, there is nothing to look the number up in, so the value is taken to be
    the global ID already and returned unchanged.

    Args:
        client: The Gitea client used for the lookup.
        owner: The owner of the repository.
        repository: The name of the repository holding the issue, or None when
            it is not known.
        issue_number: The issue number of the repository, or the global issue ID
            when `repository` is None.

    Returns:
        The global issue ID that the project endpoints expect.

    Raises:
        CommandError: If the repository has no issue with that number, the
            lookup was refused, the instance could not be reached, or the
            request failed without reaching a response.

    """
    if repository is None:
        return issue_number

    try:
        data, metadata = client.issue.get_issue(owner=owner, repository=repository, index=issue_number)
    except HTTPError as e:
        status_code = _status_code_of(e)
        if status_code != _NOT_FOUND:
            raise CommandError(
                f"Could not look up issue #{issue_number} in {owner}/{repository}: {_describe(status_code, e)}."
            ) from e
        raise CommandError(_unknown_issue_message(owner, repository, issue_number, status_code)) from e
    except (RequestsConnectionError, Timeout) as e:
        # No response came back, so nothing is known about the issue itself.
        raise CommandError(unreachable_message(e, client.base_url)) from e
    except RequestException as e:
        # Also no response, but a malformed URL or an unreadable body is not the
        # instance being unreachable, so only the request itself is blamed.
        raise CommandError(request_failed_message(e, client.base_url)) from e

    issue_id = data.get("id") if isinstance(data, dict) else None
    status_code = metadata.get("status_code", 0)
    if not _is_success(status_code) or not isinstance(issue_id, int):
        raise CommandError(_unknown_issue_message(owner, repository, issue_number, status_code))
    return issue_id

gitea.cli.utils.issue.run_project_issue_call

run_project_issue_call(
    *,
    client: Gitea,
    call: Callable[
        [int], tuple[dict[str, Any], dict[str, Any]]
    ],
    action: str,
    owner: str,
    project_id: int,
    issue_number: int,
    issue_repository: str | None,
) -> tuple[dict[str, Any], dict[str, Any]]

Run a project issue call against the issue the user named.

The issue is resolved first, so --issue-id can be the number shown in the web UI, and a rejection by the project endpoint is reported as an error naming both the issue and what to check, rather than as a bare HTTP status. On success the resolved global ID is recorded in the metadata, so the caller can see which issue was acted on.

Parameters:

Name Type Description Default
client Gitea

The Gitea client to call.

required
call Callable[[int], tuple[dict[str, Any], dict[str, Any]]]

The API call, taking the resolved global issue ID.

required
action str

The verb describing the call, used in the error message.

required
owner str

The owner of the repository.

required
project_id int

The ID of the project.

required
issue_number int

The value the user passed as --issue-id.

required
issue_repository str | None

The name of the repository holding the issue, or None when it is not known and issue_number is therefore a global ID.

required

Returns:

Type Description
dict[str, Any]

A tuple containing the payload and the metadata, the latter carrying the

dict[str, Any]

resolved global issue ID whenever the number was resolved.

Raises:

Type Description
CommandError

If the issue could not be resolved, the call was refused, the instance could not be reached, or the request failed without reaching a response.

Source code in src/gitea/cli/utils/issue.py
def run_project_issue_call(
    *,
    client: Gitea,
    call: Callable[[int], tuple[dict[str, Any], dict[str, Any]]],
    action: str,
    owner: str,
    project_id: int,
    issue_number: int,
    issue_repository: str | None,
) -> tuple[dict[str, Any], dict[str, Any]]:
    """Run a project issue call against the issue the user named.

    The issue is resolved first, so `--issue-id` can be the number shown in the
    web UI, and a rejection by the project endpoint is reported as an error
    naming both the issue and what to check, rather than as a bare HTTP status.
    On success the resolved global ID is recorded in the metadata, so the caller
    can see which issue was acted on.

    Args:
        client: The Gitea client to call.
        call: The API call, taking the resolved global issue ID.
        action: The verb describing the call, used in the error message.
        owner: The owner of the repository.
        project_id: The ID of the project.
        issue_number: The value the user passed as --issue-id.
        issue_repository: The name of the repository holding the issue, or None
            when it is not known and `issue_number` is therefore a global ID.

    Returns:
        A tuple containing the payload and the metadata, the latter carrying the
        resolved global issue ID whenever the number was resolved.

    Raises:
        CommandError: If the issue could not be resolved, the call was refused,
            the instance could not be reached, or the request failed without
            reaching a response.

    """
    issue_id = resolve_issue_id(client=client, owner=owner, repository=issue_repository, issue_number=issue_number)
    return _run_resolved_call(
        client=client,
        call=call,
        action=action,
        owner=owner,
        project_id=project_id,
        issue_number=issue_number,
        issue_id=issue_id,
        issue_repository=issue_repository,
    )

gitea.cli.utils.issue.run_project_issue_move

run_project_issue_move(
    *,
    client: Gitea,
    owner: str,
    repository: str | None,
    project_id: int,
    issue_number: int,
    column_id: int,
    sorting: int | None,
    issue_repository: str | None,
    add_if_missing: bool,
) -> tuple[dict[str, Any], dict[str, Any]]

Move an issue's card to a column of a project, and confirm it arrived.

Gitea's move endpoint moves the row relating an issue to a project, and an issue that is not on the project has no such row: the endpoint answers the call with a success and an empty body, and moves nothing. A caller reading that as a move made is left believing a card is on a board that has none, which is what the board is walked here to prevent. The card is looked for first, and the move is made only once it has been found; when it has not, the command either says so or, with add_if_missing, puts the issue in the target column instead - which is what project issue add does, and the only way to get a card there in one call.

Whichever call was made, the target column is then read back, because the success it answered with is the thing this endpoint has already been shown not to mean. Returning normally therefore says the card was seen in column_id, not that Gitea accepted a request to put it there. It does not say the card is still there: the two reads are separate requests, and no conditional move exists to make them one.

Parameters:

Name Type Description Default
client Gitea

The Gitea client to call.

required
owner str

The owner of the repository or organization holding the project.

required
repository str | None

The name of the repository holding the project, or None for an organization project.

required
project_id int

The ID of the project.

required
issue_number int

The value the user passed as --issue-id.

required
column_id int

The target column ID.

required
sorting int | None

The position within the column, ascending.

required
issue_repository str | None

The name of the repository holding the issue, or None when it is not known and issue_number is therefore a global ID.

required
add_if_missing bool

Whether to add the issue to the target column when no column of the project holds a card for it.

required

Returns:

Type Description
dict[str, Any]

A tuple containing the payload and the metadata, the latter carrying the

dict[str, Any]

resolved global issue ID whenever the number was resolved.

Raises:

Type Description
CommandError

If the issue could not be resolved, the board could not be read, the issue has no card on the project and add_if_missing is not set, the call was refused, the card is not in column_id afterwards or could not be confirmed there, the instance could not be reached, or the request failed without reaching a response.

Source code in src/gitea/cli/utils/issue.py
def run_project_issue_move(
    *,
    client: Gitea,
    owner: str,
    repository: str | None,
    project_id: int,
    issue_number: int,
    column_id: int,
    sorting: int | None,
    issue_repository: str | None,
    add_if_missing: bool,
) -> tuple[dict[str, Any], dict[str, Any]]:
    """Move an issue's card to a column of a project, and confirm it arrived.

    Gitea's move endpoint moves the row relating an issue to a project, and an
    issue that is not on the project has no such row: the endpoint answers the
    call with a success and an empty body, and moves nothing. A caller reading
    that as a move made is left believing a card is on a board that has none,
    which is what the board is walked here to prevent. The card is looked for
    first, and the move is made only once it has been found; when it has not,
    the command either says so or, with `add_if_missing`, puts the issue in the
    target column instead - which is what `project issue add` does, and the only
    way to get a card there in one call.

    Whichever call was made, the target column is then read back, because the
    success it answered with is the thing this endpoint has already been shown
    not to mean. Returning normally therefore says the card was seen in
    `column_id`, not that Gitea accepted a request to put it there. It does not
    say the card is still there: the two reads are separate requests, and no
    conditional move exists to make them one.

    Args:
        client: The Gitea client to call.
        owner: The owner of the repository or organization holding the project.
        repository: The name of the repository holding the project, or None for
            an organization project.
        project_id: The ID of the project.
        issue_number: The value the user passed as --issue-id.
        column_id: The target column ID.
        sorting: The position within the column, ascending.
        issue_repository: The name of the repository holding the issue, or None
            when it is not known and `issue_number` is therefore a global ID.
        add_if_missing: Whether to add the issue to the target column when no
            column of the project holds a card for it.

    Returns:
        A tuple containing the payload and the metadata, the latter carrying the
        resolved global issue ID whenever the number was resolved.

    Raises:
        CommandError: If the issue could not be resolved, the board could not be
            read, the issue has no card on the project and `add_if_missing` is
            not set, the call was refused, the card is not in `column_id`
            afterwards or could not be confirmed there, the instance could not be
            reached, or the request failed without reaching a response.

    """
    issue_id = resolve_issue_id(client=client, owner=owner, repository=issue_repository, issue_number=issue_number)
    on_board = (
        _card_column_id(
            client=client,
            action="move",
            owner=owner,
            repository=repository,
            project_id=project_id,
            issue_number=issue_number,
            issue_id=issue_id,
            issue_repository=issue_repository,
        )
        is not None
    )

    if not on_board and not add_if_missing:
        raise CommandError(
            _not_on_board_message(owner, repository, project_id, column_id, issue_number, issue_id, issue_repository)
        )

    if on_board:
        action = "move"

        def call(resolved_issue_id: int) -> tuple[dict[str, Any], dict[str, Any]]:
            """Move the card the board was found to hold.

            Args:
                resolved_issue_id: The global ID of the issue.

            Returns:
                A tuple containing the response data and metadata.

            """
            return client.project.move_project_issue(
                owner=owner,
                repository=repository,
                project_id=project_id,
                issue_id=resolved_issue_id,
                column_id=column_id,
                sorting=sorting,
            )
    else:
        if sorting is not None:
            raise CommandError(
                _sorting_unavailable_message(
                    owner, repository, project_id, column_id, issue_number, issue_id, issue_repository
                )
            )
        action = "add"

        def call(resolved_issue_id: int) -> tuple[dict[str, Any], dict[str, Any]]:
            """Put the issue in the target column, there being no card to move.

            Args:
                resolved_issue_id: The global ID of the issue.

            Returns:
                A tuple containing the response data and metadata.

            """
            return client.project.add_issue_to_project_column(
                owner=owner,
                repository=repository,
                project_id=project_id,
                column_id=column_id,
                issue_id=resolved_issue_id,
            )

    data, metadata = _run_resolved_call(
        client=client,
        call=call,
        action=action,
        owner=owner,
        project_id=project_id,
        issue_number=issue_number,
        issue_id=issue_id,
        issue_repository=issue_repository,
    )
    _confirm_card_in_column(
        client=client,
        action=action,
        owner=owner,
        repository=repository,
        project_id=project_id,
        column_id=column_id,
        issue_number=issue_number,
        issue_id=issue_id,
        issue_repository=issue_repository,
    )
    return data, metadata

gitea.cli.utils.issue.run_project_issue_remove

run_project_issue_remove(
    *,
    client: Gitea,
    owner: str,
    repository: str | None,
    project_id: int,
    issue_number: int,
    column_id: int | None,
    issue_repository: str | None,
) -> tuple[dict[str, Any], dict[str, Any]]

Take an issue's card off a project, from the column it is in.

Gitea's removal endpoint takes the column the card is in, not a column the caller is choosing: unlike the --column-id of add and move, which says where the card is to end up, this one says where it already is. That is something the board can be asked, so column_id may be None, and the column holding the card is then found by walking the project's columns - the same walk the move makes before moving a card. An issue with no card on the project is reported as having none, rather than removed from a column chosen for it.

A column this command found is one it also checks: the board is walked again after the removal, and the command exits zero having seen no column of the project holding a card for the issue. The two calls are separate requests against a board anything may edit, so a card moved between the walk and the removal leaves the removal addressed to the column the card has left, which is a removal with nothing to do. Whether the instance refuses that call or answers it with a success is the instance's business: what a caller is told here is that no column of the project holds the card, which is the claim the exit status makes and the one only a read can support. It is a narrower window and not a closed one - Gitea has no conditional delete, so a card put back on the board after the confirming walk is a card this command has already reported on.

A column_id that was given is passed on as it stands. Nothing is looked up for it, before or after: this is a removal the caller addressed, so the column it was told to use is the column it uses, whether or not the card is there, and reading the board back would be checking a claim the command never made. What the instance makes of such a call - a refusal, or a success that removed nothing - is reported as it came.

Parameters:

Name Type Description Default
client Gitea

The Gitea client to call.

required
owner str

The owner of the repository or organization holding the project.

required
repository str | None

The name of the repository holding the project, or None for an organization project.

required
project_id int

The ID of the project.

required
issue_number int

The value the user passed as --issue-id.

required
column_id int | None

The column holding the card, or None to find it on the board.

required
issue_repository str | None

The name of the repository holding the issue, or None when it is not known and issue_number is therefore a global ID.

required

Returns:

Type Description
dict[str, Any]

A tuple containing the payload and the metadata, the latter carrying the

dict[str, Any]

resolved global issue ID whenever the number was resolved, and the column

tuple[dict[str, Any], dict[str, Any]]

the card was removed from whenever that was looked up.

Raises:

Type Description
CommandError

If the issue could not be resolved, the board could not be read, the issue has no card on the project, the call was refused, a column of the project still holds the card afterwards or that could not be confirmed, the instance could not be reached, or the request failed without reaching a response.

Source code in src/gitea/cli/utils/issue.py
def run_project_issue_remove(
    *,
    client: Gitea,
    owner: str,
    repository: str | None,
    project_id: int,
    issue_number: int,
    column_id: int | None,
    issue_repository: str | None,
) -> tuple[dict[str, Any], dict[str, Any]]:
    """Take an issue's card off a project, from the column it is in.

    Gitea's removal endpoint takes the column the card is in, not a column the
    caller is choosing: unlike the `--column-id` of `add` and `move`, which says
    where the card is to end up, this one says where it already is. That is
    something the board can be asked, so `column_id` may be None, and the column
    holding the card is then found by walking the project's columns - the same
    walk the move makes before moving a card. An issue with no card on the
    project is reported as having none, rather than removed from a column chosen
    for it.

    A column this command found is one it also checks: the board is walked again
    after the removal, and the command exits zero having seen no column of the
    project holding a card for the issue. The two calls are separate requests
    against a board anything may edit, so a card moved between the walk and the
    removal leaves the removal addressed to the column the card has left, which
    is a removal with nothing to do. Whether the instance refuses that call or
    answers it with a success is the instance's business: what a caller is told
    here is that no column of the project holds the card, which is the claim the
    exit status makes and the one only a read can support. It is a narrower
    window and not a closed one - Gitea has no conditional delete, so a card put
    back on the board after the confirming walk is a card this command has
    already reported on.

    A `column_id` that was given is passed on as it stands. Nothing is looked up
    for it, before or after: this is a removal the caller addressed, so the
    column it was told to use is the column it uses, whether or not the card is
    there, and reading the board back would be checking a claim the command never
    made. What the instance makes of such a call - a refusal, or a success that
    removed nothing - is reported as it came.

    Args:
        client: The Gitea client to call.
        owner: The owner of the repository or organization holding the project.
        repository: The name of the repository holding the project, or None for
            an organization project.
        project_id: The ID of the project.
        issue_number: The value the user passed as --issue-id.
        column_id: The column holding the card, or None to find it on the board.
        issue_repository: The name of the repository holding the issue, or None
            when it is not known and `issue_number` is therefore a global ID.

    Returns:
        A tuple containing the payload and the metadata, the latter carrying the
        resolved global issue ID whenever the number was resolved, and the column
        the card was removed from whenever that was looked up.

    Raises:
        CommandError: If the issue could not be resolved, the board could not be
            read, the issue has no card on the project, the call was refused, a
            column of the project still holds the card afterwards or that could
            not be confirmed, the instance could not be reached, or the request
            failed without reaching a response.

    """
    issue_id = resolve_issue_id(client=client, owner=owner, repository=issue_repository, issue_number=issue_number)
    carded_column_id = column_id
    if carded_column_id is None:
        carded_column_id = _card_column_id(
            client=client,
            action="remove",
            owner=owner,
            repository=repository,
            project_id=project_id,
            issue_number=issue_number,
            issue_id=issue_id,
            issue_repository=issue_repository,
        )
        if carded_column_id is None:
            raise CommandError(
                _nothing_to_remove_message(owner, repository, project_id, issue_number, issue_id, issue_repository)
            )

    def call(resolved_issue_id: int) -> tuple[dict[str, Any], dict[str, Any]]:
        """Take the card off the column holding it.

        Args:
            resolved_issue_id: The global ID of the issue.

        Returns:
            A tuple containing the response data and metadata.

        """
        return client.project.remove_issue_from_project_column(
            owner=owner,
            repository=repository,
            project_id=project_id,
            column_id=carded_column_id,
            issue_id=resolved_issue_id,
        )

    data, metadata = _run_resolved_call(
        client=client,
        call=call,
        action="remove",
        owner=owner,
        project_id=project_id,
        issue_number=issue_number,
        issue_id=issue_id,
        issue_repository=issue_repository,
    )
    if column_id is None:
        _confirm_card_off_board(
            client=client,
            owner=owner,
            repository=repository,
            project_id=project_id,
            column_id=carded_column_id,
            issue_number=issue_number,
            issue_id=issue_id,
            issue_repository=issue_repository,
        )
        # The column was this command's answer rather than the caller's, so it is
        # reported: a removal that says nothing about where the card was leaves
        # the caller unable to put it back.
        return data, {**metadata, "resolved_column_id": carded_column_id}
    return data, metadata