| 1 |
|
package mcpsrv |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"errors" |
| 6 |
|
"fmt" |
| 7 |
|
"log/slog" |
| 8 |
|
"net/url" |
| 9 |
|
"strings" |
| 10 |
|
"time" |
| 11 |
|
|
| 12 |
|
"github.com/modelcontextprotocol/go-sdk/mcp" |
| 13 |
|
"go.bigb.es/auxilia/scribe" |
| 14 |
|
|
| 15 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/beads" |
| 16 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/core" |
| 17 |
|
) |
| 18 |
|
|
| 19 |
|
// ready_work, the last tool of docs/DESIGN.mcp.md §9.2 and the only one on this |
| 20 |
|
// surface that answers about more than one database at a time. |
| 21 |
|
// |
| 22 |
|
// "What is ready to work" is answerable inside each beads tracker on this |
| 23 |
|
// instance and, without this, nowhere across them — which is the question the |
| 24 |
|
// split into a global tracker plus per-project trackers was supposed to make |
| 25 |
|
// askable. The /ready page (docs/DESIGN.views.md ch. 4) answers it for a human; |
| 26 |
|
// this answers it for an agent. |
| 27 |
|
// |
| 28 |
|
// # One aggregator, two renderings |
| 29 |
|
// |
| 30 |
|
// The set itself is beads.ReadyAcross's, unchanged and un-re-read: the same |
| 31 |
|
// function the page calls, over the same projection cache shape, with the same |
| 32 |
|
// three bounds (the head-hash gate, the TTL, the ceiling of |
| 33 |
|
// beads.ReadyMaxDatabases). Nothing about the ready rule, the grouping or the |
| 34 |
|
// ordering is restated here — two implementations would answer differently the |
| 35 |
|
// first time either moved, and the whole point of this tool is that the page and |
| 36 |
|
// the agent agree. |
| 37 |
|
// |
| 38 |
|
// What this file owns is the other half, which is exactly what beads/ refuses to |
| 39 |
|
// know: which databases this caller may see at all. That is ListReposForViewer |
| 40 |
|
// (the listing rule) followed by core.Allowed/OpBrowse per database (the access |
| 41 |
|
// rule), applied before a single store is opened — the same two steps |
| 42 |
|
// web/handlers_ready.go takes. |
| 43 |
|
// |
| 44 |
|
// # Two arms, one answer |
| 45 |
|
// |
| 46 |
|
// With a database named the tool answers about that one, resolved through the |
| 47 |
|
// preamble every beads tool shares (openTrackerFor): the masked not-found of a |
| 48 |
|
// database the caller may not read, the "not a beads tracker" refusal, the |
| 49 |
|
// default branch. With none named it answers across every tracker the caller may |
| 50 |
|
// see. Both arms then run through ReadyAcross with the same filter and the same |
| 51 |
|
// cache, so the two cannot disagree about one database: the named arm is the |
| 52 |
|
// cross arm restricted to a single candidate. |
| 53 |
|
|
| 54 |
|
// readyWorkInput addresses the tool's two arms and carries the page's own |
| 55 |
|
// filters. |
| 56 |
|
// |
| 57 |
|
// owner and name are optional *together*: naming neither is the cross-database |
| 58 |
|
// arm, naming both is one database, and naming one of the two is a call that |
| 59 |
|
// means nothing and is refused rather than guessed at. |
| 60 |
|
// |
| 61 |
|
// There is no ref argument, and that is not an omission. Across databases there |
| 62 |
|
// is no ref to name — a branch of one tracker says nothing about another — so |
| 63 |
|
// every database is read at its own default branch, exactly as the page reads |
| 64 |
|
// it. A tracker's other branches are list_issues' business. |
| 65 |
|
type readyWorkInput struct { |
| 66 |
|
Owner string `json:"owner,omitempty" jsonschema:"the database owner's SourceHut username, without the \"~\" (a leading one is accepted). Omit it — together with name — to read every tracker you can see."` |
| 67 |
|
Name string `json:"name,omitempty" jsonschema:"the database name, as list_databases reports it. Omit it — together with owner — to read every tracker you can see."` |
| 68 |
|
|
| 69 |
|
Query string `json:"q,omitempty" jsonschema:"a case-insensitive substring of an issue's id or title; it does not search bodies"` |
| 70 |
|
Assignee string `json:"assignee,omitempty" jsonschema:"an exact assignee"` |
| 71 |
|
Priority string `json:"priority,omitempty" jsonschema:"an exact priority as stored: \"0\" (highest) through \"3\""` |
| 72 |
|
|
| 73 |
|
Limit *int `json:"limit,omitempty" jsonschema:"how many ready issues to return across all databases, at most 500; defaults to 200. It is applied after filtering."` |
| 74 |
|
} |
| 75 |
|
|
| 76 |
|
// readyCardJSON is one ready issue. |
| 77 |
|
// |
| 78 |
|
// It is issueCardJSON minus the two fields that would be constants here: every |
| 79 |
|
// card in this answer is ready and every one of them is in the open status |
| 80 |
|
// category — that is what the ready rule selects — and a field whose value is |
| 81 |
|
// fixed by the tool's own name tells a caller nothing. There is no lane either: |
| 82 |
|
// the projection this reads produces the ready set, not the board's bucketing. |
| 83 |
|
// |
| 84 |
|
// No bodies, for the reason §9.2 gives once: a listing carries identity and |
| 85 |
|
// metadata, and get_issue carries the prose, one issue at a time. |
| 86 |
|
type readyCardJSON struct { |
| 87 |
|
ID string `json:"id"` |
| 88 |
|
Title string `json:"title"` |
| 89 |
|
|
| 90 |
|
Type string `json:"type"` |
| 91 |
|
Priority string `json:"priority"` |
| 92 |
|
Assignee string `json:"assignee"` |
| 93 |
|
Labels []string `json:"labels"` |
| 94 |
|
|
| 95 |
|
// BlockedBy is how many dependencies this issue has and Blocks how many point |
| 96 |
|
// at it. A ready issue can still have dependencies — a closed blocker is a |
| 97 |
|
// dependency that does not block — so BlockedBy is not always zero, and that |
| 98 |
|
// is worth seeing. |
| 99 |
|
BlockedBy int `json:"blocked_by"` |
| 100 |
|
Blocks int `json:"blocks"` |
| 101 |
|
} |
| 102 |
|
|
| 103 |
|
// readyHeadJSON is the head commit of the branch a database's ready set was read |
| 104 |
|
// from. |
| 105 |
|
// |
| 106 |
|
// It is carried per database and not per answer because it is the one fact that |
| 107 |
|
// makes the ready set checkable: a tracker that stopped receiving pushes still |
| 108 |
|
// has a ready set, and reporting it without saying how old the data is would be |
| 109 |
|
// exactly the claim this tool must not make silently. |
| 110 |
|
type readyHeadJSON struct { |
| 111 |
|
Hash string `json:"hash"` |
| 112 |
|
Time time.Time `json:"time"` |
| 113 |
|
} |
| 114 |
|
|
| 115 |
|
// readyDatabaseJSON is one tracker's ready work. |
| 116 |
|
type readyDatabaseJSON struct { |
| 117 |
|
// Owner and Name are the address every other tool on this surface takes, so |
| 118 |
|
// a caller can go straight from a card here to get_issue there. |
| 119 |
|
Owner string `json:"owner"` |
| 120 |
|
Name string `json:"name"` |
| 121 |
|
|
| 122 |
|
// Ref is the branch this database was read at — its own default branch. |
| 123 |
|
Ref string `json:"ref"` |
| 124 |
|
|
| 125 |
|
// Head is that branch's head commit, or null when the log could not be read. |
| 126 |
|
// Null is not a claim that the tracker is empty: the ready set beside it was |
| 127 |
|
// read at that very branch. |
| 128 |
|
Head *readyHeadJSON `json:"head"` |
| 129 |
|
|
| 130 |
|
// Ready is this database's ready issues, ordered by priority (0 first) then |
| 131 |
|
// id. |
| 132 |
|
Ready []readyCardJSON `json:"ready"` |
| 133 |
|
|
| 134 |
|
// Count is how many ready issues this database has after filtering, which is |
| 135 |
|
// not always how many are carried above: the answer's limit is spent across |
| 136 |
|
// databases, and this is the honest denominator per database. |
| 137 |
|
Count int `json:"count"` |
| 138 |
|
} |
| 139 |
|
|
| 140 |
|
// readyUnreadableJSON names a database that could not be read. |
| 141 |
|
// |
| 142 |
|
// It names it and nothing else: the underlying failure carries on-disk paths and |
| 143 |
|
// dolt internals, and it goes to the log (sr-ht-dolt-7ta). The address is not a |
| 144 |
|
// disclosure — it is a database this caller was already allowed to list. |
| 145 |
|
type readyUnreadableJSON struct { |
| 146 |
|
Owner string `json:"owner"` |
| 147 |
|
Name string `json:"name"` |
| 148 |
|
} |
| 149 |
|
|
| 150 |
|
type readyWorkOutput struct { |
| 151 |
|
// Databases carry the ready work, ordered by ready count descending then by |
| 152 |
|
// address — the page's own order. A database with nothing ready is absent |
| 153 |
|
// rather than present and empty. |
| 154 |
|
Databases []readyDatabaseJSON `json:"databases"` |
| 155 |
|
|
| 156 |
|
// Total is every ready issue that matched, across every database, before |
| 157 |
|
// Limit clipped the list — the honest denominator of the answer. |
| 158 |
|
Total int `json:"total"` |
| 159 |
|
|
| 160 |
|
// Limit is the limit that was applied, which is not always the one asked |
| 161 |
|
// for: a request above the cap is answered at the cap. |
| 162 |
|
Limit int `json:"limit"` |
| 163 |
|
|
| 164 |
|
// Truncated reports that matches were left behind by Limit. Narrow with q, |
| 165 |
|
// assignee or priority, or name one database. |
| 166 |
|
Truncated bool `json:"truncated"` |
| 167 |
|
|
| 168 |
|
// Considered is how many databases were actually read (or served from the |
| 169 |
|
// cache), and MaxDatabases is the ceiling one call may open. |
| 170 |
|
Considered int `json:"considered"` |
| 171 |
|
MaxDatabases int `json:"max_databases"` |
| 172 |
|
|
| 173 |
|
// Capped says there were more candidate databases than MaxDatabases and only |
| 174 |
|
// the first of them were read. A silent cap reads as "that is everything", |
| 175 |
|
// which is the one thing an answer about a *set* must never imply. |
| 176 |
|
Capped bool `json:"capped"` |
| 177 |
|
|
| 178 |
|
// Unreadable names the databases whose stores this service could not read. |
| 179 |
|
// They are part of the answer rather than a silent omission for Capped's |
| 180 |
|
// reason: the ready set below is complete only for the databases that are not |
| 181 |
|
// in this list. |
| 182 |
|
Unreadable []readyUnreadableJSON `json:"unreadable"` |
| 183 |
|
} |
| 184 |
|
|
| 185 |
|
// borrowedSession lends an already-open session to the aggregation without |
| 186 |
|
// handing over its lifetime. |
| 187 |
|
// |
| 188 |
|
// beads.ReadyAcross closes every session its opener produced, which is right |
| 189 |
|
// when the opener opened it. In the named arm the session was opened by the |
| 190 |
|
// handler — by the same preamble that resolved the database and checked the |
| 191 |
|
// fingerprint — and is closed by that handler's own defer, so the borrowed copy |
| 192 |
|
// must not close it a second time. A Close is not documented as idempotent |
| 193 |
|
// anywhere in this service, and a double close is the kind of thing that works |
| 194 |
|
// on a fake and corrupts a handle in production. |
| 195 |
|
type borrowedSession struct{ BrowseSession } |
| 196 |
|
|
| 197 |
5 |
func (borrowedSession) Close() error { return nil } |
| 198 |
|
|
| 199 |
|
// --- registration ----------------------------------------------------------- |
| 200 |
|
|
| 201 |
|
// registerReadyWork installs ready_work (docs/DESIGN.mcp.md §9.2, the |
| 202 |
|
// cross-database row). |
| 203 |
126 |
func (s *Server) registerReadyWork() { |
| 204 |
126 |
mcp.AddTool(s.mcp, &mcp.Tool{ |
| 205 |
126 |
Name: "ready_work", |
| 206 |
126 |
Annotations: readOnlyTool, |
| 207 |
126 |
Description: "Answer \"what can be picked up right now\" — bd's ready set: issues that are open, " + |
| 208 |
126 |
"unblocked and not templates or scaffolding.\n\n" + |
| 209 |
126 |
"**Omit `owner` and `name` and it answers across every beads tracker you can see**, grouped " + |
| 210 |
126 |
"by database, busiest first. That is what this tool is for: an instance holds one tracker per " + |
| 211 |
126 |
"project plus a global one, and this is the only way to ask all of them at once. Name both to " + |
| 212 |
126 |
"ask one tracker.\n\n" + |
| 213 |
126 |
"Each database carries its own `ref` and `head` — the branch the set was read from and that " + |
| 214 |
126 |
"branch's head commit with its time. Read them: a tracker that stopped receiving pushes still " + |
| 215 |
126 |
"has a ready set, and its age is the only thing that says so.\n\n" + |
| 216 |
126 |
"`q`, `assignee` and `priority` narrow the issues; `limit` (default 200, cap 500) is spent " + |
| 217 |
126 |
"across all databases after filtering, `total` is how many matched, and `truncated` says some " + |
| 218 |
126 |
"were left behind. Each database's `count` is its own match total.\n\n" + |
| 219 |
126 |
"Two facts keep the answer honest about being a set. `capped` says there were more trackers " + |
| 220 |
126 |
"than `max_databases` and only the first were read. `unreadable` names the databases whose " + |
| 221 |
126 |
"stores could not be read at all — the set is complete only for the databases not in it.\n\n" + |
| 222 |
126 |
"Cards carry no issue bodies; call get_issue with a card's `owner`, `name` and `id` for the " + |
| 223 |
126 |
"description, design and acceptance criteria. A database you name that is not a beads tracker " + |
| 224 |
126 |
"says so; one you may not read is reported as not existing.", |
| 225 |
126 |
}, func(ctx context.Context, _ *mcp.CallToolRequest, in readyWorkInput) (*mcp.CallToolResult, readyWorkOutput, error) { |
| 226 |
45 |
out, err := s.readyWork(ctx, in) |
| 227 |
45 |
return nil, out, err |
| 228 |
45 |
}) |
| 229 |
|
} |
| 230 |
|
|
| 231 |
|
// --- the handler ------------------------------------------------------------ |
| 232 |
|
|
| 233 |
|
// readyWork answers ready_work: the ready set of one named tracker, or of every |
| 234 |
|
// tracker the caller may see. |
| 235 |
|
// |
| 236 |
|
// The clock is real and is passed into the aggregation rather than read inside |
| 237 |
|
// it, exactly as listMemories passes its own: the TTL is the one thing about |
| 238 |
|
// this answer that depends on when it was computed, and beads/ reads no hidden |
| 239 |
|
// clock. |
| 240 |
45 |
func (s *Server) readyWork(ctx context.Context, in readyWorkInput) (readyWorkOutput, error) { |
| 241 |
45 |
const tool = "ready_work" |
| 242 |
45 |
var out readyWorkOutput |
| 243 |
45 |
|
| 244 |
45 |
limit, err := pageLimit(in.Limit, defaultIssueLimit, maxIssueLimit, "issues") |
| 245 |
45 |
if err != nil { |
| 246 |
2 |
return out, err |
| 247 |
2 |
} |
| 248 |
|
// The filter is parsed by beads rather than assembled here, for boardQuery's |
| 249 |
|
// reason: the substring rule and the trimming are the page's, not a second |
| 250 |
|
// implementation of them reading the same cards. |
| 251 |
43 |
filter := beads.ParseReadyFilter(readyQuery(in)) |
| 252 |
43 |
|
| 253 |
43 |
ref := databaseRef{Owner: in.Owner, Name: in.Name} |
| 254 |
43 |
named := ref.owner() != "" || ref.name() != "" |
| 255 |
43 |
|
| 256 |
43 |
var ( |
| 257 |
43 |
dbs []beads.ReadyDatabase |
| 258 |
43 |
open beads.ReadyOpener |
| 259 |
43 |
) |
| 260 |
43 |
if named { |
| 261 |
10 |
var sess BrowseSession |
| 262 |
10 |
dbs, open, sess, err = s.readyOne(ctx, tool, ref) |
| 263 |
10 |
if err != nil { |
| 264 |
5 |
return out, err |
| 265 |
5 |
} |
| 266 |
5 |
defer sess.Close() |
| 267 |
33 |
} else if dbs, open, err = s.readyAll(ctx, tool); err != nil { |
| 268 |
0 |
return out, err |
| 269 |
0 |
} |
| 270 |
|
|
| 271 |
38 |
view := beads.ReadyAcross(ctx, dbs, open, s.ready, filter, time.Now()) |
| 272 |
38 |
|
| 273 |
38 |
// A store that cannot be read is a fact about this deployment and belongs in |
| 274 |
38 |
// the log with its cause; the answer names the database and not the failure. |
| 275 |
38 |
// |
| 276 |
38 |
// In the named arm it is not a partial answer at all: that database is the |
| 277 |
38 |
// whole subject of the call, its ref resolved and its fingerprint matched a |
| 278 |
38 |
// moment ago, so what failed afterwards is a table this service could not |
| 279 |
38 |
// read. Reporting an empty ready set there would be a false statement about |
| 280 |
38 |
// the tracker rather than a true one about the server. |
| 281 |
38 |
for _, f := range view.Failed { |
| 282 |
30 |
slog.Warn("reading a database for ready_work failed", |
| 283 |
30 |
"tool", tool, "database", f.Database.Slug(), scribe.Err(f.Err)) |
| 284 |
30 |
} |
| 285 |
38 |
if named && len(view.Failed) > 0 { |
| 286 |
0 |
return out, internalError(view.Failed[0].Err, tool) |
| 287 |
0 |
} |
| 288 |
|
|
| 289 |
38 |
out = readyWorkOutput{ |
| 290 |
38 |
Databases: make([]readyDatabaseJSON, 0, len(view.Groups)), |
| 291 |
38 |
Total: view.Total, |
| 292 |
38 |
Limit: limit, |
| 293 |
38 |
Considered: view.Considered, |
| 294 |
38 |
MaxDatabases: view.Max, |
| 295 |
38 |
Capped: view.Capped, |
| 296 |
38 |
Unreadable: make([]readyUnreadableJSON, 0, len(view.Failed)), |
| 297 |
38 |
} |
| 298 |
38 |
for _, f := range view.Failed { |
| 299 |
30 |
out.Unreadable = append(out.Unreadable, readyUnreadableJSON{ |
| 300 |
30 |
Owner: f.Database.OwnerName, |
| 301 |
30 |
Name: f.Database.Name, |
| 302 |
30 |
}) |
| 303 |
30 |
} |
| 304 |
|
|
| 305 |
|
// The limit is spent across databases in the order the aggregation produced |
| 306 |
|
// them, so what a clipped answer carries is the busiest trackers' highest |
| 307 |
|
// priorities rather than an arbitrary slice. A database left with nothing is |
| 308 |
|
// absent rather than present and empty — the same rule the aggregation |
| 309 |
|
// applies to a tracker with no ready work. |
| 310 |
38 |
carried := 0 |
| 311 |
126 |
for _, g := range view.Groups { |
| 312 |
126 |
entry := readyDatabaseJSON{ |
| 313 |
126 |
Owner: g.Database.OwnerName, |
| 314 |
126 |
Name: g.Database.Name, |
| 315 |
126 |
Ref: g.Ref, |
| 316 |
126 |
Count: len(g.Cards), |
| 317 |
126 |
Ready: make([]readyCardJSON, 0, len(g.Cards)), |
| 318 |
126 |
} |
| 319 |
126 |
if g.Head != nil { |
| 320 |
126 |
entry.Head = &readyHeadJSON{Hash: g.Head.Hash, Time: g.Head.Date} |
| 321 |
126 |
} |
| 322 |
152 |
for _, c := range g.Cards { |
| 323 |
152 |
if carried >= limit { |
| 324 |
2 |
break |
| 325 |
|
} |
| 326 |
150 |
entry.Ready = append(entry.Ready, readyCardOf(c)) |
| 327 |
150 |
carried++ |
| 328 |
|
} |
| 329 |
126 |
if len(entry.Ready) == 0 { |
| 330 |
1 |
continue |
| 331 |
|
} |
| 332 |
125 |
out.Databases = append(out.Databases, entry) |
| 333 |
|
} |
| 334 |
38 |
out.Truncated = carried < out.Total |
| 335 |
38 |
return out, nil |
| 336 |
|
} |
| 337 |
|
|
| 338 |
|
// readyOne is the named arm's candidate list: one database, resolved and opened |
| 339 |
|
// through the preamble every beads tool shares. |
| 340 |
|
// |
| 341 |
|
// Going through openTrackerFor is what makes this arm answer like the rest of |
| 342 |
|
// the surface without restating any of it — the masked not-found, the "not a |
| 343 |
|
// beads tracker" refusal, the default branch and the store that will not open |
| 344 |
|
// are all decided there. The session it returns is the caller's to close. |
| 345 |
|
func (s *Server) readyOne(ctx context.Context, tool string, ref databaseRef) ( |
| 346 |
|
[]beads.ReadyDatabase, beads.ReadyOpener, BrowseSession, error, |
| 347 |
10 |
) { |
| 348 |
10 |
if ref.owner() == "" || ref.name() == "" { |
| 349 |
2 |
return nil, nil, nil, errors.New( |
| 350 |
2 |
"address a database by both its owner and its name, as list_databases reports them — " + |
| 351 |
2 |
"or name neither, and this answers across every tracker you can see") |
| 352 |
2 |
} |
| 353 |
|
|
| 354 |
8 |
repo, sess, _, err := s.openTrackerFor(ctx, tool, ref, "") |
| 355 |
8 |
if err != nil { |
| 356 |
3 |
return nil, nil, nil, err |
| 357 |
3 |
} |
| 358 |
5 |
dbs := []beads.ReadyDatabase{{ID: repo.ID, OwnerName: repo.OwnerName, Name: repo.Name}} |
| 359 |
5 |
open := func(context.Context, beads.ReadyDatabase) (beads.ReadySession, error) { |
| 360 |
5 |
return borrowedSession{sess}, nil |
| 361 |
5 |
} |
| 362 |
5 |
return dbs, open, sess, nil |
| 363 |
|
} |
| 364 |
|
|
| 365 |
|
// readyAll is the cross-database arm's candidate list: every database this |
| 366 |
|
// caller may browse. |
| 367 |
|
// |
| 368 |
|
// It is two steps and they are not the same rule. ListReposForViewer applies the |
| 369 |
|
// *listing* rule (PUBLIC to everyone including anonymity, plus whatever the |
| 370 |
|
// caller owns or holds an ACL entry on), and core.Allowed then applies the |
| 371 |
|
// *access* rule per database. A database the caller may not browse is simply |
| 372 |
|
// absent — not a refusal, not a count, not a named group with hidden contents — |
| 373 |
|
// and it is dropped here, before any store is opened, so its very existence |
| 374 |
|
// costs nothing observable. |
| 375 |
|
// |
| 376 |
|
// An ACL lookup that fails takes the whole call with it rather than quietly |
| 377 |
|
// narrowing the answer. "I could not check" is not "you may not", and an agent |
| 378 |
|
// told that its tracker has no ready work believes it. |
| 379 |
33 |
func (s *Server) readyAll(ctx context.Context, tool string) ([]beads.ReadyDatabase, beads.ReadyOpener, error) { |
| 380 |
33 |
caller := callerOf(ctx) |
| 381 |
33 |
|
| 382 |
33 |
repos, err := s.repos.ListReposForViewer(ctx, caller) |
| 383 |
33 |
if err != nil { |
| 384 |
0 |
// No database was addressed, so there is no "that one does not exist" to |
| 385 |
0 |
// answer: whatever went wrong enumerating them is this service's. |
| 386 |
0 |
return nil, nil, internalError(err, tool) |
| 387 |
0 |
} |
| 388 |
|
|
| 389 |
|
// The on-disk path never reaches beads: it is this service's arrangement of |
| 390 |
|
// its own storage, and the aggregation addresses a database by the identity |
| 391 |
|
// its cache is keyed on. The opener closes over this map, so a database that |
| 392 |
|
// was filtered out above has no path to be opened by. |
| 393 |
33 |
paths := make(map[int]string, len(repos)) |
| 394 |
33 |
dbs := make([]beads.ReadyDatabase, 0, len(repos)) |
| 395 |
198 |
for _, repo := range repos { |
| 396 |
198 |
var mode *core.AccessMode |
| 397 |
198 |
if caller != nil { |
| 398 |
68 |
if mode, err = s.repos.EffectiveAccess(ctx, caller.UserID, repo.ID); err != nil { |
| 399 |
0 |
return nil, nil, internalError(err, tool) |
| 400 |
0 |
} |
| 401 |
|
} |
| 402 |
198 |
if !core.Allowed(caller, repo, mode, core.OpBrowse) { |
| 403 |
1 |
continue |
| 404 |
|
} |
| 405 |
197 |
paths[repo.ID] = repo.Path |
| 406 |
197 |
dbs = append(dbs, beads.ReadyDatabase{ |
| 407 |
197 |
ID: repo.ID, |
| 408 |
197 |
OwnerName: repo.OwnerName, |
| 409 |
197 |
Name: repo.Name, |
| 410 |
197 |
}) |
| 411 |
|
} |
| 412 |
|
|
| 413 |
195 |
open := func(ctx context.Context, d beads.ReadyDatabase) (beads.ReadySession, error) { |
| 414 |
195 |
path, ok := paths[d.ID] |
| 415 |
195 |
if !ok { |
| 416 |
0 |
return nil, fmt.Errorf("mcpsrv: no store path for database %s", d.Slug()) |
| 417 |
0 |
} |
| 418 |
195 |
return s.opener.Open(ctx, path) |
| 419 |
|
} |
| 420 |
33 |
return dbs, open, nil |
| 421 |
|
} |
| 422 |
|
|
| 423 |
|
// readyQuery renders the tool's filters as the query the page's filter parser |
| 424 |
|
// reads, so that what narrows this answer is beads.ReadyFilter and not a second |
| 425 |
|
// implementation of it applied to the same cards. |
| 426 |
|
// |
| 427 |
|
// The parser's ?db= is deliberately not offered: naming databases is what owner |
| 428 |
|
// and name already do, one at a time, through the resolution every other tool on |
| 429 |
|
// this surface uses. |
| 430 |
43 |
func readyQuery(in readyWorkInput) url.Values { |
| 431 |
43 |
q := url.Values{} |
| 432 |
129 |
set := func(key, value string) { |
| 433 |
129 |
if value = strings.TrimSpace(value); value != "" { |
| 434 |
9 |
q.Set(key, value) |
| 435 |
9 |
} |
| 436 |
|
} |
| 437 |
43 |
set("q", in.Query) |
| 438 |
43 |
set("assignee", in.Assignee) |
| 439 |
43 |
set("priority", in.Priority) |
| 440 |
43 |
return q |
| 441 |
|
} |
| 442 |
|
|
| 443 |
|
// readyCardOf projects one ready card. Labels are an array rather than a null, |
| 444 |
|
// so an agent can loop without a nil check. |
| 445 |
150 |
func readyCardOf(c beads.Card) readyCardJSON { |
| 446 |
150 |
labels := c.Labels |
| 447 |
150 |
if labels == nil { |
| 448 |
150 |
labels = []string{} |
| 449 |
150 |
} |
| 450 |
150 |
return readyCardJSON{ |
| 451 |
150 |
ID: c.ID, |
| 452 |
150 |
Title: c.Title, |
| 453 |
150 |
Type: c.Type, |
| 454 |
150 |
Priority: c.Priority, |
| 455 |
150 |
Assignee: c.Assignee, |
| 456 |
150 |
Labels: labels, |
| 457 |
150 |
BlockedBy: c.BlockedBy, |
| 458 |
150 |
Blocks: c.Blocks, |
| 459 |
150 |
} |
| 460 |
|
} |