package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "os" "strconv" "strings" "time" ) type TimesheetPayload struct { Date string `json:"date"` Description string `json:"description"` GiteaUsername string `json:"gitea_username"` HourSpent float64 `json:"hour_spent"` TaskID int64 `json:"task_id"` } // parseOdooTicketID extracts the numeric ticket id from a ref formatted like // "/ticket-". It scans path segments and picks the first segment // that starts with "ticket-" (case-insensitive), then reads leading digits. func parseOdooTicketID(ref string) (int64, error) { if strings.TrimSpace(ref) == "" { return 0, fmt.Errorf("empty ref") } parts := strings.Split(ref, "/") for _, seg := range parts { if seg == "" { continue } lower := strings.ToLower(seg) const pfx = "ticket-" if strings.HasPrefix(lower, pfx) { numPart := seg[len(pfx):] // collect leading digits var digits strings.Builder for _, r := range numPart { if r >= '0' && r <= '9' { digits.WriteRune(r) } else { break } } if digits.Len() == 0 { return 0, fmt.Errorf("no digits after ticket- in segment: %s", seg) } id, err := strconv.ParseInt(digits.String(), 10, 64) if err != nil { return 0, err } return id, nil } } return 0, fmt.Errorf("no ticket- segment found in ref: %s", ref) } // readEventTrackedTime tries to extract tracked_time.time and tracked_time.created // from the event JSON file if explicit env vars are missing. It supports both // GITEA_EVENT_PATH and GITHUB_EVENT_PATH for compatibility. func readEventTrackedTime(logger *log.Logger) (seconds string, created string) { paths := []string{os.Getenv("GITEA_EVENT_PATH"), os.Getenv("GITHUB_EVENT_PATH")} for _, p := range paths { if strings.TrimSpace(p) == "" { continue } b, err := os.ReadFile(p) if err != nil { logger.Printf("Could not read event file '%s': %v", p, err) continue } var evt map[string]any if err := json.Unmarshal(b, &evt); err != nil { logger.Printf("Could not parse event JSON: %v", err) continue } // tracked_time is expected at top-level for time tracking related issue events if ttRaw, ok := evt["tracked_time"]; ok { if tt, ok := ttRaw.(map[string]any); ok { // time if v, ok := tt["time"]; ok { switch t := v.(type) { case float64: seconds = strconv.FormatInt(int64(t), 10) case string: seconds = t } } // created if v, ok := tt["created"]; ok { created, _ = v.(string) } } } if seconds != "" && created != "" { return seconds, created } } return "", "" } func normalizeDate(created string) string { created = strings.TrimSpace(created) if created == "" { return "" } // Try RFC3339 if t, err := time.Parse(time.RFC3339, created); err == nil { return t.Format("2006-01-02") } // If numeric (unix seconds) if n, err := strconv.ParseInt(created, 10, 64); err == nil { return time.Unix(n, 0).UTC().Format("2006-01-02") } // Fallback: if contains T split, otherwise return as-is if idx := strings.Index(created, "T"); idx > 0 { return created[:idx] } return created } func main() { baseURL := os.Getenv("BASE_URL") branchRef := os.Getenv("BRANCH_REF") sender := os.Getenv("SENDER_LOGIN") title := os.Getenv("ISSUE_TITLE") trackedSeconds := os.Getenv("TRACKED_SECONDS") trackedCreated := os.Getenv("TRACKED_CREATED") action := os.Getenv("EVENT_ACTION") if baseURL == "" || sender == "" { fmt.Println("Some required environment variables are missing (BASE_URL or SENDER_LOGIN).") os.Exit(1) } if action != "add_time" && action != "edited" && action != "time_tracked" && action != "time_added" { // Only proceed for time-related actions; allow edited if your instance sends edited fmt.Printf("Skipping action: %s\n", action) return } // Logger logFile, err := os.OpenFile("action.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) if err != nil { fmt.Printf("Error creating log file: %v\n", err) os.Exit(1) } defer logFile.Close() logger := log.New(io.MultiWriter(os.Stdout, logFile), "", log.LstdFlags) logger.Printf("Event action: %s, initial TRACKED_SECONDS='%s', TRACKED_CREATED='%s'\n", action, trackedSeconds, trackedCreated) // Ensure tracked time fields exist; otherwise, try to read from event JSON file if strings.TrimSpace(trackedSeconds) == "" || strings.TrimSpace(trackedCreated) == "" { sec2, crt2 := readEventTrackedTime(logger) logger.Printf("Fallback from event file: seconds='%s', created='%s'\n", sec2, crt2) if trackedSeconds == "" { trackedSeconds = sec2 } if trackedCreated == "" { trackedCreated = crt2 } } if strings.TrimSpace(trackedSeconds) == "" || strings.TrimSpace(trackedCreated) == "" { logger.Println("No tracked_time information found in event. Nothing to do.") return } secondsVal, err := strconv.ParseInt(trackedSeconds, 10, 64) if err != nil { logger.Printf("Invalid TRACKED_SECONDS '%s': %v\n", trackedSeconds, err) os.Exit(1) } hours := float64(secondsVal) / 3600.0 desc := title if strings.TrimSpace(desc) == "" { desc = "/" } date := normalizeDate(trackedCreated) if date == "" { logger.Println("Unable to derive date from tracked_time.created") os.Exit(1) } // Determine task_id (Odoo Ticket ID) from BRANCH_REF using ticket- if strings.TrimSpace(branchRef) == "" { logger.Println("BRANCH_REF is missing; cannot determine Odoo ticket id") os.Exit(1) } var taskID int64 taskID, err = parseOdooTicketID(branchRef) if err != nil || taskID == 0 { logger.Printf("Failed to parse Odoo ticket from BRANCH_REF '%s': %v\n", branchRef, err) os.Exit(1) } payload := TimesheetPayload{ Date: date, Description: desc, GiteaUsername: sender, HourSpent: hours, TaskID: taskID, } body, err := json.Marshal(payload) if err != nil { logger.Printf("Error marshalling payload: %v\n", err) os.Exit(1) } endpoint := strings.TrimRight(baseURL, "/") + "/api/v1/account_analytic_gitea_odoo/" req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { logger.Printf("Error creating POST request: %v\n", err) os.Exit(1) } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { logger.Printf("Error calling FastAPI: %v\n", err) os.Exit(1) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) logger.Printf("FastAPI status: %s\nResponse: %s\n", resp.Status, string(respBody)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { os.Exit(1) } }