1
2
3
4
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
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
118
119
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
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
//! Pull the two effort-only fields out of a `work_items.raw_json` payload
//! (issue #3915): story points, and the parent key that makes a ticket a
//! child of some epic.
//!
//! Why: [`crate::core::pm_work::extract`] already recovers reporter,
//! description and creation date, and the effort scorer reuses it verbatim.
//! What it does not recover is story points — the one field issue #3915
//! calls out as structurally unreliable — or the parent link that lets the
//! loader count an epic's children. Both are effort-tier concerns, so they
//! live here rather than widening the work-tier extractor.
//!
//! What: [`extract_fields`] is a best-effort parse. A missing or
//! unparseable field yields `None` rather than failing the row.
//!
//! # Story points across four custom-field IDs
//!
//! `JiraClient::get_story_point_field` (`collect/jira/client.rs`) discovers
//! the field at COLLECT time by asking `/rest/api/3/field` for a descriptor
//! whose NAME is "Story Points" or "Story point estimate", then caches the
//! single ID it found. That discovery is unavailable here: the backfill
//! reads payloads already on disk, with no descriptor list and no HTTP.
//!
//! This module reuses the same discovery SHAPE offline — match by field
//! name first, fall back to a known-ID list — because one global lookup is
//! exactly what migration v23 warns is insufficient. The source instance
//! spells the field as four different per-project custom-field IDs
//! (`customfield_10004` / `10016` / `13001` / `13737`), so a payload
//! collected from one project carries a key a payload from another never
//! will. [`super::thresholds::STORY_POINT_FIELD_NAMES`] and
//! [`super::thresholds::STORY_POINT_FIELD_IDS`] are the v1 lists, tried in
//! that order.
//!
//! Test: `tests` in `tests.rs`.
use Value;
use ;
/// The effort-only fields recovered from one `raw_json` payload.
/// Parse `raw_json` into the fields the effort scorer reads.
///
/// Why: see the module docs.
/// What: parses once and reads both fields off the same tree, so the
/// backfill pays one `serde_json` parse per ticket for the effort tier.
/// An unparseable payload yields [`EffortFields::default`].
/// Test: `extracts_story_points_from_a_named_field`,
/// `extracts_story_points_from_each_known_custom_field_id`,
/// `implausible_story_points_are_treated_as_absent`,
/// `extracts_a_jira_parent_key`, `unparseable_payload_yields_no_fields`.
/// Story points, by field name first and known custom-field ID second.
///
/// The name pass is what keeps this working on an instance whose field ID
/// is not in the v1 list: JIRA payloads collected with `*all` carry the
/// custom-field keys, but Linear and Azure DevOps payloads name the field
/// outright. The ID pass is what keeps it working on the four spellings
/// issue #3915 measured.
/// Lowercase `key` and drop every non-alphanumeric character, so
/// `"Story Points"`, `"story_points"` and `"storyPoints"` all compare equal.
/// A number from either a JSON number or a numeric string.
///
/// Some JIRA custom fields serialize the estimate as a string; a value that
/// is neither is not an error, just not a story point.
/// Parent ticket key across the four providers.
///
/// JIRA nests it at `fields.parent.key`; Azure DevOps flattens the parent
/// work-item id into `fields["System.Parent"]`; Linear and GitHub expose a
/// top-level `parent` object. Migration v23 records that decomposition on
/// the source instance runs through `parent_key`, never `epic_key`, which is
/// 100% NULL there — so this is the only link that yields a child count.
/// A ticket key from either a bare scalar or a provider's parent object.
/// `Some(trimmed)` when `s` has non-whitespace content.