Skip to content
Merged
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
98 changes: 63 additions & 35 deletions pkg/cli/outcome_eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

"github.com/github/gh-aw/pkg/github"
"github.com/github/gh-aw/pkg/intent"
"github.com/github/gh-aw/pkg/logger"
"github.com/github/gh-aw/pkg/workflow"
)
Expand Down Expand Up @@ -37,6 +38,8 @@ type OutcomeReport struct {
ObjectURL string `json:"object_url,omitempty" console:"header:URL,omitempty"`
ObjectNumber int `json:"object_number,omitempty" console:"header:#,omitempty"`
TracedRootURL string `json:"traced_root_url,omitempty" console:"-"`
AttributionStatus string `json:"attribution_status,omitempty" console:"-"`
AttributionSource string `json:"attribution_source,omitempty" console:"-"`
Repo string `json:"repo,omitempty" console:"header:Repo,omitempty"`
Result OutcomeResult `json:"result" console:"header:Outcome"`
Detail string `json:"detail,omitempty" console:"header:Detail,omitempty"`
Expand Down Expand Up @@ -402,16 +405,18 @@ func enrichOutcomeWithObjectiveValue(report *OutcomeReport, repo string, mapping

outcomeEvalLog.Printf("Computing objective value: type=%s, repo=%s, number=%d", report.Type, repo, num)

root, err := traceOutcomeRoot(*report, repo)
resolvedIntent, err := resolveOutcomeIntent(*report, repo, mapping)
if err != nil {
outcomeEvalLog.Printf("Could not trace root for objective value computation: %v", err)
return
}
report.TracedRootURL = root.URL
report.AttributionStatus = string(resolvedIntent.Status)
report.AttributionSource = string(resolvedIntent.Source)
report.TracedRootURL = resolvedIntent.RootURL

labelNames := root.Labels
labelNames := resolvedIntent.Labels
if len(labelNames) > 0 {
outcomeEvalLog.Printf("Fetched root labels for %s#%d: root=%s labels=%v", repo, num, root.URL, labelNames)
outcomeEvalLog.Printf("Fetched root labels for %s#%d: root=%s labels=%v", repo, num, resolvedIntent.RootURL, labelNames)
}

// Compute objective value
Expand All @@ -423,17 +428,18 @@ func enrichOutcomeWithObjectiveValue(report *OutcomeReport, repo string, mapping
outcomeEvalLog.Printf("Computed objective value for %s#%d: value=%d, labels=%v", repo, num, objectiveValue, objectiveLabels)
}

type tracedOutcomeRoot struct {
URL string
Number int
Labels []string
}
func resolveOutcomeIntent(report OutcomeReport, repo string, mapping *github.ObjectiveMapping) (intent.IntentRecord, error) {
resolver := intent.Resolver{
ResolverVersion: "outcome-eval-v1",
MatchLabels: func(labels []string) []string {
return mapping.GetObjectiveLabels(labels)
},
}

func traceOutcomeRoot(report OutcomeReport, repo string) (tracedOutcomeRoot, error) {
if isPullRequestOutcomeType(report.Type) {
root, err := tracePullRequestRoot(report.ObjectNumber, repo)
if err == nil && root.Number > 0 {
return root, nil
prIntent, err := resolvePullRequestIntent(report, repo, resolver)
if err == nil {
return prIntent, nil
}
if err != nil {
outcomeEvalLog.Printf("Falling back to direct labels after PR root trace failure: %v", err)
Expand All @@ -442,13 +448,9 @@ func traceOutcomeRoot(report OutcomeReport, repo string) (tracedOutcomeRoot, err

labels, err := objectiveMappingGHAPIGetArray(fmt.Sprintf("issues/%d/labels", report.ObjectNumber), repo)
if err != nil {
return tracedOutcomeRoot{}, err
return intent.IntentRecord{}, err
}
return tracedOutcomeRoot{
URL: report.ObjectURL,
Number: report.ObjectNumber,
Labels: labelsToStringsFromMaps(labels),
}, nil
return resolver.ResolveIssue("", report.ObjectURL, labelsToStringsFromMaps(labels)), nil
}

func isPullRequestOutcomeType(outcomeType string) bool {
Expand All @@ -462,18 +464,29 @@ func isPullRequestOutcomeType(outcomeType string) bool {
}
}

func tracePullRequestRoot(prNumber int, repo string) (tracedOutcomeRoot, error) {
func resolvePullRequestIntent(report OutcomeReport, repo string, resolver intent.Resolver) (intent.IntentRecord, error) {
prData, err := loadPullRequestIntentData(report, repo)
if err != nil {
return intent.IntentRecord{}, err
}
return resolver.ResolvePullRequest(prData), nil
}

func loadPullRequestIntentData(report OutcomeReport, repo string) (intent.PullRequestData, error) {
prNumber := report.ObjectNumber
ownerRepo, _ := normalizeRepoForAPI(repo)
owner, name, found := strings.Cut(ownerRepo, "/")
if !found || owner == "" || name == "" {
return tracedOutcomeRoot{}, fmt.Errorf("invalid repo for root tracing: %s", repo)
return intent.PullRequestData{}, fmt.Errorf("invalid repo for root tracing: %s", repo)
}

query := fmt.Sprintf(`query {
repository(owner: "%s", name: "%s") {
pullRequest(number: %d) {
id
closingIssuesReferences(first: 10) {
nodes {
id
number
url
labels(first: 20) {
Expand All @@ -491,30 +504,45 @@ func tracePullRequestRoot(prNumber int, repo string) (tracedOutcomeRoot, error)

result, err := objectiveMappingGHAPIGraphQL(query, repo)
if err != nil {
return tracedOutcomeRoot{}, err
return intent.PullRequestData{}, err
}
data, _ := result["data"].(map[string]any)
repository, _ := data["repository"].(map[string]any)
pullRequest, _ := repository["pullRequest"].(map[string]any)
prData := intent.PullRequestData{URL: report.ObjectURL}
if nodeID, ok := pullRequest["id"].(string); ok {
prData.NodeID = nodeID
}
closingRefs, _ := pullRequest["closingIssuesReferences"].(map[string]any)
nodes, _ := closingRefs["nodes"].([]any)
if len(nodes) == 0 {
return tracedOutcomeRoot{}, fmt.Errorf("no closing issues found for PR #%d", prNumber)
}
firstNode, _ := nodes[0].(map[string]any)
root := tracedOutcomeRoot{}
if url, ok := firstNode["url"].(string); ok {
root.URL = url
}
if number, ok := firstNode["number"].(float64); ok {
root.Number = int(number)
labels, labelErr := objectiveMappingGHAPIGetArray(fmt.Sprintf("issues/%d/labels", report.ObjectNumber), repo)
if labelErr != nil {
return intent.PullRequestData{}, labelErr
}
prData.Labels = labelsToStringsFromMaps(labels)
return prData, nil
Comment thread
Copilot marked this conversation as resolved.
}
if labels, ok := firstNode["labels"].(map[string]any); ok {
if labelNodes, ok := labels["nodes"].([]any); ok {
root.Labels = labelsToStringsFromNodes(labelNodes)

prData.ClosingIssues = make([]intent.RootReference, 0, len(nodes))
for _, node := range nodes {
rootNode, _ := node.(map[string]any)
root := intent.RootReference{Type: "issue"}
if nodeID, ok := rootNode["id"].(string); ok {
root.NodeID = nodeID
}
if url, ok := rootNode["url"].(string); ok {
root.URL = url
}
if labels, ok := rootNode["labels"].(map[string]any); ok {
if labelNodes, ok := labels["nodes"].([]any); ok {
root.Labels = labelsToStringsFromNodes(labelNodes)
}
}
prData.ClosingIssues = append(prData.ClosingIssues, root)
}
return root, nil

return prData, nil
}

func labelsToStringsFromNodes(nodes []any) []string {
Expand Down
62 changes: 62 additions & 0 deletions pkg/cli/outcome_eval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,11 @@ func TestEnrichOutcomeWithObjectiveValue_TracesPullRequestToRootIssue(t *testing
"data": map[string]any{
"repository": map[string]any{
"pullRequest": map[string]any{
"id": "PR_kwDOAAABCD4",
"closingIssuesReferences": map[string]any{
"nodes": []any{
map[string]any{
"id": "I_kwDOAAABCQ4",
"number": float64(1234),
"url": "https://github.com/owner/repo/issues/1234",
"labels": map[string]any{"nodes": []any{
Expand Down Expand Up @@ -253,6 +255,8 @@ func TestEnrichOutcomeWithObjectiveValue_TracesPullRequestToRootIssue(t *testing
assert.Equal(t, 90, report.ObjectiveValue)
assert.Equal(t, []string{"agentic-campaign", "security"}, report.ObjectiveLabels)
assert.Equal(t, "https://github.com/owner/repo/issues/1234", report.TracedRootURL)
assert.Equal(t, "mapped", report.AttributionStatus)
assert.Equal(t, "closing_issue", report.AttributionSource)
}

func TestEnrichOutcomeWithObjectiveValue_FallsBackToDirectLabels(t *testing.T) {
Expand All @@ -278,6 +282,64 @@ func TestEnrichOutcomeWithObjectiveValue_FallsBackToDirectLabels(t *testing.T) {
assert.Equal(t, 70, report.ObjectiveValue)
assert.Equal(t, []string{"automation", "testing"}, report.ObjectiveLabels)
assert.Equal(t, "https://github.com/owner/repo/issues/42", report.TracedRootURL)
assert.Equal(t, "mapped", report.AttributionStatus)
assert.Equal(t, "issue_labels", report.AttributionSource)
}

func TestEnrichOutcomeWithObjectiveValue_MultipleClosingIssuesRemainAmbiguous(t *testing.T) {
oldGraphQL := objectiveMappingGHAPIGraphQL
oldGetArray := objectiveMappingGHAPIGetArray
t.Cleanup(func() {
objectiveMappingGHAPIGraphQL = oldGraphQL
objectiveMappingGHAPIGetArray = oldGetArray
})

objectiveMappingGHAPIGraphQL = func(query string, repo string) (map[string]any, error) {
return map[string]any{
"data": map[string]any{
"repository": map[string]any{
"pullRequest": map[string]any{
"id": "PR_kwDOAAABCD4",
"closingIssuesReferences": map[string]any{
"nodes": []any{
map[string]any{
"id": "I_kwDOAAABCQ4",
"url": "https://github.com/owner/repo/issues/1234",
"labels": map[string]any{"nodes": []any{
map[string]any{"name": "agentic-campaign"},
}},
},
map[string]any{
"id": "I_kwDOAAABCR4",
"url": "https://github.com/owner/repo/issues/1235",
"labels": map[string]any{"nodes": []any{
map[string]any{"name": "security"},
}},
},
},
},
},
},
},
}, nil
}
objectiveMappingGHAPIGetArray = func(endpoint string, repo string) ([]map[string]any, error) {
return []map[string]any{{"name": "automation"}}, nil
}

report := OutcomeReport{Type: "create_pull_request", ObjectURL: "https://github.com/owner/repo/pull/77", ObjectNumber: 77}
mapping := &github.ObjectiveMapping{
LabelToValue: map[string]int{"agentic-campaign": 90, "security": 85, "automation": 70},
MultiLabelLogic: "max",
}

enrichOutcomeWithObjectiveValue(&report, "owner/repo", mapping)

assert.Equal(t, "ambiguous", report.AttributionStatus)
assert.Equal(t, "closing_issue", report.AttributionSource)
assert.Empty(t, report.TracedRootURL)
assert.Zero(t, report.ObjectiveValue)
assert.Empty(t, report.ObjectiveLabels)
}

func TestNormalizeOutcomeEvaluationTargetExistsOnly(t *testing.T) {
Expand Down
Loading
Loading