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

feat: github pr search #16

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
5 changes: 4 additions & 1 deletion ENV_SAMPLE
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
REPO_OWNER="Git-Commit-Show"
GITHUB_PERSONAL_TOKEN="ghp_adsfdsf32sdfasdfcdcsdfsdf23sfasdf1"
GITHUB_PERSONAL_TOKEN="ghp_adsfdsf32sdfasdfcdcsdfsdf23sfasdf1"
APERTURE_SERVICE_ADDRESS="my.app.fluxninja.com:443"
APERTURE_API_KEY="4dsfs323db7dsfsde310ca6dsdf12sfr4"
MAX_RATE_LIMIT_WAIT_DURATION_MS=60000
61 changes: 61 additions & 0 deletions archive.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import * as path from 'path';
import * as fs from 'fs';

/**
* Writes json data to a csv file
* @param {Array<Object>} data - The data to be written to the file.
* @param {string} [options.archiveFolder=process.cwd()] - The folder where the file will be saved. Defaults to the current working directory.
* @param {string} [options.archiveFileName='archive-YYYYMMDDHHmmss.csv'] - The name of the file to be written. Defaults to 'archive-YYYYMMDDHHmmss.csv'.
*/
export function save(data, options = {}) {
if (!data || !Array.isArray(data) || data.length<1) {
console.log("No content to write.");
return;
Comment on lines +11 to +13
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check for data before writing to CSV is good, but consider throwing an error instead of just logging to the console for better error handling.

- console.log("No content to write.");
- return;
+ throw new Error("No content to write.");

Committable suggestion

IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
if (!data || !Array.isArray(data) || data.length<1) {
console.log("No content to write.");
return;
if (!data || !Array.isArray(data) || data.length<1) {
throw new Error("No content to write.");

}
// Prepare content for csv
let allKeys = Array.from(new Set(data.flatMap(Object.keys)));
const headers = allKeys.join(',');
const rows = data.map(obj => formatCSVRow(obj, allKeys)).join("\n");
const csvContent = headers + "\n" + rows;
writeToFile(csvContent, options);
return csvContent;
}

export function writeToFile(content, options){
const ARCHIVE_FOLDER = options.archiveFolder || process.cwd();
const ARCHIVE_FULL_PATH = path.join(ARCHIVE_FOLDER, options.archiveFileName || `archive-${getFormattedDate()}.csv`);
fs.writeFile(ARCHIVE_FULL_PATH, content, { flag: 'a+' }, err => {
if (err) {
console.error(err);
return;
}
console.log("The file was saved!");
});
}
Comment on lines +24 to +34
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The writeToFile function logs errors to the console but does not propagate them. Consider throwing an error or returning a rejected promise to allow the caller to handle it.

- console.error(err);
- return;
+ throw err;

Committable suggestion

IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
export function writeToFile(content, options){
const ARCHIVE_FOLDER = options.archiveFolder || process.cwd();
const ARCHIVE_FULL_PATH = path.join(ARCHIVE_FOLDER, options.archiveFileName || `archive-${getFormattedDate()}.csv`);
fs.writeFile(ARCHIVE_FULL_PATH, content, { flag: 'a+' }, err => {
if (err) {
console.error(err);
return;
}
console.log("The file was saved!");
});
}
export function writeToFile(content, options){
const ARCHIVE_FOLDER = options.archiveFolder || process.cwd();
const ARCHIVE_FULL_PATH = path.join(ARCHIVE_FOLDER, options.archiveFileName || `archive-${getFormattedDate()}.csv`);
fs.writeFile(ARCHIVE_FULL_PATH, content, { flag: 'a+' }, err => {
if (err) {
throw err;
}
console.log("The file was saved!");
});
}


function formatCSVRow(obj, keys) {
return keys.map(key => formatCSVCell(obj[key])).join(',');
}

function formatCSVCell(value) {
if (value === undefined || value === null) {
return '';
} else if (typeof value === 'object') {
// Stringify objects/arrays and escape double quotes
return `"${JSON.stringify(value).replace(/"/g, '""')}"`;
} else if (typeof value === 'string') {
// Check for commas or line breaks and escape double quotes
if (value.includes(',') || value.includes('\n')) {
return `"${value.replace(/"/g, '""')}"`;
} else {
return value;
}
} else {
return value.toString();
}
}

export function getFormattedDate() {
const now = new Date();
return now.toISOString().replace(/[\-\:T\.Z]/g, '');
}
Comment on lines +1 to +61
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace console.log and console.error with a more robust logging mechanism that allows for different logging levels and potentially different outputs, which is more suitable for production environments.

132 changes: 120 additions & 12 deletions contributors.js → github.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
/**
* @file Functions to analyze and archive meaningful github contributors data
* @file Functions to analyze and archive meaningful github data such as contributors
* @example To archive contributors leaderboard data in csv file, run `node contributors.js`
*/

import * as path from 'path';
import * as fs from 'fs';

import { makeRequest, makeRequestWithRateLimit } from './network.js';
import * as archive from './archive.js';

// Configurations (Optional)
// Repo owner that you want to analyze
Expand All @@ -26,6 +24,26 @@ if(GITHUB_PERSONAL_TOKEN){
GITHUB_REQUEST_OPTIONS.headers["Authorization"] = "token "+GITHUB_PERSONAL_TOKEN;
}


/**
* Get details of a Github repo
* @param {string} fullRepoNameOrUrl e.g. myorghandle/myreponame
* @param {number} pageNo
* @returns Promise<Array<Object> | String>
* @example getRepoDetail('myorghandle/myreponame').then((repoDetail) => console.log(repoDetail)).catch((err) => console.log(err))
*/
export async function getRepoDetail(fullRepoNameOrUrl, pageNo = 1) {
if(!fullRepoNameOrUrl) throw new Error("Invalid input")
let fullRepoName = fullRepoNameOrUrl.match(/github\.com(?:\/repos)?\/([^\/]+\/[^\/]+)/)?.[1] || fullRepoNameOrUrl;
let url = `https://api.github.com/repos/${fullRepoName}`;
console.log(url);
const { res, data } = await makeRequestWithRateLimit('GET', url, Object.assign({},GITHUB_REQUEST_OPTIONS));
console.log("Repo detail request finished for " + fullRepoName)
// console.log(data)
let dataJson = JSON.parse(data);
return dataJson;
}

/**
* Get all github repos of an owner(user/org)
* @param {string} owner The organization or user name on GitHub
Expand Down Expand Up @@ -176,18 +194,12 @@ function writeContributorLeaderboardToFile(contributors, options={}) {
if(!contributors || contributors.length<1){
return;
}
const ARCHIVE_FOLDER = options.archiveFolder || process.cwd();
const ARCHIVE_FULL_PATH = path.join(ARCHIVE_FOLDER, options.archiveFileName || 'archive-gh-contributors-leaderboard.csv');
// Prepare data
let ghContributorLeaderboard = contributors.map((contributor) => {
return ["@" + contributor.login, contributor.contributions, contributor.html_url, contributor.avatar_url, contributor.topContributedRepo, contributor.allContributedRepos].join();
}).join("\n");
ghContributorLeaderboard = "Github Username,Total Contributions,Profile,Avatar,Most Contribution To,Contributed To\n" + ghContributorLeaderboard;
fs.writeFile(ARCHIVE_FULL_PATH, ghContributorLeaderboard, { flag: 'a+' }, function (err) {
if (err) {
return console.log(err);
}
console.log("The file was saved!");
});
archive.writeToFile(ghContributorLeaderboard, Object.assign({ archiveFileName: 'archive-gh-contributors-leaderboard.csv' }, options));
}

/**
Expand All @@ -210,4 +222,100 @@ export async function archiveContributorsLeaderboard(owner=REPO_OWNER, options)
writeContributorLeaderboardToFile(contributors);

return ghHandles;
}

/**
* Search pull requests
* @param {string} query
* @param {Object} [options] Additional options
* @param {Object} [options.pageNo=1] Result page number
*/
export async function searchPullRequests(query, options) {
let pageNo = (options && options.pageNo) ? options.pageNo : 1;
if(options && options.GITHUB_PERSONAL_TOKEN){
GITHUB_REQUEST_OPTIONS.headers["Authorization"] = "token "+options.GITHUB_PERSONAL_TOKEN;
}
let queryString = encodeURIComponent((query || ''))+'+is:pull-request';
let url = `https://api.github.com/search/issues?q=${queryString}&per_page=100&page=${pageNo}&sort=${options.sort || 'created'}`;
const { res, data } = await makeRequestWithRateLimit('GET', url, Object.assign({},GITHUB_REQUEST_OPTIONS));
console.log("PR search request finished");
console.log('HTTP status: ', res.statusCode);
// console.log(data)
let searchResultObject = JSON.parse(data);
return searchResultObject;
}

/**
* Get all PRs matching query
* @param {string} query
* @param {Object} [options]
* @param {Object} [options.maxResults=1000] limit maximum results
*/
export async function recursivelySearchPullRequests(query, options){
let searchRequestOptions = Object.assign({ pageNo: 1, maxResults: 1000 }, options)
let prList = [];
let searchResultObject = await searchPullRequests(query, searchRequestOptions);
// Iterate over results if there are more results expected by the user
if(!searchResultObject || !searchResultObject.items || searchResultObject.items.length<1){
return prList;
}
prList.push(...searchResultObject.items);
while(prList.length < searchRequestOptions.maxResults && prList.length < searchResultObject.total_count){
searchRequestOptions.pageNo++;
try {
let nextPageSearchResultObject = await searchPullRequests(query, searchRequestOptions);
prList.push(...nextPageSearchResultObject.items);
} catch (err) {
console.log("Some issue in recursive search for pull requests");
break;
}
}
console.log("Found "+prList.length +" PRs"+" for "+query);
return prList;
}


/**
* Aggregates all pull requests based on a specified field
* @param {Object[]} pullRequests - An array of pull request objects.
* @param {string} aggregatorField - The field name used to aggregate the pull requests. Defaults to "repository_url".
* @returns {Object[]} An array of objects, each containing a unique value of the aggregator field and an array of all pull requests that share that value.
*/
export function aggregateAllPullRequests(pullRequests, aggregatorField = "repository_url") {
return pullRequests.reduce((grouped, currentItem) => {
// Skipping the items without aggregatorField
if (!currentItem[aggregatorField]) {
return grouped;
}
// Find or create the group for the current item
let group = grouped.find(g => g[aggregatorField] === currentItem[aggregatorField]);
if (!group) {
group = { [aggregatorField]: currentItem[aggregatorField], pull_requests: [] };
grouped.push(group);
}
// Add the current item to the group
group.pull_requests.push(currentItem);
return grouped;
}, []);
}

/**
* Archives repos that PRs
* @param {string} owner The organization or user name on GitHub
* @param {Object} options Additional options
*/
export async function archiveReposWithMatchingPullRequests(query, options) {
let pullRequests = await recursivelySearchPullRequests(query, options);
if (!pullRequests || pullRequests.length < 1) {
console.log("Failed to get PRs for query: "+query);
return;
}
let repos = aggregateAllPullRequests(pullRequests, 'repository_url');
if(!repos) throw new Error("No repo found");
for(let repo of repos){
let repoDetail = await getRepoDetail(repo['repository_url']);
Object.assign(repo, repoDetail);
}
archive.save(repos, { archiveFileName: `repos-pr-${query}-${options.maxResults || 1000}-${archive.getFormattedDate()}.csv` });
return repos;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing newline at the end of the file.

+ \n

Committable suggestion

IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
}
}

2 changes: 1 addition & 1 deletion index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as contributorsLib from './contributors.js';
import * as contributorsLib from './github.js';

/**
* Bundling all APIs together
Comment on lines +1 to 4
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 NOTE
Codebase verification is a beta feature.

The github.js file is present in the codebase, as confirmed by the output of the fd command. However, both the ast-grep and rg commands did not find the archiveContributorsLeaderboard function within this file. This suggests that the function is not exported from github.js, which could potentially lead to issues where it is expected to be used.

Final response to the review comment:

The archiveContribitorsLeaderboard function does not appear to be present or exported from github.js. This could lead to issues in index.js where the function is expected to be available. It is recommended to verify the presence and correct export of this function within github.js to ensure the proper functionality of the APIs that depend on it.

  • The archiveContribitorsLeaderboard function is not found in github.js.
Analysis chain

Note: This review was outside the patches, so it was mapped to the patch with the greatest overlap. Original lines [1-14]

Verify that the archiveContributorsLeaderboard function is still available and correctly exported from github.js after the file renaming, as it is being used here.


Given the warning from ast-grep and the possibility that the function could be exported in a different manner than the pattern I used, I will write a new script using ripgrep (rg) to search for the string archiveContributorsLeaderboard within github.js to verify if it is present and exported.


Before concluding, I will verify the presence of the github.js file to ensure that the file exists and is located where expected. This will help to confirm that the absence of the function archiveContribitorsLeaderboard is not due to the file not being found.

Scripts Executed

The following scripts were executed for the analysis:


Script:

#!/bin/bash
# Search for the export of `archiveContributorsLeaderboard` in `github.js`.
ast-grep --lang javascript --pattern $'export function archiveContributorsLeaderboard($_, $_)'

Length of output: 440



Script:

#!/bin/bash
# Search for the string 'archiveContributorsLeaderboard' in 'github.js' to verify if it is present and exported.
rg 'archiveContribitorsLeaderboard' --files-with-matches

Length of output: 56



Script:

#!/bin/bash
# Verify the presence of the `github.js` file in the codebase.
fd 'github.js'

Length of output: 24

Expand Down
14 changes: 10 additions & 4 deletions network.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as https from 'https';
import { ApertureClient } from "@fluxninja/aperture-js";
import { ApertureClient, FlowStatus } from "@fluxninja/aperture-js";

const MAX_RATE_LIMIT_WAIT_DURATION_MS = process.env.MAX_RATE_LIMIT_WAIT_DURATION_MS || 60000; // 10 mins

var apertureClient;

Expand Down Expand Up @@ -47,7 +48,9 @@ export async function makeRequest(method, url, requestOptions) {
// Handle HTTP response stream
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve({ res, data }));
res.on('end', function(){
resolve({ res, data })
});
});

req.on('error', error => {
Expand Down Expand Up @@ -92,17 +95,18 @@ export async function makeRequest(method, url, requestOptions) {
* }
* */
export async function makeRequestWithRateLimit(method, url, options){
if(!method) method = "GET";
let flow;
try {
flow = await getApertureClient().startFlow("external-api", {
labels: {
url: url,
priority: 1,
tokens: 1,
tokens: ["POST", "PUT", "DELETE", "PATCH"].includes(method.toUpperCase()) ? 5 : 1,
workload: 'api.github'
},
grpcCallOptions: {
deadline: Date.now() + 300, // ms
deadline: Date.now() + MAX_RATE_LIMIT_WAIT_DURATION_MS, // ms
},
});
} catch(err){
Expand All @@ -115,6 +119,8 @@ export async function makeRequestWithRateLimit(method, url, options){
if (!flow || flow.shouldRun()) {
// Add business logic to process incoming request
console.log("Request accepted. Processing...");
// Wait for 500ms
await new Promise(resolve => setTimeout(resolve, 500));
const {res, data} = await makeRequest(...arguments)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...arguments का उपयोग करने के बजाय, यह अधिक स्पष्ट होगा यदि हम method, url, और options को सीधे makeRequest फंक्शन में पास करें। इससे कोड की पठनीयता और रखरखाव में सुधार होगा।

- const {res, data} = await makeRequest(...arguments)
+ const {res, data} = await makeRequest(method, url, options)

Committable suggestion

IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
const {res, data} = await makeRequest(...arguments)
const {res, data} = await makeRequest(method, url, options)

return { res, data}
} else {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "open-community-kit",
"version": "1.2.1",
"description": "Tools and stats for open-source communities",
"main": "contributors.js",
"main": "index.js",
"type": "module",
"bin": {
"ock": "cli.js",
Expand Down
20 changes: 20 additions & 0 deletions test/archive.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { expect, assert } from "chai";
import * as archive from "../archive.js";

import * as pullRequestsFixture from './fixtures/pullRequests.fixture.js';

describe('archive.js', function() {

/** Archive test --START-- */

describe.skip('#archive(jsonArray);', async function() {
it('should save jsonArray to a csv file', async function() {
this.timeout(100000);
let content = await archive.save(pullRequestsFixture.VALID_PR_SEARCH_RESULT_ITEMS);
assert.isNotNull(content, "Repos not returned");
expect(content).to.be.an('string');
expect(content).to.have.lengthOf.greaterThan(2000);
})
})

})
43 changes: 0 additions & 43 deletions test/contributors.test.js

This file was deleted.

Loading