diff --git a/cli/fields.py b/cli/fields.py new file mode 100644 index 0000000..1e2fd0b --- /dev/null +++ b/cli/fields.py @@ -0,0 +1,212 @@ +""" +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 + + +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): + return {"value": value} + +def multi_select_formatter(values: Any): + 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, +} + + +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] + + +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, + ), +} + + +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