blob: e8e7ce94536d61fd48c89ab3767f67cf559b07f1 [file]
/*
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package tasks
import (
"regexp"
"strings"
"time"
"github.com/apache/devlake/core/errors"
)
// Kiro exports the same logical value in several shapes across its two data
// sources. Every normalization helper here exists because a real sample proved
// the variation exists; see the design spec section 3 for the evidence.
// modelColumnRe matches the per-model message count columns in the user report
// CSV, e.g. "claude_opus_4.6_messages". The column set is dynamic - it grows
// and shrinks with the models a team actually uses.
var modelColumnRe = regexp.MustCompile(`(?i)^(.+)_messages$`)
// totalMessagesColumn is the aggregate column that must never be mistaken for a
// model. It matches modelColumnRe, and its value happens to equal the sum of
// all model columns, so treating it as a model yields a plausible-looking but
// wrong distribution.
const totalMessagesColumn = "total_messages"
// SplitUserId separates an identity store prefixed user id into its parts.
//
// Interaction logs always carry the prefix ("d-1234567890.11111111-..."), while
// report CSVs usually do not - but three sampled report files did. Both paths
// must normalize, otherwise the same person lands in the table twice and every
// per-person aggregate is silently wrong.
//
// Returns the bare user id, plus the identity store id when a prefix was
// present (empty string otherwise).
func SplitUserId(raw string) (userId string, identityStoreId string) {
trimmed := strings.TrimSpace(raw)
if !strings.HasPrefix(trimmed, "d-") {
return trimmed, ""
}
// Only the first separator matters: the identity store id itself never
// contains a dot, while the trailing UUID may not be validated here.
idx := strings.Index(trimmed, ".")
if idx < 0 {
// "d-something" with no separator is not a prefixed id.
return trimmed, ""
}
prefix := trimmed[:idx]
rest := trimmed[idx+1:]
if rest == "" {
// A trailing dot with nothing after it carries no user id; keep the
// original value rather than inventing an empty one.
return trimmed, ""
}
return rest, prefix
}
// ParseKiroBool parses a boolean whose casing drifts between report versions:
// March samples emit "TRUE", July samples emit "true".
func ParseKiroBool(s string) (bool, errors.Error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "true":
return true, nil
case "false":
return false, nil
default:
return false, errors.Default.New("unrecognized boolean value: " + s)
}
}
// NormalizeTier canonicalizes a subscription tier to UPPER_SNAKE.
//
// Observed values are already UPPER_SNAKE ("POWER", "PRO_PLUS") while the
// official docs write them in CamelCase ("Power", "ProPlus"). Unknown tiers are
// upper-cased and returned as-is rather than rejected - a new tier must not
// break collection.
func NormalizeTier(s string) string {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return ""
}
// Insert a separator at lower-to-upper transitions so "ProPlus" becomes
// "PRO_PLUS", while an already-snake "PRO_PLUS" is left alone.
var b strings.Builder
runes := []rune(trimmed)
for i, r := range runes {
if i > 0 && r >= 'A' && r <= 'Z' {
prev := runes[i-1]
if prev >= 'a' && prev <= 'z' {
b.WriteRune('_')
}
}
b.WriteRune(r)
}
return strings.ToUpper(b.String())
}
// ParseModelColumn reports whether a CSV header is a per-model message count
// column, returning the model name when it is.
//
// "Total_Messages" is excluded explicitly: it matches the same pattern but is
// the aggregate, not a model.
func ParseModelColumn(header string) (string, bool) {
trimmed := strings.TrimSpace(header)
if strings.EqualFold(trimmed, totalMessagesColumn) {
return "", false
}
m := modelColumnRe.FindStringSubmatch(trimmed)
if m == nil {
return "", false
}
name := m[1]
if name == "" {
return "", false
}
return name, true
}
// CanonicalModelName folds the two spellings of a model identifier onto one
// form so they can be compared.
//
// The report CSV uses underscores ("claude_opus_4.6") while interaction logs
// use hyphens ("claude-opus-4.6"). Values are stored as the source emits them;
// this helper is for comparison only.
func CanonicalModelName(s string) string {
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(s), "-", "_"))
}
// ParseKiroTime parses an interaction log timestamp.
//
// Log timestamps carry nanosecond precision ("2026-07-27T23:03:29.027400929Z")
// but MySQL DATETIME(6) only stores microseconds, so the value is truncated
// here rather than at the storage layer - that keeps the truncation explicit
// and identical on every database backend.
func ParseKiroTime(s string) (time.Time, errors.Error) {
trimmed := strings.TrimSpace(s)
t, err := time.Parse(time.RFC3339Nano, trimmed)
if err != nil {
return time.Time{}, errors.Default.Wrap(err, "failed to parse kiro timestamp: "+trimmed)
}
return t.Truncate(time.Microsecond), nil
}
// ParseReportDate parses a user report date, which is strictly ISO 8601.
//
// Deliberately strict: a lenient parser would accept the legacy
// by_user_analytic format (MM-DD-YYYY) and silently produce a wrong date.
func ParseReportDate(s string) (time.Time, errors.Error) {
trimmed := strings.TrimSpace(s)
t, err := time.Parse(time.DateOnly, trimmed)
if err != nil {
return time.Time{}, errors.Default.Wrap(err, "failed to parse kiro report date: "+trimmed)
}
return t, nil
}