diff --git a/README.md b/README.md index fc843cb..e3c9914 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Developers report completed work via slash commands. The bot also pulls merged/o - `/report` (or `/rpt`) — Developers report work items via Slack - `/fetch` — Pull merged and open GitLab MRs and/or GitHub PRs for the current calendar week - `/generate-report` (or `/gen`) — Generate a team markdown file (or boss `.eml` draft) and upload it to Slack -- `/list` — View this week's items with inline edit/delete actions +- `/list` — View your items for this week with inline edit/delete actions (`/list all` for the team view) - `/check` — Managers: list missing members with inline nudge buttons - `/retrospect` — Managers: analyze recent corrections and suggest glossary/guide improvements - `/stats` — Managers: view classification accuracy dashboard and trends @@ -105,9 +105,9 @@ See [docs/agentic-features-overview.md](docs/agentic-features-overview.md) for a | `/report` | Report a work item | | `/rpt` | Alias of `/report` | | `/fetch` | Fetch merged and open GitLab MRs and/or GitHub PRs for this week | - | `/generate-report` | Generate the weekly report (`team`/`boss`, optional `private`) | + | `/generate-report` | Generate the weekly report (`team`/`boss`) or post latest team report (`post`), optional `private` | | `/gen` | Alias of `/generate-report` | - | `/list` | List this week's work items | + | `/list` | List your work items for this week (`/list all` for the team view) | | `/check` | List missing members with nudge buttons | | `/nudge` | Send a test nudge DM (self by default; managers can target one member) | | `/retrospect` | Analyze corrections and suggest improvements | @@ -147,6 +147,8 @@ llm_example_max_chars: 140 # optional: max chars per example snippet llm_glossary_path: "./llm_glossary.yaml" # optional glossary memory file llm_critic_enabled: false # optional: enable generator-critic second pass anthropic_api_key: "sk-ant-..." +openai_api_key: "" +openai_base_url: "https://api.openai.com/v1" # optional: OpenAI-compatible base URL (for example a lab-hosted gpt-oss endpoint) # Permissions (Slack user IDs) manager_slack_ids: @@ -190,6 +192,8 @@ export GITLAB_GROUP_ID=my-team export GITLAB_REF_TICKET_LABEL=Jira # Optional: field label used for GitLab MR ticket parsing export LLM_PROVIDER=anthropic export ANTHROPIC_API_KEY=sk-ant-... +export OPENAI_API_KEY= +export OPENAI_BASE_URL=https://api.openai.com/v1 export LLM_BATCH_SIZE=50 export LLM_CONFIDENCE_THRESHOLD=0.70 export LLM_EXAMPLE_COUNT=20 @@ -214,9 +218,11 @@ Note: Category/subcategory headings are sourced from the previous report in `rep | `openai` | `gpt-5-mini` | Set `llm_model` in YAML or `LLM_MODEL` env var to override. +When `llm_provider=openai`, section classification uses the OpenAI-compatible `responses` API with schema-constrained JSON output. Set `llm_batch_size` / `LLM_BATCH_SIZE`, `llm_confidence_threshold` / `LLM_CONFIDENCE_THRESHOLD`, and `llm_example_count` / `llm_example_max_chars` to tune throughput, confidence gating, and prompt context size. Set `llm_glossary_path` / `LLM_GLOSSARY_PATH` to apply glossary memory rules (see `llm_glossary.yaml`). Set `llm_critic_enabled` / `LLM_CRITIC_ENABLED` to enable a second LLM pass that reviews classifications for errors. +Set `openai_base_url` / `OPENAI_BASE_URL` when `llm_provider=openai` and you want to use an OpenAI-compatible endpoint instead of `api.openai.com` (for example a lab-hosted `gpt-oss-120b` server). Set `external_http_timeout_seconds` / `EXTERNAL_HTTP_TIMEOUT_SECONDS` to tune timeout limits for GitLab/GitHub/LLM API requests. Glossary example (`llm_glossary.yaml`): @@ -332,6 +338,8 @@ Manager only. Two modes: /generate-report team # Generate team markdown (.md) and upload to channel (default) /generate-report boss # Generate boss email draft (.eml) and upload to channel (default) /generate-report boss private # Send generated boss report to your DM +/generate-report post # Post latest generated team markdown report to the current channel +/generate-report post private # Post latest generated team markdown report to your DM /gen private # Generate team report and send to your DM /gen team # Alias of /generate-report team ``` @@ -359,12 +367,18 @@ Filename date suffix uses Friday of the reporting week, e.g. `TEAMX_20260220.md` ### Listing Items -Anyone can view this week's items: +By default, `/list` shows only the caller's items for the current reporting week: ``` /list ``` +Managers and members can use `/list all` to see the full team list: + +``` +/list all +``` + `/list` now includes inline actions: - Members can edit/delete only their own items. - Managers can edit/delete all items. diff --git a/config.yaml b/config.yaml index 6afd326..d043a32 100644 --- a/config.yaml +++ b/config.yaml @@ -37,6 +37,9 @@ llm_critic_enabled: false # LLM API keys (set the one that matches llm_provider) anthropic_api_key: "sk-ant-your-key" openai_api_key: "" +# Optional base URL for OpenAI-compatible endpoints. +# Leave default for api.openai.com, or point to a lab-hosted endpoint such as gpt-oss. +openai_base_url: "https://api.openai.com/v1" # Data and output paths db_path: "./reportbot.db" diff --git a/internal/app/app.go b/internal/app/app.go index 60bda79..3079833 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -17,7 +17,7 @@ func Main() { cfg := config.LoadConfig() appliedHTTPTimeout := httpx.ConfigureExternalHTTPClient(cfg.ExternalHTTPTimeoutSeconds) log.Printf( - "Config loaded. Team=%s Managers=%d TeamMembers=%d Timezone=%s LLMBatchSize=%d LLMConfidenceThreshold=%.2f LLMExampleCount=%d LLMExampleMaxChars=%d LLMGlossaryPath=%s ExternalHTTPTimeout=%s", + "Config loaded. Team=%s Managers=%d TeamMembers=%d Timezone=%s LLMBatchSize=%d LLMConfidenceThreshold=%.2f LLMExampleCount=%d LLMExampleMaxChars=%d LLMGlossaryPath=%s OpenAIBaseURL=%s ExternalHTTPTimeout=%s", cfg.TeamName, len(cfg.ManagerSlackIDs), len(cfg.TeamMembers), @@ -27,6 +27,7 @@ func Main() { cfg.LLMExampleCount, cfg.LLMExampleMaxLen, cfg.LLMGlossaryPath, + cfg.OpenAIBaseURL, appliedHTTPTimeout, ) diff --git a/internal/config/config.go b/internal/config/config.go index 9476a54..f42d2ea 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,6 +40,7 @@ type Config struct { ReportTemplatePath string `yaml:"report_template_path"` AnthropicAPIKey string `yaml:"anthropic_api_key"` OpenAIAPIKey string `yaml:"openai_api_key"` + OpenAIBaseURL string `yaml:"openai_base_url"` DBPath string `yaml:"db_path"` ReportOutputDir string `yaml:"report_output_dir"` @@ -101,6 +102,7 @@ func LoadConfig() Config { envOverride(&cfg.ReportTemplatePath, "REPORT_TEMPLATE_PATH") envOverride(&cfg.AnthropicAPIKey, "ANTHROPIC_API_KEY") envOverride(&cfg.OpenAIAPIKey, "OPENAI_API_KEY") + envOverride(&cfg.OpenAIBaseURL, "OPENAI_BASE_URL") envOverride(&cfg.DBPath, "DB_PATH") envOverride(&cfg.ReportOutputDir, "REPORT_OUTPUT_DIR") envOverride(&cfg.ReportChannelID, "REPORT_CHANNEL_ID") @@ -146,6 +148,9 @@ func LoadConfig() Config { if cfg.DBPath == "" { cfg.DBPath = "./reportbot.db" } + if cfg.OpenAIBaseURL == "" { + cfg.OpenAIBaseURL = "https://api.openai.com/v1" + } if cfg.ReportOutputDir == "" { cfg.ReportOutputDir = "./reports" } @@ -246,6 +251,9 @@ func LoadConfig() Config { if cfg.ExternalHTTPTimeoutSeconds < 5 { log.Fatalf("invalid external_http_timeout_seconds '%d': must be >= 5", cfg.ExternalHTTPTimeoutSeconds) } + if cfg.OpenAIBaseURL != "" { + cfg.OpenAIBaseURL = strings.TrimRight(cfg.OpenAIBaseURL, "/") + } if cfg.LLMGlossaryPath != "" { if err := validateGlossaryPath(cfg.LLMGlossaryPath); err != nil { log.Fatalf("invalid llm_glossary_path '%s': %v", cfg.LLMGlossaryPath, err) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e4445c5..11eb2d4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -37,6 +37,9 @@ func TestLoadConfigFromEnvWithDefaults(t *testing.T) { if cfg.DBPath != "./reportbot.db" { t.Fatalf("unexpected db path default: %q", cfg.DBPath) } + if cfg.OpenAIBaseURL != "https://api.openai.com/v1" { + t.Fatalf("unexpected OpenAI base URL default: %q", cfg.OpenAIBaseURL) + } if cfg.ReportOutputDir != "./reports" { t.Fatalf("unexpected report output dir default: %q", cfg.ReportOutputDir) } @@ -77,6 +80,7 @@ external_http_timeout_seconds: 75 t.Setenv("CONFIG_PATH", cfgPath) t.Setenv("LLM_PROVIDER", "openai") t.Setenv("OPENAI_API_KEY", "sk-env") + t.Setenv("OPENAI_BASE_URL", "https://api.fazai.fortinet.com/v1/") t.Setenv("TEAM_NAME", "Env Team") t.Setenv("DB_PATH", "/tmp/env.db") t.Setenv("EXTERNAL_HTTP_TIMEOUT_SECONDS", "120") @@ -93,6 +97,9 @@ external_http_timeout_seconds: 75 if cfg.OpenAIAPIKey != "sk-env" { t.Fatalf("expected openai key from env override") } + if cfg.OpenAIBaseURL != "https://api.fazai.fortinet.com/v1" { + t.Fatalf("expected openai base URL from env override, got %q", cfg.OpenAIBaseURL) + } if cfg.DBPath != "/tmp/env.db" { t.Fatalf("expected db path from env override, got %q", cfg.DBPath) } diff --git a/internal/integrations/llm/llm.go b/internal/integrations/llm/llm.go index d9a7472..5937c64 100644 --- a/internal/integrations/llm/llm.go +++ b/internal/integrations/llm/llm.go @@ -9,8 +9,6 @@ import ( "log" "net/http" "os" - "regexp" - "strconv" "strings" "sync" @@ -24,7 +22,6 @@ type sectionClassifiedItem struct { NormalizedStatus string `json:"normalized_status"` TicketIDs json.RawMessage `json:"ticket_ids"` DuplicateOf string `json:"duplicate_of"` - Confidence float64 `json:"confidence"` } type ExistingItemContext struct { @@ -67,8 +64,6 @@ const defaultOpenAIModel = "gpt-4o-mini" const maxTemplateGuidanceChars = 8000 const defaultLLMMaxConcurrentBatches = 4 -var confidenceFieldRe = regexp.MustCompile(`("confidence"\s*:\s*)([^,}\]]+)`) - func llmBatchConcurrencyLimit(totalBatches int) int { if totalBatches <= 0 { return 1 @@ -159,7 +154,7 @@ func CategorizeItemsToSections( model = defaultOpenAIModel } log.Printf("llm section-classify provider=openai model=%s items=%d sections=%d batch=%d", model, len(batch), len(options), idx) - responseText, usage, callErr = callOpenAI(cfg.OpenAIAPIKey, model, systemPrompt, userPrompt) + responseText, usage, callErr = callOpenAISectionStructured(cfg.OpenAIAPIKey, cfg.OpenAIBaseURL, model, systemPrompt, userPrompt, options) default: model := cfg.LLMModel if model == "" { @@ -179,7 +174,8 @@ func CategorizeItemsToSections( results[idx] = batchResult{usage: usage, err: parseErr} return } - applyGlossaryOverrides(batch, parsed, glossary, glossarySectionMap) + glossaryOverrides := applyGlossaryOverrides(batch, parsed, glossary, glossarySectionMap) + assignLocalConfidence(parsed, options, glossaryOverrides) results[idx] = batchResult{decisions: parsed, usage: usage} }(i, batch) } @@ -297,13 +293,12 @@ Choose exactly one section_id for each item from: If none fit, use section_id "UND". Also: - choose normalized_status from: done, in testing, in progress, other -- extract ticket IDs if present (e.g. [1247202] or bare ticket numbers) +- extract ticket IDs if present (e.g. [1247202] or bare ticket numbers); return them as an array of strings - if this item is the same underlying work as an existing item, set duplicate_of to that existing key (Kxx); otherwise empty string -- set confidence between 0 and 1, using digits only (example: 0.91). Never spell out numbers. %s%s Respond with JSON only (no markdown): -[{"id": 1, "section_id": "S0_2", "normalized_status": "in progress", "ticket_ids": "1247202", "duplicate_of": "K3", "confidence": 0.91}, ...]`, sectionLines.String(), templateBlock, correctionsNote) +[{"id": 1, "section_id": "S0_2", "normalized_status": "in progress", "ticket_ids": ["1247202"], "duplicate_of": "K3"}, ...]`, sectionLines.String(), templateBlock, correctionsNote) correctionsBlock := "" if len(corrections) > 0 { @@ -366,14 +361,7 @@ func parseSectionClassifiedResponse(responseText string) (map[int64]LLMSectionDe var classified []sectionClassifiedItem if err := json.Unmarshal([]byte(responseText), &classified); err != nil { - repaired := repairSectionClassifiedResponse(responseText) - if repaired == responseText { - return nil, fmt.Errorf("parsing LLM section response: %w (response: %s)", err, responseText) - } - if repairErr := json.Unmarshal([]byte(repaired), &classified); repairErr != nil { - return nil, fmt.Errorf("parsing LLM section response after repair: %w (original error: %v, response: %s)", repairErr, err, repaired) - } - log.Printf("llm section response repaired before parse") + return nil, fmt.Errorf("parsing LLM section response: %w (response: %s)", err, responseText) } decisions := make(map[int64]LLMSectionDecision) @@ -384,7 +372,6 @@ func parseSectionClassifiedResponse(responseText string) (map[int64]LLMSectionDe NormalizedStatus: normalizeStatus(strings.TrimSpace(c.NormalizedStatus)), TicketIDs: ticketIDs, DuplicateOf: strings.TrimSpace(c.DuplicateOf), - Confidence: c.Confidence, } } return decisions, nil @@ -435,92 +422,6 @@ func parseTicketIDsField(raw json.RawMessage) string { return "" } -func repairSectionClassifiedResponse(responseText string) string { - return confidenceFieldRe.ReplaceAllStringFunc(responseText, func(match string) string { - parts := confidenceFieldRe.FindStringSubmatch(match) - if len(parts) != 3 { - return match - } - return parts[1] + normalizeConfidenceLiteral(parts[2]) - }) -} - -func normalizeConfidenceLiteral(raw string) string { - text := strings.TrimSpace(strings.Trim(raw, `"`)) - if text == "" { - return "0" - } - if parsed, ok := parseLooseConfidence(text); ok { - return strconv.FormatFloat(parsed, 'f', -1, 64) - } - return "0" -} - -func parseLooseConfidence(raw string) (float64, bool) { - text := strings.TrimSpace(raw) - if text == "" { - return 0, false - } - if parsed, err := strconv.ParseFloat(text, 64); err == nil { - return clampConfidence(parsed), true - } - - compact := strings.ReplaceAll(strings.ToLower(text), " ", "") - if parsed, err := strconv.ParseFloat(compact, 64); err == nil { - return clampConfidence(parsed), true - } - - if strings.HasPrefix(compact, "0.") { - if digit, ok := digitWord(compact[2:]); ok { - return float64(digit) / 10, true - } - } - if compact == "zero" { - return 0, true - } - if compact == "one" { - return 1, true - } - return 0, false -} - -func digitWord(s string) (int, bool) { - switch s { - case "zero": - return 0, true - case "one": - return 1, true - case "two": - return 2, true - case "three": - return 3, true - case "four": - return 4, true - case "five": - return 5, true - case "six": - return 6, true - case "seven": - return 7, true - case "eight": - return 8, true - case "nine": - return 9, true - default: - return 0, false - } -} - -func clampConfidence(v float64) float64 { - if v < 0 { - return 0 - } - if v > 1 { - return 1 - } - return v -} - func loadGlossaryIfConfigured(cfg Config) (*LLMGlossary, error) { if strings.TrimSpace(cfg.LLMGlossaryPath) == "" { return nil, nil @@ -563,9 +464,10 @@ func applyGlossaryOverrides( decisions map[int64]LLMSectionDecision, glossary *LLMGlossary, glossarySectionMap map[string]string, -) { +) map[int64]bool { + overrides := map[int64]bool{} if glossary == nil { - return + return overrides } for _, item := range items { @@ -574,10 +476,10 @@ func applyGlossaryOverrides( for phrase, sectionID := range glossarySectionMap { if phrase != "" && strings.Contains(desc, phrase) { - decision.SectionID = sectionID - if decision.Confidence < 0.99 { - decision.Confidence = 0.99 + if decision.SectionID != sectionID { + overrides[item.ID] = true } + decision.SectionID = sectionID break } } @@ -592,6 +494,39 @@ func applyGlossaryOverrides( decisions[item.ID] = decision } + return overrides +} + +func assignLocalConfidence(decisions map[int64]LLMSectionDecision, options []sectionOption, glossaryOverrides map[int64]bool) { + validSections := make(map[string]bool, len(options)+1) + validSections["UND"] = true + for _, option := range options { + validSections[strings.TrimSpace(option.ID)] = true + } + for id, decision := range decisions { + decisions[id] = withDerivedConfidence(decision, validSections, glossaryOverrides[id]) + } +} + +func withDerivedConfidence(decision LLMSectionDecision, validSections map[string]bool, glossaryOverride bool) LLMSectionDecision { + sectionID := strings.TrimSpace(decision.SectionID) + if strings.EqualFold(sectionID, "UND") { + sectionID = "UND" + decision.SectionID = sectionID + } + switch { + case glossaryOverride: + decision.Confidence = 0.99 + case sectionID == "" || !validSections[sectionID]: + decision.Confidence = 0.20 + case strings.EqualFold(sectionID, "UND"): + decision.Confidence = 0.40 + case strings.TrimSpace(decision.DuplicateOf) != "": + decision.Confidence = 0.95 + default: + decision.Confidence = 0.90 + } + return decision } // --- Anthropic --- @@ -629,88 +564,191 @@ func callAnthropic(apiKey, model, systemPrompt, userPrompt string) (string, LLMU return "", usage, fmt.Errorf("no text content in Anthropic response") } -// --- OpenAI --- +// --- OpenAI / OpenAI-compatible Responses API --- -type openAIRequest struct { - Model string `json:"model"` - Messages []openAIMessage `json:"messages"` +type openAIResponsesRequest struct { + Model string `json:"model"` + Input string `json:"input"` + Temperature float64 `json:"temperature,omitempty"` + Text *openAIResponsesTextParam `json:"text,omitempty"` } -type openAIMessage struct { - Role string `json:"role"` - Content string `json:"content"` +type openAIResponsesTextParam struct { + Format openAIResponsesFormatParam `json:"format"` } -type openAIResponse struct { - Choices []struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - } `json:"choices"` +type openAIResponsesFormatParam struct { + Type string `json:"type"` + Name string `json:"name"` + Strict bool `json:"strict"` + Schema any `json:"schema"` +} + +type openAIResponsesResponse struct { + Output []struct { + Type string `json:"type"` + Role string `json:"role,omitempty"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content,omitempty"` + } `json:"output"` Usage *struct { - PromptTokens int64 `json:"prompt_tokens"` - CompletionTokens int64 `json:"completion_tokens"` - TotalTokens int64 `json:"total_tokens"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + TotalTokens int64 `json:"total_tokens"` } `json:"usage"` Error *struct { Message string `json:"message"` } `json:"error"` } -func callOpenAI(apiKey, model, systemPrompt, userPrompt string) (string, LLMUsage, error) { - reqBody := openAIRequest{ +func buildSectionJSONSchema(options []sectionOption) map[string]any { + sections := make([]string, 0, len(options)+1) + sections = append(sections, "UND") + for _, option := range options { + id := strings.TrimSpace(option.ID) + if id != "" { + sections = append(sections, id) + } + } + return map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "id": map[string]any{ + "type": "integer", + }, + "section_id": map[string]any{ + "type": "string", + "enum": sections, + }, + "normalized_status": map[string]any{ + "type": "string", + "enum": []string{"done", "in testing", "in progress", "other"}, + }, + "ticket_ids": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "string", + }, + }, + "duplicate_of": map[string]any{ + "type": "string", + }, + }, + "required": []string{"id", "section_id", "normalized_status", "ticket_ids", "duplicate_of"}, + }, + } +} + +func callOpenAISectionStructured(apiKey, baseURL, model, systemPrompt, userPrompt string, options []sectionOption) (string, LLMUsage, error) { + reqBody := openAIResponsesRequest{ Model: model, - Messages: []openAIMessage{ - {Role: "system", Content: systemPrompt}, - {Role: "user", Content: userPrompt}, + Input: buildResponsesInput(systemPrompt, userPrompt), + Text: &openAIResponsesTextParam{ + Format: openAIResponsesFormatParam{ + Type: "json_schema", + Name: "section_classification_batch", + Strict: true, + Schema: buildSectionJSONSchema(options), + }, }, } + responseText, usage, err := doOpenAIResponsesRequest(apiKey, baseURL, reqBody) + if err != nil { + return "", usage, err + } + log.Printf("llm openai responses size=%d tokens_in=%d tokens_out=%d", len(responseText), usage.InputTokens, usage.OutputTokens) + return responseText, usage, nil +} +func buildResponsesInput(systemPrompt, userPrompt string) string { + systemPrompt = strings.TrimSpace(systemPrompt) + userPrompt = strings.TrimSpace(userPrompt) + switch { + case systemPrompt == "": + return userPrompt + case userPrompt == "": + return systemPrompt + default: + return systemPrompt + "\n\n" + userPrompt + } +} + +func doOpenAIResponsesRequest(apiKey, baseURL string, reqBody openAIResponsesRequest) (string, LLMUsage, error) { bodyBytes, err := json.Marshal(reqBody) if err != nil { - return "", LLMUsage{}, fmt.Errorf("marshaling request: %w", err) + return "", LLMUsage{}, fmt.Errorf("marshaling responses request: %w", err) } - req, err := http.NewRequest("POST", "https://api.openai.com/v1/chat/completions", bytes.NewReader(bodyBytes)) + req, err := http.NewRequest("POST", strings.TrimRight(baseURL, "/")+"/responses", bytes.NewReader(bodyBytes)) if err != nil { - return "", LLMUsage{}, fmt.Errorf("creating request: %w", err) + return "", LLMUsage{}, fmt.Errorf("creating responses request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+apiKey) resp, err := externalHTTPClient.Do(req) if err != nil { - log.Printf("llm openai error: %v", err) - return "", LLMUsage{}, fmt.Errorf("OpenAI API error: %w", err) + log.Printf("llm openai responses error: %v", err) + return "", LLMUsage{}, fmt.Errorf("OpenAI Responses API error: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { - return "", LLMUsage{}, fmt.Errorf("reading response: %w", err) + return "", LLMUsage{}, fmt.Errorf("reading responses body: %w", err) } - var openAIResp openAIResponse - if err := json.Unmarshal(respBody, &openAIResp); err != nil { - return "", LLMUsage{}, fmt.Errorf("parsing OpenAI response: %w", err) + var responsesResp openAIResponsesResponse + if err := json.Unmarshal(respBody, &responsesResp); err != nil { + return "", LLMUsage{}, fmt.Errorf("parsing OpenAI Responses payload: %w", err) } - - if openAIResp.Error != nil { - log.Printf("llm openai api error: %s", openAIResp.Error.Message) - return "", LLMUsage{}, fmt.Errorf("OpenAI API error: %s", openAIResp.Error.Message) + if responsesResp.Error != nil { + log.Printf("llm openai responses api error: %s", responsesResp.Error.Message) + return "", LLMUsage{}, fmt.Errorf("OpenAI Responses API error: %s", responsesResp.Error.Message) } - if len(openAIResp.Choices) == 0 { - return "", LLMUsage{}, fmt.Errorf("no choices in OpenAI response") + responseText, err := extractResponsesOutputText(responsesResp) + if err != nil { + return "", LLMUsage{}, err } usage := LLMUsage{} - if openAIResp.Usage != nil { - usage.InputTokens = openAIResp.Usage.PromptTokens - usage.OutputTokens = openAIResp.Usage.CompletionTokens + if responsesResp.Usage != nil { + usage.InputTokens = responsesResp.Usage.InputTokens + usage.OutputTokens = responsesResp.Usage.OutputTokens + } + return responseText, usage, nil +} + +func extractResponsesOutputText(resp openAIResponsesResponse) (string, error) { + for _, output := range resp.Output { + for _, content := range output.Content { + if strings.TrimSpace(content.Text) == "" { + continue + } + switch content.Type { + case "output_text", "text": + return content.Text, nil + } + } } + return "", fmt.Errorf("no structured text content in OpenAI Responses payload") +} - log.Printf("llm openai response size=%d tokens_in=%d tokens_out=%d", len(openAIResp.Choices[0].Message.Content), usage.InputTokens, usage.OutputTokens) - return openAIResp.Choices[0].Message.Content, usage, nil +func callOpenAI(apiKey, baseURL, model, systemPrompt, userPrompt string) (string, LLMUsage, error) { + responseText, usage, err := doOpenAIResponsesRequest(apiKey, baseURL, openAIResponsesRequest{ + Model: model, + Input: buildResponsesInput(systemPrompt, userPrompt), + }) + if err != nil { + return "", usage, err + } + log.Printf("llm openai responses size=%d tokens_in=%d tokens_out=%d", len(responseText), usage.InputTokens, usage.OutputTokens) + return responseText, usage, nil } // --- Generator-Critic Loop --- @@ -758,7 +796,7 @@ Respond with JSON only (no markdown): model = defaultOpenAIModel } log.Printf("llm critic provider=openai model=%s items=%d", model, len(items)) - responseText, usage, err = callOpenAI(cfg.OpenAIAPIKey, model, systemPrompt, userPrompt) + responseText, usage, err = callOpenAI(cfg.OpenAIAPIKey, cfg.OpenAIBaseURL, model, systemPrompt, userPrompt) default: model := cfg.LLMModel if model == "" { @@ -861,7 +899,7 @@ Respond with JSON only (no markdown): model = defaultOpenAIModel } log.Printf("llm retrospective provider=openai model=%s corrections=%d", model, len(corrections)) - responseText, usage, err = callOpenAI(cfg.OpenAIAPIKey, model, systemPrompt, userPrompt) + responseText, usage, err = callOpenAI(cfg.OpenAIAPIKey, cfg.OpenAIBaseURL, model, systemPrompt, userPrompt) default: model := cfg.LLMModel if model == "" { diff --git a/internal/integrations/llm/llm_test.go b/internal/integrations/llm/llm_test.go index 089fb76..3714202 100644 --- a/internal/integrations/llm/llm_test.go +++ b/internal/integrations/llm/llm_test.go @@ -46,7 +46,8 @@ func TestApplyGlossaryOverrides(t *testing.T) { 1: {SectionID: "S0_0", Confidence: 0.20}, } - applyGlossaryOverrides(items, decisions, glossary, sectionMap) + overrides := applyGlossaryOverrides(items, decisions, glossary, sectionMap) + assignLocalConfidence(decisions, options, overrides) got := decisions[1] if got.SectionID != "S1_0" { @@ -55,8 +56,8 @@ func TestApplyGlossaryOverrides(t *testing.T) { if got.NormalizedStatus != "in testing" { t.Fatalf("expected glossary status override to in testing, got %s", got.NormalizedStatus) } - if got.Confidence < 0.99 { - t.Fatalf("expected glossary override to raise confidence, got %f", got.Confidence) + if got.Confidence != 0.99 { + t.Fatalf("expected glossary override confidence 0.99, got %f", got.Confidence) } } @@ -109,9 +110,9 @@ func TestBuildSectionPrompts_IncludesTemplateGuidance(t *testing.T) { func TestParseSectionClassifiedResponse_AcceptsArrayTicketIDs(t *testing.T) { response := `[ - {"id": 1, "section_id": "S0_0", "normalized_status": "in progress", "ticket_ids": [], "duplicate_of": "", "confidence": 0.9}, - {"id": 2, "section_id": "S0_0", "normalized_status": "in progress", "ticket_ids": ["1136790"], "duplicate_of": "", "confidence": 0.9}, - {"id": 3, "section_id": "S0_0", "normalized_status": "in progress", "ticket_ids": "1247202", "duplicate_of": "", "confidence": 0.9} + {"id": 1, "section_id": "S0_0", "normalized_status": "in progress", "ticket_ids": [], "duplicate_of": ""}, + {"id": 2, "section_id": "S0_0", "normalized_status": "in progress", "ticket_ids": ["1136790"], "duplicate_of": ""}, + {"id": 3, "section_id": "S0_0", "normalized_status": "in progress", "ticket_ids": "1247202", "duplicate_of": ""} ]` got, err := parseSectionClassifiedResponse(response) @@ -129,52 +130,83 @@ func TestParseSectionClassifiedResponse_AcceptsArrayTicketIDs(t *testing.T) { } } -func TestParseSectionClassifiedResponse_RepairsMalformedConfidence(t *testing.T) { - response := `[ - {"id": 1, "section_id": "S0_0", "normalized_status": "done", "ticket_ids": "", "duplicate_of": "", "confidence": 0. Nine}, - {"id": 2, "section_id": "S0_1", "normalized_status": "in progress", "ticket_ids": "", "duplicate_of": "", "confidence": 0. seven}, - {"id": 3, "section_id": "S0_2", "normalized_status": "done", "ticket_ids": "", "duplicate_of": "", "confidence": nope} - ]` +func TestParseTicketIDsField_MixedArray(t *testing.T) { + raw := json.RawMessage(`[ "123", 456, "", " 789 " ]`) + got := parseTicketIDsField(raw) + if got != "123,456,789" { + t.Fatalf("unexpected ticket IDs normalization: %q", got) + } +} - got, err := parseSectionClassifiedResponse(response) - if err != nil { - t.Fatalf("parseSectionClassifiedResponse should repair malformed confidence: %v", err) +func TestAssignLocalConfidence(t *testing.T) { + decisions := map[int64]LLMSectionDecision{ + 1: {SectionID: "S0_0"}, + 2: {SectionID: "UND"}, + 3: {SectionID: "S0_0", DuplicateOf: "K2"}, + 4: {SectionID: "UNKNOWN"}, + 5: {SectionID: "und"}, + } + options := []sectionOption{{ID: "S0_0", Label: "Query Service"}} + assignLocalConfidence(decisions, options, map[int64]bool{1: true}) + + if decisions[1].Confidence != 0.99 { + t.Fatalf("expected glossary override confidence, got %v", decisions[1].Confidence) + } + if decisions[2].Confidence != 0.40 { + t.Fatalf("expected UND confidence, got %v", decisions[2].Confidence) } - if got[1].Confidence != 0.9 { - t.Fatalf("expected repaired confidence 0.9, got %v", got[1].Confidence) + if decisions[3].Confidence != 0.95 { + t.Fatalf("expected duplicate confidence, got %v", decisions[3].Confidence) } - if got[2].Confidence != 0.7 { - t.Fatalf("expected repaired confidence 0.7, got %v", got[2].Confidence) + if decisions[4].Confidence != 0.20 { + t.Fatalf("expected invalid section confidence, got %v", decisions[4].Confidence) } - if got[3].Confidence != 0 { - t.Fatalf("expected fallback confidence 0, got %v", got[3].Confidence) + if decisions[5].Confidence != 0.40 { + t.Fatalf("expected lowercase und confidence, got %v", decisions[5].Confidence) + } + if decisions[5].SectionID != "UND" { + t.Fatalf("expected lowercase und to normalize to UND, got %q", decisions[5].SectionID) } } -func TestNormalizeConfidenceLiteral(t *testing.T) { - tests := []struct { - raw string - want string - }{ - {raw: "0.91", want: "0.91"}, - {raw: `"0.82"`, want: "0.82"}, - {raw: "0. Nine", want: "0.9"}, - {raw: "0. seven", want: "0.7"}, - {raw: "garbage", want: "0"}, +func TestExtractResponsesOutputText(t *testing.T) { + resp := openAIResponsesResponse{ + Output: []struct { + Type string `json:"type"` + Role string `json:"role,omitempty"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content,omitempty"` + }{ + { + Type: "reasoning", + Content: []struct { + Type string `json:"type"` + Text string `json:"text"` + }{ + {Type: "reasoning_text", Text: "thinking"}, + }, + }, + { + Type: "message", + Role: "assistant", + Content: []struct { + Type string `json:"type"` + Text string `json:"text"` + }{ + {Type: "output_text", Text: `[{"id":1,"section_id":"S0_0","normalized_status":"done","ticket_ids":[],"duplicate_of":""}]`}, + }, + }, + }, } - for _, tt := range tests { - if got := normalizeConfidenceLiteral(tt.raw); got != tt.want { - t.Fatalf("normalizeConfidenceLiteral(%q) = %q, want %q", tt.raw, got, tt.want) - } + got, err := extractResponsesOutputText(resp) + if err != nil { + t.Fatalf("extractResponsesOutputText error: %v", err) } -} - -func TestParseTicketIDsField_MixedArray(t *testing.T) { - raw := json.RawMessage(`[ "123", 456, "", " 789 " ]`) - got := parseTicketIDsField(raw) - if got != "123,456,789" { - t.Fatalf("unexpected ticket IDs normalization: %q", got) + if !strings.Contains(got, `"section_id":"S0_0"`) { + t.Fatalf("unexpected extracted output: %q", got) } } diff --git a/internal/integrations/slack/slack.go b/internal/integrations/slack/slack.go index bbd33e7..ca54700 100644 --- a/internal/integrations/slack/slack.go +++ b/internal/integrations/slack/slack.go @@ -22,6 +22,8 @@ var delegatedAuthorRegex = regexp.MustCompile(`^\{([^{}]+)\}\s*`) const ( listItemsPageSize = 15 + listScopeMine = "mine" + listScopeAll = "all" actionDeleteItem = "list_items_delete" actionEditItemOpen = "list_items_edit_open" actionPagePrev = "list_items_page_prev" @@ -134,7 +136,7 @@ func handleMemberJoined(api *slack.Client, cfg Config, ev *slackevents.MemberJoi intro := fmt.Sprintf("Welcome to %s! I'm ReportAgent — I help track work items and generate weekly reports.\n\n"+ "Here's how to get started:\n"+ "• `/report (status)` — Report a work item (e.g. `/report Fix login bug (done)`)\n"+ - "• `/list` — View this week's items\n"+ + "• `/list` — View your items for this week (`/list all` for the team)\n"+ "• `/help` — See all available commands\n\n"+ "You can report multiple items at once with newlines, and set a shared status on the last line.", teamName, @@ -486,18 +488,23 @@ func deriveBossReportFromTeamReport(reportOutputDir, teamName string, friday tim func parseGenerateReportArgs(text string) (mode string, sendPrivate bool, err error) { mode = "team" sendPrivate = false + modeSet := false fields := strings.Fields(strings.ToLower(strings.TrimSpace(text))) for _, f := range fields { switch f { - case "team", "boss": + case "team", "boss", "post": + if modeSet && mode != f { + return "", false, fmt.Errorf("Usage: /generate-report [team|boss|post] [private]\nExamples: /generate-report team, /generate-report boss private, /generate-report post, /gen post private") + } mode = f + modeSet = true case "private": sendPrivate = true case "channel": sendPrivate = false default: - return "", false, fmt.Errorf("Usage: /generate-report [team|boss] [private]\nExamples: /generate-report team, /generate-report boss private, /gen private") + return "", false, fmt.Errorf("Usage: /generate-report [team|boss|post] [private]\nExamples: /generate-report team, /generate-report boss private, /generate-report post, /gen post private") } } return mode, sendPrivate, nil @@ -532,6 +539,11 @@ func handleGenerateReport(api *slack.Client, db *sql.DB, cfg Config, cmd slack.S monday, nextMonday := ReportWeekRange(cfg, time.Now().In(cfg.Location)) friday := FridayOfWeek(monday) + if mode == "post" { + postLatestTeamReport(api, cfg, cmd, sendPrivate) + return + } + // Boss mode shortcut: derive from existing team report if available. if mode == "boss" { filePath, bossReport, err := deriveBossReportFromTeamReport(cfg.ReportOutputDir, cfg.TeamName, friday) @@ -735,6 +747,114 @@ func handleGenerateReport(api *slack.Client, db *sql.DB, cfg Config, cmd slack.S sendUncertaintyMessages(api, cfg, cmd, result, items) } +func postLatestTeamReport(api *slack.Client, cfg Config, cmd slack.SlashCommand, sendPrivate bool) { + postEphemeral(api, cmd, "Posting latest team report...") + filePath, reportDate, err := findLatestTeamReportFile(cfg.ReportOutputDir, cfg.TeamName) + if err != nil { + postEphemeral(api, cmd, fmt.Sprintf("Error finding latest team report: %v", err)) + log.Printf("post-report find error: %v", err) + return + } + + fi, err := os.Stat(filePath) + if err != nil { + postEphemeral(api, cmd, fmt.Sprintf("Error reading report file: %v", err)) + log.Printf("post-report stat error path=%s err=%v", filePath, err) + return + } + if fi.Size() <= 0 { + postEphemeral(api, cmd, "Latest team report file is empty.") + log.Printf("post-report empty file path=%s", filePath) + return + } + + title := fmt.Sprintf("%s team report", cfg.TeamName) + initialComment := fmt.Sprintf("Latest team report for week containing %s", reportDate.Format("2006-01-02")) + uploadChannel := cmd.ChannelID + if sendPrivate { + ch, _, _, err := api.OpenConversation(&slack.OpenConversationParameters{Users: []string{cmd.UserID}}) + if err != nil { + postEphemeral(api, cmd, "Error opening DM to send private report. Check bot permissions.") + log.Printf("post-report dm open error user=%s: %v", cmd.UserID, err) + return + } + uploadChannel = ch.ID + } + + _, err = api.UploadFileV2(slack.UploadFileV2Parameters{ + File: filePath, + FileSize: int(fi.Size()), + Filename: filepath.Base(filePath), + Channel: uploadChannel, + Title: title, + InitialComment: initialComment, + }) + if err != nil { + postEphemeral(api, cmd, "Error posting latest team report to channel.") + log.Printf("post-report upload error path=%s err=%v", filePath, err) + return + } + + delivery := "channel" + if sendPrivate { + delivery = "private" + } + postEphemeral(api, cmd, fmt.Sprintf("Posted latest team report (%s): %s", delivery, filepath.Base(filePath))) + log.Printf("post-report done path=%s private=%t", filePath, sendPrivate) +} + +func findLatestTeamReportFile(outputDir, teamName string) (string, time.Time, error) { + entries, err := os.ReadDir(outputDir) + if err != nil { + return "", time.Time{}, fmt.Errorf("reading report output dir: %w", err) + } + + prefix := sanitizeReportFilenamePart(teamName) + "_" + var latestPath string + var latestDate time.Time + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".md") { + continue + } + rawDate := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".md") + reportDate, err := time.Parse("20060102", rawDate) + if err != nil { + continue + } + if latestPath == "" || reportDate.After(latestDate) { + latestPath = filepath.Join(outputDir, name) + latestDate = reportDate + } + } + + if latestPath == "" { + return "", time.Time{}, fmt.Errorf("no team report markdown file found in %s for team %s", outputDir, teamName) + } + return latestPath, latestDate, nil +} + +func sanitizeReportFilenamePart(s string) string { + var cleaned strings.Builder + for _, r := range s { + if r == 0 || (r >= 0x01 && r <= 0x1F) || r == 0x7F { + continue + } + cleaned.WriteRune(r) + } + sanitized := cleaned.String() + replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_") + sanitized = replacer.Replace(sanitized) + sanitized = strings.Trim(sanitized, " .") + if sanitized == "" || strings.Trim(sanitized, "_") == "" { + return "report" + } + return sanitized +} + func formatTokenCount(tokens int64) string { if tokens < 1000 { return fmt.Sprintf("%d", tokens) @@ -749,10 +869,16 @@ func formatTokenCount(tokens int64) string { } func handleListItems(api *slack.Client, db *sql.DB, cfg Config, cmd slack.SlashCommand) { - renderListItems(api, db, cfg, cmd.ChannelID, cmd.UserID, 0) + scope, err := parseListScope(cmd.Text) + if err != nil { + postEphemeral(api, cmd, err.Error()) + return + } + renderListItems(api, db, cfg, cmd.ChannelID, cmd.UserID, 0, scope) } -func renderListItems(api *slack.Client, db *sql.DB, cfg Config, channelID, userID string, page int) { +func renderListItems(api *slack.Client, db *sql.DB, cfg Config, channelID, userID string, page int, scope string) { + scope = normalizeListScope(scope) monday, nextMonday := ReportWeekRange(cfg, time.Now().In(cfg.Location)) items, err := GetItemsByDateRange(db, monday, nextMonday) if err != nil { @@ -760,9 +886,26 @@ func renderListItems(api *slack.Client, db *sql.DB, cfg Config, channelID, userI return } + isManager, _ := isManagerUser(api, cfg, userID) + user, _ := api.GetUserInfo(userID) + if scope == listScopeMine { + filtered := make([]WorkItem, 0, len(items)) + for _, item := range items { + if itemBelongsToViewer(item, userID, user) { + filtered = append(filtered, item) + } + } + items = filtered + } + if len(items) == 0 { - postEphemeralTo(api, channelID, userID, fmt.Sprintf("No items for this week (%s - %s)", - monday.Format("Jan 2"), nextMonday.AddDate(0, 0, -1).Format("Jan 2"))) + msg := fmt.Sprintf("No items for this week (%s - %s)", + monday.Format("Jan 2"), nextMonday.AddDate(0, 0, -1).Format("Jan 2")) + if scope == listScopeMine { + msg = fmt.Sprintf("You have no items for this week (%s - %s)", + monday.Format("Jan 2"), nextMonday.AddDate(0, 0, -1).Format("Jan 2")) + } + postEphemeralTo(api, channelID, userID, msg) log.Printf("list-items empty") return } @@ -795,10 +938,18 @@ func renderListItems(api *slack.Client, db *sql.DB, cfg Config, channelID, userI end = len(items) } + titlePrefix := "Items" + if scope == listScopeMine { + titlePrefix = "Your items" + } else if scope == listScopeAll { + titlePrefix = "Team items" + } + blocks := []slack.Block{ slack.NewHeaderBlock( slack.NewTextBlockObject(slack.PlainTextType, - fmt.Sprintf("Items for %s - %s (%d total)", + fmt.Sprintf("%s for %s - %s (%d total)", + titlePrefix, monday.Format("Jan 2"), nextMonday.AddDate(0, 0, -1).Format("Jan 2"), len(items)), @@ -807,8 +958,6 @@ func renderListItems(api *slack.Client, db *sql.DB, cfg Config, channelID, userI ), } - isManager, _ := isManagerUser(api, cfg, userID) - user, _ := api.GetUserInfo(userID) for idx, item := range items[start:end] { lineNumber := start + idx + 1 source := "" @@ -823,14 +972,14 @@ func renderListItems(api *slack.Client, db *sql.DB, cfg Config, channelID, userI category = fmt.Sprintf(" _%s_", item.Category) } text := formatListItemText(lineNumber, item, source, category) - if canManageItem(item, isManager, user) { + if canManageItem(item, isManager, userID, user) { editOpt := slack.NewOptionBlockObject( - fmt.Sprintf("edit:%d", item.ID), + fmt.Sprintf("edit:%s:%d", scope, item.ID), slack.NewTextBlockObject(slack.PlainTextType, "Edit", false, false), nil, ) deleteOpt := slack.NewOptionBlockObject( - fmt.Sprintf("delete:%d", item.ID), + fmt.Sprintf("delete:%s:%d", scope, item.ID), slack.NewTextBlockObject(slack.PlainTextType, "Delete", false, false), nil, ) @@ -854,14 +1003,14 @@ func renderListItems(api *slack.Client, db *sql.DB, cfg Config, channelID, userI if page > 0 { nav = append(nav, slack.NewButtonBlockElement( actionPagePrev, - strconv.Itoa(page-1), + fmt.Sprintf("%s|%d", scope, page-1), slack.NewTextBlockObject(slack.PlainTextType, "Prev", false, false), )) } if end < len(items) { nav = append(nav, slack.NewButtonBlockElement( actionPageNext, - strconv.Itoa(page+1), + fmt.Sprintf("%s|%d", scope, page+1), slack.NewTextBlockObject(slack.PlainTextType, "Next", false, false), )) } @@ -1093,25 +1242,22 @@ func handleBlockActions(api *slack.Client, db *sql.DB, cfg Config, cb slack.Inte switch act.ActionID { case actionPagePrev, actionPageNext: - page, err := strconv.Atoi(strings.TrimSpace(act.Value)) - if err != nil { - page = 0 - } - renderListItems(api, db, cfg, channelID, userID, page) + scope, page := parseListPageValue(act.Value) + renderListItems(api, db, cfg, channelID, userID, page, scope) case actionDeleteItem: itemID, err := strconv.ParseInt(strings.TrimSpace(act.Value), 10, 64) if err != nil { postEphemeralTo(api, channelID, userID, "Invalid item id.") return } - deleteItemAction(api, db, cfg, channelID, userID, itemID) + deleteItemAction(api, db, cfg, channelID, userID, itemID, listScopeMine) case actionEditItemOpen: itemID, err := strconv.ParseInt(strings.TrimSpace(act.Value), 10, 64) if err != nil { postEphemeralTo(api, channelID, userID, "Invalid item id.") return } - openEditModal(api, db, cfg, cb.TriggerID, channelID, userID, itemID) + openEditModal(api, db, cfg, cb.TriggerID, channelID, userID, itemID, listScopeMine) case actionUncertaintySelect: handleUncertaintySelect(api, db, cfg, cb, act) return @@ -1121,7 +1267,7 @@ func handleBlockActions(api *slack.Client, db *sql.DB, cfg Config, cb slack.Inte postEphemeralTo(api, channelID, userID, "Invalid item id.") return } - openEditModal(api, db, cfg, cb.TriggerID, channelID, userID, itemID) + openEditModal(api, db, cfg, cb.TriggerID, channelID, userID, itemID, listScopeMine) return case actionNudgeMember: openNudgeConfirmModal(api, cfg, cb.TriggerID, channelID, act.Value) @@ -1154,21 +1300,21 @@ func handleBlockActions(api *slack.Client, db *sql.DB, cfg Config, cb slack.Inte val = strings.TrimSpace(act.Value) } if strings.HasPrefix(val, "edit:") { - itemID, err := strconv.ParseInt(strings.TrimPrefix(val, "edit:"), 10, 64) - if err != nil { + scope, itemID, ok := parseListRowAction(val, "edit") + if !ok { postEphemeralTo(api, channelID, userID, "Invalid item id.") return } - openEditModal(api, db, cfg, cb.TriggerID, channelID, userID, itemID) + openEditModal(api, db, cfg, cb.TriggerID, channelID, userID, itemID, scope) return } if strings.HasPrefix(val, "delete:") { - itemID, err := strconv.ParseInt(strings.TrimPrefix(val, "delete:"), 10, 64) - if err != nil { + scope, itemID, ok := parseListRowAction(val, "delete") + if !ok { postEphemeralTo(api, channelID, userID, "Invalid item id.") return } - openDeleteModal(api, db, cfg, cb.TriggerID, channelID, userID, itemID) + openDeleteModal(api, db, cfg, cb.TriggerID, channelID, userID, itemID, scope) return } } @@ -1184,7 +1330,7 @@ func handleBlockActions(api *slack.Client, db *sql.DB, cfg Config, cb slack.Inte postEphemeralTo(api, channelID, userID, "Invalid item id.") return } - openEditModal(api, db, cfg, cb.TriggerID, channelID, userID, itemID) + openEditModal(api, db, cfg, cb.TriggerID, channelID, userID, itemID, listScopeMine) return } } @@ -1209,11 +1355,11 @@ func handleViewSubmission(api *slack.Client, db *sql.DB, cfg Config, cb slack.In if channelID == "" { channelID = cb.Channel.ID } - itemID, err := strconv.ParseInt(strings.TrimPrefix(parts[0], modalMetaPrefix), 10, 64) - if err != nil { + scope, itemID, ok := parseListModalMeta(parts[0]) + if !ok { return } - deleteItemAction(api, db, cfg, channelID, userID, itemID) + deleteItemAction(api, db, cfg, channelID, userID, itemID, scope) return } @@ -1227,8 +1373,8 @@ func handleViewSubmission(api *slack.Client, db *sql.DB, cfg Config, cb slack.In return } channelID := strings.TrimSpace(parts[1]) - itemID, err := strconv.ParseInt(strings.TrimPrefix(parts[0], modalMetaPrefix), 10, 64) - if err != nil { + scope, itemID, ok := parseListModalMeta(parts[0]) + if !ok { return } if cb.View.State == nil { @@ -1262,7 +1408,7 @@ func handleViewSubmission(api *slack.Client, db *sql.DB, cfg Config, cb slack.In } isManager, _ := isManagerUser(api, cfg, userID) user, _ := api.GetUserInfo(userID) - if !canManageItem(item, isManager, user) { + if !canManageItem(item, isManager, userID, user) { return } if description == "" { @@ -1293,10 +1439,10 @@ func handleViewSubmission(api *slack.Client, db *sql.DB, cfg Config, cb slack.In if channelID == "" { channelID = cb.Channel.ID } - renderListItems(api, db, cfg, channelID, userID, 0) + renderListItems(api, db, cfg, channelID, userID, 0, scope) } -func deleteItemAction(api *slack.Client, db *sql.DB, cfg Config, channelID, userID string, itemID int64) { +func deleteItemAction(api *slack.Client, db *sql.DB, cfg Config, channelID, userID string, itemID int64, scope string) { item, err := GetWorkItemByID(db, itemID) if err != nil { postEphemeralTo(api, channelID, userID, "Item not found.") @@ -1310,7 +1456,7 @@ func deleteItemAction(api *slack.Client, db *sql.DB, cfg Config, channelID, user isManager, _ := isManagerUser(api, cfg, userID) user, _ := api.GetUserInfo(userID) - if !canManageItem(item, isManager, user) { + if !canManageItem(item, isManager, userID, user) { postEphemeralTo(api, channelID, userID, "You are not allowed to delete this item.") return } @@ -1319,10 +1465,10 @@ func deleteItemAction(api *slack.Client, db *sql.DB, cfg Config, channelID, user postEphemeralTo(api, channelID, userID, fmt.Sprintf("Delete failed: %v", err)) return } - renderListItems(api, db, cfg, channelID, userID, 0) + renderListItems(api, db, cfg, channelID, userID, 0, scope) } -func openEditModal(api *slack.Client, db *sql.DB, cfg Config, triggerID, channelID, userID string, itemID int64) { +func openEditModal(api *slack.Client, db *sql.DB, cfg Config, triggerID, channelID, userID string, itemID int64, scope string) { item, err := GetWorkItemByID(db, itemID) if err != nil { postEphemeralTo(api, channelID, userID, "Item not found.") @@ -1336,7 +1482,7 @@ func openEditModal(api *slack.Client, db *sql.DB, cfg Config, triggerID, channel isManager, _ := isManagerUser(api, cfg, userID) user, _ := api.GetUserInfo(userID) - if !canManageItem(item, isManager, user) { + if !canManageItem(item, isManager, userID, user) { postEphemeralTo(api, channelID, userID, "You are not allowed to edit this item.") return } @@ -1460,7 +1606,7 @@ func openEditModal(api *slack.Client, db *sql.DB, cfg Config, triggerID, channel Close: slack.NewTextBlockObject(slack.PlainTextType, "Cancel", false, false), Submit: slack.NewTextBlockObject(slack.PlainTextType, "Save", false, false), CallbackID: modalEditCallbackID, - PrivateMetadata: fmt.Sprintf("%s%d|%s", modalMetaPrefix, itemID, channelID), + PrivateMetadata: fmt.Sprintf("%s%s:%d|%s", modalMetaPrefix, normalizeListScope(scope), itemID, channelID), Blocks: slack.Blocks{BlockSet: blocks}, } if _, err := api.OpenView(triggerID, view); err != nil { @@ -1471,7 +1617,7 @@ func openEditModal(api *slack.Client, db *sql.DB, cfg Config, triggerID, channel } } -func openDeleteModal(api *slack.Client, db *sql.DB, cfg Config, triggerID, channelID, userID string, itemID int64) { +func openDeleteModal(api *slack.Client, db *sql.DB, cfg Config, triggerID, channelID, userID string, itemID int64, scope string) { item, err := GetWorkItemByID(db, itemID) if err != nil { postEphemeralTo(api, channelID, userID, "Item not found.") @@ -1484,7 +1630,7 @@ func openDeleteModal(api *slack.Client, db *sql.DB, cfg Config, triggerID, chann } isManager, _ := isManagerUser(api, cfg, userID) user, _ := api.GetUserInfo(userID) - if !canManageItem(item, isManager, user) { + if !canManageItem(item, isManager, userID, user) { postEphemeralTo(api, channelID, userID, "You are not allowed to delete this item.") return } @@ -1495,7 +1641,7 @@ func openDeleteModal(api *slack.Client, db *sql.DB, cfg Config, triggerID, chann Close: slack.NewTextBlockObject(slack.PlainTextType, "Cancel", false, false), Submit: slack.NewTextBlockObject(slack.PlainTextType, "Delete", false, false), CallbackID: modalDeleteCallbackID, - PrivateMetadata: fmt.Sprintf("%s%d|%s", modalMetaPrefix, itemID, channelID), + PrivateMetadata: fmt.Sprintf("%s%s:%d|%s", modalMetaPrefix, normalizeListScope(scope), itemID, channelID), Blocks: slack.Blocks{BlockSet: []slack.Block{ slack.NewSectionBlock( slack.NewTextBlockObject( @@ -1535,6 +1681,75 @@ func formatListItemText(lineNumber int, item WorkItem, source, category string) lineNumber, item.Author, formatItemDescriptionForList(item), item.Status, source, category) } +func parseListScope(raw string) (string, error) { + text := strings.TrimSpace(strings.ToLower(raw)) + switch text { + case "", listScopeMine: + return listScopeMine, nil + case listScopeAll: + return listScopeAll, nil + default: + return "", fmt.Errorf("Usage: `/list` or `/list all`") + } +} + +func normalizeListScope(scope string) string { + if strings.EqualFold(strings.TrimSpace(scope), listScopeAll) { + return listScopeAll + } + return listScopeMine +} + +func parseListPageValue(raw string) (string, int) { + parts := strings.Split(strings.TrimSpace(raw), "|") + if len(parts) == 2 { + page, err := strconv.Atoi(parts[1]) + if err == nil { + return normalizeListScope(parts[0]), page + } + } + page, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil { + return listScopeMine, 0 + } + return listScopeMine, page +} + +func parseListRowAction(raw, expectedAction string) (string, int64, bool) { + parts := strings.Split(strings.TrimSpace(raw), ":") + if len(parts) == 3 && parts[0] == expectedAction { + itemID, err := strconv.ParseInt(parts[2], 10, 64) + if err == nil { + return normalizeListScope(parts[1]), itemID, true + } + } + if len(parts) == 2 && parts[0] == expectedAction { + itemID, err := strconv.ParseInt(parts[1], 10, 64) + if err == nil { + return listScopeMine, itemID, true + } + } + return "", 0, false +} + +func parseListModalMeta(raw string) (string, int64, bool) { + meta := strings.TrimPrefix(strings.TrimSpace(raw), modalMetaPrefix) + if meta == "" { + return "", 0, false + } + if parts := strings.Split(meta, ":"); len(parts) == 2 { + itemID, err := strconv.ParseInt(parts[1], 10, 64) + if err == nil { + return normalizeListScope(parts[0]), itemID, true + } + } + itemID, err := strconv.ParseInt(meta, 10, 64) + if err != nil { + return "", 0, false + } + return listScopeMine, itemID, true +} + func leadingTicketPrefix(description string) (string, bool) { description = strings.TrimSpace(description) if !strings.HasPrefix(description, "[") { @@ -1563,10 +1778,17 @@ func canonicalTicketList(ticketIDs string) string { return strings.Join(cleaned, ",") } -func canManageItem(item WorkItem, isManager bool, user *slack.User) bool { +func canManageItem(item WorkItem, isManager bool, userID string, user *slack.User) bool { if isManager { return true } + return itemBelongsToViewer(item, userID, user) +} + +func itemBelongsToViewer(item WorkItem, userID string, user *slack.User) bool { + if strings.TrimSpace(item.AuthorID) != "" && strings.TrimSpace(item.AuthorID) == strings.TrimSpace(userID) { + return true + } if user == nil { return false } @@ -1989,7 +2211,7 @@ func handleHelp(api *slack.Client, cfg Config, cmd slack.SlashCommand) { ">Item B", ">(in progress)```", "", - "`/list` — List this week's items.", + "`/list` — List your items for this week (`/list all` for the team).", "`/nudge` — Send yourself a test nudge DM.", "`/help` — Show this help.", } @@ -2000,7 +2222,7 @@ func handleHelp(api *slack.Client, cfg Config, cmd slack.SlashCommand) { "*Manager Commands*", "", "`/fetch` — Fetch *merged + open* GitLab MRs and/or GitHub PRs for this week.", - "`/generate-report [team|boss] [private]` — Generate weekly report (channel by default).", + "`/generate-report [team|boss|post] [private]` — Generate weekly report, or post latest team report.", "`/gen` — Alias of `/generate-report`.", "`/check` — List missing members with inline nudge buttons.", "`/nudge ` — Send a test nudge DM to one member.", diff --git a/internal/integrations/slack/slack_logic_test.go b/internal/integrations/slack/slack_logic_test.go index 67dffba..06b6476 100644 --- a/internal/integrations/slack/slack_logic_test.go +++ b/internal/integrations/slack/slack_logic_test.go @@ -60,8 +60,11 @@ func TestParseGenerateReportArgs(t *testing.T) { {name: "default", input: "", wantMode: "team", wantPrivate: false}, {name: "team private", input: "team private", wantMode: "team", wantPrivate: true}, {name: "boss private", input: "boss private", wantMode: "boss", wantPrivate: true}, + {name: "post channel", input: "post", wantMode: "post", wantPrivate: false}, + {name: "post private", input: "post private", wantMode: "post", wantPrivate: true}, {name: "private only", input: "private", wantMode: "team", wantPrivate: true}, {name: "boss channel", input: "boss channel", wantMode: "boss", wantPrivate: false}, + {name: "conflicting modes", input: "post boss", wantErr: true}, {name: "unknown token", input: "boss now", wantErr: true}, } @@ -266,6 +269,36 @@ func TestFormatListItemText_AddsLineNumber(t *testing.T) { } } +func TestParseListScope(t *testing.T) { + tests := []struct { + input string + want string + wantErr bool + }{ + {input: "", want: listScopeMine}, + {input: "mine", want: listScopeMine}, + {input: "all", want: listScopeAll}, + {input: " ALL ", want: listScopeAll}, + {input: "team", wantErr: true}, + } + + for _, tt := range tests { + got, err := parseListScope(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("parseListScope(%q) expected error", tt.input) + } + continue + } + if err != nil { + t.Fatalf("parseListScope(%q) error: %v", tt.input, err) + } + if got != tt.want { + t.Fatalf("parseListScope(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + func TestMemberReportedThisWeek(t *testing.T) { reportedIDs := map[string]bool{"U123": true} reportedAuthors := []string{"Alex Rivera", "Jordan Patel"} @@ -417,3 +450,44 @@ func TestDeriveBossReportFromTeamReport_MalformedContent(t *testing.T) { t.Error("expected non-empty bossReport even with malformed content") } } + +func TestFindLatestTeamReportFile(t *testing.T) { + dir := t.TempDir() + teamName := "Demo Team" + + files := map[string]string{ + "Demo Team_20260214.md": "old", + "Demo Team_20260221.md": "new", + "Demo Team_20260221.eml": "ignore-eml", + "Other_20260228.md": "ignore-other-team", + "Demo Team_latest.md": "ignore-invalid-date", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatalf("write test file %s: %v", name, err) + } + } + + path, date, err := findLatestTeamReportFile(dir, teamName) + if err != nil { + t.Fatalf("findLatestTeamReportFile error: %v", err) + } + if filepath.Base(path) != "Demo Team_20260221.md" { + t.Fatalf("unexpected latest file: %s", path) + } + if got := date.Format("20060102"); got != "20260221" { + t.Fatalf("unexpected latest date: %s", got) + } +} + +func TestFindLatestTeamReportFile_NoMatch(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "OtherTeam_20260221.md"), []byte("x"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + + _, _, err := findLatestTeamReportFile(dir, "Demo Team") + if err == nil { + t.Fatal("expected error when no matching team report exists") + } +}