Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ dep: go-dep python-dep
swag: mock
scripts/swag.sh

build-metrics-api:
go build -o bin/metrics-api ./plugins/metrics/cmd/

build-plugin:
@if [ -d "$(HOME)/local/lib/pkgconfig" ] && [ -f "$(HOME)/local/lib/pkgconfig/libgit2.pc" ]; then \
export PKG_CONFIG_PATH="$(HOME)/local/lib/pkgconfig:$$PKG_CONFIG_PATH"; \
Expand Down
119 changes: 119 additions & 0 deletions backend/plugins/metrics/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# metrics Plugin — Agent Context

Standalone HTTP API that queries DevLake's MySQL database and exposes pre-computed
metrics. Not part of the DevLake plugin framework — ships as an independent binary.

## Build & Run

```bash
# From backend/
make build-metrics-api

# Run locally
export MYSQL_HOST=<MYSQL_HOST>
export MYSQL_PORT=<MYSQL_PORT>
export MYSQL_USER=<MYSQL_USER>
export MYSQL_PASS=<MYSQL_PASS>
export MYSQL_DB=<MYSQL_DB>
export METRICS_ADDR=:<METRICS_ADDR>
export METRICS_ALLOWED_ORIGIN=<METRICS_ALLOWED_ORIGIN>
./bin/metrics-api

# Test
go test ./plugins/metrics/... -v
golangci-lint run ./plugins/metrics/...
```

## Layout

```
cmd/
main.go
api/
server.go
middleware.go
params.go
routes/pr/
register.go
handlers/
utils.go
key_metrics.go
stages.go
cycle_time.go
productivity.go
flow.go
zscore.go
scatter.go
model/
response.go
query/
db.go
pr/
rows.go
build.go
queries.go
testsupport/
time.go
transform/
botfilter/
botfilter.go
pr/
helpers.go
key_metrics.go
stages.go
cycle_time.go
flow.go
zscore.go
productivity.go
scatter.go
*_test.go
```

## URL Scheme

All routes: `POST /api/metrics/<category>/<name>`

| Category | Example path |
|---|---|
| PR | `/api/metrics/pr/key-metrics` |


## Request Body

All endpoints accept the same JSON body:

```json
{
"owner": ["org"],
"name": ["repo"],
"from": 1700000000,
"to": 1702000000,
"blueprintid": "72",
"connectionid": "1",
"jiraproject": "PROJ",
"projects": ["project-name"],
"userwhitelist": [],
"teamrepos": [],
"projectname": "PRCT - Team"
}
```

## Response Shape

All endpoints return FORMAT.md JSON — see `model/response.go` for Go types.

## Conventions

- Transforms are pure functions — no I/O, no DB; takes rows → returns `model.MetricResponse`
- SQL lives in `query/pr/queries.go`; no SQL in handlers or transform files
- New SQL fragments use string concatenation (`+`) not `fmt.Sprintf` — see existing queries for the pattern
- Unit tests live next to source: `transform/pr/foo.go` → `transform/pr/foo_test.go`
- Use `testsupport.ParseTime()` for `*time.Time` fixtures in tests
- This binary is excluded from `build-plugins.sh` — build separately with `go build ./plugins/metrics/cmd/`
- Apache 2.0 license header required on all `.go` files


## Dont's

- Don't import from other plugins (plugins must be independent)
- Don't skip the Apache 2.0 license header on new `.go` files
24 changes: 24 additions & 0 deletions backend/plugins/metrics/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
FROM golang:1.26-alpine AS builder

WORKDIR /build

# Copy go module files first for layer caching
COPY go.mod go.sum ./
RUN go mod download

# Copy source
COPY . .

# Build the standalone binary
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w" \
-o /metrics-api \
./plugins/metrics/cmd/

# ---- runtime image ----
FROM gcr.io/distroless/static:nonroot

Check failure on line 19 in backend/plugins/metrics/Dockerfile

View workflow job for this annotation

GitHub Actions / Dockerfile lint

DL3026 error: Use only an allowed registry in the FROM image

COPY --from=builder /metrics-api /metrics-api

EXPOSE 8181
ENTRYPOINT ["/metrics-api"]
70 changes: 70 additions & 0 deletions backend/plugins/metrics/api/middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
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 api

import (
"log"
"net/http"
"time"
)

// corsMiddleware adds CORS headers and handles pre-flight OPTIONS requests.
func corsMiddleware(allowedOrigin string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}

// loggingMiddleware logs method, path, status, and latency for every request.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rw, r)
log.Printf("metrics-api: %s %s %d %s", r.Method, sanitizePath(r.URL.Path), rw.status, time.Since(start)) //nolint:gosec // G706: path is sanitized by sanitizePath (strips \n and \r)
})
}

type responseWriter struct {
http.ResponseWriter
status int
}

func (rw *responseWriter) WriteHeader(status int) {
rw.status = status
rw.ResponseWriter.WriteHeader(status)
}

// sanitizePath strips newlines and carriage returns from a URL path to prevent
// log injection (gosec G706).
func sanitizePath(p string) string {
out := make([]byte, 0, len(p))
for i := range len(p) {
if p[i] != '\n' && p[i] != '\r' {
out = append(out, p[i])
}
}
return string(out)
}
61 changes: 61 additions & 0 deletions backend/plugins/metrics/api/params.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
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 api

import (
"encoding/json"
"fmt"
"net/http"
)

// QueryParams holds the normalized request body sent by the dashboard.
// All fields mirror what dashboard.js builds in buildDiagramRequest().
type QueryParams struct {
// Owner is the list of GitHub/GitLab organisation names.
Owner []string `json:"owner"`
// Name is the list of repository names (parallel to Owner).
Name []string `json:"name"`
// From is the start of the time window as a Unix timestamp in seconds.
From int64 `json:"from"`
// To is the end of the time window as a Unix timestamp in seconds.
To int64 `json:"to"`
// BlueprintID is the DevLake blueprint ID for this team/product.
BlueprintID string `json:"blueprintid"`
// ConnectionID is the DevLake connection ID for the primary data source.
ConnectionID string `json:"connectionid"`
// JiraProject is the Jira project key (only for issue dashboards).
JiraProject string `json:"jiraproject"`
// Projects is used by issue dashboards in place of owner/name pairs.
Projects []string `json:"projects"`
// UserWhitelist restricts metrics to a specific set of contributors.
UserWhitelist []string `json:"userwhitelist"`
// TeamRepos is an explicit repo list used by some AR flows.
TeamRepos []string `json:"teamrepos"`
// ProjectName is the DevLake project_name scoping key (used by AI Review, AICS).
ProjectName string `json:"projectname"`
}

// DecodeQueryParams reads and validates the POST body into a QueryParams value.
// Returns an error suitable for returning as a 400 to the caller.
func DecodeQueryParams(r *http.Request) (QueryParams, error) {
var p QueryParams
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
return p, fmt.Errorf("decoding request body: %w", err)
}
return p, nil
}
46 changes: 46 additions & 0 deletions backend/plugins/metrics/api/routes/pr/handlers/cycle_time.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
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 pr

import (
"database/sql"
"log"
"net/http"

"github.com/apache/incubator-devlake/plugins/metrics/api"
qpr "github.com/apache/incubator-devlake/plugins/metrics/query/pr"
tpr "github.com/apache/incubator-devlake/plugins/metrics/transform/pr"
)

// CycleTime handles POST /api/metrics/pr/cycle-time.
func CycleTime(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p, err := api.DecodeQueryParams(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
prs, err := qpr.BasePRs(r.Context(), db, toParams(p))
if err != nil {
log.Printf("pr/cycle-time: %v", err)
http.Error(w, "query error", http.StatusInternalServerError)
return
}
api.WriteJSON(w, tpr.BuildCycleTime(prs))
}
}
54 changes: 54 additions & 0 deletions backend/plugins/metrics/api/routes/pr/handlers/flow.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
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 pr

import (
"database/sql"
"log"
"net/http"

"github.com/apache/incubator-devlake/plugins/metrics/api"
qpr "github.com/apache/incubator-devlake/plugins/metrics/query/pr"
tpr "github.com/apache/incubator-devlake/plugins/metrics/transform/pr"
)

// Flow handles POST /api/metrics/pr/flow.
func Flow(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p, err := api.DecodeQueryParams(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
params := toParams(p)

prs, err := qpr.BasePRs(r.Context(), db, params)
if err != nil {
log.Printf("pr/flow: BasePRs: %v", err)
http.Error(w, "query error", http.StatusInternalServerError)
return
}
reviews, err := qpr.Reviews(r.Context(), db, params)
if err != nil {
log.Printf("pr/flow: Reviews: %v", err)
http.Error(w, "query error", http.StatusInternalServerError)
return
}
api.WriteJSON(w, tpr.BuildFlow(prs, reviews))
}
}
Loading
Loading