Coverage for src/pullapprove/forms.py: 62%
82 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-12-15 16:55 -0600
« prev ^ index » next coverage.py v7.8.2, created at 2025-12-15 16:55 -0600
1"""
2Configuration models for review forms.
4Follows GitHub's form schema structure:
5https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-githubs-form-schema
6"""
8from __future__ import annotations
10import hashlib
11import json
12from enum import Enum
13from typing import Any, Literal
15from pydantic import (
16 BaseModel,
17 ConfigDict,
18 Field,
19 field_validator,
20)
23class FormFieldType(str, Enum):
24 CHECKBOXES = "checkboxes"
25 DROPDOWN = "dropdown"
26 INPUT = "input"
27 TEXTAREA = "textarea"
30class CheckboxOption(BaseModel):
31 """A single checkbox option within a checkboxes field."""
33 model_config = ConfigDict(extra="forbid")
35 label: str = Field(min_length=1)
36 required: bool = False
37 value: bool = False # Whether this checkbox is checked
40class FormFieldBase(BaseModel):
41 """Base class for form fields."""
43 model_config = ConfigDict(extra="forbid")
45 type: FormFieldType
46 label: str = ""
47 description: str = ""
50class CheckboxesField(FormFieldBase):
51 """Multiple attestation checkboxes."""
53 type: Literal["checkboxes"]
54 label: str = Field(min_length=1)
55 options: list[CheckboxOption] = Field(min_length=1)
58class DropdownField(FormFieldBase):
59 """Single-select dropdown."""
61 type: Literal["dropdown"]
62 label: str = Field(min_length=1)
63 options: list[str] = Field(min_length=1)
64 required: bool = False
65 value: str | None = None
68class InputField(FormFieldBase):
69 """Single-line text input."""
71 type: Literal["input"]
72 label: str = Field(min_length=1)
73 placeholder: str = ""
74 required: bool = False
75 value: str = ""
78class TextareaField(FormFieldBase):
79 """Multi-line text input."""
81 type: Literal["textarea"]
82 label: str = Field(min_length=1)
83 placeholder: str = ""
84 required: bool = False
85 value: str = ""
88# Union type for all field types
89FormField = CheckboxesField | DropdownField | InputField | TextareaField
92class ScopeFormModel(BaseModel):
93 """Configuration for review forms."""
95 model_config = ConfigDict(extra="forbid")
97 fields: list[FormField] = Field(min_length=1)
99 @field_validator("fields", mode="before")
100 @classmethod
101 def parse_fields(cls, fields: list[dict[str, Any] | FormField]) -> list[FormField]:
102 """Parse field dicts into appropriate field types based on 'type' key."""
103 parsed = []
104 for field_data in fields:
105 # Already a FormField instance, pass through
106 if isinstance(
107 field_data, CheckboxesField | DropdownField | InputField | TextareaField
108 ):
109 parsed.append(field_data)
110 continue
112 field_type = field_data.get("type")
113 match field_type:
114 case "checkboxes":
115 parsed.append(CheckboxesField(**field_data))
116 case "dropdown":
117 parsed.append(DropdownField(**field_data))
118 case "input":
119 parsed.append(InputField(**field_data))
120 case "textarea":
121 parsed.append(TextareaField(**field_data))
122 case _:
123 raise ValueError(f"Unknown form field type: {field_type}")
124 return parsed
126 def compute_hash(self) -> str:
127 """Compute deterministic hash of form definition.
129 Only includes semantic attributes that define the "form contract":
130 - type, label, required, options
131 Excludes presentational attributes:
132 - description, placeholder, value
133 """
134 serialized: list[dict[str, Any]] = []
135 for f in self.fields:
136 field_dict = f.model_dump() if not isinstance(f, dict) else f
137 # Extract only semantic attributes
138 semantic: dict[str, Any] = {
139 "type": field_dict["type"],
140 "label": field_dict["label"],
141 }
142 if "required" in field_dict:
143 semantic["required"] = field_dict["required"]
144 if "options" in field_dict:
145 # For checkboxes, extract label and required from each option
146 if field_dict["type"] == "checkboxes":
147 semantic["options"] = [
148 {"label": opt["label"], "required": opt.get("required", False)}
149 for opt in field_dict["options"]
150 ]
151 else:
152 # For dropdown, options is just a list of strings
153 semantic["options"] = field_dict["options"]
154 serialized.append(semantic)
155 canonical = json.dumps(serialized, sort_keys=True)
156 return hashlib.sha256(canonical.encode()).hexdigest()