Skip to content

Commit ac64579

Browse files
committed
feat(review): ingest PR-linked issue context
1 parent a72d399 commit ac64579

File tree

9 files changed

+773
-10
lines changed

9 files changed

+773
-10
lines changed

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
7878
42. [x] Surface review rule file sources in the Settings UI instead of requiring config edits by hand.
7979
43. [x] Add structured UI editing for custom context notes, files, and scopes.
8080
44. [x] Add per-path scoped review instructions in the Settings UI for common repo areas.
81-
45. [ ] Support Jira/Linear issue context ingestion for PR-linked reviews.
81+
45. [x] Support Jira/Linear issue context ingestion for PR-linked reviews.
8282
46. [ ] Support document-backed context ingestion for design docs, RFCs, and runbooks.
8383
47. [ ] Add explicit "intent mismatch" review checks comparing PR changes to ticket acceptance criteria.
8484
48. [x] Add review artifacts that show which external context sources influenced a finding.

src/config.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,45 @@ pub struct GitHubConfig {
116116
pub webhook_secret: Option<String>,
117117
}
118118

119+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
120+
pub struct JiraConfig {
121+
#[serde(default, rename = "jira_base_url")]
122+
pub base_url: Option<String>,
123+
124+
#[serde(default, rename = "jira_email")]
125+
pub email: Option<String>,
126+
127+
#[serde(default, rename = "jira_api_token")]
128+
pub api_token: Option<String>,
129+
}
130+
131+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
132+
pub struct LinearConfig {
133+
#[serde(default, rename = "linear_api_key")]
134+
pub api_key: Option<String>,
135+
}
136+
137+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
138+
#[serde(rename_all = "snake_case")]
139+
pub enum LinkedIssueProvider {
140+
Jira,
141+
Linear,
142+
}
143+
144+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145+
pub struct LinkedIssueContext {
146+
pub provider: LinkedIssueProvider,
147+
pub identifier: String,
148+
#[serde(default)]
149+
pub title: Option<String>,
150+
#[serde(default)]
151+
pub status: Option<String>,
152+
#[serde(default)]
153+
pub url: Option<String>,
154+
#[serde(default)]
155+
pub summary: String,
156+
}
157+
119158
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
120159
pub struct AutomationConfig {
121160
/// Outbound webhook URL for downstream automation consumers.
@@ -459,6 +498,9 @@ pub struct Config {
459498
#[serde(default)]
460499
pub custom_context: Vec<CustomContextConfig>,
461500

501+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
502+
pub linked_issue_contexts: Vec<LinkedIssueContext>,
503+
462504
#[serde(default)]
463505
pub pattern_repositories: Vec<PatternRepositoryConfig>,
464506

@@ -477,6 +519,12 @@ pub struct Config {
477519
#[serde(default, flatten)]
478520
pub github: GitHubConfig,
479521

522+
#[serde(default, flatten)]
523+
pub jira: JiraConfig,
524+
525+
#[serde(default, flatten)]
526+
pub linear: LinearConfig,
527+
480528
#[serde(default, flatten)]
481529
pub automation: AutomationConfig,
482530

@@ -679,12 +727,15 @@ impl Default for Config {
679727
exclude_patterns: default_exclude_patterns(),
680728
paths: HashMap::new(),
681729
custom_context: Vec::new(),
730+
linked_issue_contexts: Vec::new(),
682731
pattern_repositories: Vec::new(),
683732
rules_files: Vec::new(),
684733
max_active_rules: default_max_active_rules(),
685734
rule_priority: Vec::new(),
686735
providers: HashMap::new(),
687736
github: GitHubConfig::default(),
737+
jira: JiraConfig::default(),
738+
linear: LinearConfig::default(),
688739
automation: AutomationConfig::default(),
689740
server_security: ServerSecurityConfig::default(),
690741
multi_pass_specialized: false,

src/core/context_provenance.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ pub enum ContextProvenance {
1010
name: String,
1111
},
1212
CustomContextNotes,
13+
JiraIssueContext {
14+
issue_key: String,
15+
},
16+
LinearIssueContext {
17+
issue_id: String,
18+
},
1319
DependencyGraphNeighborhood,
1420
PathSpecificFocusAreas,
1521
RepositoryGraphMetadata,
@@ -53,6 +59,18 @@ impl ContextProvenance {
5359
}
5460
}
5561

62+
pub fn jira_issue_context(issue_key: impl Into<String>) -> Self {
63+
Self::JiraIssueContext {
64+
issue_key: issue_key.into(),
65+
}
66+
}
67+
68+
pub fn linear_issue_context(issue_id: impl Into<String>) -> Self {
69+
Self::LinearIssueContext {
70+
issue_id: issue_id.into(),
71+
}
72+
}
73+
5674
pub fn semantic_retrieval(similarity: f32, symbol_name: impl Into<String>) -> Self {
5775
Self::SemanticRetrieval {
5876
similarity,
@@ -100,6 +118,8 @@ impl ContextProvenance {
100118
}
101119
Self::Analyzer { .. }
102120
| Self::CustomContextNotes
121+
| Self::JiraIssueContext { .. }
122+
| Self::LinearIssueContext { .. }
103123
| Self::DependencyGraphNeighborhood
104124
| Self::PathSpecificFocusAreas
105125
| Self::RepositoryGraphMetadata
@@ -121,6 +141,8 @@ impl ContextProvenance {
121141
let source = match self {
122142
Self::ActiveReviewRules | Self::Analyzer { .. } => return None,
123143
Self::CustomContextNotes => "custom-context".to_string(),
144+
Self::JiraIssueContext { .. } => "jira-issue".to_string(),
145+
Self::LinearIssueContext { .. } => "linear-issue".to_string(),
124146
Self::DependencyGraphNeighborhood => "dependency-graph".to_string(),
125147
Self::PathSpecificFocusAreas => "path-focus".to_string(),
126148
Self::RepositoryGraphMetadata => "repository-graph".to_string(),
@@ -143,6 +165,10 @@ impl ContextProvenance {
143165
Self::ActiveReviewRules => "active review rules".to_string(),
144166
Self::Analyzer { name } => format!("{name} analyzer"),
145167
Self::CustomContextNotes => "custom context notes".to_string(),
168+
Self::JiraIssueContext { issue_key } => format!("jira issue context: {issue_key}"),
169+
Self::LinearIssueContext { issue_id } => {
170+
format!("linear issue context: {issue_id}")
171+
}
146172
Self::DependencyGraphNeighborhood => "dependency graph neighborhood".to_string(),
147173
Self::PathSpecificFocusAreas => "path-specific focus areas".to_string(),
148174
Self::RepositoryGraphMetadata => "repository graph metadata".to_string(),
@@ -234,6 +260,14 @@ mod tests {
234260
ContextProvenance::RepositoryGraphMetadata.to_string(),
235261
"repository graph metadata"
236262
);
263+
assert_eq!(
264+
ContextProvenance::jira_issue_context("ENG-123").to_string(),
265+
"jira issue context: ENG-123"
266+
);
267+
assert_eq!(
268+
ContextProvenance::linear_issue_context("LIN-42").to_string(),
269+
"linear issue context: LIN-42"
270+
);
237271
}
238272

239273
#[test]
@@ -256,6 +290,18 @@ mod tests {
256290
.as_deref(),
257291
Some("context-source:symbol-graph")
258292
);
293+
assert_eq!(
294+
ContextProvenance::jira_issue_context("ENG-123")
295+
.artifact_tag()
296+
.as_deref(),
297+
Some("context-source:jira-issue")
298+
);
299+
assert_eq!(
300+
ContextProvenance::linear_issue_context("LIN-42")
301+
.artifact_tag()
302+
.as_deref(),
303+
Some("context-source:linear-issue")
304+
);
259305
assert!(ContextProvenance::ActiveReviewRules
260306
.artifact_tag()
261307
.is_none());

src/review/context_helpers.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ mod pattern_repositories;
55
#[path = "context_helpers/ranking.rs"]
66
mod ranking;
77

8-
pub use injection::{inject_custom_context, inject_pattern_repository_context};
8+
pub use injection::{
9+
inject_custom_context, inject_linked_issue_context, inject_pattern_repository_context,
10+
};
911
pub use pattern_repositories::{resolve_pattern_repositories, PatternRepositoryMap};
1012
pub use ranking::rank_and_trim_context_chunks;

src/review/context_helpers/injection.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,61 @@ pub async fn inject_custom_context(
3737
Ok(())
3838
}
3939

40+
pub fn inject_linked_issue_context(
41+
config: &config::Config,
42+
diff: &core::UnifiedDiff,
43+
context_chunks: &mut Vec<core::LLMContextChunk>,
44+
) {
45+
for issue in &config.linked_issue_contexts {
46+
let mut lines = vec![format!(
47+
"Linked {} issue context: {}",
48+
match issue.provider {
49+
config::LinkedIssueProvider::Jira => "Jira",
50+
config::LinkedIssueProvider::Linear => "Linear",
51+
},
52+
issue.identifier
53+
)];
54+
55+
if let Some(title) = issue
56+
.title
57+
.as_deref()
58+
.filter(|value| !value.trim().is_empty())
59+
{
60+
lines.push(format!("Title: {title}"));
61+
}
62+
if let Some(status) = issue
63+
.status
64+
.as_deref()
65+
.filter(|value| !value.trim().is_empty())
66+
{
67+
lines.push(format!("Status: {status}"));
68+
}
69+
if let Some(url) = issue
70+
.url
71+
.as_deref()
72+
.filter(|value| !value.trim().is_empty())
73+
{
74+
lines.push(format!("URL: {url}"));
75+
}
76+
if !issue.summary.trim().is_empty() {
77+
lines.push("Summary:".to_string());
78+
lines.push(issue.summary.trim().to_string());
79+
}
80+
81+
context_chunks.push(
82+
core::LLMContextChunk::documentation(diff.file_path.clone(), lines.join("\n"))
83+
.with_provenance(match issue.provider {
84+
config::LinkedIssueProvider::Jira => {
85+
core::ContextProvenance::jira_issue_context(issue.identifier.clone())
86+
}
87+
config::LinkedIssueProvider::Linear => {
88+
core::ContextProvenance::linear_issue_context(issue.identifier.clone())
89+
}
90+
}),
91+
);
92+
}
93+
}
94+
4095
pub async fn inject_pattern_repository_context(
4196
config: &config::Config,
4297
resolved_repositories: &PatternRepositoryMap,
@@ -149,6 +204,50 @@ mod tests {
149204
);
150205
}
151206

207+
#[test]
208+
fn inject_linked_issue_context_uses_provider_specific_provenance() {
209+
let mut config = config::Config::default();
210+
config.linked_issue_contexts = vec![
211+
config::LinkedIssueContext {
212+
provider: config::LinkedIssueProvider::Jira,
213+
identifier: "ENG-123".to_string(),
214+
title: Some("Keep API status enum aligned".to_string()),
215+
status: Some("In Progress".to_string()),
216+
url: Some("https://example.atlassian.net/browse/ENG-123".to_string()),
217+
summary: "The API contract must remain backwards compatible.".to_string(),
218+
},
219+
config::LinkedIssueContext {
220+
provider: config::LinkedIssueProvider::Linear,
221+
identifier: "OPS-9".to_string(),
222+
title: Some("Propagate webhook secret rename".to_string()),
223+
status: Some("Todo".to_string()),
224+
url: Some(
225+
"https://linear.app/evalops/issue/OPS-9/rename-webhook-secret".to_string(),
226+
),
227+
summary: "Deployment manifests should use the new secret name.".to_string(),
228+
},
229+
];
230+
231+
let mut context_chunks = Vec::new();
232+
inject_linked_issue_context(&config, &diff_for("src/lib.rs"), &mut context_chunks);
233+
234+
assert_eq!(context_chunks.len(), 2);
235+
assert_eq!(
236+
context_chunks[0].provenance,
237+
Some(core::ContextProvenance::jira_issue_context("ENG-123"))
238+
);
239+
assert!(context_chunks[0]
240+
.content
241+
.contains("Keep API status enum aligned"));
242+
assert_eq!(
243+
context_chunks[1].provenance,
244+
Some(core::ContextProvenance::linear_issue_context("OPS-9"))
245+
);
246+
assert!(context_chunks[1]
247+
.content
248+
.contains("Deployment manifests should use the new secret name."));
249+
}
250+
152251
#[tokio::test]
153252
async fn inject_pattern_repository_context_tags_source_and_chunks() {
154253
let dir = tempfile::tempdir().unwrap();

src/review/pipeline/file_context/sources/repo.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ pub(in super::super) async fn inject_repository_context(
99
diff: &core::UnifiedDiff,
1010
context_chunks: &mut Vec<core::LLMContextChunk>,
1111
) -> Result<()> {
12+
super::super::super::super::context_helpers::inject_linked_issue_context(
13+
&services.config,
14+
diff,
15+
context_chunks,
16+
);
1217
super::super::super::super::context_helpers::inject_custom_context(
1318
&services.config,
1419
&services.context_fetcher,

src/server/api.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,12 +226,14 @@ mod tests {
226226
"github_webhook_secret".to_string(),
227227
serde_json::json!("secret5"),
228228
);
229+
obj.insert("jira_api_token".to_string(), serde_json::json!("secret6"));
230+
obj.insert("linear_api_key".to_string(), serde_json::json!("secret7"));
229231
obj.insert(
230232
"automation_webhook_secret".to_string(),
231-
serde_json::json!("secret6"),
233+
serde_json::json!("secret8"),
232234
);
233-
obj.insert("server_api_key".to_string(), serde_json::json!("secret7"));
234-
obj.insert("vault_token".to_string(), serde_json::json!("secret8"));
235+
obj.insert("server_api_key".to_string(), serde_json::json!("secret9"));
236+
obj.insert("vault_token".to_string(), serde_json::json!("secret10"));
235237
mask_config_secrets(&mut obj);
236238
assert_eq!(obj.get("api_key").unwrap(), &serde_json::json!("***"));
237239
assert_eq!(obj.get("github_token").unwrap(), &serde_json::json!("***"));
@@ -247,6 +249,14 @@ mod tests {
247249
obj.get("github_webhook_secret").unwrap(),
248250
&serde_json::json!("***")
249251
);
252+
assert_eq!(
253+
obj.get("jira_api_token").unwrap(),
254+
&serde_json::json!("***")
255+
);
256+
assert_eq!(
257+
obj.get("linear_api_key").unwrap(),
258+
&serde_json::json!("***")
259+
);
250260
assert_eq!(
251261
obj.get("automation_webhook_secret").unwrap(),
252262
&serde_json::json!("***")

src/server/api/admin.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ pub(crate) fn mask_config_secrets(obj: &mut serde_json::Map<String, serde_json::
132132
"github_client_secret",
133133
"github_private_key",
134134
"github_webhook_secret",
135+
"jira_api_token",
136+
"linear_api_key",
135137
"automation_webhook_secret",
136138
"server_api_key",
137139
"vault_token",

0 commit comments

Comments
 (0)