Skip to content

API documentation

cli

auth_verify

auth_verify(instance_name: InstanceNameOption) -> None

Verify the authentication to the instance.

Source code in src/issx/cli.py
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
@app.command()
def auth_verify(instance_name: InstanceNameOption) -> None:
    """Verify the authentication to the instance."""
    config = GenericConfigParser.from_file()
    instance_manager = InstanceManager(config)
    try:
        instance = instance_manager.get_instance_client(instance_name)
    except Exception as e:
        console.print_exception()
        console.print("Error when configuring client instance.\n", style="red")
        raise typer.Exit(1) from e
    try:
        username = asyncio.run(instance.auth())
        if not username:
            raise Exception("Authentication failed")
    except Exception as e:
        console.print_exception()
        console.print(
            f"Error when authenticating to {instance.get_instance_url()}",
            style="red",
        )
        raise typer.Exit(1) from e
    else:
        console.print(
            "Authentication successful",
            f"Instance: {instance.get_instance_url()}",
            f"User: {username}",
            sep="\n",
            style="green bold italic",
        )

copy

copy(
    source_project_name: Annotated[
        str,
        typer.Option(
            --source,
            help="Source project name configured in the config file",
        ),
    ],
    target_project_name: Annotated[
        str,
        typer.Option(
            --target,
            help="Target project name configured in the config file",
        ),
    ],
    issue_id: int,
    title_format: Annotated[
        str,
        typer.Option(
            --title - format,
            -T,
            help="Template of a new issue title. Can contain placeholders of the issue attributes: {id}, {title}, {description}, {web_url}, {reference}",
        ),
    ] = "{title}",
    description_format: Annotated[
        str,
        typer.Option(
            --description - format,
            -D,
            help="Template of a new issue description. Can contain placeholders of the issue attributes: {id}, {title}, {description}, {web_url}, {reference}",
        ),
    ] = "{description}",
    allow_duplicates: Annotated[
        bool,
        typer.Option(
            --allow - duplicates,
            -A,
            help="Allow for duplicate issues. If set, the command will return the first issue found with the same title. If no issues are found, a new issue will be created.",
        ),
    ] = False,
    assign_to_me: Annotated[
        bool,
        typer.Option(
            --assign - to - me,
            -M,
            help="Whether to assign a newly created issue to the current user",
        ),
    ] = False,
) -> int

Copy an issue from one project to another.

Source code in src/issx/cli.py
 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
@app.command()
def copy(
    source_project_name: Annotated[
        str,
        typer.Option(
            "--source", help="Source project name configured in the config file"
        ),
    ],
    target_project_name: Annotated[
        str,
        typer.Option(
            "--target", help="Target project name configured in the config file"
        ),
    ],
    issue_id: int,
    title_format: Annotated[
        str,
        typer.Option(
            "--title-format",
            "-T",
            help="Template of a new issue title. Can contain placeholders of the"
            " issue attributes: {id}, {title}, {description}, {web_url}, {reference}",
        ),
    ] = "{title}",
    description_format: Annotated[
        str,
        typer.Option(
            "--description-format",
            "-D",
            help="Template of a new issue description. Can contain placeholders of the"
            " issue attributes: {id}, {title}, {description}, {web_url}, {reference}",
        ),
    ] = "{description}",
    allow_duplicates: Annotated[
        bool,
        typer.Option(
            "--allow-duplicates",
            "-A",
            help="Allow for duplicate issues. If set, the command will return the first"
            " issue found with the same title. If no issues are found,"
            " a new issue will be created.",
        ),
    ] = False,
    assign_to_me: Annotated[
        bool,
        typer.Option(
            "--assign-to-me",
            "-M",
            help="Whether to assign a newly created issue to the current user",
        ),
    ] = False,
) -> int:
    """Copy an issue from one project to another."""

    console.print(
        Text.assemble(
            f"Copying issue {issue_id} from project ",
            (source_project_name, "bold magenta"),
            " to project ",
            (target_project_name, "bold magenta"),
        )
    )
    config = GenericConfigParser.from_file()
    instance_manager = InstanceManager(config)
    try:
        source_client = instance_manager.get_project_client(source_project_name)
        target_client = instance_manager.get_project_client(target_project_name)
    except Exception as e:
        console.print_exception()
        console.print("Error when configuring client instance.\n", style="red")
        raise typer.Exit(1) from e
    new_issue = asyncio.run(
        CopyIssueService(source_client, target_client).copy(
            issue_id,
            title_format,
            description_format,
            allow_duplicates=allow_duplicates,
            assign_to_me=assign_to_me,
        )
    )
    console.print(f"Success!\n{new_issue}", style="green")
    return 0

generate_instance

generate_instance(
    instance_name: Annotated[
        str,
        typer.Option(
            --instance,
            help="Name of then instance to generate new_config for. Only alphanumeric and -_ allowed",
        ),
    ]
) -> None

Generate instance's new_config string

Source code in src/issx/cli.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
@config_app.command()
def generate_instance(
    instance_name: Annotated[
        str,
        typer.Option(
            "--instance",
            help="Name of then instance to generate new_config for."
            " Only alphanumeric and -_ allowed",
        ),
    ],
) -> None:
    """Generate instance's new_config string"""

    new_config = RichConfigReader().read(InstanceConfig)
    console.print()
    console.print(Text(new_config.as_toml(f"instances.{instance_name}")))

    console.print(
        "\nSuccess!\nCopy the above config to your config file", style="green"
    )

generate_project

generate_project(
    project_name: Annotated[
        str,
        typer.Option(
            --project,
            help="Name of then project to generate new_config for. Only alphanumeric and -_ allowed",
        ),
    ]
) -> None

Generate project's new_config string. Instance must be already configured.

Source code in src/issx/cli.py
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
@config_app.command()
def generate_project(
    project_name: Annotated[
        str,
        typer.Option(
            "--project",
            help="Name of then project to generate new_config for."
            " Only alphanumeric and -_ allowed",
        ),
    ],
) -> None:
    """Generate project's new_config string. Instance must be already configured."""

    new_config = RichConfigReader().read(ProjectFlatConfig)

    console.print()
    console.print(Text(new_config.as_toml(f"projects.{project_name}")))
    config = GenericConfigParser.from_file()

    try:
        config.get_instance_config(new_config.instance)
    except KeyError:
        console.print(
            f"\nWarning: instance [bold]{new_config.instance}[/bold] not found"
            f" in the config file. If you want to generate a missing config run"
            f" [bold]issx config generate-instance --instance {new_config.instance}"
            f"[/bold] command",
            style="yellow",
        )

    console.print(
        "\nSuccess!\nCopy the above config to your config file", style="green"
    )

cli_utils

clients

GitlabClient

Bases: IssueClientInterface, GitlabInstanceClient

Gitlab client implementations

Source code in src/issx/clients/gitlab.py
 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
class GitlabClient(IssueClientInterface, GitlabInstanceClient):
    """Gitlab client implementations"""

    def __init__(self, client: Gitlab, project_id: int):
        self.project_id = project_id
        self._project: Project | None = None
        super().__init__(client)

    async def create_issue(
        self, title: str, description: str, assign_to_me: bool = False
    ) -> Issue:
        project = await self._get_project()
        issue: ProjectIssue = cast(
            ProjectIssue,
            project.issues.create(
                {
                    "title": title,
                    "description": description,
                    "assignee_id": self.get_user().id,
                }
            ),
        )
        return IssueMapper.issue_to_domain(issue)

    async def get_issue(self, issue_id: int) -> Issue:
        issue: ProjectIssue = await self._get_issue(issue_id)
        return IssueMapper.issue_to_domain(issue)

    async def find_issues(self, title: str) -> list[Issue]:
        project = await self._get_project()
        issues = cast(list[ProjectIssue], list(project.issues.list(search=title)))
        return IssueMapper.issues_to_domain_list(issues)

    async def _get_project(self) -> Project:
        if self._project is None:
            try:
                self._project = self.client.projects.get(self.project_id)
            except GitlabGetError as e:
                raise ProjectDoesNotExistError(
                    f"Project with id={self.project_id} does not exist"
                ) from e
        return self._project

    async def _get_issue(self, issue_id: int) -> ProjectIssue:
        project = await self._get_project()
        try:
            return project.issues.get(issue_id)
        except GitlabGetError as e:
            raise IssueDoesNotExistError(issue_id) from e

    @classmethod
    def from_config(
        cls, instance_config: InstanceConfig, project_config: ProjectFlatConfig
    ) -> Self:
        project_config = cls.project_config_class(**asdict(project_config))
        return cls(
            GitlabInstanceClient.instance_from_config(instance_config).client,
            project_id=int(project_config.project),
        )

RedmineClient

Bases: IssueClientInterface, RedmineInstanceClient

Redmine client implementation

Source code in src/issx/clients/redmine.py
 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
class RedmineClient(IssueClientInterface, RedmineInstanceClient):
    """Redmine client implementation"""

    def __init__(self, client: Redmine, project_id: int):  # type: ignore[no-any-unimported]
        self._project_id = project_id
        self._project: Project | None = None  # type: ignore[no-any-unimported]
        super().__init__(client)

    async def create_issue(
        self, title: str, description: str, assign_to_me: bool = False
    ) -> Issue:
        issue = self.client.issue.create(
            project_id=(await self.get_project()).id,
            subject=title,
            description=description,
            assigned_to_id="me" if assign_to_me else None,
        )
        return RedmineIssueMapper.issue_to_domain(issue)

    async def get_issue(self, issue_id: int) -> Issue:
        try:
            issue = self.client.issue.get(issue_id)
        except ResourceNotFoundError as e:
            raise IssueDoesNotExistError(issue_id) from e
        return RedmineIssueMapper.issue_to_domain(issue)

    async def find_issues(self, title: str) -> list[Issue]:
        return RedmineIssueMapper.issues_to_domain_list(
            self.client.issue.filter(
                project_id=(await self.get_project()).id, subject=title
            )
        )

    async def get_project(self) -> Project:  # type: ignore[no-any-unimported]
        if self._project is None:
            try:
                self._project = self.client.project.get(self._project_id)
            except ResourceNotFoundError as e:
                raise ProjectDoesNotExistError(
                    f"Project with id={self._project_id} does not exist"
                ) from e
        return self._project

    @classmethod
    def from_config(
        cls, instance_config: InstanceConfig, project_config: ProjectFlatConfig
    ) -> Self:
        project_config = cls.project_config_class(**asdict(project_config))
        return cls(
            RedmineInstanceClient.instance_from_config(instance_config).client,
            project_id=int(project_config.project),
        )

gitlab

GitlabClient

Bases: IssueClientInterface, GitlabInstanceClient

Gitlab client implementations

Source code in src/issx/clients/gitlab.py
 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
class GitlabClient(IssueClientInterface, GitlabInstanceClient):
    """Gitlab client implementations"""

    def __init__(self, client: Gitlab, project_id: int):
        self.project_id = project_id
        self._project: Project | None = None
        super().__init__(client)

    async def create_issue(
        self, title: str, description: str, assign_to_me: bool = False
    ) -> Issue:
        project = await self._get_project()
        issue: ProjectIssue = cast(
            ProjectIssue,
            project.issues.create(
                {
                    "title": title,
                    "description": description,
                    "assignee_id": self.get_user().id,
                }
            ),
        )
        return IssueMapper.issue_to_domain(issue)

    async def get_issue(self, issue_id: int) -> Issue:
        issue: ProjectIssue = await self._get_issue(issue_id)
        return IssueMapper.issue_to_domain(issue)

    async def find_issues(self, title: str) -> list[Issue]:
        project = await self._get_project()
        issues = cast(list[ProjectIssue], list(project.issues.list(search=title)))
        return IssueMapper.issues_to_domain_list(issues)

    async def _get_project(self) -> Project:
        if self._project is None:
            try:
                self._project = self.client.projects.get(self.project_id)
            except GitlabGetError as e:
                raise ProjectDoesNotExistError(
                    f"Project with id={self.project_id} does not exist"
                ) from e
        return self._project

    async def _get_issue(self, issue_id: int) -> ProjectIssue:
        project = await self._get_project()
        try:
            return project.issues.get(issue_id)
        except GitlabGetError as e:
            raise IssueDoesNotExistError(issue_id) from e

    @classmethod
    def from_config(
        cls, instance_config: InstanceConfig, project_config: ProjectFlatConfig
    ) -> Self:
        project_config = cls.project_config_class(**asdict(project_config))
        return cls(
            GitlabInstanceClient.instance_from_config(instance_config).client,
            project_id=int(project_config.project),
        )

IssueMapper

Maps Gitlab API objects to domain objects

Source code in src/issx/clients/gitlab.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class IssueMapper:
    """
    Maps Gitlab API objects to domain objects
    """

    @classmethod
    def issue_to_domain(cls, issue: ProjectIssue) -> Issue:
        return Issue(
            id=issue.iid,
            title=issue.title,
            description=issue.description,
            web_url=issue.web_url,
            reference=issue.references["full"],
        )

    @classmethod
    def issues_to_domain_list(cls, issues: list[ProjectIssue]) -> list[Issue]:
        return [cls.issue_to_domain(issue) for issue in issues]

interfaces

InstanceClientInterface

Bases: ABC

Interface for a client that interacts with an issue tracker instance

Source code in src/issx/clients/interfaces.py
 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
class InstanceClientInterface(abc.ABC):
    """
    Interface for a client that interacts with an issue tracker instance
    """

    instance_config_class: ClassVar[type[InstanceConfig]] = InstanceConfig

    @abc.abstractmethod
    async def auth(self) -> str | None:
        """
        Authenticate the client with the instance.
        Raises an internal error if the authentication fails or returns None.
        :return: The username of the authenticated user or None
        """
        pass

    @abc.abstractmethod
    def get_instance_url(self) -> str:
        """
        :return: The URL of the instance
        """
        pass

    @classmethod
    @abc.abstractmethod
    def instance_from_config(cls, instance_config: InstanceConfig) -> Self:
        """
        Create an instance of the client from a configuration dictionary.
        If required, the instance_config can be converted to an instance-specific
        configuration according to the `instance_config_class` attribute of the client.

        :param instance_config: The configuration object. Higher level code
        should validate the configuration.
        :return: An instance of the client
        """
        pass
auth abstractmethod async
auth() -> str | None

Authenticate the client with the instance. Raises an internal error if the authentication fails or returns None.

Returns:

Type Description
str | None

The username of the authenticated user or None

Source code in src/issx/clients/interfaces.py
15
16
17
18
19
20
21
22
@abc.abstractmethod
async def auth(self) -> str | None:
    """
    Authenticate the client with the instance.
    Raises an internal error if the authentication fails or returns None.
    :return: The username of the authenticated user or None
    """
    pass
get_instance_url abstractmethod
get_instance_url() -> str

Returns:

Type Description
str

The URL of the instance

Source code in src/issx/clients/interfaces.py
24
25
26
27
28
29
@abc.abstractmethod
def get_instance_url(self) -> str:
    """
    :return: The URL of the instance
    """
    pass
instance_from_config abstractmethod classmethod
instance_from_config(
    instance_config: InstanceConfig,
) -> Self

Create an instance of the client from a configuration dictionary. If required, the instance_config can be converted to an instance-specific configuration according to the instance_config_class attribute of the client.

Parameters:

Name Type Description Default
instance_config InstanceConfig

The configuration object. Higher level code should validate the configuration.

required

Returns:

Type Description
Self

An instance of the client

Source code in src/issx/clients/interfaces.py
31
32
33
34
35
36
37
38
39
40
41
42
43
@classmethod
@abc.abstractmethod
def instance_from_config(cls, instance_config: InstanceConfig) -> Self:
    """
    Create an instance of the client from a configuration dictionary.
    If required, the instance_config can be converted to an instance-specific
    configuration according to the `instance_config_class` attribute of the client.

    :param instance_config: The configuration object. Higher level code
    should validate the configuration.
    :return: An instance of the client
    """
    pass

IssueClientInterface

Bases: ABC

Source code in src/issx/clients/interfaces.py
 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
class IssueClientInterface(abc.ABC):
    project_config_class: ClassVar[type[ProjectFlatConfig]] = ProjectFlatConfig

    @abc.abstractmethod
    async def get_issue(self, issue_id: int) -> Issue:
        """
        Retrieve an issue by its ID.
        Raises IssueDoesNotExistError if the issue does not exist.
        :param issue_id: The ID of the issue
        :return: Issue object
        """
        pass

    @abc.abstractmethod
    async def create_issue(
        self, title: str, description: str, assign_to_me: bool = False
    ) -> Issue:
        """
        Create a new issue.
        :param title: The title of the issue
        :param description: The description of the issue
        :param assign_to_me: Assign the issue to the authenticated user
        :return:
        """
        pass

    @abc.abstractmethod
    async def find_issues(self, title: str) -> list[Issue]:
        """
        Find issues by title using an exact match.
        :param title: The title of the issue
        :return: List of issues
        """
        pass

    @classmethod
    @abc.abstractmethod
    def from_config(
        cls, instance_config: InstanceConfig, project_config: ProjectFlatConfig
    ) -> Self:
        """
        Create an instance of the client from configuration classes.
        If required, the project_config can be converted to a project-specific
        configuration according to the `project_config_class` attribute of the client.

        Args:
            instance_config: InstanceConfig to configure the client to the instance
            project_config: ProjectFlatConfig to configure client. This config can then
            be converted to a project-specific config according to
            the `project_config_class` attribute of the client.
            to the particular project

        Returns:
            An instance of the client

        """
        pass
create_issue abstractmethod async
create_issue(
    title: str, description: str, assign_to_me: bool = False
) -> Issue

Create a new issue.

Parameters:

Name Type Description Default
title str

The title of the issue

required
description str

The description of the issue

required
assign_to_me bool

Assign the issue to the authenticated user

False

Returns:

Type Description
Issue
Source code in src/issx/clients/interfaces.py
59
60
61
62
63
64
65
66
67
68
69
70
@abc.abstractmethod
async def create_issue(
    self, title: str, description: str, assign_to_me: bool = False
) -> Issue:
    """
    Create a new issue.
    :param title: The title of the issue
    :param description: The description of the issue
    :param assign_to_me: Assign the issue to the authenticated user
    :return:
    """
    pass
find_issues abstractmethod async
find_issues(title: str) -> list[Issue]

Find issues by title using an exact match.

Parameters:

Name Type Description Default
title str

The title of the issue

required

Returns:

Type Description
list[Issue]

List of issues

Source code in src/issx/clients/interfaces.py
72
73
74
75
76
77
78
79
@abc.abstractmethod
async def find_issues(self, title: str) -> list[Issue]:
    """
    Find issues by title using an exact match.
    :param title: The title of the issue
    :return: List of issues
    """
    pass
from_config abstractmethod classmethod
from_config(
    instance_config: InstanceConfig,
    project_config: ProjectFlatConfig,
) -> Self

Create an instance of the client from configuration classes. If required, the project_config can be converted to a project-specific configuration according to the project_config_class attribute of the client.

Args: instance_config: InstanceConfig to configure the client to the instance project_config: ProjectFlatConfig to configure client. This config can then be converted to a project-specific config according to the project_config_class attribute of the client. to the particular project

Returns: An instance of the client

Source code in src/issx/clients/interfaces.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
@classmethod
@abc.abstractmethod
def from_config(
    cls, instance_config: InstanceConfig, project_config: ProjectFlatConfig
) -> Self:
    """
    Create an instance of the client from configuration classes.
    If required, the project_config can be converted to a project-specific
    configuration according to the `project_config_class` attribute of the client.

    Args:
        instance_config: InstanceConfig to configure the client to the instance
        project_config: ProjectFlatConfig to configure client. This config can then
        be converted to a project-specific config according to
        the `project_config_class` attribute of the client.
        to the particular project

    Returns:
        An instance of the client

    """
    pass
get_issue abstractmethod async
get_issue(issue_id: int) -> Issue

Retrieve an issue by its ID. Raises IssueDoesNotExistError if the issue does not exist.

Parameters:

Name Type Description Default
issue_id int

The ID of the issue

required

Returns:

Type Description
Issue

Issue object

Source code in src/issx/clients/interfaces.py
49
50
51
52
53
54
55
56
57
@abc.abstractmethod
async def get_issue(self, issue_id: int) -> Issue:
    """
    Retrieve an issue by its ID.
    Raises IssueDoesNotExistError if the issue does not exist.
    :param issue_id: The ID of the issue
    :return: Issue object
    """
    pass

redmine

RedmineClient

Bases: IssueClientInterface, RedmineInstanceClient

Redmine client implementation

Source code in src/issx/clients/redmine.py
 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
class RedmineClient(IssueClientInterface, RedmineInstanceClient):
    """Redmine client implementation"""

    def __init__(self, client: Redmine, project_id: int):  # type: ignore[no-any-unimported]
        self._project_id = project_id
        self._project: Project | None = None  # type: ignore[no-any-unimported]
        super().__init__(client)

    async def create_issue(
        self, title: str, description: str, assign_to_me: bool = False
    ) -> Issue:
        issue = self.client.issue.create(
            project_id=(await self.get_project()).id,
            subject=title,
            description=description,
            assigned_to_id="me" if assign_to_me else None,
        )
        return RedmineIssueMapper.issue_to_domain(issue)

    async def get_issue(self, issue_id: int) -> Issue:
        try:
            issue = self.client.issue.get(issue_id)
        except ResourceNotFoundError as e:
            raise IssueDoesNotExistError(issue_id) from e
        return RedmineIssueMapper.issue_to_domain(issue)

    async def find_issues(self, title: str) -> list[Issue]:
        return RedmineIssueMapper.issues_to_domain_list(
            self.client.issue.filter(
                project_id=(await self.get_project()).id, subject=title
            )
        )

    async def get_project(self) -> Project:  # type: ignore[no-any-unimported]
        if self._project is None:
            try:
                self._project = self.client.project.get(self._project_id)
            except ResourceNotFoundError as e:
                raise ProjectDoesNotExistError(
                    f"Project with id={self._project_id} does not exist"
                ) from e
        return self._project

    @classmethod
    def from_config(
        cls, instance_config: InstanceConfig, project_config: ProjectFlatConfig
    ) -> Self:
        project_config = cls.project_config_class(**asdict(project_config))
        return cls(
            RedmineInstanceClient.instance_from_config(instance_config).client,
            project_id=int(project_config.project),
        )

RedmineIssueMapper

Maps Redmine API objects to domain objects

Source code in src/issx/clients/redmine.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class RedmineIssueMapper:
    """
    Maps Redmine API objects to domain objects
    """

    @classmethod
    def issue_to_domain(cls, issue: RedmineIssue) -> Issue:  # type: ignore[no-any-unimported]
        return Issue(
            id=issue.id,
            title=issue.subject,
            description=issue.description,
            web_url=issue.url,
            reference=issue.id,
        )

    @classmethod
    def issues_to_domain_list(cls, issues: ResourceSet) -> list[Issue]:  # type: ignore[no-any-unimported]
        return [cls.issue_to_domain(issue) for issue in issues]

domain

config

BaseConfig

Base class for config classes. It contains a raw_config attribute that should store the raw dictionary that was used to create the object.

Source code in src/issx/domain/config.py
 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
@define(kw_only=True)
class BaseConfig:
    """
    Base class for config classes. It contains a raw_config attribute
    that should store the raw dictionary that was used to create the object.
    """

    raw_config: dict = attr.ib(
        validator=attr.validators.instance_of(dict),
        factory=dict,
    )

    @classmethod
    def get_meaningful_fields(cls) -> Iterable[attr.Attribute]:
        """
        Get all fields except the raw_config field.

        Returns: Iterable of fields
        """
        fields = attr.fields(cls)
        return [field for field in fields if field != fields.raw_config]

    def as_toml(self, key: str) -> str:
        """
        Convert the object to a TOML string.
        Args:
            key: Key to use for the TOML table

        Returns: TOML string
        """
        nl = "\n"
        header = f"[{key}]"
        attributes = [
            f"{field.name} = {repr(getattr(self, field.name))}"
            for field in self.get_meaningful_fields()
        ]
        return f"{header}\n{nl.join(attributes)}"
as_toml
as_toml(key: str) -> str

Convert the object to a TOML string. Args: key: Key to use for the TOML table

Returns: TOML string

Source code in src/issx/domain/config.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def as_toml(self, key: str) -> str:
    """
    Convert the object to a TOML string.
    Args:
        key: Key to use for the TOML table

    Returns: TOML string
    """
    nl = "\n"
    header = f"[{key}]"
    attributes = [
        f"{field.name} = {repr(getattr(self, field.name))}"
        for field in self.get_meaningful_fields()
    ]
    return f"{header}\n{nl.join(attributes)}"
get_meaningful_fields classmethod
get_meaningful_fields() -> Iterable[attr.Attribute]

Get all fields except the raw_config field.

Returns: Iterable of fields

Source code in src/issx/domain/config.py
21
22
23
24
25
26
27
28
29
@classmethod
def get_meaningful_fields(cls) -> Iterable[attr.Attribute]:
    """
    Get all fields except the raw_config field.

    Returns: Iterable of fields
    """
    fields = attr.fields(cls)
    return [field for field in fields if field != fields.raw_config]

instance_managers

managers

InstanceManager

Source code in src/issx/instance_managers/managers.py
 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
class InstanceManager:
    backends: dict[
        SupportedBackend,
        tuple[type[InstanceClientInterface], type[IssueClientInterface]],
    ] = {}

    def __init__(self, config: GenericConfigParser):
        self.config = config

    @classmethod
    def register_backend(
        cls,
        backend: SupportedBackend,
        instance_client_class: type[InstanceClientInterface],
        project_client_class: type[IssueClientInterface],
    ) -> None:
        cls.backends[backend] = (instance_client_class, project_client_class)

    @classmethod
    def clear_backends(cls) -> None:
        cls.backends.clear()

    def get_instance_client(self, instance: str) -> InstanceClientInterface:
        """
        Get an instance client for a given instance name.

        Converts the instance config to the appropriate config class
         and creates an instance client.
        Args:
            instance: Instance name

        Returns: Instance of an instance client
        """
        instance_config = self.config.get_instance_config(instance)
        client_class = self.backends[instance_config.backend][0]
        instance_config = client_class.instance_config_class(
            **instance_config.raw_config, raw_config=instance_config.raw_config
        )
        return client_class.instance_from_config(instance_config)

    def get_project_client(self, project: str) -> IssueClientInterface:
        """
        Get a project client for a given project name.

        Converts the project config to the appropriate config class
        and creates a project client.
        Args:
            project: Project name

        Returns: Instance of a project client

        """
        project_config = self.config.get_project_config(project)
        instance_config = self.config.get_instance_config(project_config.instance)
        instance_client_class, project_client_class = self.backends[
            instance_config.backend
        ]
        instance_config = instance_client_class.instance_config_class(
            **instance_config.raw_config, raw_config=instance_config.raw_config
        )
        project_config = project_client_class.project_config_class(
            **project_config.raw_config, raw_config=project_config.raw_config
        )
        return project_client_class.from_config(instance_config, project_config)
get_instance_client
get_instance_client(
    instance: str,
) -> InstanceClientInterface

Get an instance client for a given instance name.

Converts the instance config to the appropriate config class and creates an instance client. Args: instance: Instance name

Returns: Instance of an instance client

Source code in src/issx/instance_managers/managers.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
def get_instance_client(self, instance: str) -> InstanceClientInterface:
    """
    Get an instance client for a given instance name.

    Converts the instance config to the appropriate config class
     and creates an instance client.
    Args:
        instance: Instance name

    Returns: Instance of an instance client
    """
    instance_config = self.config.get_instance_config(instance)
    client_class = self.backends[instance_config.backend][0]
    instance_config = client_class.instance_config_class(
        **instance_config.raw_config, raw_config=instance_config.raw_config
    )
    return client_class.instance_from_config(instance_config)
get_project_client
get_project_client(project: str) -> IssueClientInterface

Get a project client for a given project name.

Converts the project config to the appropriate config class and creates a project client. Args: project: Project name

Returns: Instance of a project client

Source code in src/issx/instance_managers/managers.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def get_project_client(self, project: str) -> IssueClientInterface:
    """
    Get a project client for a given project name.

    Converts the project config to the appropriate config class
    and creates a project client.
    Args:
        project: Project name

    Returns: Instance of a project client

    """
    project_config = self.config.get_project_config(project)
    instance_config = self.config.get_instance_config(project_config.instance)
    instance_client_class, project_client_class = self.backends[
        instance_config.backend
    ]
    instance_config = instance_client_class.instance_config_class(
        **instance_config.raw_config, raw_config=instance_config.raw_config
    )
    project_config = project_client_class.project_config_class(
        **project_config.raw_config, raw_config=project_config.raw_config
    )
    return project_client_class.from_config(instance_config, project_config)

services

CopyIssueService

Source code in src/issx/services.py
 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
class CopyIssueService:
    def __init__(
        self, source_client: IssueClientInterface, target_client: IssueClientInterface
    ):
        self.source_client = source_client
        self.target_client = target_client

    async def copy(
        self,
        issue_id: int,
        title_format: str = "{title}",
        description_format: str = "{description}",
        allow_duplicates: bool = False,
        assign_to_me: bool = False,
    ) -> Issue:
        """
        Copy an issue from the source client to the target client optionally
        applying a title and description format. If `allow_duplicates` is `False`,
        the method will return a first issue found with the same title.

        :param issue_id: The ID of the issue to copy
        :param title_format: The format for the new issue title
        :param description_format: The format for the new issue description
        :param allow_duplicates: Whether to allow duplicate issues
        :param assign_to_me: Whether to assign the new issue to the current user
        :return: Newly created or existing issue in the target client
        """
        source_issue = await self.source_client.get_issue(issue_id)
        target_title = self._prepare_string(source_issue, title_format)
        if not allow_duplicates and (
            issues := await self.target_client.find_issues(target_title)
        ):
            return issues[0]
        new_issue = await self.target_client.create_issue(
            title=target_title,
            description=self._prepare_string(source_issue, description_format),
            assign_to_me=assign_to_me,
        )
        return new_issue

    @staticmethod
    def _prepare_string(issue: Issue, title_format: str) -> str:
        """
        :param issue: Issue object from which to create the new title
        :param title_format: template string for the new title.
        Can contain placeholders for the issue attributes:
        {id}, {title}, {description}, {web_url}, {reference}
        :return:
        """
        return title_format.format(
            id=issue.id,
            title=issue.title,
            description=issue.description,
            web_url=issue.web_url,
            reference=issue.reference,
        )

copy async

copy(
    issue_id: int,
    title_format: str = "{title}",
    description_format: str = "{description}",
    allow_duplicates: bool = False,
    assign_to_me: bool = False,
) -> Issue

Copy an issue from the source client to the target client optionally applying a title and description format. If allow_duplicates is False, the method will return a first issue found with the same title.

Parameters:

Name Type Description Default
issue_id int

The ID of the issue to copy

required
title_format str

The format for the new issue title

'{title}'
description_format str

The format for the new issue description

'{description}'
allow_duplicates bool

Whether to allow duplicate issues

False
assign_to_me bool

Whether to assign the new issue to the current user

False

Returns:

Type Description
Issue

Newly created or existing issue in the target client

Source code in src/issx/services.py
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
async def copy(
    self,
    issue_id: int,
    title_format: str = "{title}",
    description_format: str = "{description}",
    allow_duplicates: bool = False,
    assign_to_me: bool = False,
) -> Issue:
    """
    Copy an issue from the source client to the target client optionally
    applying a title and description format. If `allow_duplicates` is `False`,
    the method will return a first issue found with the same title.

    :param issue_id: The ID of the issue to copy
    :param title_format: The format for the new issue title
    :param description_format: The format for the new issue description
    :param allow_duplicates: Whether to allow duplicate issues
    :param assign_to_me: Whether to assign the new issue to the current user
    :return: Newly created or existing issue in the target client
    """
    source_issue = await self.source_client.get_issue(issue_id)
    target_title = self._prepare_string(source_issue, title_format)
    if not allow_duplicates and (
        issues := await self.target_client.find_issues(target_title)
    ):
        return issues[0]
    new_issue = await self.target_client.create_issue(
        title=target_title,
        description=self._prepare_string(source_issue, description_format),
        assign_to_me=assign_to_me,
    )
    return new_issue