Skip to content
6 changes: 3 additions & 3 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
75 changes: 67 additions & 8 deletions internal/integrations/slack/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -867,37 +867,64 @@ 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))
log.Printf("list-missing load error: %v", err)
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
}
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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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}
Expand Down
15 changes: 15 additions & 0 deletions internal/integrations/slack/slack_logic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
90 changes: 80 additions & 10 deletions internal/report/report_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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)
}
}
}
Expand All @@ -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))
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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 == "" {
Expand Down Expand Up @@ -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":
Expand Down
Loading