Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: increase look back period for customer issues needing triage #5295

Merged
merged 7 commits into from
Nov 8, 2024
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
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.github/
194 changes: 194 additions & 0 deletions .github/.scripts/collect-customer-issues.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Utility function to create a hyperlink to a GitHub user
function createGithubLink(username) {
return `<https://github.com/${username}|${username}>`;
}

// Helper function to check if an issue has any of the required labels
function hasLabel(issueLabels, requiredLabels) {
return issueLabels.some((label) => requiredLabels.includes(label.name));
}

// Helper function to calculate the difference in days and format as "Today," "Yesterday," or "X days ago"
function formatDateDifference(date) {
const now = new Date();
const millisecondsInADay = 1000 * 60 * 60 * 24;
const daysDifference = Math.floor(
(now - new Date(date)) / millisecondsInADay,
);

if (daysDifference === 0) return "Today";
if (daysDifference === 1) return "Yesterday";
return `${daysDifference} days ago`;
}

// Helper function to get a sorted list of unique participants from comments on an issue, excluding the author
async function getParticipants(github, context, issueNumber, author) {
try {
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100,
});

const participants = new Set(
comments
.map((comment) => comment.user.login)
.filter((username) => username !== author), // Exclude the author
);
return Array.from(participants).sort();
} catch (error) {
console.error(`Error fetching comments for issue #${issueNumber}:`, error);
return [];
}
}

// Helper function to filter issues based on required labels and a cutoff date
function filterIssues(issues, requiredLabels, cutoffDate) {
return issues
.filter((issue) => {
const hasLabels = hasLabel(issue.labels, requiredLabels);
const isRecent = new Date(issue.created_at) > cutoffDate;
const isNotPR = !issue.pull_request;
return hasLabels && isRecent && isNotPR;
})
.sort((a, b) => b.number - a.number); // Sort by issue number, newest first
}

// Helper function to separate issues into "stale" and "fresh" based on staleness threshold
function splitIssuesByStaleness(issues, stalenessThreshold) {
const staleIssues = [];
const freshIssues = [];
const now = new Date();

issues.forEach((issue) => {
const daysOld = Math.floor(
(now - new Date(issue.created_at)) / (1000 * 60 * 60 * 24),
);
if (daysOld > stalenessThreshold) {
staleIssues.push(issue);
} else {
freshIssues.push(issue);
}
});

return { staleIssues, freshIssues };
}

// Helper function to categorize fresh issues as either "bug" or "enhancement/inquiry"
function categorizeIssues(issues) {
const bugIssues = [];
const enhancementIssues = [];

issues.forEach((issue) => {
if (issue.labels.some((label) => label.name === "bug")) {
bugIssues.push(issue);
} else {
enhancementIssues.push(issue);
}
});

return { bugIssues, enhancementIssues };
}

// Helper function to build a detailed description for each issue
async function formatIssueLine(github, context, issue, index) {
let line = `${index + 1}. *<${issue.html_url}|#${issue.number}>:* ${issue.title}`;
line += ` (by ${createGithubLink(issue.user.login)}; ${formatDateDifference(issue.created_at)})`;

if (issue.comments > 0) {
line += `; ${issue.comments} comment${issue.comments > 1 ? "s" : ""}`;
const participants = await getParticipants(
github,
context,
issue.number,
issue.user.login,
);
if (participants.length > 0) {
const participantLinks = participants.map(createGithubLink).join(", ");
line += `; participants: ${participantLinks}`;
}
}

if (issue.assignees.length > 0) {
const assigneeLinks = issue.assignees
.map((assignee) => createGithubLink(assignee.login))
.join(", ");
line += `; assigned to: ${assigneeLinks}`;
}

return line;
}

// Helper function to build a message for Slack with grouped and formatted issues
async function buildSlackMessage(github, context, issueGroups, lookbackDays) {
const messageLines = [
`*🛠️ Phoenix Customer Issues Opened in the Last ${lookbackDays} Day(s) Pending <https://github.com/Arize-ai/phoenix/issues?q=is%3Aissue+is%3Aopen+label%3Atriage|Triage>*\n`,
];

for (const [issuesArray, header] of issueGroups) {
if (issuesArray.length > 0) {
messageLines.push(header); // Add the group header (e.g., "🐛 Bugs")
const issueDescriptions = await Promise.all(
issuesArray.map((issue, index) =>
formatIssueLine(github, context, issue, index),
),
);
messageLines.push(...issueDescriptions);
}
}

return messageLines.join("\n");
}

// Main function to fetch and format issues, then send the Slack message
module.exports = async ({ github, context, core }) => {
const requiredLabels = ["triage"];
const lookbackDays = parseInt(process.env.LOOKBACK_DAYS || "120", 10);
const stalenessThreshold = parseInt(
process.env.STALENESS_THRESHOLD_IN_DAYS || "14",
10,
);

const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - lookbackDays);

// Retrieve issues created within the specified lookback period
const issues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
since: cutoffDate.toISOString(),
per_page: 100,
});

// Filter issues by label and date, then categorize by staleness and type
const filteredIssues = filterIssues(issues, requiredLabels, cutoffDate);
if (filteredIssues.length === 0) {
core.setOutput("has_issues", "false");
return;
}

core.setOutput("has_issues", "true");

const { staleIssues, freshIssues } = splitIssuesByStaleness(
filteredIssues,
stalenessThreshold,
);
const { bugIssues, enhancementIssues } = categorizeIssues(freshIssues);

const issueGroups = [
[bugIssues, "*🐛 Bugs*"],
[enhancementIssues, "*💡 Enhancements or Inquiries*"],
[staleIssues, `*🥀 Stale Issues (>${stalenessThreshold} days)*`],
];

// Build the Slack message and set as output
const message = await buildSlackMessage(
github,
context,
issueGroups,
lookbackDays,
);
core.setOutput("slack_message", message);
};
140 changes: 10 additions & 130 deletions .github/workflows/collect-customer-issues.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,149 +2,29 @@ name: Collect customer issues

on:
schedule:
- cron: "0 15 * * *"
- cron: "0 15 * * 1-5"
workflow_dispatch:

jobs:
collect-issues-pending-triage:
name: Pending Triage
runs-on: ubuntu-latest
env:
DAYS: 30
LOOKBACK_DAYS: 120
STALENESS_THRESHOLD_IN_DAYS: 14
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
sparse-checkout: |
.github/
- name: Retrieve and format issues
id: retrieve-issues
uses: actions/github-script@v7
with:
script: |
// List of labels to filter by
const requiredLabels = [
"triage",
];

// List of core developers to exclude
const coreDevelopers = [
"mikeldking",
"axiomofjoy",
"anticorrelator",
"cephalization",
"Parker-Stafford",
"Jgilhuly",
"RogerHYang",
];

// Access the DAYS environment variable
const days = parseInt(process.env.DAYS || '7', 10);

// Calculate the cutoff date
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - days);

// Fetch issues created since DAYS ago
const issues = await github.paginate(
github.rest.issues.listForRepo,
{
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
since: cutoffDate.toISOString(),
per_page: 100,
}
);

// Check if issue has any of the required labels
function hasRequiredLabel(issueLabels, requiredLabels) {
return issueLabels.some(label => requiredLabels.includes(label.name));
}

// Filter issues
const filteredIssues = issues.filter(issue =>
!coreDevelopers.includes(issue.user.login) &&
hasRequiredLabel(issue.labels, requiredLabels) &&
new Date(issue.created_at) > cutoffDate &&
!issue.pull_request
).sort((a, b) => b.number - a.number);

// Function to calculate "X days ago" from created_at date
function timeAgo(createdAt) {
const createdDate = new Date(createdAt);
const now = new Date();
const diffInMs = now - createdDate;
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
if (diffInDays === 0) {
return "Today";
} else if (diffInDays === 1) {
return "Yesterday";
} else {
return `${diffInDays} days ago`;
}
}

// Function to get unique participants from comments on an issue, excluding the author
async function getParticipants(issueNumber, author) {
try {
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100,
});
// Extract unique usernames of commenters, excluding the author
const uniqueParticipants = [
...new Set(comments.map(comment => comment.user.login).filter(username => username !== author))
].sort();
return uniqueParticipants;
} catch (error) {
console.error(`Error fetching comments for issue #${issueNumber}: ${error}`);
return [];
}
}

// Format the issues as a Markdown message for Slack
if (filteredIssues.length === 0) {
core.setOutput("has_issues", "false");
} else {
core.setOutput("has_issues", "true");
let message = `*🛠️ Phoenix Customer Issues Opened in the Last ${days} Day(s)`;
message += ` Pending <https://github.com/Arize-ai/phoenix/issues?q=is%3Aissue+is%3Aopen+label%3Atriage|Triage>`;
message += `*\n\n`;
// Separate issues into two lists: those with the "bug" label and those without
const bugIssues = filteredIssues.filter(issue =>
issue.labels.some(label => label.name === 'bug')
);
const enhancementIssues = filteredIssues.filter(issue =>
!issue.labels.some(label => label.name === 'bug')
);
const issueGroups = [
[bugIssues, "*🐛 Bugs*"],
[enhancementIssues, "*💡 Enhancements or Inquiries*"]
];
// Use `for...of` loop to allow async/await inside the loop
for (const [issues, header] of issueGroups) {
if (issues.length > 0) {
message += `${header}\n`;
for (const [i, issue] of issues.entries()) {
message += `${i + 1}. *<${issue.html_url}|#${issue.number}>:* ${issue.title}`;
message += ` (by <https://github.com/${issue.user.login}|${issue.user.login}>`;
message += `; ${timeAgo(issue.created_at)}`;
if (issue.comments > 0) {
message += `; ${issue.comments} comments`;
const participants = await getParticipants(issue.number, issue.user.login);
if (participants.length > 0) {
message += `; participants: `;
message += participants.map(participant => `<https://github.com/${participant}|${participant}>`).join(", ");
}
}
if (issue.assignees.length > 0) {
message += `; assigned to: `
message += issue.assignees.map(assignee => `<https://github.com/${assignee.login}|${assignee.login}>`).join(", ");
}
message += `)\n`;
}
}
}
core.setOutput("slack_message", message);
}
const script = require(".github/.scripts/collect-customer-issues.js");
await script({github, context, core});
- name: Send message to Slack
uses: slackapi/slack-github-action@v1
if: steps.retrieve-issues.outputs.has_issues == 'true'
Expand Down
Loading