package roadmapsync import ( "bufio" "errors" "strings" "github.com/nomadcode/nomadcode-core/internal/workitem" ) // ErrNoProviderIdentity is returned when a Milestone document carries no // provider identity block. A Milestone without provider + work item id cannot // be matched to a Plane ticket, so this is an explicit miss rather than a // silently empty success. var ErrNoProviderIdentity = errors.New("milestone has no provider identity") // identityFieldKeys maps the bullet/field labels accepted inside a provider // identity block to the Identity field they populate. Keys are matched // case-insensitively after trimming, with both spaced and snake_case spellings // accepted so the authoring agent's Markdown does not have to match one exact // form. var identityFieldKeys = map[string]string{ "provider": "provider", "tenant": "tenant", "project": "project", "work item id": "work_item_id", "work_item_id": "work_item_id", "parent work item id": "parent_work_item_id", "parent_work_item_id": "parent_work_item_id", "roadmap item id": "roadmap_item_id", "roadmap_item_id": "roadmap_item_id", "milestone id": "roadmap_item_id", } // identityHeading is the section/bullet label that opens a provider identity // block. Matched case-insensitively. const identityHeading = "provider identity" // ParseMilestoneIdentity reads a strict provider identity block out of a // Milestone Markdown document and returns a normalized Identity. milestonePath // is the repository-relative path of the Milestone file; it becomes the // identity's RoadmapMilestonePath since the path is not duplicated inside the // block. ad hoc string scanning is confined to this function; the external // result is a typed Identity only. // // The block is recognized either as a heading ("## Provider identity") followed // by `- field: value` bullets, or as a single "Provider identity" bullet whose // nested bullets carry the fields. Parsing stops at the next heading or the end // of the indented bullet group. func ParseMilestoneIdentity(milestonePath, markdown string) (Identity, error) { path := strings.TrimSpace(milestonePath) if path == "" { return Identity{}, ErrInvalidIdentity } fields, found := scanIdentityFields(markdown) if !found || len(fields) == 0 { return Identity{}, ErrNoProviderIdentity } identity := Identity{ Shape: ShapeMilestone, RoadmapMilestonePath: path, RoadmapItemID: fields["roadmap_item_id"], Provider: workitem.ProviderID(fields["provider"]), Tenant: fields["tenant"], Project: fields["project"], WorkItemID: fields["work_item_id"], ParentWorkItemID: fields["parent_work_item_id"], } if identity.ParentWorkItemID != "" { identity.Shape = ShapeTask } normalized, err := identity.Normalize() if err != nil { // A present-but-incomplete identity block is a provider identity miss, // not a generic invalid-identity, so callers can distinguish "no block" // from "block missing required fields" via the same not-ready signal. return Identity{}, ErrNoProviderIdentity } return normalized, nil } // blockKind distinguishes how the open provider identity block was introduced, // because the two forms have different boundaries. type blockKind int const ( blockNone blockKind = iota blockHeading // "## Provider identity" heading; fields run until the next heading. blockBullet // "- Provider identity:" bullet; fields are only deeper-indented child bullets. ) // scanIdentityFields walks the document looking for the provider identity block // and collects its field/value pairs keyed by the normalized Identity field // name. The second return reports whether the block heading/bullet was found at // all, so a present-but-empty block is distinguishable from an absent one. // // Heading blocks ("## Provider identity") accept field lines until the next // heading. Bullet blocks ("- Provider identity:") only accept child bullets // indented deeper than the parent bullet; a blank line, an outdent, a same- or // shallower-indent sibling, or a heading closes the bullet block so a later // unrelated `provider:`/`work item id:` bullet is never absorbed. func scanIdentityFields(markdown string) (map[string]string, bool) { fields := map[string]string{} scanner := bufio.NewScanner(strings.NewReader(markdown)) kind := blockNone found := false parentIndent := 0 for scanner.Scan() { raw := scanner.Text() line := strings.TrimSpace(raw) if line == "" { // A blank line ends a bullet block; heading blocks tolerate blanks. if kind == blockBullet { kind = blockNone } continue } if isMarkdownHeading(line) { if headingMatchesIdentity(line) { kind = blockHeading found = true continue } // Any other heading closes an open block. if kind != blockNone { kind = blockNone } continue } indent := leadingIndent(raw) key, value, ok := parseFieldLine(line) if !ok { // A non field-shaped line breaks an open bullet block. if kind == blockBullet { kind = blockNone } continue } // A standalone "Provider identity" bullet opens a bullet block; only its // deeper-indented child bullets count as fields. if kind == blockNone && strings.EqualFold(key, identityHeading) { kind = blockBullet found = true parentIndent = indent continue } switch kind { case blockBullet: if indent <= parentIndent { // Sibling or outdent: the bullet block is over. The current line // is not a child field; do not absorb it. kind = blockNone continue } case blockHeading: // Heading blocks accept any field line until the next heading. default: continue } if field, known := identityFieldKeys[strings.ToLower(key)]; known && value != "" { fields[field] = value } } return fields, found } // leadingIndent counts the leading whitespace columns of a raw line, treating a // tab as one column. Only relative depth between the parent bullet and its // children matters, so an exact column model is unnecessary. func leadingIndent(raw string) int { return len(raw) - len(strings.TrimLeft(raw, " \t")) } func isMarkdownHeading(line string) bool { return strings.HasPrefix(line, "#") } func headingMatchesIdentity(line string) bool { text := strings.TrimSpace(strings.TrimLeft(line, "#")) return strings.EqualFold(text, identityHeading) } // parseFieldLine extracts a `key: value` pair from a bullet or plain field // line, stripping a leading list marker. It reports ok=false for lines that are // not key/value shaped. func parseFieldLine(line string) (key, value string, ok bool) { trimmed := strings.TrimSpace(strings.TrimLeft(line, "-*")) idx := strings.Index(trimmed, ":") if idx < 0 { return "", "", false } key = strings.TrimSpace(trimmed[:idx]) value = strings.TrimSpace(trimmed[idx+1:]) value = strings.Trim(value, "`") value = strings.TrimSpace(value) if key == "" { return "", "", false } return key, value, true }