Repository navigation
feat(roadmap): read-only epic status and ready work items (#333) #356
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a36ebcc
657e53f
546e485
fd9bbb1
639bdcc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,6 +48,7 @@ import ( | |
| "github.com/mctlhq/mctl-api/internal/loki" | ||
| mctlmcp "github.com/mctlhq/mctl-api/internal/mcp" | ||
| "github.com/mctlhq/mctl-api/internal/operations" | ||
| "github.com/mctlhq/mctl-api/internal/roadmap" | ||
| "github.com/mctlhq/mctl-api/internal/temporalclient" | ||
| "github.com/mctlhq/mctl-api/internal/usage" | ||
| "github.com/mctlhq/mctl-api/internal/vault" | ||
|
|
@@ -241,6 +242,24 @@ func main() { | |
| } | ||
| } | ||
|
|
||
| // Roadmap read model (mctl-api#333). A second gitops.Reader on the | ||
| // generated roadmap-state branch of mctlhq/.github: mctl-api only reads | ||
| // the published RoadmapPublication, it never runs the evaluator. The | ||
| // repository is public, so no credential is needed; ROADMAP_STATE_TOKEN | ||
| // exists for a private fork. ROADMAP_STATE_DISABLED (any value but false/0/no/off) turns it off and | ||
| // the roadmap endpoints answer 503. | ||
| var roadmapGit *gitops.Reader | ||
| var roadmapReader *roadmap.Reader | ||
| if !killSwitchOn(os.Getenv("ROADMAP_STATE_DISABLED")) { | ||
| roadmapGit = gitops.NewReader( | ||
| envOr("ROADMAP_STATE_REPO_URL", "https://github.com/mctlhq/.github.git"), | ||
| envOr("ROADMAP_STATE_BRANCH", "roadmap-state"), | ||
| envOr("ROADMAP_STATE_LOCAL_PATH", "/tmp/roadmap-state"), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3 — the second checkout defaults onto the container's root filesystem, where the first one deliberately does not.
The blast radius is small in practice — A |
||
| ghtoken.Static(os.Getenv("ROADMAP_STATE_TOKEN")), "", "", | ||
| ) | ||
| roadmapReader = roadmap.NewReader(roadmapGit) | ||
| } | ||
|
|
||
| // Model usage / cost ledger (mctl-api#266, ADR-012). Optional — enabled | ||
| // when USAGE_DB_URL or AUDIT_DB_URL is set. A nil store makes the usage | ||
| // endpoints 503; see requireUsageAdmin for why that must not degrade to an | ||
|
|
@@ -314,7 +333,7 @@ func main() { | |
| workItemsDBURL = postgresURL(os.Getenv("AUDIT_DB_URL")) | ||
| } | ||
| switch { | ||
| case workItemsDisabled(os.Getenv("WORK_ITEMS_DISABLED")): | ||
| case killSwitchOn(os.Getenv("WORK_ITEMS_DISABLED")): | ||
| slog.Warn("WORK_ITEMS_DISABLED is set; /api/v1/work-items routes will return 503") | ||
| case workItemsDBURL == "": | ||
| slog.Warn("no WORK_ITEMS_DB_URL or AUDIT_DB_URL; /api/v1/work-items routes will return 503") | ||
|
|
@@ -593,6 +612,7 @@ func main() { | |
| AlertStore: alertStore, | ||
| AgentRegistry: agentRegistryStore, | ||
| Lifecycle: lifecycleStore, | ||
| Roadmap: roadmapReader, | ||
| Usage: usageStore, | ||
| DomainStore: domainStore, | ||
| DomainVerifier: domainVerifier, | ||
|
|
@@ -627,6 +647,11 @@ func main() { | |
| ctx, cancel := context.WithCancel(rootCtx) | ||
| defer cancel() | ||
| go gitReader.RefreshLoop(ctx, 60*time.Second) | ||
| if roadmapGit != nil { | ||
| // The publication changes at most every couple of hours; freshness is | ||
| // the capture time it carries, not how often this pulls. | ||
| go roadmapGit.RefreshLoop(ctx, 5*time.Minute) | ||
| } | ||
|
|
||
| // Close out audit rows the Argo completion webhook cannot reach. The hook's | ||
| // HMAC secret exists only in the argo-workflows namespace, so operations | ||
|
|
@@ -1177,11 +1202,12 @@ func splitCSV(v string) []string { | |
| return out | ||
| } | ||
|
|
||
| // workItemsDisabled reads the WORK_ITEMS_DISABLED kill switch. A kill switch | ||
| // errs toward off: any value turns work items off except an explicit | ||
| // "false"/"f"/"0"/"no"/"off" (or empty), so a template that renders the flag as | ||
| // "false" keeps them on while "yes" or "disabled" never silently fails open. | ||
| func workItemsDisabled(v string) bool { | ||
| // killSwitchOn reads a kill-switch value (WORK_ITEMS_DISABLED, | ||
| // ROADMAP_STATE_DISABLED). It errs toward off: any value except an explicit | ||
| // "false"/"f"/"0"/"no"/"off" (or empty) turns the feature off, so a template | ||
| // that renders the flag as "false" keeps it on while "yes" or "disabled" never | ||
| // silently fails open. | ||
| func killSwitchOn(v string) bool { | ||
| switch strings.ToLower(strings.TrimSpace(v)) { | ||
| case "", "false", "f", "0", "no", "off": | ||
| return false | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| // Copyright 2025 MCTL Authors | ||
| // | ||
| // Licensed 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 ( | ||
| "errors" | ||
| "log/slog" | ||
| "net/http" | ||
| "strconv" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/mctlhq/mctl-api/internal/roadmap" | ||
| ) | ||
|
|
||
| // Roadmap read model (mctl-api#333). Every answer here is selected from the | ||
| // RoadmapPublication that mctlhq/.github publishes to its roadmap-state | ||
| // branch; nothing is evaluated, inferred or written. A publication that is | ||
| // missing or fails verification answers 503, which means "cannot say", never | ||
| // "nothing is ready". | ||
|
|
||
| const ( | ||
| roadmapCodeUnavailable = "roadmap_unavailable" | ||
| roadmapCodeEpicNotFound = "epic_not_found" | ||
| roadmapCodeInvalid = "invalid_request" | ||
| ) | ||
|
|
||
| func (h *Handlers) roadmapPublication(w http.ResponseWriter) (*roadmap.Publication, bool) { | ||
| pub, err := h.opts.Roadmap.Current() | ||
| if err != nil { | ||
| // The detail (paths, git output) goes to the log; the caller needs | ||
| // only to know nothing verified, which means "cannot say". | ||
| slog.Warn("roadmap publication unavailable", "error", err) | ||
| writeErrorCode(w, http.StatusServiceUnavailable, roadmapCodeUnavailable, | ||
| "no verified roadmap publication: readiness is unknown, not empty", nil) | ||
| return nil, false | ||
| } | ||
| return pub, true | ||
| } | ||
|
|
||
| func writeRoadmapLookupError(w http.ResponseWriter, err error) { | ||
| if errors.Is(err, roadmap.ErrEpicNotFound) { | ||
| writeErrorCode(w, http.StatusNotFound, roadmapCodeEpicNotFound, err.Error(), nil) | ||
| return | ||
| } | ||
| writeError(w, http.StatusInternalServerError, "roadmap lookup failed") | ||
| } | ||
|
|
||
| // ListRoadmapEpics handles GET /api/v1/roadmap/epics. | ||
| func (h *Handlers) ListRoadmapEpics(w http.ResponseWriter, r *http.Request) { | ||
| pub, ok := h.roadmapPublication(w) | ||
| if !ok { | ||
| return | ||
| } | ||
| writeJSON(w, http.StatusOK, map[string]any{ | ||
| "epics": pub.Epics(), | ||
| "provenance": pub.Provenance(time.Now()), | ||
| }) | ||
| } | ||
|
|
||
| // GetRoadmapEpicStatus handles GET /api/v1/roadmap/epic-status?epic=<name or owner/repo#N>. | ||
| func (h *Handlers) GetRoadmapEpicStatus(w http.ResponseWriter, r *http.Request) { | ||
| epic := strings.TrimSpace(r.URL.Query().Get("epic")) | ||
| if epic == "" { | ||
| writeErrorCode(w, http.StatusBadRequest, roadmapCodeInvalid, "epic is required: a name or a root issue such as mctlhq/.github#57", nil) | ||
| return | ||
| } | ||
| pub, ok := h.roadmapPublication(w) | ||
| if !ok { | ||
| return | ||
| } | ||
| status, err := pub.EpicStatus(epic, time.Now()) | ||
| if err != nil { | ||
| writeRoadmapLookupError(w, err) | ||
| return | ||
| } | ||
| writeJSON(w, http.StatusOK, status) | ||
| } | ||
|
|
||
| // GetRoadmapReadyWorkItems handles GET /api/v1/roadmap/ready?epic=&required_only=. | ||
| // Without epic it answers for every active epic. required_only defaults to | ||
| // true: the safe wave-selection default. | ||
| func (h *Handlers) GetRoadmapReadyWorkItems(w http.ResponseWriter, r *http.Request) { | ||
| q := r.URL.Query() | ||
| requiredOnly := true | ||
| if v := strings.TrimSpace(q.Get("required_only")); v != "" { | ||
| b, err := strconv.ParseBool(v) | ||
| if err != nil { | ||
| writeErrorCode(w, http.StatusBadRequest, roadmapCodeInvalid, "required_only must be true or false", nil) | ||
| return | ||
| } | ||
| requiredOnly = b | ||
| } | ||
| pub, ok := h.roadmapPublication(w) | ||
| if !ok { | ||
| return | ||
| } | ||
| ready, err := pub.ReadyWorkItems(q.Get("epic"), requiredOnly, time.Now()) | ||
| if err != nil { | ||
| writeRoadmapLookupError(w, err) | ||
| return | ||
| } | ||
| writeJSON(w, http.StatusOK, ready) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3 — this commit touches
.env.exampleto correct theWORK_ITEMS_DISABLEDwording, but does not add the fiveROADMAP_STATE_*variables it introduces.README.md gains all five rows (
ROADMAP_STATE_REPO_URL,_BRANCH,_LOCAL_PATH,_TOKEN,_DISABLED), and.env.exampleis the file an operator copies to.env—WORK_ITEMS_DB_URL/WORK_ITEMS_DISABLEDare both listed there for exactly that reason. Someone bringing up a local API from the template has no indication that a second git checkout is about to be cloned into/tmp/roadmap-state, nor the one variable that stops it. That matters more for this feature than for most, because the reader starts unconditionally at boot and fails only into log lines.Five commented lines mirroring the
WORK_ITEMS_*block would keep the template and the README saying the same thing.