- 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
210 lines
7 KiB
Go
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
|
|
}
|