|
| 1 | +#!/bin/env python3 |
| 2 | + |
| 3 | +# Reference: https://jira.readthedocs.io |
| 4 | + |
| 5 | +import typing as t |
| 6 | +from pathlib import Path |
| 7 | +import json |
| 8 | +import tomllib |
| 9 | + |
| 10 | +from jira import JIRA |
| 11 | +from jira.utils import remove_empty_attributes |
| 12 | +from pydantic.dataclasses import dataclass |
| 13 | +import click |
| 14 | + |
| 15 | + |
| 16 | +@dataclass |
| 17 | +class Config: |
| 18 | + server: str = "https://issues.redhat.com" |
| 19 | + token: str = "" |
| 20 | + project: str = "PULP" |
| 21 | + |
| 22 | + |
| 23 | +def read_config() -> Config: |
| 24 | + conf_path = Path(".") / ".jiraauth" |
| 25 | + data = tomllib.loads(conf_path.read_text())["default"] |
| 26 | + return Config(**data) |
| 27 | + |
| 28 | + |
| 29 | +def search_issues_paginated(jira: JIRA, jql: str): |
| 30 | + start_at = 0 |
| 31 | + max_results = 50 |
| 32 | + while results := jira.search_issues( |
| 33 | + jql, |
| 34 | + maxResults=max_results, |
| 35 | + startAt=start_at, |
| 36 | + ): |
| 37 | + yield from results |
| 38 | + start_at += max_results |
| 39 | + |
| 40 | + |
| 41 | +@click.group() |
| 42 | +@click.pass_context |
| 43 | +def main(ctx: click.Context) -> None: |
| 44 | + config = read_config() |
| 45 | + jira = JIRA(server=config.server, token_auth=config.token) |
| 46 | + ctx.obj = { |
| 47 | + "jira": jira, |
| 48 | + "project": jira.project(config.project), |
| 49 | + } |
| 50 | + |
| 51 | + |
| 52 | +@main.command() |
| 53 | +@click.pass_context |
| 54 | +def issues(ctx: click.Context) -> None: |
| 55 | + jira = ctx.obj["jira"] |
| 56 | + project = ctx.obj["project"] |
| 57 | + for issue in search_issues_paginated( |
| 58 | + jira, |
| 59 | + f"project = {project} AND resolution = Unresolved ORDER BY priority DESC, updated DESC", |
| 60 | + ): |
| 61 | + print(issue, issue.fields.summary) |
| 62 | + |
| 63 | + |
| 64 | +@main.command() |
| 65 | +@click.pass_context |
| 66 | +def blocker(ctx: click.Context) -> None: |
| 67 | + jira = ctx.obj["jira"] |
| 68 | + project = ctx.obj["project"] |
| 69 | + for issue in jira.search_issues( |
| 70 | + f"project = {project} AND resolution = Unresolved AND priority = blocker ORDER BY updated DESC" |
| 71 | + ): |
| 72 | + print(issue, issue.fields.summary) |
| 73 | + |
| 74 | + |
| 75 | +@main.command() |
| 76 | +@click.pass_context |
| 77 | +def my_issues(ctx: click.Context) -> None: |
| 78 | + jira = ctx.obj["jira"] |
| 79 | + for issue in jira.search_issues( |
| 80 | + "assignee = currentUser() AND resolution = Unresolved order by updated DESC" |
| 81 | + ): |
| 82 | + print(issue, issue.fields.summary) |
| 83 | + |
| 84 | + |
| 85 | +@main.command() |
| 86 | +def my_next_issue() -> None: |
| 87 | + print("Who do you think I am?") |
| 88 | + |
| 89 | + |
| 90 | +@main.command() |
| 91 | +@click.argument("search_phrase") |
| 92 | +@click.pass_context |
| 93 | +def search( |
| 94 | + ctx: click.Context, |
| 95 | + search_phrase: str, |
| 96 | +) -> None: |
| 97 | + jira = ctx.obj["jira"] |
| 98 | + project = ctx.obj["project"] |
| 99 | + jql = f"project = {project} AND resolution = Unresolved AND text ~ '{search_phrase}' ORDER BY updated DESC" |
| 100 | + for issue in jira.search_issues(jql): |
| 101 | + print(issue, issue.fields.summary) |
| 102 | + |
| 103 | + |
| 104 | +@main.command() |
| 105 | +@click.option("--raw", is_flag=True, default=False) |
| 106 | +@click.argument("issue_id") |
| 107 | +@click.pass_context |
| 108 | +def show( |
| 109 | + ctx: click.Context, |
| 110 | + raw: bool, |
| 111 | + issue_id: str, |
| 112 | +) -> None: |
| 113 | + jira = ctx.obj["jira"] |
| 114 | + issue = jira.issue(issue_id) |
| 115 | + if raw: |
| 116 | + raw_issue = issue.raw |
| 117 | + raw_issue["fields"] = remove_empty_attributes(raw_issue["fields"]) |
| 118 | + raw_issue = remove_empty_attributes(raw_issue) |
| 119 | + print(json.dumps(raw_issue)) |
| 120 | + else: |
| 121 | + print(issue) |
| 122 | + print(issue.fields.summary) |
| 123 | + print(issue.fields.description) |
| 124 | + print("Status:", issue.fields.status.name) |
| 125 | + fields = jira.fields() |
| 126 | + for fieldname in ["Story Points", "Resolution"]: |
| 127 | + field_id = next( |
| 128 | + (field["id"] for field in fields if field["name"] == fieldname) |
| 129 | + ) |
| 130 | + print(fieldname + ":", issue.get_field(field_id)) |
| 131 | + |
| 132 | + |
| 133 | +@main.command() |
| 134 | +@click.option("--assign/--no-assign", default=True) |
| 135 | +@click.argument("summary") |
| 136 | +@click.argument("description") |
| 137 | +@click.pass_context |
| 138 | +def create( |
| 139 | + ctx: click.Context, |
| 140 | + assign: bool, |
| 141 | + summary: str, |
| 142 | + description: str, |
| 143 | +) -> None: |
| 144 | + jira = ctx.obj["jira"] |
| 145 | + fields: t.MutableMapping[str, t.Any] = { |
| 146 | + "project": str(ctx.obj["project"]), |
| 147 | + "issuetype": "Task", |
| 148 | + "summary": summary, |
| 149 | + "description": description, |
| 150 | + } |
| 151 | + if assign: |
| 152 | + fields["assignee"] = {"name": jira.current_user()} |
| 153 | + issue = jira.create_issue(fields) |
| 154 | + print(issue) |
| 155 | + |
| 156 | + |
| 157 | +@main.command() |
| 158 | +@click.argument("issue_id") |
| 159 | +@click.argument("story_points") |
| 160 | +@click.pass_context |
| 161 | +def storypoint( |
| 162 | + ctx: click.Context, |
| 163 | + issue_id: str, |
| 164 | + story_points: float, |
| 165 | +): |
| 166 | + """ |
| 167 | + Mark issue with a certain number of storypoints. |
| 168 | + """ |
| 169 | + jira = ctx.obj["jira"] |
| 170 | + issue = jira.issue(issue_id) |
| 171 | + sp_field_id = next( |
| 172 | + (field["id"] for field in jira.fields() if field["name"] == "Story Points") |
| 173 | + ) |
| 174 | + print("Story Points:", issue.get_field(sp_field_id), "->", story_points) |
| 175 | + issue.update(fields={sp_field_id: float(story_points)}) |
| 176 | + |
| 177 | + |
| 178 | +@main.command() |
| 179 | +@click.argument("issue_id") |
| 180 | +@click.pass_context |
| 181 | +def close( |
| 182 | + ctx: click.Context, |
| 183 | + issue_id: str, |
| 184 | +): |
| 185 | + """ |
| 186 | + Close issue as done. |
| 187 | + (ATM this is the only supported resolution.) |
| 188 | + """ |
| 189 | + jira = ctx.obj["jira"] |
| 190 | + issue = jira.issue(issue_id) |
| 191 | + resolution_id = next((res.id for res in jira.resolutions() if res.name == "Done")) |
| 192 | + transitions = jira.transitions(issue) |
| 193 | + close_id = next((t["id"] for t in transitions if t["name"] == "Closed")) |
| 194 | + jira.transition_issue(issue, close_id, resolution={"id": resolution_id}) |
| 195 | + |
| 196 | + |
| 197 | +@main.command() |
| 198 | +@click.pass_context |
| 199 | +def types(ctx: click.Context) -> None: |
| 200 | + """ |
| 201 | + Dump available issue types for the project. |
| 202 | + """ |
| 203 | + jira = ctx.obj["jira"] |
| 204 | + for issue_type in jira.issue_types_for_project(ctx.obj["project"]): |
| 205 | + print(issue_type.name, f"(id={issue_type.id})") |
| 206 | + |
| 207 | + |
| 208 | +@main.command() |
| 209 | +@click.pass_context |
| 210 | +def resolutions(ctx: click.Context) -> None: |
| 211 | + """ |
| 212 | + Dump available resolutions. |
| 213 | + """ |
| 214 | + jira = ctx.obj["jira"] |
| 215 | + for resolution in jira.resolutions(): |
| 216 | + print(resolution.name, f"(id={resolution.id})") |
| 217 | + |
| 218 | + |
| 219 | +@main.command() |
| 220 | +@click.pass_context |
| 221 | +def fields(ctx: click.Context) -> None: |
| 222 | + """ |
| 223 | + Dump available fields. |
| 224 | + """ |
| 225 | + jira = ctx.obj["jira"] |
| 226 | + for field in jira.fields(): |
| 227 | + print(field["name"], "(id=" + field["id"] + ")") |
| 228 | + |
| 229 | + |
| 230 | +@main.command() |
| 231 | +@click.pass_context |
| 232 | +def shell(ctx: click.Context) -> None: |
| 233 | + """ |
| 234 | + Start an interactive ipython shell. |
| 235 | +
|
| 236 | + The objects `jira` and `project` are preloaded. |
| 237 | + """ |
| 238 | + import IPython |
| 239 | + |
| 240 | + IPython.start_ipython(argv=[], user_ns=ctx.obj) |
| 241 | + |
| 242 | + |
| 243 | +if __name__ == "__main__": |
| 244 | + main() |
0 commit comments