nomadcode/services/core/internal/roadmapsync/milestone_parser.go
toki dde07f9dea feat(core): add roadmapsync package for milestone work item creation
- Add develop_match.go/handlers for matching Plane tasks to local develop areas
- Add identity.go for external ID mapping and lookup
- Add milestone_parser.go for parsing agent-roadmap milestone files
- Include unit tests for all new modules
- Archive completed subtask documents for 03+02_develop_match
2026-06-13 22:09:45 +09:00

210 lines
7 KiB
Go

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
}