Skip to content

Commit 755b351

Browse files
authored
make the parser fully agnostic of type
1 parent f84a67c commit 755b351

4 files changed

Lines changed: 97 additions & 112 deletions

File tree

pkg/github/granular_tools_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ func TestGranularUpdateIssueLabelsInvalidRationale(t *testing.T) {
441441
map[string]any{"rationale": "no name provided"},
442442
},
443443
},
444-
expectedErrText: "each label object must have a 'name' string",
444+
expectedErrText: "each labels object must have a 'name' string",
445445
},
446446
}
447447

pkg/github/issues.go

Lines changed: 4 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1988,7 +1988,7 @@ Options are:
19881988
assigneesProvided = assigneesProvided && assigneesValue != nil
19891989

19901990
// Get labels (plain names or per-label intent objects)
1991-
labels, labelsPayload, labelsHaveIntent, labelsProvided, err := parseIssueWriteLabels(args)
1991+
labels, labelsPayload, labelsHaveIntent, labelsProvided, err := parseParamWithIntent(args, "labels", "name")
19921992
if err != nil {
19931993
return utils.NewToolResultError(err.Error()), nil, nil
19941994
}
@@ -2241,7 +2241,7 @@ Options are:
22412241
assigneesProvided = assigneesProvided && assigneesValue != nil
22422242

22432243
// Get labels (plain names or per-label intent objects)
2244-
labels, labelsPayload, labelsHaveIntent, labelsProvided, err := parseIssueWriteLabels(args)
2244+
labels, labelsPayload, labelsHaveIntent, labelsProvided, err := parseParamWithIntent(args, "labels", "name")
22452245
if err != nil {
22462246
return utils.NewToolResultError(err.Error()), nil, nil
22472247
}
@@ -2379,71 +2379,15 @@ type UpdateIssueOptions struct {
23792379
// LabelsProvided sends the labels field even when the slice is empty.
23802380
LabelsProvided bool
23812381
// LabelsWithIntent, when non-empty, sends labels in object form (a mix of
2382-
// plain label names and labelWithIntent objects) via a custom request so
2382+
// plain label names and valueWithIntent objects) via a custom request so
23832383
// per-label rationale and suggestion intent are preserved. When set, it
23842384
// takes precedence over the labels slice.
23852385
LabelsWithIntent []any
23862386
}
23872387

2388-
// parseIssueWriteLabels parses the issue_write "labels" parameter, which accepts
2389-
// either plain label names (strings) or objects carrying intent metadata
2390-
// (name, rationale, confidence, is_suggestion). It returns the plain label names
2391-
// (intent stripped), the object-form payload to send when any label carries
2392-
// intent, whether any label carried intent, whether the labels parameter was
2393-
// provided at all, and an error.
2394-
func parseIssueWriteLabels(args map[string]any) (names []string, payload []any, hasIntent bool, provided bool, err error) {
2395-
raw, ok := args["labels"]
2396-
if !ok || raw == nil {
2397-
return []string{}, nil, false, false, nil
2398-
}
2399-
2400-
var labelsSlice []any
2401-
switch v := raw.(type) {
2402-
case []any:
2403-
labelsSlice = v
2404-
case []string:
2405-
labelsSlice = make([]any, len(v))
2406-
for i, s := range v {
2407-
labelsSlice[i] = s
2408-
}
2409-
default:
2410-
return nil, nil, false, true, fmt.Errorf("labels must be an array")
2411-
}
2412-
2413-
names = make([]string, 0, len(labelsSlice))
2414-
payload = make([]any, 0, len(labelsSlice))
2415-
for _, item := range labelsSlice {
2416-
switch v := item.(type) {
2417-
case string:
2418-
names = append(names, v)
2419-
payload = append(payload, v)
2420-
case map[string]any:
2421-
name, nameErr := RequiredParam[string](v, "name")
2422-
if nameErr != nil {
2423-
return nil, nil, false, true, fmt.Errorf("each label object must have a 'name' string")
2424-
}
2425-
names = append(names, name)
2426-
2427-
intent, itemHasIntent, intentErr := parseValueIntent(v)
2428-
if intentErr != nil {
2429-
return nil, nil, false, true, intentErr
2430-
}
2431-
if itemHasIntent {
2432-
hasIntent = true
2433-
payload = append(payload, labelWithIntent{Name: name, valueIntent: intent})
2434-
} else {
2435-
payload = append(payload, name)
2436-
}
2437-
default:
2438-
return nil, nil, false, true, fmt.Errorf("each label must be a string or an object with 'name' and optional 'rationale', 'confidence', and/or 'is_suggestion'")
2439-
}
2440-
}
2441-
return names, payload, hasIntent, true, nil
2442-
}
2443-
24442388
// issueRequestWithLabels marshals an IssueRequest into a generic map and sets
24452389
// the labels field to the provided object-form payload (a mix of plain label
2446-
// names and labelWithIntent objects). This lets an issue update carry per-label
2390+
// names and valueWithIntent objects). This lets an issue update carry per-label
24472391
// rationale and suggestion intent that github.IssueRequest cannot represent.
24482392
func issueRequestWithLabels(issueRequest *github.IssueRequest, labels []any) (map[string]any, error) {
24492393
data, err := json.Marshal(issueRequest)

pkg/github/issues_granular.go

Lines changed: 91 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -345,20 +345,100 @@ func withIntentProperties(props map[string]*jsonschema.Schema) map[string]*jsons
345345
return props
346346
}
347347

348-
// labelWithIntent represents the object form of a label entry, carrying optional
349-
// intent metadata (rationale, confidence, suggest) alongside the label name.
350-
type labelWithIntent struct {
351-
Name string `json:"name"`
348+
// valueWithIntent is the object form of a written value: an identity field whose
349+
// JSON key varies by value type (e.g. "name" for a label) plus optional intent
350+
// metadata. It marshals to a single object merging the identity field with the
351+
// rationale/confidence/suggest fields, so the same structure serves any value
352+
// type that travels as a named object with intent.
353+
type valueWithIntent struct {
354+
identityKey string
355+
identity string
352356
valueIntent
353357
}
354358

359+
// MarshalJSON renders the value as a single object with the identity field under
360+
// its configured key alongside the embedded intent metadata.
361+
func (v valueWithIntent) MarshalJSON() ([]byte, error) {
362+
data, err := json.Marshal(v.valueIntent)
363+
if err != nil {
364+
return nil, err
365+
}
366+
obj := map[string]any{}
367+
if err := json.Unmarshal(data, &obj); err != nil {
368+
return nil, err
369+
}
370+
obj[v.identityKey] = v.identity
371+
return json.Marshal(obj)
372+
}
373+
355374
// labelsUpdateRequest is a custom request body for updating an issue's labels
356-
// where individual labels may optionally include a rationale. Each element of
357-
// Labels is either a string (label name) or a labelWithIntent object.
375+
// where individual labels may optionally include intent metadata. Each element
376+
// of Labels is either a string (label name) or a valueWithIntent object.
358377
type labelsUpdateRequest struct {
359378
Labels []any `json:"labels"`
360379
}
361380

381+
// parseParamWithIntent parses an array parameter whose entries are either plain
382+
// identity strings or objects carrying intent metadata (an identity field named
383+
// by identityField plus optional rationale, confidence, is_suggestion). param is
384+
// the argument key (e.g. "labels") and identityField is the identity key within
385+
// each object (e.g. "name").
386+
//
387+
// It returns the plain identities (intent stripped), the wire payload (a mix of
388+
// plain identity strings and valueWithIntent objects, with intent objects only
389+
// for entries that carry intent), whether any entry carried intent, whether the
390+
// parameter was provided at all, and an error. It is reusable across value types
391+
// that travel as named objects with intent.
392+
func parseParamWithIntent(args map[string]any, param, identityField string) (identities []string, payload []any, hasIntent bool, provided bool, err error) {
393+
raw, ok := args[param]
394+
if !ok || raw == nil {
395+
return []string{}, nil, false, false, nil
396+
}
397+
398+
var entries []any
399+
switch v := raw.(type) {
400+
case []any:
401+
entries = v
402+
case []string:
403+
entries = make([]any, len(v))
404+
for i, s := range v {
405+
entries[i] = s
406+
}
407+
default:
408+
return nil, nil, false, true, fmt.Errorf("%s must be an array", param)
409+
}
410+
411+
identities = make([]string, 0, len(entries))
412+
payload = make([]any, 0, len(entries))
413+
for _, item := range entries {
414+
switch v := item.(type) {
415+
case string:
416+
identities = append(identities, v)
417+
payload = append(payload, v)
418+
case map[string]any:
419+
identity, identityErr := RequiredParam[string](v, identityField)
420+
if identityErr != nil {
421+
return nil, nil, false, true, fmt.Errorf("each %s object must have a '%s' string", param, identityField)
422+
}
423+
identities = append(identities, identity)
424+
425+
intent, itemHasIntent, intentErr := parseValueIntent(v)
426+
if intentErr != nil {
427+
return nil, nil, false, true, intentErr
428+
}
429+
if itemHasIntent {
430+
hasIntent = true
431+
payload = append(payload, valueWithIntent{identityKey: identityField, identity: identity, valueIntent: intent})
432+
} else {
433+
payload = append(payload, identity)
434+
}
435+
default:
436+
return nil, nil, false, true, fmt.Errorf("each %s entry must be a string or an object with '%s' and optional 'rationale', 'confidence', and/or 'is_suggestion'", param, identityField)
437+
}
438+
}
439+
return identities, payload, hasIntent, true, nil
440+
}
441+
362442
// GranularUpdateIssueLabels creates a tool to update an issue's labels.
363443
func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.ServerTool {
364444
st := NewTool(
@@ -442,47 +522,12 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S
442522
return utils.NewToolResultError(err.Error()), nil, nil
443523
}
444524

445-
labelsRaw, ok := args["labels"]
446-
if !ok {
447-
return utils.NewToolResultError("missing required parameter: labels"), nil, nil
448-
}
449-
labelsSlice, ok := labelsRaw.([]any)
450-
if !ok {
451-
// Also accept []string for callers that pre-typed the array.
452-
if strs, ok := labelsRaw.([]string); ok {
453-
labelsSlice = make([]any, len(strs))
454-
for i, s := range strs {
455-
labelsSlice[i] = s
456-
}
457-
} else {
458-
return utils.NewToolResultError("parameter labels must be an array"), nil, nil
459-
}
525+
names, payload, useObjectForm, provided, err := parseParamWithIntent(args, "labels", "name")
526+
if err != nil {
527+
return utils.NewToolResultError(err.Error()), nil, nil
460528
}
461-
462-
useObjectForm := false
463-
payload := make([]any, 0, len(labelsSlice))
464-
for _, item := range labelsSlice {
465-
switch v := item.(type) {
466-
case string:
467-
payload = append(payload, v)
468-
case map[string]any:
469-
name, err := RequiredParam[string](v, "name")
470-
if err != nil {
471-
return utils.NewToolResultError("each label object must have a 'name' string"), nil, nil
472-
}
473-
intent, itemHasIntent, err := parseValueIntent(v)
474-
if err != nil {
475-
return utils.NewToolResultError(err.Error()), nil, nil
476-
}
477-
if itemHasIntent {
478-
useObjectForm = true
479-
payload = append(payload, labelWithIntent{Name: name, valueIntent: intent})
480-
} else {
481-
payload = append(payload, name)
482-
}
483-
default:
484-
return utils.NewToolResultError("each label must be a string or an object with 'name' and optional 'rationale', 'confidence', and/or 'is_suggestion'"), nil, nil
485-
}
529+
if !provided {
530+
return utils.NewToolResultError("missing required parameter: labels"), nil, nil
486531
}
487532

488533
client, err := deps.GetClient(ctx)
@@ -495,10 +540,6 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S
495540
body = &labelsUpdateRequest{Labels: payload}
496541
} else {
497542
// Preserve the standard wire format when no rationale or suggest is supplied.
498-
names := make([]string, len(payload))
499-
for i, p := range payload {
500-
names[i] = p.(string)
501-
}
502543
body = &github.IssueRequest{Labels: &names}
503544
}
504545

pkg/github/issues_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3598,7 +3598,7 @@ func Test_IssueWrite_UpdateLabelsWithIntentErrors(t *testing.T) {
35983598
labels: []any{
35993599
map[string]any{"rationale": "no name provided"},
36003600
},
3601-
expectedErrText: "each label object must have a 'name' string",
3601+
expectedErrText: "each labels object must have a 'name' string",
36023602
},
36033603
}
36043604

0 commit comments

Comments
 (0)