diff --git a/config.yaml b/config.yaml index 820bdde..6afd326 100644 --- a/config.yaml +++ b/config.yaml @@ -23,10 +23,10 @@ github_repos: [] # optional: limit to specific repos, e.g. ["org/repo1", "org/r # supported values: anthropic, openai llm_provider: "anthropic" llm_model: "" # optional; uses provider default when empty -llm_batch_size: 50 +llm_batch_size: 20 llm_confidence_threshold: 0.70 -llm_example_count: 20 -llm_example_max_chars: 140 +llm_example_count: 8 +llm_example_max_chars: 100 # Optional glossary memory file for phrase/status hints llm_glossary_path: "./llm_glossary.yaml" diff --git a/internal/integrations/slack/slack.go b/internal/integrations/slack/slack.go index 5d878d1..398ec64 100644 --- a/internal/integrations/slack/slack.go +++ b/internal/integrations/slack/slack.go @@ -867,6 +867,14 @@ func handleListMissing(api *slack.Client, db *sql.DB, cfg Config, cmd slack.Slas } monday, nextMonday := ReportWeekRange(cfg, time.Now().In(cfg.Location)) + weekItems, err := GetItemsByDateRange(db, monday, nextMonday) + if err != nil { + postEphemeral(api, cmd, fmt.Sprintf("Error loading items: %v", err)) + log.Printf("list-missing load error: %v", err) + return + } + reportedAuthors := uniqueReportedAuthors(weekItems) + reportedAuthorIDs, err := GetSlackAuthorIDsByDateRange(db, monday, nextMonday) if err != nil { postEphemeral(api, cmd, fmt.Sprintf("Error loading items: %v", err)) @@ -874,6 +882,17 @@ func handleListMissing(api *slack.Client, db *sql.DB, cfg Config, cmd slack.Slas return } + // Build an ID→User map from the cached users list to avoid N individual + // GetUserInfo calls (one per team member) inside the loop below. + cachedUsers, err := getCachedUsers(api) + if err != nil { + log.Printf("list-missing: getCachedUsers error: %v", err) + } + userByID := make(map[string]slack.User, len(cachedUsers)) + for _, u := range cachedUsers { + userByID[u.ID] = u + } + type missingMember struct { display string userID string @@ -881,23 +900,31 @@ func handleListMissing(api *slack.Client, db *sql.DB, cfg Config, cmd slack.Slas var missing []missingMember var missingIDs []string for _, uid := range memberIDs { - if reportedAuthorIDs[uid] { + u, found := userByID[uid] + nameCandidates := []string{uid} + if found { + if u.Profile.DisplayName != "" { + nameCandidates = append(nameCandidates, u.Profile.DisplayName) + } + if u.RealName != "" { + nameCandidates = append(nameCandidates, u.RealName) + } + } + if memberReportedThisWeek(uid, nameCandidates, reportedAuthorIDs, reportedAuthors) { continue } - - user, err := api.GetUserInfo(uid) - if err != nil { + if !found { missing = append(missing, missingMember{display: uid, userID: uid}) missingIDs = append(missingIDs, uid) continue } - display := user.Profile.DisplayName + display := u.Profile.DisplayName if display == "" { - display = user.RealName + display = u.RealName } if display == "" { - display = user.Name + display = u.Name } if display == "" { display = uid @@ -963,6 +990,38 @@ func handleListMissing(api *slack.Client, db *sql.DB, cfg Config, cmd slack.Slas log.Printf("list-missing count=%d", len(missing)+len(unresolved)) } +func uniqueReportedAuthors(items []WorkItem) []string { + seen := make(map[string]bool) + var out []string + for _, item := range items { + author := strings.TrimSpace(item.Author) + if author == "" || seen[author] { + continue + } + seen[author] = true + out = append(out, author) + } + return out +} + +func memberReportedThisWeek(userID string, nameCandidates []string, reportedAuthorIDs map[string]bool, reportedAuthors []string) bool { + if reportedAuthorIDs[userID] { + return true + } + for _, candidate := range nameCandidates { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + continue + } + for _, author := range reportedAuthors { + if strings.EqualFold(candidate, author) || nameMatches(candidate, author) || nameMatches(author, candidate) { + return true + } + } + } + return false +} + func postEphemeral(api *slack.Client, cmd slack.SlashCommand, text string) { postEphemeralTo(api, cmd.ChannelID, cmd.UserID, text) } @@ -1291,7 +1350,7 @@ func openEditModal(api *slack.Client, db *sql.DB, cfg Config, triggerID, channel } noChangeOpt := slack.NewOptionBlockObject( noCategoryChangeValue, - slack.NewTextBlockObject(slack.PlainTextType, "(no change)", false, false), + slack.NewTextBlockObject(slack.PlainTextType, "Auto", false, false), nil, ) catOptions := []*slack.OptionBlockObject{noChangeOpt} diff --git a/internal/integrations/slack/slack_logic_test.go b/internal/integrations/slack/slack_logic_test.go index 1a6be14..f3a23b0 100644 --- a/internal/integrations/slack/slack_logic_test.go +++ b/internal/integrations/slack/slack_logic_test.go @@ -183,6 +183,21 @@ func TestFormatItemDescriptionForList(t *testing.T) { } } +func TestMemberReportedThisWeek(t *testing.T) { + reportedIDs := map[string]bool{"U123": true} + reportedAuthors := []string{"Alex Rivera", "Jordan Patel"} + + if !memberReportedThisWeek("U123", []string{"Alex Rivera"}, reportedIDs, reportedAuthors) { + t.Fatal("expected member to be reported when author_id is present") + } + if !memberReportedThisWeek("U999", []string{"Alex"}, map[string]bool{}, reportedAuthors) { + t.Fatal("expected fuzzy name match against fetched author to count as reported") + } + if memberReportedThisWeek("U999", []string{"Taylor"}, map[string]bool{}, reportedAuthors) { + t.Fatal("expected unmatched member name to be considered missing") + } +} + func TestDeriveBossReportFromTeamReport_FileExists(t *testing.T) { dir := t.TempDir() teamName := "TestTeam" diff --git a/internal/report/report_builder.go b/internal/report/report_builder.go index aa1a04e..f8543d9 100644 --- a/internal/report/report_builder.go +++ b/internal/report/report_builder.go @@ -403,6 +403,9 @@ func mergeIncomingItems( } func chooseNormalizedStatus(incomingStatus, llmStatus string, useLLM bool) string { + if isFreeTextStatus(incomingStatus) { + return strings.TrimSpace(incomingStatus) + } if useLLM { switch normalizeStatus(llmStatus) { case "done", "in testing", "in progress": @@ -513,7 +516,12 @@ func mergeExistingItem(existing, incoming TemplateItem) TemplateItem { func reorderTemplateItems(t *ReportTemplate) { for ci := range t.Categories { for si := range t.Categories[ci].Subsections { - t.Categories[ci].Subsections[si].Items = reorderItems(t.Categories[ci].Subsections[si].Items) + sub := &t.Categories[ci].Subsections[si] + if isSupportCasesSubsection(*sub) { + sub.Items = reorderSupportCasesItems(sub.Items) + continue + } + sub.Items = reorderItems(sub.Items) } } } @@ -537,6 +545,37 @@ func reorderItems(items []TemplateItem) []TemplateItem { return sorted } +func isSupportCasesSubsection(sub TemplateSubsection) bool { + if strings.EqualFold(strings.TrimSpace(sub.Name), "Support Cases") { + return true + } + header := strings.ToLower(strings.TrimSpace(sub.HeaderLine)) + return strings.Contains(header, "support cases") +} + +func reorderSupportCasesItems(items []TemplateItem) []TemplateItem { + existing := make([]TemplateItem, 0, len(items)) + newlyAdded := make([]TemplateItem, 0, len(items)) + for _, item := range items { + if item.IsNew { + newlyAdded = append(newlyAdded, item) + continue + } + existing = append(existing, item) + } + + existing = reorderItems(existing) + sort.SliceStable(newlyAdded, func(i, j int) bool { + zi, zj := newlyAdded[i].ReportedAt.IsZero(), newlyAdded[j].ReportedAt.IsZero() + if zi != zj { + return zi + } + return newlyAdded[i].ReportedAt.Before(newlyAdded[j].ReportedAt) + }) + + return append(existing, newlyAdded...) +} + func itemIdentityKey(item TemplateItem) string { return strings.ToLower(strings.TrimSpace(item.Description)) } @@ -627,10 +666,7 @@ func categoryAuthors(cat TemplateCategory) []string { } func formatTeamItem(item TemplateItem) string { - status := normalizeStatus(item.Status) - if status == "" { - status = "done" - } + status := statusForDisplay(item.Status) author := synthesizeName(item.Author) tickets := canonicalTicketIDs(item.TicketIDs) description := stripLeadingTicketPrefixIfSame(item.Description, tickets) @@ -646,10 +682,7 @@ func formatTeamItem(item TemplateItem) string { } func formatBossItem(item TemplateItem) string { - status := normalizeStatus(item.Status) - if status == "" { - status = "done" - } + status := statusForDisplay(item.Status) tickets := canonicalTicketIDs(item.TicketIDs) description := stripLeadingTicketPrefixIfSame(item.Description, tickets) description = synthesizeDescription(description) @@ -666,16 +699,33 @@ func canonicalTicketIDs(ticketIDs string) string { } parts := strings.Split(ticketIDs, ",") cleaned := make([]string, 0, len(parts)) + seen := make(map[string]bool, len(parts)) for _, p := range parts { - p = strings.TrimSpace(p) + p = normalizeTicketTokenForOutput(p) if p == "" { continue } + key := strings.ToLower(p) + if seen[key] { + continue + } + seen[key] = true cleaned = append(cleaned, p) } return strings.Join(cleaned, ",") } +func normalizeTicketTokenForOutput(token string) string { + t := strings.TrimSpace(token) + if t == "" { + return "" + } + t = strings.Trim(t, "[]") + t = strings.TrimSpace(t) + t = strings.TrimLeft(t, "#") + return strings.TrimSpace(t) +} + func stripLeadingTicketPrefixIfSame(description, tickets string) string { description = strings.TrimSpace(description) if description == "" || tickets == "" { @@ -734,6 +784,26 @@ func normalizeStatus(status string) string { } } +func statusForDisplay(status string) string { + trimmed := strings.TrimSpace(status) + if trimmed == "" { + return "done" + } + if isFreeTextStatus(trimmed) { + return trimmed + } + return normalizeStatus(trimmed) +} + +func isFreeTextStatus(status string) bool { + switch strings.ToLower(strings.TrimSpace(status)) { + case "done", "in testing", "in test", "in progress": + return false + default: + return strings.TrimSpace(status) != "" + } +} + func statusBucket(status string) int { switch normalizeStatus(status) { case "done": diff --git a/internal/report/report_builder_test.go b/internal/report/report_builder_test.go index eebedca..ed307bc 100644 --- a/internal/report/report_builder_test.go +++ b/internal/report/report_builder_test.go @@ -231,6 +231,54 @@ func TestBuildReportsFromLast_LLMConfidenceAndDuplicate(t *testing.T) { } } +func TestBuildReportsFromLast_PreservesFreeTextStatus(t *testing.T) { + dir := t.TempDir() + prev := `### TEAMX 20260202 + +#### Top Focus + +- **Feature A** + - **Pat One** - Existing ongoing item (in progress) +` + if err := os.WriteFile(filepath.Join(dir, "TEAMX_20260202.md"), []byte(prev), 0644); err != nil { + t.Fatalf("write previous report: %v", err) + } + + cfg := Config{ + ReportOutputDir: dir, + TeamName: "TEAMX", + } + + orig := classifySectionsFn + classifySectionsFn = func(_ Config, items []WorkItem, _ []sectionOption, _ []existingItemContext, _ []ClassificationCorrection, _ []historicalItem) (map[int64]LLMSectionDecision, LLMUsage, error) { + out := make(map[int64]LLMSectionDecision, len(items)) + for _, item := range items { + out[item.ID] = LLMSectionDecision{ + SectionID: "S0_0", + NormalizedStatus: "in progress", + Confidence: 0.95, + } + } + return out, LLMUsage{}, nil + } + defer func() { classifySectionsFn = orig }() + + freeTextStatus := "resolved in session; root cause analysis in progress" + items := []WorkItem{ + {ID: 41, Author: "Pat Two", Description: "Investigate customer database startup issue", Status: freeTextStatus}, + } + + result, err := BuildReportsFromLast(cfg, items, mustDate(t, "20260209"), nil, nil) + if err != nil { + t.Fatalf("BuildReportsFromLast failed: %v", err) + } + + team := renderTeamMarkdown(result.Template) + if !strings.Contains(team, "Investigate customer database startup issue ("+freeTextStatus+")") { + t.Fatalf("free-text status should be preserved in team report:\n%s", team) + } +} + func TestBuildReportsFromLast_PreservesPrefixBlocks(t *testing.T) { dir := t.TempDir() prev := `### Product Alpha - 20260130 @@ -344,6 +392,18 @@ func TestFormatItemDedupesLeadingTicketPrefix(t *testing.T) { if gotPlain != wantPlain { t.Fatalf("unexpected deduped plain-prefix item:\nwant: %s\ngot: %s", wantPlain, gotPlain) } + + bracketedTicketIDs := TemplateItem{ + Author: "Howard Shen", + Description: "[1201950] make up siem MVs prepare test document and assist QA on testing", + TicketIDs: "[1201950]", + Status: "in progress", + } + gotBracketed := formatTeamItem(bracketedTicketIDs) + wantBracketed := "**Howard Shen** - [1201950] Make up siem MVs prepare test document and assist QA on testing (in progress)" + if gotBracketed != wantBracketed { + t.Fatalf("unexpected bracketed ticket normalization:\nwant: %s\ngot: %s", wantBracketed, gotBracketed) + } } func TestMergeCategoryHeadingAuthors(t *testing.T) { @@ -533,6 +593,52 @@ func TestReorderItems_ComprehensiveSorting(t *testing.T) { } } +func TestReorderTemplateItems_SupportCasesKeepsNewItemsAtBottom(t *testing.T) { + template := &ReportTemplate{ + Categories: []TemplateCategory{ + { + Name: "Release and Support", + Subsections: []TemplateSubsection{ + { + Name: "Support Cases", + Items: []TemplateItem{ + { + Description: "Existing case in progress", + Status: "in progress", + IsNew: false, + }, + { + Description: "Newly reported resolved case", + Status: "resolved in session; root cause analysis in progress", + IsNew: true, + ReportedAt: time.Date(2026, 2, 21, 9, 0, 0, 0, time.UTC), + }, + { + Description: "Existing done case", + Status: "done", + IsNew: false, + }, + }, + }, + }, + }, + }, + } + + reorderTemplateItems(template) + items := template.Categories[0].Subsections[0].Items + + if items[0].Description != "Existing done case" { + t.Fatalf("expected existing done case first, got %q", items[0].Description) + } + if items[1].Description != "Existing case in progress" { + t.Fatalf("expected existing in-progress case second, got %q", items[1].Description) + } + if items[2].Description != "Newly reported resolved case" { + t.Fatalf("expected new support case at bottom, got %q", items[2].Description) + } +} + func mustDate(t *testing.T, ymd string) time.Time { t.Helper() d, err := time.Parse("20060102", ymd)