Skip to content
Closed
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
9 changes: 9 additions & 0 deletions stovepipe/extension/issueattribution/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
load("@rules_go//go:def.bzl", "go_library")

go_library(
name = "go_default_library",
srcs = ["issueattribution.go"],
importpath = "github.com/uber/submitqueue/stovepipe/extension/issueattribution",
visibility = ["//visibility:public"],
deps = ["//stovepipe/entity:go_default_library"],
)
25 changes: 25 additions & 0 deletions stovepipe/extension/issueattribution/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Issue project attribution

Issue attribution answers which projects an individual validation issue affects. It is separate from project status resolution, which reports whether a project is green or broken.

## Inputs and results

A queue-bound resolver accepts a source build ID and a batch of issue openings. Each opening is identified by task, unit, and first-seen build. The source build may be newer than the opening build. Implementations resolve the build's metadata and relevant projects through their injected dependencies; callers do not supply dependency graphs or project mappings.

The result identifies the projects successfully assessed for the entire batch and the affected projects for each requested issue. Affected projects must be within that assessed scope. An implementation using dependency analysis includes only projects both within the build's relevant validation scope and affected by the failed unit. Task-wide issues have no project associations.

For a nonempty assessed scope, a successful result must contain exactly one entry for every requested issue opening, including issues that affect none of the assessed projects. Input openings must be distinct by task and unit; output identities must match them, including the first-seen build. Project lists contain distinct, nonempty IDs.

## Absence and reassessment

An issue's empty project list means it affects none of the assessed projects. Existing associations within that scope can be removed; associations outside the scope have not been reassessed and must be preserved. Changing project associations does not close or reopen the underlying issue.

An empty assessed scope means no attribution was performed and must have no issue results. It cannot add or remove associations. The noop implementation returns this result, so leaving attribution unconfigured does not clear existing associations.

## Failure and concurrency

Resolvers must support concurrent calls, leave caller-owned inputs unchanged, and not consume validation evidence. A dependency failure or inability to complete the requested assessment is an error, not a successful empty attribution. Results returned alongside an error must not be used.

A source build does not necessarily freeze external project configuration. Repeated calls can produce different successful results; callers must retain a successful assessment before using it to update associations.

The factory contract resolves a resolver for one queue. Concrete backend selection and queue routing belong to service wiring. This package does not choose when attribution runs or change issue detection or project status recording.
65 changes: 65 additions & 0 deletions stovepipe/extension/issueattribution/issueattribution.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2026 Uber Technologies, Inc.
//
// 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 issueattribution defines project attribution for validation issues,
// independently of project status resolution.
package issueattribution

//go:generate mockgen -source=issueattribution.go -destination=mock/issueattribution_mock.go -package=mock

import (
"context"

"github.com/uber/submitqueue/stovepipe/entity"
)

// IssueProjects associates one issue opening with projects within an assessed scope.
type IssueProjects struct {
// Issue identifies the requested condition and its opening.
Issue entity.OpenIssue
// Projects contains the distinct, nonempty project IDs affected by this issue.
// Every project must belong to the result's AssessedProjects.
Projects []string
}

// Result describes attribution within an explicitly assessed project scope.
type Result struct {
// AssessedProjects contains the distinct, nonempty project IDs assessed for every
// requested issue; projects outside this scope have not been reassessed.
// An empty scope means no attribution was performed.
AssessedProjects []string
// Issues contains exactly one result per requested opening when the scope is
// nonempty, including an empty Projects list for an issue affecting none of it.
// Issues must be empty when AssessedProjects is empty.
Issues []IssueProjects
}

// Resolver attributes a batch of issue openings using one build's validation scope.
// Implementations resolve build metadata and project scope through injected dependencies.
// Calls must be concurrency-safe and must not mutate inputs or consume evidence.
// Return plain dependency errors; results returned with an error must not be used.
type Resolver interface {
Resolve(ctx context.Context, buildID entity.BuildID, issues []entity.OpenIssue) (Result, error)
}

// Config identifies the queue whose attribution resolver is requested.
type Config struct {
// QueueName identifies the validation queue.
QueueName string
}

// Factory resolves queue-bound attribution resolvers; routing belongs to service wiring.
type Factory interface {
For(cfg Config) (Resolver, error)
}
13 changes: 13 additions & 0 deletions stovepipe/extension/issueattribution/mock/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
load("@rules_go//go:def.bzl", "go_library")

go_library(
name = "go_default_library",
srcs = ["issueattribution_mock.go"],
importpath = "github.com/uber/submitqueue/stovepipe/extension/issueattribution/mock",
visibility = ["//visibility:public"],
deps = [
"//stovepipe/entity:go_default_library",
"//stovepipe/extension/issueattribution:go_default_library",
"@org_uber_go_mock//gomock:go_default_library",
],
)

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions stovepipe/extension/issueattribution/noop/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
load("@rules_go//go:def.bzl", "go_library", "go_test")

go_library(
name = "go_default_library",
srcs = ["noop.go"],
importpath = "github.com/uber/submitqueue/stovepipe/extension/issueattribution/noop",
visibility = ["//visibility:public"],
deps = [
"//stovepipe/entity:go_default_library",
"//stovepipe/extension/issueattribution:go_default_library",
],
)

go_test(
name = "go_default_test",
srcs = ["noop_test.go"],
embed = [":go_default_library"],
deps = [
"//stovepipe/entity:go_default_library",
"//stovepipe/extension/issueattribution:go_default_library",
"@com_github_stretchr_testify//require:go_default_library",
],
)
42 changes: 42 additions & 0 deletions stovepipe/extension/issueattribution/noop/noop.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) 2026 Uber Technologies, Inc.
//
// 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 noop provides issue attribution without assessing any projects.
package noop

import (
"context"

"github.com/uber/submitqueue/stovepipe/entity"
"github.com/uber/submitqueue/stovepipe/extension/issueattribution"
)

// Factory returns an attribution resolver with no assessed scope for every queue.
type Factory struct{}

// New returns a no-op attribution factory.
func New() Factory { return Factory{} }

func (Factory) For(issueattribution.Config) (issueattribution.Resolver, error) {
return resolver{}, nil
}

type resolver struct{}

func (resolver) Resolve(context.Context, entity.BuildID, []entity.OpenIssue) (issueattribution.Result, error) {
return issueattribution.Result{}, nil
}

var _ issueattribution.Factory = Factory{}
var _ issueattribution.Resolver = resolver{}
65 changes: 65 additions & 0 deletions stovepipe/extension/issueattribution/noop/noop_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2026 Uber Technologies, Inc.
//
// 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 noop

import (
"context"
"slices"
"testing"

"github.com/stretchr/testify/require"
"github.com/uber/submitqueue/stovepipe/entity"
"github.com/uber/submitqueue/stovepipe/extension/issueattribution"
)

func TestResolveLeavesProjectsUnassessed(t *testing.T) {
for _, tt := range []struct {
name string
queue string
issues []entity.OpenIssue
}{
{
name: "no issues",
queue: "validation/main",
},
{
name: "unit issues",
queue: "validation/main",
issues: []entity.OpenIssue{
{TaskID: "test", UnitID: "//lib:a_test", FirstSeenBuildID: "build/100"},
{TaskID: "test", UnitID: "//lib:b_test", FirstSeenBuildID: "build/101"},
},
},
{
name: "task-wide issue",
queue: "validation/release",
issues: []entity.OpenIssue{
{TaskID: "compile", FirstSeenBuildID: "build/100"},
},
},
} {
t.Run(tt.name, func(t *testing.T) {
resolver, err := New().For(issueattribution.Config{QueueName: tt.queue})
require.NoError(t, err)

original := slices.Clone(tt.issues)
result, err := resolver.Resolve(context.Background(), entity.BuildID{ID: "build/102"}, tt.issues)
require.NoError(t, err)
require.Empty(t, result.AssessedProjects)
require.Empty(t, result.Issues)
require.Equal(t, original, tt.issues)
})
}
}