feat(github): collect GitHub issue fields and map them onto domain issues (#9038)
* feat(github): collect GitHub issue fields and map them onto domain issues
GitHub issue fields are organization-level structured issue metadata that
went generally available on 2026-07-02. They are typed, mutually exclusive
within a field, and shared across every repository in the organization —
which is what teams currently approximate with `type:`-style labels.
Collects issue field values and lets a scope config map a field onto an
issue column, where it takes precedence over the existing label regexes.
- New table `_tool_github_issue_field_values`, one row per issue per field,
carrying a queryable text form of the value alongside the original JSON.
- New subtasks Collect/Extract Issue Field Values, both disabled by default
so existing pipelines are unaffected until a mapping is configured.
- New scope config keys issueFieldPriority, issueFieldSeverity,
issueFieldComponent, issueFieldStoryPoint and issueFieldDueDate, each
holding a field *name*.
The mapping is applied in the issue convertor rather than written back into
`_tool_github_issues`: the collector iterates that table to build its request
URLs, so a subtask that both read and wrote it was a cycle in the subtask
graph. Converting instead also keeps the tool layer as raw GitHub truth and
avoids adding columns there.
A 404 from the field-values endpoint is treated as "no field values" so an
organization that has never configured issue fields, or a token that cannot
see them, does not fail the whole task.
* test(github): add an e2e dataflow test for issue field values
Covers extraction and the scope config mapping end to end against a real
database, which the unit tests could not reach.
Extraction asserts the value normalisation per data type: a single_select
resolving to its option name and colour, an integral number rendering as
"5" rather than "5.0", a fractional number keeping its precision, a
multi_select joining option names while keeping the raw JSON array, and a
null value producing an empty value.
Conversion asserts the mapping reaches the domain issue -- priority,
component, story point and due date -- and, for the case that matters,
that an unparseable value is skipped with a warning rather than failing
the task or writing a wrong value: issue #7 carries "soon" in a date
field and a null priority, and comes out with neither set while the other
issues are untouched.
* fix(github): register the issue field values table in GetTablesInfo
Test_GetPluginTablesInfo compares the plugin's declared tables against the
ones its migrations create, and the new table was missing from the list:
table_info_test.go:121: The following tables are not returned by the
TablesInfo method
_tool_github_issue_field_values
Adds GithubIssueFieldValue to Github.GetTablesInfo(). Verified inside the
mericodev/lake-builder image the unit-test job uses, since the plugins
package needs libgit2 to build.diff --git a/backend/plugins/github/e2e/issue_field_value_test.go b/backend/plugins/github/e2e/issue_field_value_test.go
new file mode 100644
index 0000000..63b9872
--- /dev/null
+++ b/backend/plugins/github/e2e/issue_field_value_test.go
@@ -0,0 +1,95 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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 e2e
+
+import (
+ "testing"
+
+ "github.com/apache/incubator-devlake/core/models/domainlayer/ticket"
+ "github.com/apache/incubator-devlake/helpers/e2ehelper"
+ "github.com/apache/incubator-devlake/plugins/github/impl"
+ "github.com/apache/incubator-devlake/plugins/github/models"
+ "github.com/apache/incubator-devlake/plugins/github/tasks"
+)
+
+// Extraction of issue field values, and the scope config mapping that carries them into the
+// domain issue.
+func TestIssueFieldValueDataFlow(t *testing.T) {
+ var plugin impl.Github
+ dataflowTester := e2ehelper.NewDataFlowTester(t, "github", plugin)
+
+ taskData := &tasks.GithubTaskData{
+ Options: &tasks.GithubOptions{
+ ConnectionId: 1,
+ Name: "panjf2000/ants",
+ GithubId: 134018330,
+ ScopeConfig: &models.GithubScopeConfig{
+ // Mapped by field name. "Effort" and "Target date" are two of the four fields
+ // GitHub preconfigures for every organization.
+ IssueFieldPriority: "Priority",
+ IssueFieldComponent: "Squad",
+ IssueFieldStoryPoint: "Effort",
+ IssueFieldDueDate: "Target date",
+ },
+ },
+ }
+
+ dataflowTester.ImportCsvIntoRawTable(
+ "./raw_tables/_raw_github_api_issue_field_values.csv",
+ "_raw_github_api_issue_field_values")
+
+ // verify extraction
+ dataflowTester.FlushTabler(&models.GithubIssueFieldValue{})
+ dataflowTester.Subtask(tasks.ExtractApiIssueFieldValuesMeta, taskData)
+ dataflowTester.VerifyTableWithOptions(
+ models.GithubIssueFieldValue{},
+ e2ehelper.TableOptions{
+ CSVRelPath: "./snapshot_tables/_tool_github_issue_field_values.csv",
+ TargetFields: []string{
+ "connection_id",
+ "issue_id",
+ "field_id",
+ "field_name",
+ "data_type",
+ "value",
+ "raw_value",
+ "option_color",
+ }},
+ )
+
+ // verify the mapping onto the domain issue
+ dataflowTester.ImportCsvIntoTabler("./raw_tables/_tool_github_issues.csv", &models.GithubIssue{})
+ dataflowTester.FlushTabler(&ticket.Issue{})
+ dataflowTester.FlushTabler(&ticket.BoardIssue{})
+ dataflowTester.Subtask(tasks.ConvertIssuesMeta, taskData)
+ dataflowTester.VerifyTableWithOptions(
+ &ticket.Issue{},
+ e2ehelper.TableOptions{
+ CSVRelPath: "./snapshot_tables/issues_with_field_values.csv",
+ TargetFields: []string{
+ "id",
+ "issue_key",
+ "priority",
+ "component",
+ "story_point",
+ "due_date",
+ },
+ IgnoreTypes: []interface{}{},
+ },
+ )
+}
diff --git a/backend/plugins/github/e2e/raw_tables/_raw_github_api_issue_field_values.csv b/backend/plugins/github/e2e/raw_tables/_raw_github_api_issue_field_values.csv
new file mode 100644
index 0000000..76588f5
--- /dev/null
+++ b/backend/plugins/github/e2e/raw_tables/_raw_github_api_issue_field_values.csv
@@ -0,0 +1,9 @@
+id,params,data,url,input,created_at
+1,"{""ConnectionId"":1,""Name"":""panjf2000/ants""}","{""issue_field_id"":123,""issue_field_name"":""Priority"",""node_id"":""IFV_1"",""data_type"":""single_select"",""value"":""Critical"",""single_select_option"":{""id"":1,""name"":""Critical"",""color"":""ff0000""},""multi_select_options"":null}",https://api.github.com/repos/panjf2000/ants/issues/5/issue-field-values?page=1&per_page=100,"{""Number"":5,""GithubId"":346842831}",2026-08-09 12:00:00.000000+00:00
+2,"{""ConnectionId"":1,""Name"":""panjf2000/ants""}","{""issue_field_id"":456,""issue_field_name"":""Effort"",""node_id"":""IFV_2"",""data_type"":""number"",""value"":5,""single_select_option"":null,""multi_select_options"":null}",https://api.github.com/repos/panjf2000/ants/issues/5/issue-field-values?page=1&per_page=100,"{""Number"":5,""GithubId"":346842831}",2026-08-09 12:00:00.000000+00:00
+3,"{""ConnectionId"":1,""Name"":""panjf2000/ants""}","{""issue_field_id"":789,""issue_field_name"":""Target date"",""node_id"":""IFV_3"",""data_type"":""date"",""value"":""2024-12-31"",""single_select_option"":null,""multi_select_options"":null}",https://api.github.com/repos/panjf2000/ants/issues/5/issue-field-values?page=1&per_page=100,"{""Number"":5,""GithubId"":346842831}",2026-08-09 12:00:00.000000+00:00
+4,"{""ConnectionId"":1,""Name"":""panjf2000/ants""}","{""issue_field_id"":123,""issue_field_name"":""Priority"",""node_id"":""IFV_4"",""data_type"":""single_select"",""value"":""Low"",""single_select_option"":{""id"":4,""name"":""Low"",""color"":""0052cc""},""multi_select_options"":null}",https://api.github.com/repos/panjf2000/ants/issues/6/issue-field-values?page=1&per_page=100,"{""Number"":6,""GithubId"":347255859}",2026-08-09 12:00:00.000000+00:00
+5,"{""ConnectionId"":1,""Name"":""panjf2000/ants""}","{""issue_field_id"":456,""issue_field_name"":""Effort"",""node_id"":""IFV_5"",""data_type"":""number"",""value"":2.5,""single_select_option"":null,""multi_select_options"":null}",https://api.github.com/repos/panjf2000/ants/issues/6/issue-field-values?page=1&per_page=100,"{""Number"":6,""GithubId"":347255859}",2026-08-09 12:00:00.000000+00:00
+6,"{""ConnectionId"":1,""Name"":""panjf2000/ants""}","{""issue_field_id"":901,""issue_field_name"":""Squad"",""node_id"":""IFV_6"",""data_type"":""multi_select"",""value"":[""backend"",""urgent""],""single_select_option"":null,""multi_select_options"":[{""id"":2,""name"":""backend"",""color"":""0052cc""},{""id"":3,""name"":""urgent"",""color"":""ff6600""}]}",https://api.github.com/repos/panjf2000/ants/issues/6/issue-field-values?page=1&per_page=100,"{""Number"":6,""GithubId"":347255859}",2026-08-09 12:00:00.000000+00:00
+7,"{""ConnectionId"":1,""Name"":""panjf2000/ants""}","{""issue_field_id"":789,""issue_field_name"":""Target date"",""node_id"":""IFV_7"",""data_type"":""date"",""value"":""soon"",""single_select_option"":null,""multi_select_options"":null}",https://api.github.com/repos/panjf2000/ants/issues/7/issue-field-values?page=1&per_page=100,"{""Number"":7,""GithubId"":348630179}",2026-08-09 12:00:00.000000+00:00
+8,"{""ConnectionId"":1,""Name"":""panjf2000/ants""}","{""issue_field_id"":123,""issue_field_name"":""Priority"",""node_id"":""IFV_8"",""data_type"":""single_select"",""value"":null,""single_select_option"":null,""multi_select_options"":null}",https://api.github.com/repos/panjf2000/ants/issues/7/issue-field-values?page=1&per_page=100,"{""Number"":7,""GithubId"":348630179}",2026-08-09 12:00:00.000000+00:00
diff --git a/backend/plugins/github/e2e/snapshot_tables/_tool_github_issue_field_values.csv b/backend/plugins/github/e2e/snapshot_tables/_tool_github_issue_field_values.csv
new file mode 100644
index 0000000..e4f3dd4
--- /dev/null
+++ b/backend/plugins/github/e2e/snapshot_tables/_tool_github_issue_field_values.csv
@@ -0,0 +1,9 @@
+connection_id,issue_id,field_id,field_name,data_type,value,raw_value,option_color
+1,346842831,123,Priority,single_select,Critical,"""Critical""",ff0000
+1,346842831,456,Effort,number,5,5,
+1,346842831,789,Target date,date,2024-12-31,"""2024-12-31""",
+1,347255859,123,Priority,single_select,Low,"""Low""",0052cc
+1,347255859,456,Effort,number,2.5,2.5,
+1,347255859,901,Squad,multi_select,"backend,urgent","[""backend"",""urgent""]",
+1,348630179,123,Priority,single_select,,null,
+1,348630179,789,Target date,date,soon,"""soon""",
diff --git a/backend/plugins/github/e2e/snapshot_tables/issues_with_field_values.csv b/backend/plugins/github/e2e/snapshot_tables/issues_with_field_values.csv
new file mode 100644
index 0000000..f06f110
--- /dev/null
+++ b/backend/plugins/github/e2e/snapshot_tables/issues_with_field_values.csv
@@ -0,0 +1,27 @@
+id,issue_key,priority,component,story_point,due_date
+github:GithubIssue:1:346842831,5,Critical,,5,2024-12-31T00:00:00.000+00:00
+github:GithubIssue:1:347255859,6,Low,"backend,urgent",2.5,
+github:GithubIssue:1:348630179,7,,,,
+github:GithubIssue:1:356703393,10,,,,
+github:GithubIssue:1:364361014,12,,,,
+github:GithubIssue:1:381941219,17,,,,
+github:GithubIssue:1:382039050,18,,,,
+github:GithubIssue:1:382574800,20,,,,
+github:GithubIssue:1:388907811,21,,,,
+github:GithubIssue:1:401277739,22,,,,
+github:GithubIssue:1:402513849,24,,,,
+github:GithubIssue:1:405951301,25,,,,
+github:GithubIssue:1:413968505,26,,,,
+github:GithubIssue:1:419183961,27,,,,
+github:GithubIssue:1:419268851,28,,,,
+github:GithubIssue:1:424634533,29,,,,
+github:GithubIssue:1:429972115,31,,,,
+github:GithubIssue:1:433564955,32,,,,
+github:GithubIssue:1:434069015,33,,,,
+github:GithubIssue:1:435486645,34,,,,
+github:GithubIssue:1:461280653,35,,,,
+github:GithubIssue:1:462631417,37,,,,
+github:GithubIssue:1:472125082,38,,,,
+github:GithubIssue:1:483164833,42,,,,
+github:GithubIssue:1:483736247,43,,,,
+github:GithubIssue:1:484311063,44,,,,
diff --git a/backend/plugins/github/impl/impl.go b/backend/plugins/github/impl/impl.go
index ada24da..f012077 100644
--- a/backend/plugins/github/impl/impl.go
+++ b/backend/plugins/github/impl/impl.go
@@ -103,6 +103,7 @@
&models.GithubReviewer{},
&models.GithubRun{},
&models.GithubIssueAssignee{},
+ &models.GithubIssueFieldValue{},
&models.GithubScopeConfig{},
&models.GithubDeployment{},
&models.GithubRelease{},
diff --git a/backend/plugins/github/models/issue_field_value.go b/backend/plugins/github/models/issue_field_value.go
new file mode 100644
index 0000000..2679532
--- /dev/null
+++ b/backend/plugins/github/models/issue_field_value.go
@@ -0,0 +1,53 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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 models
+
+import (
+ "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// GithubIssueFieldValue holds one issue field value for one issue.
+//
+// Issue fields are organization-level structured metadata that GitHub made generally
+// available on 2026-07-02. Unlike labels they are typed, mutually exclusive within a
+// field, and shared across every repository in the organization.
+//
+// Value carries a queryable text form of the value regardless of DataType:
+// - single_select: the selected option name
+// - multi_select: the selected option names, comma separated, in API order
+// - text: the text
+// - number: the number, formatted without a trailing ".0" when integral
+// - date: the date as returned by the API (YYYY-MM-DD)
+//
+// RawValue keeps the original JSON so a value that does not fit the text form
+// (a multi_select array, for instance) is still recoverable downstream.
+type GithubIssueFieldValue struct {
+ ConnectionId uint64 `gorm:"primaryKey"`
+ IssueId int `gorm:"primaryKey;comment:GitHub issue id, matches _tool_github_issues.github_id"`
+ FieldId int `gorm:"primaryKey"`
+ FieldName string `gorm:"type:varchar(255);index"`
+ DataType string `gorm:"type:varchar(50)"`
+ Value string `gorm:"type:text"`
+ RawValue string `gorm:"type:text"`
+ OptionColor string `gorm:"type:varchar(50)"`
+ common.NoPKModel
+}
+
+func (GithubIssueFieldValue) TableName() string {
+ return "_tool_github_issue_field_values"
+}
diff --git a/backend/plugins/github/models/migrationscripts/20260809_add_github_issue_fields.go b/backend/plugins/github/models/migrationscripts/20260809_add_github_issue_fields.go
new file mode 100644
index 0000000..f648090
--- /dev/null
+++ b/backend/plugins/github/models/migrationscripts/20260809_add_github_issue_fields.go
@@ -0,0 +1,71 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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 migrationscripts
+
+import (
+ "github.com/apache/incubator-devlake/core/context"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/models/migrationscripts/archived"
+ "github.com/apache/incubator-devlake/helpers/migrationhelper"
+)
+
+type githubIssueFieldValue20260809 struct {
+ ConnectionId uint64 `gorm:"primaryKey"`
+ IssueId int `gorm:"primaryKey"`
+ FieldId int `gorm:"primaryKey"`
+ FieldName string `gorm:"type:varchar(255);index"`
+ DataType string `gorm:"type:varchar(50)"`
+ Value string `gorm:"type:text"`
+ RawValue string `gorm:"type:text"`
+ OptionColor string `gorm:"type:varchar(50)"`
+ archived.NoPKModel
+}
+
+func (githubIssueFieldValue20260809) TableName() string {
+ return "_tool_github_issue_field_values"
+}
+
+type githubScopeConfig20260809 struct {
+ IssueFieldPriority string `gorm:"type:varchar(255)"`
+ IssueFieldSeverity string `gorm:"type:varchar(255)"`
+ IssueFieldComponent string `gorm:"type:varchar(255)"`
+ IssueFieldStoryPoint string `gorm:"type:varchar(255)"`
+ IssueFieldDueDate string `gorm:"type:varchar(255)"`
+}
+
+func (githubScopeConfig20260809) TableName() string {
+ return "_tool_github_scope_configs"
+}
+
+type addGithubIssueFields struct{}
+
+func (*addGithubIssueFields) Up(basicRes context.BasicRes) errors.Error {
+ return migrationhelper.AutoMigrateTables(
+ basicRes,
+ &githubIssueFieldValue20260809{},
+ &githubScopeConfig20260809{},
+ )
+}
+
+func (*addGithubIssueFields) Version() uint64 {
+ return 20260809000001
+}
+
+func (*addGithubIssueFields) Name() string {
+ return "add github issue field values table and scope config issue field mappings"
+}
diff --git a/backend/plugins/github/models/migrationscripts/register.go b/backend/plugins/github/models/migrationscripts/register.go
index 65f538f..0a35123 100644
--- a/backend/plugins/github/models/migrationscripts/register.go
+++ b/backend/plugins/github/models/migrationscripts/register.go
@@ -58,5 +58,6 @@
new(addRefreshTokenFields),
new(modifyTokenExpiresAtToNullable),
new(addPrSizeExcludedFileExtensions),
+ new(addGithubIssueFields),
}
}
diff --git a/backend/plugins/github/models/scope_config.go b/backend/plugins/github/models/scope_config.go
index a19f33d..ae3fdf7 100644
--- a/backend/plugins/github/models/scope_config.go
+++ b/backend/plugins/github/models/scope_config.go
@@ -26,16 +26,25 @@
var _ plugin.ToolLayerScopeConfig = (*GithubScopeConfig)(nil)
type GithubScopeConfig struct {
- common.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"`
- PrType string `mapstructure:"prType,omitempty" json:"prType" gorm:"type:varchar(255)"`
- PrComponent string `mapstructure:"prComponent,omitempty" json:"prComponent" gorm:"type:varchar(255)"`
- PrBodyClosePattern string `mapstructure:"prBodyClosePattern,omitempty" json:"prBodyClosePattern" gorm:"type:varchar(255)"`
- IssueSeverity string `mapstructure:"issueSeverity,omitempty" json:"issueSeverity" gorm:"type:varchar(255)"`
- IssuePriority string `mapstructure:"issuePriority,omitempty" json:"issuePriority" gorm:"type:varchar(255)"`
- IssueComponent string `mapstructure:"issueComponent,omitempty" json:"issueComponent" gorm:"type:varchar(255)"`
- IssueTypeBug string `mapstructure:"issueTypeBug,omitempty" json:"issueTypeBug" gorm:"type:varchar(255)"`
- IssueTypeIncident string `mapstructure:"issueTypeIncident,omitempty" json:"issueTypeIncident" gorm:"type:varchar(255)"`
- IssueTypeRequirement string `mapstructure:"issueTypeRequirement,omitempty" json:"issueTypeRequirement" gorm:"type:varchar(255)"`
+ common.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"`
+ PrType string `mapstructure:"prType,omitempty" json:"prType" gorm:"type:varchar(255)"`
+ PrComponent string `mapstructure:"prComponent,omitempty" json:"prComponent" gorm:"type:varchar(255)"`
+ PrBodyClosePattern string `mapstructure:"prBodyClosePattern,omitempty" json:"prBodyClosePattern" gorm:"type:varchar(255)"`
+ IssueSeverity string `mapstructure:"issueSeverity,omitempty" json:"issueSeverity" gorm:"type:varchar(255)"`
+ IssuePriority string `mapstructure:"issuePriority,omitempty" json:"issuePriority" gorm:"type:varchar(255)"`
+ IssueComponent string `mapstructure:"issueComponent,omitempty" json:"issueComponent" gorm:"type:varchar(255)"`
+ IssueTypeBug string `mapstructure:"issueTypeBug,omitempty" json:"issueTypeBug" gorm:"type:varchar(255)"`
+ IssueTypeIncident string `mapstructure:"issueTypeIncident,omitempty" json:"issueTypeIncident" gorm:"type:varchar(255)"`
+ IssueTypeRequirement string `mapstructure:"issueTypeRequirement,omitempty" json:"issueTypeRequirement" gorm:"type:varchar(255)"`
+ // Issue field mappings. Each holds the *name* of a GitHub issue field (organization-level
+ // structured metadata) whose value should populate the matching issue column. When a
+ // mapping is set and the issue carries a value for that field, it takes precedence over
+ // the label regexes above, which cannot express mutual exclusion or span repositories.
+ IssueFieldPriority string `mapstructure:"issueFieldPriority,omitempty" json:"issueFieldPriority" gorm:"type:varchar(255)"`
+ IssueFieldSeverity string `mapstructure:"issueFieldSeverity,omitempty" json:"issueFieldSeverity" gorm:"type:varchar(255)"`
+ IssueFieldComponent string `mapstructure:"issueFieldComponent,omitempty" json:"issueFieldComponent" gorm:"type:varchar(255)"`
+ IssueFieldStoryPoint string `mapstructure:"issueFieldStoryPoint,omitempty" json:"issueFieldStoryPoint" gorm:"type:varchar(255)"`
+ IssueFieldDueDate string `mapstructure:"issueFieldDueDate,omitempty" json:"issueFieldDueDate" gorm:"type:varchar(255)"`
DeploymentPattern string `mapstructure:"deploymentPattern,omitempty" json:"deploymentPattern" gorm:"type:varchar(255)"`
ProductionPattern string `mapstructure:"productionPattern,omitempty" json:"productionPattern" gorm:"type:varchar(255)"`
EnvNamePattern string `mapstructure:"envNamePattern,omitempty" json:"envNamePattern" gorm:"type:varchar(255)"`
diff --git a/backend/plugins/github/tasks/issue_convertor.go b/backend/plugins/github/tasks/issue_convertor.go
index 13e7952..2cb60d1 100644
--- a/backend/plugins/github/tasks/issue_convertor.go
+++ b/backend/plugins/github/tasks/issue_convertor.go
@@ -42,8 +42,9 @@
Description: "Convert tool layer table github_issues into domain layer table issues",
DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET},
DependencyTables: []string{
- models.GithubIssue{}.TableName(), // cursor
- models.GithubAccount{}.TableName(), // id generator
+ models.GithubIssue{}.TableName(), // cursor
+ models.GithubAccount{}.TableName(), // id generator
+ models.GithubIssueFieldValue{}.TableName(), // issue field mappings
//models.GithubRepo{}.TableName(), // id generator, but config not regard as dependency
RAW_ISSUE_TABLE},
ProductTables: []string{
@@ -60,6 +61,15 @@
accountIdGen := didgen.NewDomainIdGenerator(&models.GithubAccount{})
boardIdGen := didgen.NewDomainIdGenerator(&models.GithubRepo{})
+ // GitHub issue fields are organization-level structured metadata. When the scope config
+ // maps one onto an issue column it wins over the label regexes, which cannot express
+ // mutual exclusion and do not span repositories.
+ fieldMapping := resolveIssueFieldMapping(data.Options.ScopeConfig)
+ fieldValues, err := loadIssueFieldValues(taskCtx, data.Options.ConnectionId, fieldMapping)
+ if err != nil {
+ return err
+ }
+
converter, err := api.NewStatefulDataConverter(&api.StatefulDataConverterArgs[models.GithubIssue]{
SubtaskCommonArgs: &api.SubtaskCommonArgs{
SubTaskContext: taskCtx,
@@ -68,6 +78,7 @@
ConnectionId: data.Options.ConnectionId,
Name: data.Options.Name,
},
+ SubtaskConfig: fieldMapping.asSubtaskConfig(),
},
Input: func(stateManager *api.SubtaskStateManager) (dal.Rows, errors.Error) {
clauses := []dal.Clause{
@@ -102,6 +113,7 @@
Severity: issue.Severity,
Component: issue.Component,
}
+ applyIssueFields(taskCtx, fieldMapping, fieldValues, issue, domainIssue)
if issue.AssigneeId != 0 {
domainIssue.AssigneeId = accountIdGen.Generate(data.Options.ConnectionId, issue.AssigneeId)
}
diff --git a/backend/plugins/github/tasks/issue_field_mapping.go b/backend/plugins/github/tasks/issue_field_mapping.go
new file mode 100644
index 0000000..aee0901
--- /dev/null
+++ b/backend/plugins/github/tasks/issue_field_mapping.go
@@ -0,0 +1,202 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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 tasks
+
+import (
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/dal"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/models/domainlayer/ticket"
+ "github.com/apache/incubator-devlake/core/plugin"
+ "github.com/apache/incubator-devlake/plugins/github/models"
+)
+
+// issueFieldMapping is the resolved set of GitHub issue field names that should populate
+// domain issue columns, taken from the scope config.
+//
+// The mapping is applied when converting to the domain layer rather than written back into
+// _tool_github_issues: the collector iterates that table to build its request URLs, so a
+// subtask that both reads and writes it would be a cycle in the subtask graph.
+type issueFieldMapping struct {
+ priority string
+ severity string
+ component string
+ storyPoint string
+ dueDate string
+}
+
+func resolveIssueFieldMapping(scopeConfig *models.GithubScopeConfig) issueFieldMapping {
+ if scopeConfig == nil {
+ return issueFieldMapping{}
+ }
+ return issueFieldMapping{
+ priority: strings.TrimSpace(scopeConfig.IssueFieldPriority),
+ severity: strings.TrimSpace(scopeConfig.IssueFieldSeverity),
+ component: strings.TrimSpace(scopeConfig.IssueFieldComponent),
+ storyPoint: strings.TrimSpace(scopeConfig.IssueFieldStoryPoint),
+ dueDate: strings.TrimSpace(scopeConfig.IssueFieldDueDate),
+ }
+}
+
+func (m issueFieldMapping) isEmpty() bool {
+ return m.priority == "" && m.severity == "" && m.component == "" &&
+ m.storyPoint == "" && m.dueDate == ""
+}
+
+// names returns the distinct, lower-cased field names the mapping refers to.
+func (m issueFieldMapping) names() []string {
+ seen := map[string]bool{}
+ var out []string
+ for _, name := range []string{m.priority, m.severity, m.component, m.storyPoint, m.dueDate} {
+ if name == "" {
+ continue
+ }
+ lower := strings.ToLower(name)
+ if !seen[lower] {
+ seen[lower] = true
+ out = append(out, lower)
+ }
+ }
+ return out
+}
+
+// asSubtaskConfig lets the stateful converter re-run in full when a mapping changes, so
+// clearing or repointing one is not silently skipped by an incremental run.
+func (m issueFieldMapping) asSubtaskConfig() map[string]string {
+ return map[string]string{
+ "issueFieldPriority": m.priority,
+ "issueFieldSeverity": m.severity,
+ "issueFieldComponent": m.component,
+ "issueFieldStoryPoint": m.storyPoint,
+ "issueFieldDueDate": m.dueDate,
+ }
+}
+
+// issueFieldValues maps a GitHub issue id to its mapped field values, keyed by lower-cased
+// field name.
+type issueFieldValues map[int]map[string]string
+
+// loadIssueFieldValues fetches the mapped fields for a connection in one query. The result is
+// bounded by (issues x mapped fields), so this stays a single round trip rather than one per
+// issue. Returns nil when nothing is mapped.
+func loadIssueFieldValues(
+ taskCtx plugin.SubTaskContext,
+ connectionId uint64,
+ mapping issueFieldMapping,
+) (issueFieldValues, errors.Error) {
+ if mapping.isEmpty() {
+ return nil, nil
+ }
+ db := taskCtx.GetDal()
+ var values []models.GithubIssueFieldValue
+ err := db.All(&values,
+ dal.From(models.GithubIssueFieldValue{}.TableName()),
+ dal.Where("connection_id = ? and LOWER(field_name) in ?", connectionId, mapping.names()),
+ )
+ if err != nil {
+ return nil, err
+ }
+ byIssue := make(issueFieldValues, len(values))
+ for _, value := range values {
+ if value.Value == "" {
+ continue
+ }
+ fields, ok := byIssue[value.IssueId]
+ if !ok {
+ fields = map[string]string{}
+ byIssue[value.IssueId] = fields
+ }
+ fields[strings.ToLower(value.FieldName)] = value.Value
+ }
+ return byIssue, nil
+}
+
+// lookup returns the value for a mapped field name on one issue, reporting false when the
+// mapping is unset or the issue carries no value for it.
+func (v issueFieldValues) lookup(issueId int, fieldName string) (string, bool) {
+ if v == nil || fieldName == "" {
+ return "", false
+ }
+ fields, ok := v[issueId]
+ if !ok {
+ return "", false
+ }
+ text, ok := fields[strings.ToLower(fieldName)]
+ return text, ok
+}
+
+// dateLayouts covers the date form the API documents plus the full timestamp form, since a
+// date field can round-trip either way.
+var dateLayouts = []string{"2006-01-02", time.RFC3339, "2006-01-02T15:04:05Z"}
+
+func parseFieldDate(text string) (*time.Time, error) {
+ var lastErr error
+ for _, layout := range dateLayouts {
+ parsed, err := time.Parse(layout, text)
+ if err == nil {
+ return &parsed, nil
+ }
+ lastErr = err
+ }
+ return nil, lastErr
+}
+
+// applyIssueFields overlays the mapped GitHub issue field values onto a domain issue. Values
+// that do not parse into their target type are logged and skipped, leaving whatever the label
+// regexes produced rather than writing a wrong value.
+func applyIssueFields(
+ taskCtx plugin.SubTaskContext,
+ mapping issueFieldMapping,
+ values issueFieldValues,
+ issue *models.GithubIssue,
+ domainIssue *ticket.Issue,
+) {
+ if values == nil || mapping.isEmpty() {
+ return
+ }
+ logger := taskCtx.GetLogger()
+
+ if text, ok := values.lookup(issue.GithubId, mapping.priority); ok {
+ domainIssue.Priority = text
+ }
+ if text, ok := values.lookup(issue.GithubId, mapping.severity); ok {
+ domainIssue.Severity = text
+ }
+ if text, ok := values.lookup(issue.GithubId, mapping.component); ok {
+ domainIssue.Component = text
+ }
+ if text, ok := values.lookup(issue.GithubId, mapping.storyPoint); ok {
+ if number, err := strconv.ParseFloat(text, 64); err == nil {
+ domainIssue.StoryPoint = &number
+ } else {
+ logger.Warn(nil, "issue #%d: field %q value %q is not a number, story point left unset",
+ issue.Number, mapping.storyPoint, text)
+ }
+ }
+ if text, ok := values.lookup(issue.GithubId, mapping.dueDate); ok {
+ if parsed, err := parseFieldDate(text); err == nil {
+ domainIssue.DueDate = parsed
+ } else {
+ logger.Warn(nil, "issue #%d: field %q value %q is not a date, due date left unset",
+ issue.Number, mapping.dueDate, text)
+ }
+ }
+}
diff --git a/backend/plugins/github/tasks/issue_field_mapping_test.go b/backend/plugins/github/tasks/issue_field_mapping_test.go
new file mode 100644
index 0000000..6c159bf
--- /dev/null
+++ b/backend/plugins/github/tasks/issue_field_mapping_test.go
@@ -0,0 +1,91 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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 tasks
+
+import (
+ "testing"
+ "time"
+
+ "github.com/apache/incubator-devlake/plugins/github/models"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestResolveIssueFieldMappingTrimsAndTolueratesNilConfig(t *testing.T) {
+ assert.True(t, resolveIssueFieldMapping(nil).isEmpty())
+
+ mapping := resolveIssueFieldMapping(&models.GithubScopeConfig{
+ IssueFieldPriority: " Priority ",
+ IssueFieldStoryPoint: "Effort",
+ })
+ assert.Equal(t, "Priority", mapping.priority)
+ assert.Equal(t, "Effort", mapping.storyPoint)
+ assert.False(t, mapping.isEmpty())
+}
+
+func TestIssueFieldMappingIsEmpty(t *testing.T) {
+ assert.True(t, issueFieldMapping{}.isEmpty())
+ assert.False(t, issueFieldMapping{priority: "Priority"}.isEmpty())
+ assert.False(t, issueFieldMapping{dueDate: "Target date"}.isEmpty())
+}
+
+func TestIssueFieldMappingNamesAreLowercasedAndDeduplicated(t *testing.T) {
+ mapping := issueFieldMapping{
+ priority: "Priority",
+ severity: "priority", // one field driving two columns
+ component: "",
+ storyPoint: "Effort",
+ dueDate: "Target Date",
+ }
+ assert.Equal(t, []string{"priority", "effort", "target date"}, mapping.names())
+}
+
+func TestIssueFieldValuesLookupIsCaseInsensitiveAndNilSafe(t *testing.T) {
+ var missing issueFieldValues
+ _, ok := missing.lookup(1, "Priority")
+ assert.False(t, ok, "nil map must not panic")
+
+ values := issueFieldValues{7: {"priority": "Critical"}}
+
+ text, ok := values.lookup(7, "PRIORITY")
+ assert.True(t, ok)
+ assert.Equal(t, "Critical", text)
+
+ _, ok = values.lookup(7, "")
+ assert.False(t, ok, "an unset mapping must never match")
+
+ _, ok = values.lookup(8, "Priority")
+ assert.False(t, ok, "another issue's values must not leak")
+}
+
+func TestParseFieldDateAcceptsTheDocumentedAndTimestampForms(t *testing.T) {
+ expected := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
+
+ for _, text := range []string{"2024-12-31", "2024-12-31T00:00:00Z", "2024-12-31T00:00:00+00:00"} {
+ parsed, err := parseFieldDate(text)
+ assert.NoError(t, err, text)
+ assert.Equal(t, expected, parsed.UTC(), text)
+ }
+}
+
+func TestParseFieldDateRejectsNonDates(t *testing.T) {
+ _, err := parseFieldDate("soon")
+ assert.Error(t, err)
+
+ _, err = parseFieldDate("")
+ assert.Error(t, err)
+}
diff --git a/backend/plugins/github/tasks/issue_field_value_collector.go b/backend/plugins/github/tasks/issue_field_value_collector.go
new file mode 100644
index 0000000..1721191
--- /dev/null
+++ b/backend/plugins/github/tasks/issue_field_value_collector.go
@@ -0,0 +1,124 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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 tasks
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "reflect"
+
+ "github.com/apache/incubator-devlake/core/dal"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/github/models"
+)
+
+func init() {
+ RegisterSubtaskMeta(&CollectApiIssueFieldValuesMeta)
+}
+
+const RAW_ISSUE_FIELD_VALUE_TABLE = "github_api_issue_field_values"
+
+// SimpleIssue carries the two identifiers the field-value endpoint needs: the number to
+// build the URL, and the id to key the extracted rows on.
+type SimpleIssue struct {
+ Number int
+ GithubId int
+}
+
+var CollectApiIssueFieldValuesMeta = plugin.SubTaskMeta{
+ Name: "Collect Issue Field Values",
+ EntryPoint: CollectApiIssueFieldValues,
+ EnabledByDefault: false,
+ Description: "Collect issue field values from the GitHub API, supports both timeFilter and diffSync.",
+ DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET},
+ DependencyTables: []string{models.GithubIssue{}.TableName()},
+ ProductTables: []string{RAW_ISSUE_FIELD_VALUE_TABLE},
+}
+
+func CollectApiIssueFieldValues(taskCtx plugin.SubTaskContext) errors.Error {
+ db := taskCtx.GetDal()
+ data := taskCtx.GetData().(*GithubTaskData)
+
+ apiCollector, err := helper.NewStatefulApiCollector(helper.RawDataSubTaskArgs{
+ Ctx: taskCtx,
+ Params: GithubApiParams{
+ ConnectionId: data.Options.ConnectionId,
+ Name: data.Options.Name,
+ },
+ Table: RAW_ISSUE_FIELD_VALUE_TABLE,
+ })
+ if err != nil {
+ return err
+ }
+
+ clauses := []dal.Clause{
+ dal.Select("number, github_id"),
+ dal.From(models.GithubIssue{}.TableName()),
+ dal.Where("repo_id = ? and connection_id = ?", data.Options.GithubId, data.Options.ConnectionId),
+ }
+ if apiCollector.IsIncremental() && apiCollector.GetSince() != nil {
+ clauses = append(clauses, dal.Where("github_updated_at > ?", apiCollector.GetSince()))
+ }
+
+ cursor, err := db.Cursor(clauses...)
+ if err != nil {
+ return err
+ }
+
+ iterator, err := helper.NewDalCursorIterator(db, cursor, reflect.TypeOf(SimpleIssue{}))
+ if err != nil {
+ return err
+ }
+
+ err = apiCollector.InitCollector(helper.ApiCollectorArgs{
+ ApiClient: data.ApiClient,
+ PageSize: 100,
+ Input: iterator,
+
+ UrlTemplate: "repos/{{ .Params.Name }}/issues/{{ .Input.Number }}/issue-field-values",
+
+ Query: func(reqData *helper.RequestData) (url.Values, errors.Error) {
+ query := url.Values{}
+ query.Set("page", fmt.Sprintf("%v", reqData.Pager.Page))
+ query.Set("per_page", fmt.Sprintf("%v", reqData.Pager.Size))
+ return query, nil
+ },
+ ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) {
+ // An organization that has never configured issue fields, or a token without
+ // visibility of them, gets 404 rather than an empty list. Treat that as "this
+ // issue has no field values" so one such repository cannot fail the whole task.
+ if res.StatusCode == http.StatusNotFound {
+ return nil, nil
+ }
+ var items []json.RawMessage
+ err := helper.UnmarshalResponse(res, &items)
+ if err != nil {
+ return nil, err
+ }
+ return items, nil
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return apiCollector.Execute()
+}
diff --git a/backend/plugins/github/tasks/issue_field_value_extractor.go b/backend/plugins/github/tasks/issue_field_value_extractor.go
new file mode 100644
index 0000000..4ec955d
--- /dev/null
+++ b/backend/plugins/github/tasks/issue_field_value_extractor.go
@@ -0,0 +1,143 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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 tasks
+
+import (
+ "encoding/json"
+ "strconv"
+ "strings"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/github/models"
+)
+
+func init() {
+ RegisterSubtaskMeta(&ExtractApiIssueFieldValuesMeta)
+}
+
+var ExtractApiIssueFieldValuesMeta = plugin.SubTaskMeta{
+ Name: "Extract Issue Field Values",
+ EntryPoint: ExtractApiIssueFieldValues,
+ EnabledByDefault: false,
+ Description: "Extract raw issue field value data into the tool layer table _tool_github_issue_field_values",
+ DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET},
+ DependencyTables: []string{RAW_ISSUE_FIELD_VALUE_TABLE},
+ ProductTables: []string{models.GithubIssueFieldValue{}.TableName()},
+}
+
+type IssueFieldValueSelectOption struct {
+ Id int `json:"id"`
+ Name string `json:"name"`
+ Color string `json:"color"`
+}
+
+type IssueFieldValueResponse struct {
+ IssueFieldId int `json:"issue_field_id"`
+ IssueFieldName string `json:"issue_field_name"`
+ DataType string `json:"data_type"`
+ Value json.RawMessage `json:"value"`
+ SingleSelectOption *IssueFieldValueSelectOption `json:"single_select_option"`
+ MultiSelectOptions []*IssueFieldValueSelectOption `json:"multi_select_options"`
+}
+
+func ExtractApiIssueFieldValues(taskCtx plugin.SubTaskContext) errors.Error {
+ data := taskCtx.GetData().(*GithubTaskData)
+ extractor, err := helper.NewStatefulApiExtractor(&helper.StatefulApiExtractorArgs[IssueFieldValueResponse]{
+ SubtaskCommonArgs: &helper.SubtaskCommonArgs{
+ SubTaskContext: taskCtx,
+ Params: GithubApiParams{
+ ConnectionId: data.Options.ConnectionId,
+ Name: data.Options.Name,
+ },
+ Table: RAW_ISSUE_FIELD_VALUE_TABLE,
+ },
+ Extract: func(body *IssueFieldValueResponse, row *helper.RawData) ([]any, errors.Error) {
+ if body.IssueFieldId == 0 {
+ return nil, nil
+ }
+ issue := &SimpleIssue{}
+ if err := errors.Convert(json.Unmarshal(row.Input, issue)); err != nil {
+ return nil, err
+ }
+
+ fieldValue := &models.GithubIssueFieldValue{
+ ConnectionId: data.Options.ConnectionId,
+ IssueId: issue.GithubId,
+ FieldId: body.IssueFieldId,
+ FieldName: body.IssueFieldName,
+ DataType: body.DataType,
+ Value: displayValue(body),
+ RawValue: string(body.Value),
+ }
+ if body.SingleSelectOption != nil {
+ fieldValue.OptionColor = body.SingleSelectOption.Color
+ }
+ return []any{fieldValue}, nil
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return extractor.Execute()
+}
+
+// displayValue renders the API value as queryable text. Select options are preferred over
+// the raw value because the option objects carry the canonical names.
+func displayValue(body *IssueFieldValueResponse) string {
+ if body.SingleSelectOption != nil {
+ return body.SingleSelectOption.Name
+ }
+ if len(body.MultiSelectOptions) > 0 {
+ names := make([]string, 0, len(body.MultiSelectOptions))
+ for _, option := range body.MultiSelectOptions {
+ if option != nil {
+ names = append(names, option.Name)
+ }
+ }
+ return strings.Join(names, ",")
+ }
+ return scalarValue(body.Value)
+}
+
+// scalarValue unwraps a JSON scalar into text. Numbers are rendered without a trailing
+// ".0" so an effort of 5 reads as "5" rather than "5.0" in a dashboard.
+func scalarValue(raw json.RawMessage) string {
+ if len(raw) == 0 || string(raw) == "null" {
+ return ""
+ }
+ var asString string
+ if err := json.Unmarshal(raw, &asString); err == nil {
+ return asString
+ }
+ var asNumber float64
+ if err := json.Unmarshal(raw, &asNumber); err == nil {
+ return strconv.FormatFloat(asNumber, 'f', -1, 64)
+ }
+ var asBool bool
+ if err := json.Unmarshal(raw, &asBool); err == nil {
+ return strconv.FormatBool(asBool)
+ }
+ // Arrays of plain strings can arrive without option objects on multi_select.
+ var asStrings []string
+ if err := json.Unmarshal(raw, &asStrings); err == nil {
+ return strings.Join(asStrings, ",")
+ }
+ return string(raw)
+}
diff --git a/backend/plugins/github/tasks/issue_field_value_extractor_test.go b/backend/plugins/github/tasks/issue_field_value_extractor_test.go
new file mode 100644
index 0000000..38efc36
--- /dev/null
+++ b/backend/plugins/github/tasks/issue_field_value_extractor_test.go
@@ -0,0 +1,97 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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 tasks
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// The bodies below are the shapes documented for
+// GET /repos/{owner}/{repo}/issues/{issue_number}/issue-field-values.
+func TestDisplayValueAcrossDataTypes(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ expected string
+ }{
+ {
+ name: "single_select prefers the option name",
+ body: `{"issue_field_id":123,"issue_field_name":"Priority","data_type":"single_select",
+ "value":"Critical","single_select_option":{"id":1,"name":"Critical","color":"ff0000"}}`,
+ expected: "Critical",
+ },
+ {
+ name: "multi_select joins option names in API order",
+ body: `{"issue_field_id":101,"issue_field_name":"Labels","data_type":"multi_select",
+ "value":["backend","urgent"],
+ "multi_select_options":[{"id":2,"name":"backend"},{"id":3,"name":"urgent"}]}`,
+ expected: "backend,urgent",
+ },
+ {
+ name: "text passes through",
+ body: `{"issue_field_id":202,"issue_field_name":"Description","data_type":"text","value":"Fix auth flow"}`,
+ expected: "Fix auth flow",
+ },
+ {
+ name: "integral number has no trailing decimal",
+ body: `{"issue_field_id":456,"issue_field_name":"Effort","data_type":"number","value":5}`,
+ expected: "5",
+ },
+ {
+ name: "fractional number keeps its precision",
+ body: `{"issue_field_id":456,"issue_field_name":"Effort","data_type":"number","value":2.5}`,
+ expected: "2.5",
+ },
+ {
+ name: "date passes through",
+ body: `{"issue_field_id":789,"issue_field_name":"Target Date","data_type":"date","value":"2024-12-31"}`,
+ expected: "2024-12-31",
+ },
+ {
+ name: "null value yields empty text",
+ body: `{"issue_field_id":789,"issue_field_name":"Target Date","data_type":"date","value":null}`,
+ expected: "",
+ },
+ {
+ name: "multi_select without option objects falls back to the raw array",
+ body: `{"issue_field_id":101,"issue_field_name":"Labels","data_type":"multi_select",
+ "value":["backend","urgent"]}`,
+ expected: "backend,urgent",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ body := &IssueFieldValueResponse{}
+ assert.NoError(t, json.Unmarshal([]byte(tt.body), body))
+ assert.Equal(t, tt.expected, displayValue(body))
+ })
+ }
+}
+
+func TestScalarValueHandlesMissingAndUnknownShapes(t *testing.T) {
+ assert.Equal(t, "", scalarValue(nil))
+ assert.Equal(t, "", scalarValue(json.RawMessage("")))
+ assert.Equal(t, "", scalarValue(json.RawMessage("null")))
+ assert.Equal(t, "true", scalarValue(json.RawMessage("true")))
+ // An object we do not model is preserved verbatim rather than dropped.
+ assert.Equal(t, `{"a":1}`, scalarValue(json.RawMessage(`{"a":1}`)))
+}