302 lines
11 KiB
Python
302 lines
11 KiB
Python
"""
|
|
Jira Field Utilities for CLI-based Issue Updates.
|
|
|
|
This module provides a clean abstraction for working with Jira issue fields
|
|
in a CLI or automation context. It hides the complexity of Jira's API field
|
|
IDs (e.g. "customfield_10302") and automatically formats values according
|
|
to the field's type, allowing developers to reference fields by friendly
|
|
names instead of raw Jira IDs.
|
|
|
|
Key Components:
|
|
- FieldType: Enum describing how each Jira field type (e.g. single select,
|
|
multi select, user picker) must be structured for API updates.
|
|
- JiraFields: Developer-friendly enum for referencing Jira fields by
|
|
logical names (e.g. RELEASE_TRAIN) instead of raw custom field IDs.
|
|
- JiraFieldInfo: Dataclass tying together a Jira field's API ID and its
|
|
FieldType, along with an appropriate formatter.
|
|
- FIELD_REGISTRY: The single source of truth mapping JiraFields to their
|
|
JiraFieldInfo, used to build correctly formatted update payloads.
|
|
- UpdateFields: A builder class for collecting field changes and generating
|
|
a payload dictionary ready to pass into jira.issue.update(fields=...).
|
|
|
|
Usage Example:
|
|
>>> update_fields = UpdateFields()
|
|
>>> update_fields.add_field(JiraFields.RELEASE_TRAIN, "Beta Train")
|
|
>>> update_fields.add_field(JiraFields.REPORTER, "jdoe")
|
|
>>> jira.issue("AETHER-1").update(fields=update_fields.as_dict())
|
|
|
|
Notes:
|
|
- Currently defaults to Jira Server/DC formatting for user fields
|
|
({"name": ...}). Jira Cloud users can easily switch to {"accountId": ...}
|
|
by swapping the formatter for FieldType.USER.
|
|
- FIELD_REGISTRY is the single source of truth for all Jira field mappings.
|
|
To add new fields (e.g. cascading select or date fields), simply register
|
|
them here with their appropriate type and formatter.
|
|
|
|
This design centralizes all field formatting rules and mappings, making Jira
|
|
updates cleaner, safer, and easier to maintain.
|
|
"""
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
from typing import Any, Callable
|
|
|
|
from jira import Issue
|
|
from jira.resources import CustomFieldOption, User
|
|
|
|
|
|
class DeploymentRequirements(Enum):
|
|
"""Checklist of deployment requirements for the Jira 'Deployment Requirements' multi-select field."""
|
|
CODE_REVIEW_COMPLETED = "Code Review Completed"
|
|
UNIT_TESTS_PASSED = "Unit Tests Passed"
|
|
QA_SIGN_OFF = "QA Sign-off"
|
|
DOCUMENTATION_UPDATED = "Documentation Updated"
|
|
SECURITY_SCAN_APPROVED = "Security Scan Approved"
|
|
CHANGE_MANAGEMENT_TICKET_LINKED = "Change Management Ticket Linked"
|
|
|
|
|
|
class ReleaseTrain(Enum):
|
|
"""Valid release train options for the Jira 'Release Train' single-select field."""
|
|
ALPHA_TRAIN = "Alpha Train"
|
|
BETA_TRAIN = "Beta Train"
|
|
GAMMA_TRAIN = "Gamma Train"
|
|
STABLE_TRAIN = "Stable Train"
|
|
|
|
def __str__(self):
|
|
return self.value
|
|
|
|
|
|
class FieldType(Enum):
|
|
"""
|
|
Enumeration of supported Jira field types used by UpdateFields.
|
|
|
|
Each value represents how data for that field type should be structured
|
|
when sent to the Jira API. The value is paired with a formatter function
|
|
in FIELD_FORMATTERS to handle the conversion.
|
|
|
|
Members:
|
|
TEXT: A simple text or raw value (string, number, etc.) sent as-is.
|
|
SINGLE_SELECT: A single-choice select list, formatted as {"value": "..."}.
|
|
MULTI_SELECT: A multi-choice select list, formatted as [{"value": "..."}, ...].
|
|
USER: A user picker field, typically formatted as {"name": "..."} on Server/DC,
|
|
or {"accountId": "..."} on Jira Cloud.
|
|
GROUP: A group picker field, formatted as {"name": "..."}.
|
|
DATE: A date field, expected in ISO-8601 string format (e.g., "2025-07-26").
|
|
LABELS: A label field, sent as a list of strings.
|
|
"""
|
|
TEXT = "text"
|
|
SINGLE_SELECT = "single_select"
|
|
MULTI_SELECT = "multi_select"
|
|
USER = "user"
|
|
GROUP = "group"
|
|
DATE = "date"
|
|
LABELS = "labels"
|
|
|
|
def single_select_formatter(value: Any):
|
|
if isinstance(value, Enum):
|
|
return {"value": value.value}
|
|
return {"value": value}
|
|
|
|
def multi_select_formatter(values: Any):
|
|
if all(isinstance(v, Enum) for v in values):
|
|
return [{"value": v.value} for v in values]
|
|
return [{"value": value} for value in (values if isinstance(values, list) else [values])]
|
|
|
|
def user_formatter(value: Any):
|
|
return {"name": value}
|
|
|
|
def labels_formatter(value: Any):
|
|
return value if isinstance(value, list) else [value]
|
|
|
|
|
|
FIELD_FORMATTERS = {
|
|
FieldType.TEXT: lambda v: v,
|
|
FieldType.SINGLE_SELECT: single_select_formatter,
|
|
FieldType.MULTI_SELECT: multi_select_formatter,
|
|
FieldType.USER: user_formatter,
|
|
FieldType.GROUP: user_formatter, # same shape as USER on Server/DC
|
|
FieldType.DATE: lambda v: v, # could later enforce ISO 8601
|
|
FieldType.LABELS: labels_formatter,
|
|
}
|
|
|
|
|
|
def get_single_select_formatter(value: CustomFieldOption) -> str:
|
|
if not isinstance(value, CustomFieldOption):
|
|
raise ValueError(f"Expected CustomFieldOption, got {type(value)}")
|
|
return value.value
|
|
|
|
|
|
def get_multi_select_formatter(values: list[CustomFieldOption]) -> list[str]:
|
|
if not isinstance(values, list):
|
|
raise ValueError(f"Expected list of CustomFieldOption, got {type(values)}")
|
|
if not all(isinstance(v, CustomFieldOption) for v in values):
|
|
raise ValueError("All items in the list must be CustomFieldOption instances")
|
|
return [get_single_select_formatter(v) for v in values]
|
|
|
|
|
|
def get_user_formatter(user: User) -> dict:
|
|
if not isinstance(user, User):
|
|
raise ValueError(f"Expected User, got {type(user)}")
|
|
return user.name
|
|
|
|
|
|
GET_FIELD_FORMATTERS = {
|
|
FieldType.TEXT: lambda v: v,
|
|
FieldType.SINGLE_SELECT: get_single_select_formatter,
|
|
FieldType.MULTI_SELECT: get_multi_select_formatter,
|
|
FieldType.USER: get_user_formatter,
|
|
FieldType.LABELS: lambda v: v,
|
|
}
|
|
|
|
|
|
class JiraFields(Enum):
|
|
"""
|
|
Enumeration of high-level Jira field names used in the CLI.
|
|
|
|
This enum provides developer-friendly identifiers for Jira fields,
|
|
hiding Jira's internal custom field IDs (e.g. "customfield_10302").
|
|
|
|
Members:
|
|
REPORTER: The user who created or is assigned responsibility for the issue.
|
|
RELEASE_TRAIN: A single-select custom field for specifying the release train.
|
|
DEPLOYMENT_REQUIREMENTS: A multi-select custom field for deployment prerequisites.
|
|
|
|
Notes:
|
|
JiraFields values are mapped to Jira API field IDs and types
|
|
through the FIELD_REGISTRY, which is the single source of truth
|
|
for how each field is formatted and sent to Jira.
|
|
"""
|
|
REPORTER = "reporter"
|
|
RELEASE_TRAIN = "release_train"
|
|
DEPLOYMENT_REQUIREMENTS = "deployment_requirements"
|
|
|
|
def __str__(self):
|
|
return self.value
|
|
|
|
def __eq__(self, other):
|
|
if isinstance(other, str):
|
|
return self.value == other
|
|
return super().__eq__(other)
|
|
|
|
def __hash__(self):
|
|
return hash((self.name, self.value))
|
|
|
|
|
|
@dataclass
|
|
class JiraFieldInfo:
|
|
"""
|
|
Metadata container for a Jira field.
|
|
|
|
Each JiraFieldInfo instance holds the information needed to
|
|
correctly serialize a Jira field for API updates.
|
|
|
|
Attributes:
|
|
field_id (str): The Jira API identifier for the field
|
|
(e.g. "customfield_10302" or "reporter").
|
|
field_type (FieldType): The type of field, which determines
|
|
how its value should be formatted.
|
|
|
|
Properties:
|
|
formatter (Callable[[Any], Any]): Returns the correct formatter
|
|
function for the field_type, so values are automatically
|
|
wrapped or structured as required by the Jira API.
|
|
|
|
Usage:
|
|
>>> FIELD_REGISTRY[JiraFields.RELEASE_TRAIN].formatter("Beta Train")
|
|
{"value": "Beta Train"}
|
|
"""
|
|
field_id: str
|
|
field_type: FieldType
|
|
|
|
@property
|
|
def formatter(self) -> Callable[[Any], Any]:
|
|
return FIELD_FORMATTERS[self.field_type]
|
|
|
|
@property
|
|
def get_field_formatter(self) -> Callable[[Any], Any]:
|
|
return GET_FIELD_FORMATTERS[self.field_type]
|
|
|
|
|
|
FIELD_REGISTRY = {
|
|
JiraFields.RELEASE_TRAIN: JiraFieldInfo(
|
|
field_id="customfield_10302",
|
|
field_type=FieldType.SINGLE_SELECT,
|
|
),
|
|
JiraFields.DEPLOYMENT_REQUIREMENTS: JiraFieldInfo(
|
|
field_id="customfield_10300",
|
|
field_type=FieldType.MULTI_SELECT,
|
|
),
|
|
JiraFields.REPORTER: JiraFieldInfo(
|
|
field_id="reporter",
|
|
field_type=FieldType.USER,
|
|
),
|
|
}
|
|
|
|
|
|
def get_field(issue: Issue, name: JiraFields) -> JiraFieldInfo:
|
|
"""
|
|
Retrieve a field from a Jira issue and format it according to FieldType.
|
|
|
|
Args:
|
|
issue (Issue): Jira issue instance (from jira-python).
|
|
name (JiraFields): Logical Jira field enum member.
|
|
|
|
Returns:
|
|
Any: The formatted field data (e.g. str, list, dict) appropriate to the type.
|
|
|
|
Raises:
|
|
ValueError: If the field name is not a valid JiraFields enum member
|
|
AttributeError: If the issue does not have the field
|
|
"""
|
|
if not isinstance(name, JiraFields):
|
|
raise ValueError(f"Expected JiraFields enum, got {type(name)}")
|
|
try:
|
|
info = FIELD_REGISTRY[name]
|
|
except KeyError:
|
|
raise ValueError(f"Field {name} is not registered in FIELD_REGISTRY")
|
|
try:
|
|
data = getattr(issue.fields, info.field_id)
|
|
except AttributeError:
|
|
raise ValueError(f"Issue does not have field {name} ({FIELD_REGISTRY[name].field_id})")
|
|
return info.get_field_formatter(data)
|
|
|
|
|
|
class UpdateFields:
|
|
"""
|
|
Builder class for constructing Jira issue field update payloads.
|
|
|
|
This class uses FIELD_REGISTRY to look up how each JiraFields
|
|
enum member should be formatted and which Jira API field ID
|
|
it maps to. The resulting dictionary can be passed directly
|
|
to Jira's issue.update() method.
|
|
|
|
Attributes:
|
|
fields (dict[str, Any]): A dictionary mapping Jira API field IDs
|
|
to their correctly formatted values.
|
|
|
|
Methods:
|
|
add_field(name: JiraFields, value: Any):
|
|
Adds a field to the update payload. Automatically applies
|
|
the correct formatter based on the field's FieldType.
|
|
|
|
as_dict() -> dict:
|
|
Returns the completed payload as a dictionary suitable
|
|
for passing to jira.issue.update(fields=...).
|
|
|
|
Example:
|
|
>>> update_fields = UpdateFields()
|
|
>>> update_fields.add_field(JiraFields.RELEASE_TRAIN, "Beta Train")
|
|
>>> update_fields.add_field(JiraFields.REPORTER, "jdoe")
|
|
>>> jira.issue("AETHER-1").update(fields=update_fields.as_dict())
|
|
"""
|
|
def __init__(self):
|
|
self.fields = {}
|
|
|
|
def add_field(self, name: JiraFields, value: Any):
|
|
if not isinstance(name, JiraFields):
|
|
raise ValueError(f"Expected JiraFields enum, got {type(name)}")
|
|
info: JiraFieldInfo = FIELD_REGISTRY[name]
|
|
self.fields[info.field_id] = info.formatter(value)
|
|
|
|
def as_dict(self):
|
|
return self.fields
|